diff --git a/assets/translations/de.json b/assets/translations/de.json index ad839d4f..3082c0ee 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -61,7 +61,7 @@ "back": "Zurück", "backToDashboard": "Zurück zu Wallet", "balance": "Guthaben", - "balanceAfterTX": "Guthaben nach Zahlung", + "balanceAfterTX": "Mint Guthaben nach Zahlung", "balTooLow": "Nicht genug Guthaben", "bigQrMsg": "Die Datenmenge ist zu groß für einen QR-Code.", "calculateFeeEst": "Gebühr wird geschätzt", @@ -250,7 +250,11 @@ "swapHint": "Diese Option erfordert eine Lightning-Zahlung, bringt Gebühren mit sich und kann die unbekannte Mint dennoch zur Liste hinzufügen, wenn eine Gebühren-Rückzahlung erfolgt.", "trustHint": "Die mit dem Token verbundene Mint wird zu Ihrer Vertrauensliste hinzugefügt.", "noDefaultHint": "Sie müssen eine Standard-Mint einrichten, um einen automatischen Tausch durchzuführen.", - "autoSwapSuccess": "Tausch erfolgreich!" + "autoSwapSuccess": "Tausch erfolgreich!", + "paidInvoice": "{{ count }} Rechnung wurde mit einem Gesamtbetrag von {{ total }} Sats bezahlt", + "paidInvoices": "{{ count }} Rechnungen wurden mit einem Gesamtbetrag von {{ total }} Sats bezahlt", + "checkPayment": "Zahlung überprüfen", + "lnPaymentSpamHint": "Bitte warten Sie {{remainingSeconds}} Sekunden um die Mint zu entlasten." }, "error": { "checkSpendableErr": "Fehler beim Überprüfen, ob der Token ausgegeben werden kann", diff --git a/assets/translations/en.json b/assets/translations/en.json index 4ad3ab1b..e622ae59 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -61,7 +61,7 @@ "back": "Back", "backToDashboard": "Back to dashboard", "balance": "Balance", - "balanceAfterTX": "Balance after TX", + "balanceAfterTX": "Mint balance after TX", "balTooLow": "Balance too low", "bigQrMsg": "The amount of data is too big for a QR code.", "calculateFeeEst": "Calculating fee", @@ -250,7 +250,11 @@ "swapHint": "This option requires a Lightning payment, involves fees, and may still add the unknown mint to the list if a fee refund occurs.", "trustHint": "The mint associated with the token will be added to your trusted list.", "noDefaultHint": "You need to setup a default mint to perform an auto swap.", - "autoSwapSuccess": "Swap successful!" + "autoSwapSuccess": "Swap successful!", + "paidInvoice": "{{ count }} invoice has been paid with a total amount of {{ total }} Sats", + "paidInvoices": "{{ count }} invoices have been paid with a total amount of {{ total }} Sats", + "checkPayment": "Check payment", + "lnPaymentSpamHint": "Please wait {{remainingSeconds}} seconds to avoid spamming the mint." }, "error": { "checkSpendableErr": "Error while checking if token is spendable", diff --git a/assets/translations/es.json b/assets/translations/es.json index 18b9c0a1..4a3c4035 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -61,7 +61,7 @@ "back": "Volver", "backToDashboard": "Volver al panel de control", "balance": "Saldo", - "balanceAfterTX": "Saldo tras la transacción", + "balanceAfterTX": "Mint saldo tras la transacción", "balTooLow": "Saldo insuficiente", "bigQrMsg": "La cantidad de datos es demasiado grande para un código QR.", "calculateFeeEst": "Calculando comisión", @@ -250,7 +250,11 @@ "swapHint": "Esta opción requiere un pago Lightning, implica una tarifa y aún puede agregar la ceca desconocida a la lista si se produce un reembolso de tarifa.", "trustHint": "La ceca asociada al token se añadirá a tu lista de confianza.", "noDefaultHint": "Necesitas configurar una ceca predeterminada para realizar un intercambio automático.", - "autoSwapSuccess": "¡Intercambio exitoso!" + "autoSwapSuccess": "¡Intercambio exitoso!", + "paidInvoice": "Se ha pagado {{ count }} factura con un importe total de {{ total }} Sats", + "paidInvoices": "Se han pagado {{ count }} facturas con un importe total de {{ total }} Sats", + "checkPayment": "Comprobar pago", + "lnPaymentSpamHint": "Por favor, espere {{remainingSeconds}} segundos para aliviar la Mint." }, "error": { "checkSpendableErr": "Error al comprobar si el token puede ser gastado", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 1b4d9b7c..943dc60a 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -61,7 +61,7 @@ "back": "Retour", "backToDashboard": "Retour au tableau de bord", "balance": "Solde", - "balanceAfterTX": "Solde après paiement", + "balanceAfterTX": "Mint solde après paiement", "balTooLow": "Solde insuffisant", "bigQrMsg": "La quantité de données est trop grand pour un code QR.", "calculateFeeEst": "Calcul des frais", @@ -250,7 +250,11 @@ "swapHint": "Ce choix nécessite un paiement Lightning et peut entraîner des frais associés.", "trustHint": "La menthe associée au jeton sera ajoutée à votre liste de confiance.", "noDefaultHint": "Vous devez configurer une mint par défaut pour effectuer un échange automatique.", - "autoSwapSuccess": "Échange réussi!" + "autoSwapSuccess": "Échange réussi!", + "paidInvoice": "{{ count }} facture a été payée pour un montant total de {{ total }} Sats", + "paidInvoices": "{{ count }} factures ont été payées pour un montant total de {{ total }} Sats", + "checkPayment": "Vérifier le paiement", + "lnPaymentSpamHint": "Veuillez attendre {{remainingSeconds}} secondes pour soulager la Mint." }, "error": { "checkSpendableErr": "Erreur lors de la vérification si le token est dépensable", diff --git a/assets/translations/hu.json b/assets/translations/hu.json index 5a992d92..54a1b474 100644 --- a/assets/translations/hu.json +++ b/assets/translations/hu.json @@ -61,7 +61,7 @@ "back": "Vissza", "backToDashboard": "Vissza a kezdőképernyőre", "balance": "Egyenleg", - "balanceAfterTX": "Utalás utáni egyenleg", + "balanceAfterTX": "Verde utalás utáni egyenleg", "balTooLow": "Egyenleg túl alacsony", "bigQrMsg": "Az adatmennyiség túl nagy egy QR kód számára.", "calculateFeeEst": "Díjszámítás", @@ -250,7 +250,11 @@ "swapHint": "Ez az opció egy Lightning-fizetést igényel ami költségekkel jár, és még akkor is hozzáadhatja az ismeretlen verdét a listádhoz, ha költségvisszatérítésre kerül sor.", "trustHint": "A tokenhez kapcsolódó verde hozzá lesz adva a megbízott listához.", "noDefaultHint": "Be kell állítanod egy alapértelmezett verdét az automatikus cseréhez.", - "autoSwapSuccess": "Csere sikeres!" + "autoSwapSuccess": "Csere sikeres!", + "paidInvoice": "{{ count }} számla kifizetésre került, összesen {{ total }} Sats összegben", + "paidInvoices": "{{ count }} számla kifizetésre került, összesen {{ total }} Sats összegben", + "checkPayment": "Fizetés ellenőrzése", + "lnPaymentSpamHint": "Kérjük, várjon {{remainingSeconds}} másodpercet a Mint tehermentesítése érdekében." }, "error": { "checkSpendableErr": "Hiba a token elkölthetőségének ellenőrzése közben", diff --git a/assets/translations/sw.json b/assets/translations/sw.json index b39ac54f..459d70cc 100644 --- a/assets/translations/sw.json +++ b/assets/translations/sw.json @@ -61,7 +61,7 @@ "back": "Rudi", "backToDashboard": "Rudi kwenye dashibodi", "balance": "Salio", - "balanceAfterTX": "Salio baada ya TX", + "balanceAfterTX": "Mint salio baada ya TX", "balTooLow": "Salio ni dogo mno", "bigQrMsg": "Kiasi cha data ni kikubwa sana kwa nambari ya QR.", "calculateFeeEst": "Kuhesabu ada", @@ -250,7 +250,11 @@ "swapHint": "Chaguo hili linahitaji malipo ya Lightning, linajumuisha ada, na linaweza bado kuongeza mint isiyojulikana kwenye orodha ikiwa kuna marejesho ya ada.", "trustHint": "Minti inayohusiana na alama itaongezwa kwenye orodha yako ya kuaminika.", "noDefaultHint": "Unahitaji kuweka kalibu ya kufanya ubadilishaji wa moja kwa moja.", - "autoSwapSuccess": "Kubadilishana kufanikiwa!" + "autoSwapSuccess": "Kubadilishana kufanikiwa!", + "paidInvoice": "{{ count }} ankara imelipwa kwa jumla ya {{ total }} Sats", + "paidInvoices": "Bilansi ya ankara {{ count }} zimelipwa kwa jumla ya {{ total }} Sats", + "checkPayment": "Angalia malipo", + "lnPaymentSpamHint": "Tafadhali subiri {{remainingSeconds}} sekunde ili kupunguza mzigo kwa Mint." }, "error": { "checkSpendableErr": "Kumetokea kosa wakati wa kuangalia ikiwa kijenzi kina pesa za kutumiwa", diff --git a/package.json b/package.json index feedfd3f..1cfa49a5 100644 --- a/package.json +++ b/package.json @@ -152,7 +152,7 @@ "blind-signatures", "lightning-network" ], - "version": "0.3.1", + "version": "0.4.0", "license": "AGPL-3.0-only", "bugs": { "url": "https://github.com/cashubtc/eNuts/issues" diff --git a/src/components/App.tsx b/src/components/App.tsx index e64b7c48..0e732792 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -8,7 +8,9 @@ import { NavigationContainer, NavigationContainerRef } from '@react-navigation/n import { CustomErrorBoundary } from '@screens/ErrorScreen/ErrorBoundary' import { ErrorDetails } from '@screens/ErrorScreen/ErrorDetails' import * as Sentry from '@sentry/react-native' +import { BalanceProvider } from '@src/context/Balance' import { FocusClaimProvider } from '@src/context/FocusClaim' +import { HistoryProvider } from '@src/context/History' import { KeyboardProvider } from '@src/context/Keyboard' import { NostrProvider } from '@src/context/Nostr' import { PinCtx } from '@src/context/Pin' @@ -48,7 +50,7 @@ interface ILockData { l('[APP] Starting app...') void SplashScreen.preventAutoHideAsync() -function App(_: { exp: Record} ) { +function App(_: { exp: Record }) { if (!env?.SENTRY_DSN) { return ( @@ -206,23 +208,27 @@ function _App() { - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/src/components/Balance.tsx b/src/components/Balance.tsx index 416e023f..83294e6f 100644 --- a/src/components/Balance.tsx +++ b/src/components/Balance.tsx @@ -1,18 +1,18 @@ -import { CheckmarkIcon, EcashIcon, SwapCurrencyIcon, ZapIcon } from '@comps/Icons' +import { CheckmarkIcon, ClockIcon, CloseCircleIcon, EcashIcon, SwapCurrencyIcon, ZapIcon } from '@comps/Icons' import { setPreferences } from '@db' -import { type IHistoryEntry, TTXType,txType } from '@model' +import { type TTXType, txType } from '@model' import type { RootStackParamList } from '@model/nav' import type { NativeStackNavigationProp } from '@react-navigation/native-stack' import EntryTime from '@screens/History/entryTime' -import { useFocusClaimContext } from '@src/context/FocusClaim' +import { useBalanceContext } from '@src/context/Balance' +import { useHistoryContext } from '@src/context/History' import { usePrivacyContext } from '@src/context/Privacy' import { useThemeContext } from '@src/context/Theme' import { NS } from '@src/i18n' -import { getLatestHistory } from '@store/latestHistoryEntries' import { globals, highlight as hi } from '@styles' import { getColor } from '@styles/colors' import { formatBalance, formatInt, formatSatStr, isBool } from '@util' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import { Text, TouchableOpacity, View } from 'react-native' import { s, ScaledSheet } from 'react-native-size-matters' @@ -22,23 +22,16 @@ import Logo from './Logo' import Txt from './Txt' interface IBalanceProps { - balance: number nav?: NativeStackNavigationProp } -export default function Balance({ balance, nav }: IBalanceProps) { +export default function Balance({ nav }: IBalanceProps) { const { t } = useTranslation([NS.common]) const { pref, color, highlight } = useThemeContext() - // State to indicate token claim from clipboard after app comes to the foreground, to re-render total balance - const { claimed } = useFocusClaimContext() const { hidden, handleLogoPress } = usePrivacyContext() const [formatSats, setFormatSats] = useState(pref?.formatBalance) - const [history, setHistory] = useState([]) - - const setHistoryEntries = async () => { - const stored = (await getLatestHistory()).reverse() - setHistory(stored) - } + const { balance } = useBalanceContext() + const { latestHistory } = useHistoryContext() const toggleBalanceFormat = () => { setFormatSats(prev => !prev) @@ -54,23 +47,6 @@ export default function Balance({ balance, nav }: IBalanceProps) { return t('seedBackup') } - useEffect(() => { - void setHistoryEntries() - }, []) - - // get history after navigating to this page - useEffect(() => { - // eslint-disable-next-line @typescript-eslint/no-misused-promises - const focusHandler = nav?.addListener('focus', async () => { - await setHistoryEntries() - }) - return focusHandler - }, [nav]) - - useEffect(() => { - void setHistoryEntries() - }, [claimed]) - return ( } {/* No transactions yet */} - {!history.length && + {!latestHistory.length && } {/* latest 3 history entries */} - {history.length > 0 && !hidden.txs && - history.map(h => ( + {latestHistory.length > 0 && !hidden.txs && + latestHistory.map(h => ( - : - h.type === txType.RESTORE ? - + icon={ + h.isPending && !h.isExpired ? + : - + h.isExpired ? + + : + h.type === txType.RESTORE ? + + : + h.type === txType.LIGHTNING || h.type === txType.SWAP ? + + : + } isSwap={h.type === txType.SWAP} txType={getTxTypeStr(h.type)} timestamp={h.timestamp} amount={h.amount} + isExpired={h.isExpired} onPress={() => nav?.navigate('history entry details', { entry: h })} /> )) } - {(history.length === 3 || (history.length > 0 && hidden.txs)) && + {(latestHistory.length === 3 || (latestHistory.length > 0 && hidden.txs)) && nav?.navigate('history')} @@ -144,10 +128,11 @@ interface IHistoryEntryProps { isSwap?: boolean timestamp: number amount: number + isExpired?: boolean onPress: () => void } -function HistoryEntry({ icon, txType, isSwap, timestamp, amount, onPress }: IHistoryEntryProps) { +function HistoryEntry({ icon, txType, isSwap, timestamp, amount, isExpired, onPress }: IHistoryEntryProps) { const { t } = useTranslation([NS.history]) const { color, highlight } = useThemeContext() @@ -169,7 +154,15 @@ function HistoryEntry({ icon, txType, isSwap, timestamp, amount, onPress }: IHis - + ) } diff --git a/src/components/ClipboardModal.tsx b/src/components/ClipboardModal.tsx index f0d6f13e..bac68009 100644 --- a/src/components/ClipboardModal.tsx +++ b/src/components/ClipboardModal.tsx @@ -2,10 +2,10 @@ import { getEncodedToken } from '@cashu/cashu-ts' import { type RootStackParamList } from '@model/nav' import { type NavigationProp, useNavigation } from '@react-navigation/core' import { useFocusClaimContext } from '@src/context/FocusClaim' +import { useHistoryContext } from '@src/context/History' import { usePromptContext } from '@src/context/Prompt' import { useThemeContext } from '@src/context/Theme' import { NS } from '@src/i18n' -import { addToHistory } from '@store/latestHistoryEntries' import { globals, mainColors } from '@styles' import { copyStrToClipboard, formatInt, formatMintUrl, formatSatStr, isErr } from '@util' import { claimToken } from '@wallet' @@ -28,6 +28,7 @@ export default function ClipboardModal() { const { tokenInfo, claimOpen, setClaimOpen, setClaimed, closeModal } = useFocusClaimContext() const { loading, startLoading, stopLoading } = useLoading() const { openPromptAutoClose } = usePromptContext() + const { addHistoryEntry } = useHistoryContext() const handleRedeem = async () => { startLoading() @@ -56,8 +57,7 @@ export default function ClipboardModal() { } stopLoading() setClaimOpen(false) - // add as history entry (receive ecash) - await addToHistory({ + await addHistoryEntry({ amount: info.value, type: 1, value: encoded, diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index c162ff95..25936655 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -639,6 +639,21 @@ export function ConnectionErrorIcon({ width, height, color }: TIconProps) { ) } +export function ClockIcon({ width, height, color }: TIconProps) { + return ( + + + + + ) +} +export function CloseCircleIcon({ width, height, color }: TIconProps) { + return ( + + + + ) +} const styles = StyleSheet.create({ nostrIcon: { marginLeft: -5 diff --git a/src/components/Toaster.tsx b/src/components/Toaster.tsx index bc3c86b6..a5f26d42 100644 --- a/src/components/Toaster.tsx +++ b/src/components/Toaster.tsx @@ -25,7 +25,7 @@ export default function Toaster() { style={styles.txtWrap} testID={`${prompt.success ? 'success' : 'error'}-toaster`} > - + ) diff --git a/src/components/hooks/Restore.tsx b/src/components/hooks/Restore.tsx index b2a02b78..71e88f29 100644 --- a/src/components/hooks/Restore.tsx +++ b/src/components/hooks/Restore.tsx @@ -4,9 +4,9 @@ import { addToken, getMintBalance } from '@db' import { l } from '@log' import type { RootStackParamList } from '@model/nav' import { type NavigationProp, useNavigation } from '@react-navigation/core' +import { useHistoryContext } from '@src/context/History' import { usePromptContext } from '@src/context/Prompt' import { NS } from '@src/i18n' -import { addToHistory } from '@store/latestHistoryEntries' import { saveSeed } from '@store/restore' import { isErr } from '@util' import { _setKeys, getCounterByMintUrl, getSeedWalletByMnemonic, incrementCounterByMintUrl } from '@wallet' @@ -35,6 +35,7 @@ export function useRestore({ mintUrl, mnemonic, comingFromOnboarding }: IUseRest const navigation = useNavigation() const { t } = useTranslation([NS.common]) const { openPromptAutoClose } = usePromptContext() + const { addHistoryEntry } = useHistoryContext() const [restored, setRestored] = useState({ ...defaultRestoreState }) @@ -52,7 +53,7 @@ export function useRestore({ mintUrl, mnemonic, comingFromOnboarding }: IUseRest return navigation.navigate('dashboard') } const bal = await getMintBalance(mintUrl) - await addToHistory({ + await addHistoryEntry({ mints: [mintUrl], amount: bal, type: 4, @@ -130,7 +131,8 @@ export function useRestore({ mintUrl, mnemonic, comingFromOnboarding }: IUseRest } } void restore() - }, [comingFromOnboarding, mintUrl, mnemonic, navigation, openPromptAutoClose, t]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mintUrl]) return { ...restored } } diff --git a/src/context/Balance.tsx b/src/context/Balance.tsx new file mode 100644 index 00000000..488ad44b --- /dev/null +++ b/src/context/Balance.tsx @@ -0,0 +1,45 @@ +/* eslint-disable @typescript-eslint/require-await */ +/* eslint-disable require-await */ +import { getBalance } from '@db' +import { l } from '@log' +import { createContext, useContext, useEffect, useState } from 'react' + +import { useFocusClaimContext } from './FocusClaim' + +// Total Balance state (all mints) +const useBalance = () => { + const [balance, setBalance] = useState(0) + const { claimed } = useFocusClaimContext() + + const updateBalance = async () => { + const bal = await getBalance() + setBalance(bal) + } + + useEffect(() => { + void updateBalance() + }, []) + + useEffect(() => { + void updateBalance() + }, [claimed]) + + return { + balance, + updateBalance + } +} +type useBalanceType = ReturnType + +const BalanceCtx = createContext({ + balance: 0, + updateBalance: async () => l(''), +}) + +export const useBalanceContext = () => useContext(BalanceCtx) + +export const BalanceProvider = ({ children }: { children: React.ReactNode }) => ( + + {children} + +) \ No newline at end of file diff --git a/src/context/History.tsx b/src/context/History.tsx new file mode 100644 index 00000000..dec56d2f --- /dev/null +++ b/src/context/History.tsx @@ -0,0 +1,182 @@ +/* eslint-disable no-await-in-loop */ +import { delInvoice, getAllInvoices, getInvoiceByPr } from '@db' +import { l } from '@log' +import type { IHistoryEntry } from '@model' +import { NS } from '@src/i18n' +import { historyStore, store } from '@store' +import { STORE_KEYS } from '@store/consts' +import { getHistory, getHistoryEntryByInvoice } from '@store/HistoryStore' +import { addToHistory, getLatestHistory, updateHistory } from '@store/latestHistoryEntries' +import { decodeLnInvoice, formatInt } from '@util' +import { requestToken } from '@wallet' +import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { useBalanceContext } from './Balance' +import { useFocusClaimContext } from './FocusClaim' +import { usePromptContext } from './Prompt' + +const useHistory = () => { + const { t } = useTranslation([NS.common]) + const [history, setHistory] = useState>({}) + const [latestHistory, setLatestHistory] = useState([]) + // State to indicate token claim from clipboard after app comes to the foreground, to re-render total balance + const { claimed } = useFocusClaimContext() + const { openPromptAutoClose } = usePromptContext() + const { updateBalance } = useBalanceContext() + const hasEntries = useMemo(() => Object.keys(history).length > 0, [history]) + const lastCalled = useRef(0) + + const setHistoryEntries = async () => { + const [all, latest] = await Promise.all([getHistory(), getLatestHistory()]) + setHistory(all) + setLatestHistory(latest.reverse()) + } + + const handlePendingInvoices = async () => { + const invoices = await getAllInvoices() + if (!invoices.length) { return } + let paid = { count: 0, amount: 0 } + for (const invoice of invoices) { + const entry = await getHistoryEntryByInvoice(invoice.pr) + try { + const { success } = await requestToken(invoice.mintUrl, invoice.amount, invoice.hash) + if (success) { + paid.count++ + paid.amount += invoice.amount + if (entry) { + await updateHistoryEntry(entry, { ...entry, isPending: false }) + } + await delInvoice(invoice.hash) + continue + } + } catch (_) {/* ignore */ } + const { expiry } = decodeLnInvoice(invoice.pr) + const date = new Date((invoice.time * 1000) + (expiry * 1000)).getTime() + if (Date.now() > date) { + l('INVOICE EXPIRED!', invoice.pr) + await delInvoice(invoice.hash) + if (entry) { + await updateHistoryEntry(entry, { ...entry, isExpired: true }) + } + } + } + // notify user + if (paid.count > 0) { + openPromptAutoClose({ + msg: t(paid.count > 1 ? 'paidInvoices' : 'paidInvoice', { count: paid.count, total: formatInt(paid.amount) }), + success: true + }) + paid = { count: 0, amount: 0 } + } + } + + const checkLnPr = async (pr: string) => { + const delay = 20_000 + const now = Date.now() + const timeSinceLastCall = now - lastCalled.current + const remainingSeconds = Math.ceil((delay - timeSinceLastCall) / 1000) + // restrict usage to 20 seconds + if (timeSinceLastCall < delay) { + return openPromptAutoClose({ msg: t('lnPaymentSpamHint', { remainingSeconds }), success: false }) + } + lastCalled.current = now + const invoice = await getInvoiceByPr(pr) + const entry = await getHistoryEntryByInvoice(pr) + if (!invoice) { + if (entry) { + await updateHistoryEntry(entry, { ...entry, isExpired: true }) + } + return openPromptAutoClose({ msg: t('invoiceExpired'), success: false }) + } + const { success } = await requestToken(invoice.mintUrl, invoice.amount, invoice.hash) + if (success) { + openPromptAutoClose({ msg: t('paidInvoice', { count: 1, total: formatInt(invoice.amount) }), success: true }) + if (entry) { + await updateHistoryEntry(entry, { ...entry, isPending: false }) + } + await delInvoice(invoice.hash) + } else { + openPromptAutoClose({ msg: t('paymentPending'), success: false }) + } + } + + const addHistoryEntry = async (entry: Omit) => { + const resp = await addToHistory(entry) + await setHistoryEntries() + await updateBalance() + return resp + } + + const updateHistoryEntry = async (oldEntry: IHistoryEntry, newEntry: IHistoryEntry) => { + await updateHistory(oldEntry, newEntry) + await setHistoryEntries() + await updateBalance() + } + + const deleteHistory = async () => { + const [success] = await Promise.all([ + historyStore.clear(), + store.delete(STORE_KEYS.latestHistory), + ]) + setHistory({}) + setLatestHistory([]) + openPromptAutoClose({ + msg: success ? t('historyDeleted') : t('delHistoryErr'), + success + }) + } + + useEffect(() => { + void handlePendingInvoices() + void setHistoryEntries() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => void setHistoryEntries(), [claimed]) + + return { + history, + latestHistory, + hasEntries, + addHistoryEntry, + updateHistoryEntry, + deleteHistory, + checkLnPr + } +} +type useHistoryType = ReturnType + +const HistoryCtx = createContext({ + history: {}, + latestHistory: [], + hasEntries: false, + // eslint-disable-next-line require-await, @typescript-eslint/require-await + addHistoryEntry: async () => ({ + timestamp: 0, + amount: 0, + value: '', + mints: [], + fee: 0, + sender: '', + recipient: '', + type: 1, + preImage: '', + isSpent: false, + isPending: false + }), + // eslint-disable-next-line no-return-await, @typescript-eslint/await-thenable + updateHistoryEntry: async () => await l(''), + // eslint-disable-next-line no-return-await, @typescript-eslint/await-thenable + deleteHistory: async () => await l(''), + // eslint-disable-next-line no-return-await, @typescript-eslint/await-thenable + checkLnPr: async () => await l('') +}) + +export const useHistoryContext = () => useContext(HistoryCtx) + +export const HistoryProvider = ({ children }: { children: React.ReactNode }) => ( + + {children} + +) \ No newline at end of file diff --git a/src/model/index.ts b/src/model/index.ts index aaf9c7c8..a3bc27e9 100644 --- a/src/model/index.ts +++ b/src/model/index.ts @@ -118,6 +118,8 @@ export interface IHistoryEntry { preImage?: string, fee?: number, isSpent?: boolean // is token spendable + isPending?: boolean // is LN invoice pending + isExpired?: boolean // is LN invoice expired } diff --git a/src/screens/Dashboard.tsx b/src/screens/Dashboard.tsx index 8df578e3..4e4f5ce4 100644 --- a/src/screens/Dashboard.tsx +++ b/src/screens/Dashboard.tsx @@ -8,13 +8,14 @@ import OptsModal from '@comps/modal/OptsModal' import { PromptModal } from '@comps/modal/Prompt' import Txt from '@comps/Txt' import { _testmintUrl, env } from '@consts' -import { addMint, getBalance, getMintsUrls, hasMints } from '@db' +import { addMint, getMintsUrls, hasMints } from '@db' import { l } from '@log' import TrustMintModal from '@modal/TrustMint' import type { TBeforeRemoveEvent, TDashboardPageProps } from '@model/nav' import BottomNav from '@nav/BottomNav' import { preventBack } from '@nav/utils' import { useFocusClaimContext } from '@src/context/FocusClaim' +import { useHistoryContext } from '@src/context/History' import { useInitialURL } from '@src/context/Linking' import { useNostrContext } from '@src/context/Nostr' import { usePromptContext } from '@src/context/Prompt' @@ -22,7 +23,6 @@ import { useThemeContext } from '@src/context/Theme' import { NS } from '@src/i18n' import { store } from '@store' import { STORE_KEYS } from '@store/consts' -import { addToHistory } from '@store/latestHistoryEntries' import { getDefaultMint } from '@store/mintStore' import { highlight as hi, mainColors } from '@styles' import { extractStrFromURL, getStrFromClipboard, hasTrustedMint, isCashuToken, isLnInvoice, isStr } from '@util' @@ -46,6 +46,7 @@ export default function Dashboard({ navigation, route }: TDashboardPageProps) { const { loading, startLoading, stopLoading } = useLoading() // Prompt modal const { openPromptAutoClose } = usePromptContext() + const { addHistoryEntry } = useHistoryContext() // Cashu token hook const { token, @@ -55,8 +56,6 @@ export default function Dashboard({ navigation, route }: TDashboardPageProps) { trustModal, setTrustModal } = useCashuToken() - // Total Balance state (all mints) - const [balance, setBalance] = useState(0) const [hasMint, setHasMint] = useState(false) // modals const [modal, setModal] = useState({ @@ -131,7 +130,7 @@ export default function Dashboard({ navigation, route }: TDashboardPageProps) { return } // add as history entry (receive ecash) - await addToHistory({ + await addHistoryEntry({ amount: info.value, type: 1, value: encodedToken, @@ -235,7 +234,6 @@ export default function Dashboard({ navigation, route }: TDashboardPageProps) { })) clearTimeout(t) }, 1000) - })() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -243,16 +241,13 @@ export default function Dashboard({ navigation, route }: TDashboardPageProps) { // check for available mints of the user useEffect(() => { void (async () => { - const [userHasMints, explainerSeen, balance] = await Promise.all([ + const [userHasMints, explainerSeen] = await Promise.all([ hasMints(), store.get(STORE_KEYS.explainer), - getBalance(), ]) setHasMint(userHasMints) setModal(prev => ({ ...prev, mint: !userHasMints && explainerSeen !== '1' })) - setBalance(balance) })() - // eslint-disable-next-line react-hooks/exhaustive-deps }, [claimed]) // handle deep links @@ -273,15 +268,11 @@ export default function Dashboard({ navigation, route }: TDashboardPageProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [url]) - // get balance after navigating to this page + // update states after navigating to this page useEffect(() => { const focusHandler = navigation.addListener('focus', async () => { - const data = await Promise.all([ - getBalance(), - hasMints() - ]) - setBalance(data[0]) - setHasMint(data[1]) + const data = await hasMints() + setHasMint(data) }) return focusHandler // eslint-disable-next-line react-hooks/exhaustive-deps @@ -297,7 +288,7 @@ export default function Dashboard({ navigation, route }: TDashboardPageProps) { return ( {/* Balance, Disclaimer & History */} - + {/* Receive/send/mints buttons */} {/* Send button or add first mint */} diff --git a/src/screens/History/Details.tsx b/src/screens/History/Details.tsx index aaee0a46..15fe907d 100644 --- a/src/screens/History/Details.tsx +++ b/src/screens/History/Details.tsx @@ -8,12 +8,11 @@ import Txt from '@comps/Txt' import type { THistoryEntryPageProps } from '@model/nav' import TopNav from '@nav/TopNav' import { truncateStr } from '@nostr/util' +import { useHistoryContext } from '@src/context/History' import { usePromptContext } from '@src/context/Prompt' import { useThemeContext } from '@src/context/Theme' import { NS } from '@src/i18n' import { txType } from '@src/model' -import { historyStore } from '@store' -import { addToHistory } from '@store/latestHistoryEntries' import { getCustomMintNames } from '@store/mintStore' import { globals, mainColors } from '@styles' import { copyStrToClipboard, formatInt, formatMintUrl, formatSatStr, getLnInvoiceInfo, isNum, isUndef } from '@util' @@ -24,7 +23,6 @@ import { ScrollView, Text, TouchableOpacity, View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { s, ScaledSheet } from 'react-native-size-matters' - const initialCopyState = { value: false, hash: false, @@ -43,9 +41,12 @@ export default function DetailsPage({ navigation, route }: THistoryEntryPageProp sender, recipient, fee, - isSpent + isSpent, + isPending, + isExpired } = route.params.entry const { color } = useThemeContext() + const { addHistoryEntry, updateHistoryEntry, checkLnPr } = useHistoryContext() const [copy, setCopy] = useState(initialCopyState) const [spent, setSpent] = useState(isSpent) const { loading, startLoading, stopLoading } = useLoading() @@ -68,7 +69,7 @@ export default function DetailsPage({ navigation, route }: THistoryEntryPageProp }, [mints]) const getTxColor = () => { - if (type === txType.SWAP || type === txType.RESTORE) { return color.TEXT } + if (type === txType.SWAP || type === txType.RESTORE || isPending) { return color.TEXT } return amount < 0 ? mainColors.ERROR : mainColors.VALID } @@ -108,7 +109,7 @@ export default function DetailsPage({ navigation, route }: THistoryEntryPageProp const isSpendable = await isTokenSpendable(value) setSpent(!isSpendable) // update history item - await historyStore.updateHistoryEntry(route.params.entry, { ...route.params.entry, isSpent: !isSpendable }) + await updateHistoryEntry(route.params.entry, { ...route.params.entry, isSpent: !isSpendable }) stopLoading() } @@ -118,13 +119,12 @@ export default function DetailsPage({ navigation, route }: THistoryEntryPageProp if (!success) { openPromptAutoClose({ msg: t('invalidOrSpent') }) setSpent(true) - stopLoading() - return + return stopLoading() } // entry.isSpent can only be false here and is not undefined anymore - await historyStore.updateHistoryEntry({ ...route.params.entry, isSpent: false }, { ...route.params.entry, isSpent: true }) + await updateHistoryEntry({ ...route.params.entry, isSpent: false }, { ...route.params.entry, isSpent: true }) setSpent(true) - await addToHistory({ ...route.params.entry, amount: Math.abs(route.params.entry.amount), isSpent: true }) + await addHistoryEntry({ ...route.params.entry, amount: Math.abs(route.params.entry.amount), isSpent: true }) stopLoading() openPromptAutoClose({ msg: t( @@ -160,22 +160,22 @@ export default function DetailsPage({ navigation, route }: THistoryEntryPageProp } // used in interval to check if token is spent while qr sheet is open - const checkPayment = async () => { + const checkEcashPayment = async () => { + if (type > txType.SEND_RECEIVE) { return clearTokenInterval() } const isSpendable = await isTokenSpendable(value) setSpent(!isSpendable) if (!isSpendable) { clearTokenInterval() setQr({ ...qr, open: false }) openPromptAutoClose({ msg: t('isSpent', { ns: NS.history }), success: true }) - // update history item - await historyStore.updateHistoryEntry(route.params.entry, { ...route.params.entry, isSpent: true }) + await updateHistoryEntry(route.params.entry, { ...route.params.entry, isSpent: true }) } } const startTokenInterval = () => { - if (spent) { return } + if (spent || type > txType.SEND_RECEIVE) { return } intervalRef.current = setInterval(() => { - void checkPayment() + void checkEcashPayment() }, 3000) } @@ -187,7 +187,7 @@ export default function DetailsPage({ navigation, route }: THistoryEntryPageProp // auto check payment in intervals useEffect(() => { - if (!qr.open || spent) { return clearTokenInterval() } + if (!qr.open || spent || type === txType.SEND_RECEIVE) { return clearTokenInterval() } startTokenInterval() return () => clearTokenInterval() // eslint-disable-next-line react-hooks/exhaustive-deps @@ -203,15 +203,37 @@ export default function DetailsPage({ navigation, route }: THistoryEntryPageProp - - {getAmount()} - - + {isPending && !isExpired && + + } + {isExpired ? + + : + <> + + {getAmount()} + + + + } + {/* Manual check of pending invoice */} + {isPending && !isExpired && + <> + void checkLnPr(value)} + > + + + + + + } {/* Settle Time */} @@ -344,11 +366,15 @@ export default function DetailsPage({ navigation, route }: THistoryEntryPageProp {/* LN payment fees */} - - - - - + {!isPending && + <> + + + + + + + } } {/* QR code */} diff --git a/src/screens/History/Entry.tsx b/src/screens/History/Entry.tsx index dd3b5c42..bc5cac69 100644 --- a/src/screens/History/Entry.tsx +++ b/src/screens/History/Entry.tsx @@ -1,6 +1,6 @@ -import { IncomingArrowIcon, OutgoingArrowIcon } from '@comps/Icons' +import { ClockIcon, CloseCircleIcon, IncomingArrowIcon, OutgoingArrowIcon } from '@comps/Icons' import Txt from '@comps/Txt' -import { type IHistoryEntry,txType } from '@model' +import { type IHistoryEntry, txType } from '@model' import type { THistoryPageProps } from '@model/nav' import { useThemeContext } from '@src/context/Theme' import { NS } from '@src/i18n' @@ -8,7 +8,7 @@ import { globals, mainColors } from '@styles' import { formatSatStr, isNum } from '@util' import { useTranslation } from 'react-i18next' import { Text, TouchableOpacity, View } from 'react-native' -import { ScaledSheet, vs } from 'react-native-size-matters' +import { s, ScaledSheet } from 'react-native-size-matters' import EntryTime from './entryTime' @@ -29,41 +29,55 @@ export default function HistoryEntry({ nav, item }: IHistoryEntryProps) { } const getTxColor = () => { - if (item.type === txType.SWAP || item.type === txType.RESTORE) { return color.TEXT } + if (item.type === txType.SWAP || item.type === txType.RESTORE || item.isPending || item.isExpired) { return color.TEXT } return item.amount < 0 ? mainColors.ERROR : mainColors.VALID } const getIcon = () => item.amount < 0 ? : - + item.isPending && !item.isExpired ? + + + + : + item.isExpired ? + + + + : + return ( nav.navigation.navigate('history entry details', { entry: item })} > - + {getIcon()} - - + + - + 0 ? 0 : s(10) }]}> 0 && item.type < txType.SWAP ? '+' : ''}${formatSatStr(item.type === txType.SWAP || item.type === txType.RESTORE ? Math.abs(item.amount) : item.amount, 'standard')}`} - styles={[{ color: getTxColor(), marginBottom: vs(5), textAlign: 'right' }]} + txt={ + item.isExpired ? + t('expired', { ns: NS.common }) + : + `${item.amount > 0 && item.type < txType.SWAP ? '+' : ''}${formatSatStr(item.type === txType.SWAP || item.type === txType.RESTORE ? Math.abs(item.amount) : item.amount, 'standard')}` + } + styles={[{ color: getTxColor(), marginBottom: s(5), textAlign: 'right' }]} /> - {isNum(item.fee) && - + {isNum(item.fee) && item.fee > 0 && + {t('fee', { ns: NS.common })}: {item.fee} } - ) @@ -78,7 +92,7 @@ const styles = ScaledSheet.create({ }, infoWrap: { alignItems: 'center', - paddingBottom: '10@vs', + paddingBottom: '10@s', }, placeholder: { width: '30@s', @@ -87,4 +101,7 @@ const styles = ScaledSheet.create({ position: 'absolute', right: 0, }, + clockIconWrap: { + marginLeft: '-5@s', + }, }) \ No newline at end of file diff --git a/src/screens/History/index.tsx b/src/screens/History/index.tsx index d4136de0..601c2589 100644 --- a/src/screens/History/index.tsx +++ b/src/screens/History/index.tsx @@ -4,19 +4,14 @@ import { BottomModal } from '@comps/modal/Question' import Separator from '@comps/Separator' import Txt from '@comps/Txt' import { isIOS } from '@consts' -import type { IHistoryEntry } from '@model' import type { THistoryPageProps } from '@model/nav' import TopNav from '@nav/TopNav' import { FlashList } from '@shopify/flash-list' -import { useFocusClaimContext } from '@src/context/FocusClaim' -import { usePromptContext } from '@src/context/Prompt' +import { useHistoryContext } from '@src/context/History' import { useThemeContext } from '@src/context/Theme' import { NS } from '@src/i18n' -import { store } from '@src/storage/store' -import { STORE_KEYS } from '@store/consts' -import { getHistory, historyStore } from '@store/HistoryStore' import { globals } from '@styles' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import { View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' @@ -28,40 +23,14 @@ export default function HistoryPage({ navigation, route }: THistoryPageProps) { const insets = useSafeAreaInsets() const { t } = useTranslation([NS.common]) const { color } = useThemeContext() - const { claimed } = useFocusClaimContext() - const [data, setData] = useState>({}) - const { openPromptAutoClose } = usePromptContext() + const { history, hasEntries, deleteHistory } = useHistoryContext() const [confirm, setConfirm] = useState(false) - const hasEntries = Object.keys(data).length > 0 - const handleDeleteHistory = async () => { - const success = await historyStore.clear() - await store.delete(STORE_KEYS.latestHistory) - setData({}) - openPromptAutoClose({ - msg: success ? t('historyDeleted') : t('delHistoryErr'), - success - }) + await deleteHistory() setConfirm(false) } - // update history after claiming from clipboard when the app comes to the foreground - useEffect(() => { - void (async () => { - setData(await getHistory()) - })() - }, [claimed]) - - // update history after navigating to this page - useEffect(() => { - // eslint-disable-next-line @typescript-eslint/no-misused-promises - const focusHandler = navigation.addListener('focus', async () => { - setData(await getHistory()) - }) - return focusHandler - }, [navigation]) - return ( {/* History list grouped by settled date */} ( <> diff --git a/src/screens/Onboarding.tsx b/src/screens/Onboarding.tsx index 36dda72c..b68d3c06 100644 --- a/src/screens/Onboarding.tsx +++ b/src/screens/Onboarding.tsx @@ -4,7 +4,7 @@ import type { TOnboardingPageProps } from '@model/nav' import { NS } from '@src/i18n' import { store } from '@src/storage/store' import { STORE_KEYS } from '@src/storage/store/consts' -import { H_Colors } from '@styles/colors' +import { H_Colors, mainColors } from '@styles/colors' import { useTranslation } from 'react-i18next' import { Image, TouchableOpacity } from 'react-native' import Onboarding from 'react-native-onboarding-swiper' @@ -53,7 +53,7 @@ export default function OnboardingScreen({ navigation }: TOnboardingPageProps) { style={{ marginRight: s(20) }} testID='onboarding-done' > - + )} /> diff --git a/src/screens/Payment/Processing.tsx b/src/screens/Payment/Processing.tsx index db365a72..d751fe21 100644 --- a/src/screens/Payment/Processing.tsx +++ b/src/screens/Payment/Processing.tsx @@ -6,13 +6,12 @@ import type { TBeforeRemoveEvent, TProcessingPageProps } from '@model/nav' import { preventBack } from '@nav/utils' import { pool } from '@nostr/class/Pool' import { getNostrUsername } from '@nostr/util' +import { useHistoryContext } from '@src/context/History' import { useInitialURL } from '@src/context/Linking' import { useNostrContext } from '@src/context/Nostr' import { useThemeContext } from '@src/context/Theme' import { NS } from '@src/i18n' import { isLnurlOrAddress } from '@src/util/lnurl' -import { addLnPaymentToHistory } from '@store/HistoryStore' -import { addToHistory, updateLatestHistory } from '@store/latestHistoryEntries' import { getDefaultMint } from '@store/mintStore' import { globals } from '@styles' import { decodeLnInvoice, getInvoiceFromLnurl, isErr, isNum, uniqByIContacts } from '@util' @@ -32,6 +31,7 @@ export default function ProcessingScreen({ navigation, route }: TProcessingPageP const { color } = useThemeContext() const { setNostr } = useNostrContext() const { clearUrl } = useInitialURL() + const { addHistoryEntry } = useHistoryContext() const { mint, tokenInfo, @@ -118,21 +118,12 @@ export default function ProcessingScreen({ navigation, route }: TProcessingPageP // here it could be a routing path finding issue return handleError({ e: isErr(res.error) ? res.error : undefined }) } - // payment success, add as history entry - await addLnPaymentToHistory( - res, - [mint.mintUrl], - -amount, - target - ) - // update latest 3 history entries - await updateLatestHistory({ - amount: -amount, - fee: res.realFee, + await addHistoryEntry({ + amount: -amount - (isNum(res.realFee) ? res.realFee : 0), type: 2, - value: target, + value: invoice, mints: [mint.mintUrl], - timestamp: Math.ceil(Date.now() / 1000) + fee: res.realFee }) // reset zap deep link clearUrl() @@ -157,8 +148,8 @@ export default function ProcessingScreen({ navigation, route }: TProcessingPageP // TODO this process can take a while, we need to add it as pending transaction const res = await autoMintSwap(mint.mintUrl, targetMint.mintUrl, amount, estFee ?? 0, proofs) // add as history entry (multimint swap) - await addToHistory({ - amount: -amount, + await addHistoryEntry({ + amount: -amount - (isNum(res.payResult.realFee) ? res.payResult.realFee : 0), fee: res.payResult.realFee, type: 3, value: res.requestTokenResult.invoice?.pr || '', @@ -193,8 +184,8 @@ export default function ProcessingScreen({ navigation, route }: TProcessingPageP } const amountSent = tokenInfo.value - estFeeResp // add as history entry (multimint swap) - await addToHistory({ - amount: -amountSent, + await addHistoryEntry({ + amount: -amountSent - (isNum(payResult.realFee) ? payResult.realFee : 0), fee: payResult.realFee, type: 3, value: requestTokenResult.invoice?.pr || '', @@ -213,7 +204,7 @@ export default function ProcessingScreen({ navigation, route }: TProcessingPageP try { const token = await sendToken(mint.mintUrl, amount, memo || '', proofs) // add as history entry (send ecash) - const entry = await addToHistory({ + const entry = await addHistoryEntry({ amount: -amount, type: 1, value: token, @@ -307,7 +298,6 @@ export default function ProcessingScreen({ navigation, route }: TProcessingPageP // start payment process useEffect(() => { - if (isZap) { if (payZap) { return void handleMelting() } return void handleZap() diff --git a/src/screens/Payment/Receive/Invoice.tsx b/src/screens/Payment/Receive/Invoice.tsx index fe40f475..d26f610d 100644 --- a/src/screens/Payment/Receive/Invoice.tsx +++ b/src/screens/Payment/Receive/Invoice.tsx @@ -5,14 +5,14 @@ import Loading from '@comps/Loading' import QR from '@comps/QR' import Txt from '@comps/Txt' import { _testmintUrl, isIOS } from '@consts' -import { getAllInvoices } from '@db' import { l } from '@log' +import type { IHistoryEntry } from '@model' import type { TMintInvoicePageProps } from '@model/nav' import TopNav from '@nav/TopNav' +import { useHistoryContext } from '@src/context/History' import { usePromptContext } from '@src/context/Prompt' import { useThemeContext } from '@src/context/Theme' import { NS } from '@src/i18n' -import { addToHistory } from '@store/latestHistoryEntries' import { globals, highlight as hi, mainColors } from '@styles' import { getColor } from '@styles/colors' import { formatMintUrl, formatSeconds, isErr, openUrl, share } from '@util' @@ -27,6 +27,10 @@ export default function InvoiceScreen({ navigation, route }: TMintInvoicePagePro const { openPromptAutoClose } = usePromptContext() const { t } = useTranslation([NS.common]) const { color, highlight } = useThemeContext() + const { + addHistoryEntry, + updateHistoryEntry, + } = useHistoryContext() const intervalRef = useRef(null) const [expire, setExpire] = useState(expiry) const [expiryTime,] = useState(expire * 1000 + Date.now()) @@ -38,48 +42,23 @@ export default function InvoiceScreen({ navigation, route }: TMintInvoicePagePro } } - const handlePayment = async (isCancelling?: boolean) => { + const handlePaidInvoice = async (entry: IHistoryEntry) => { + clearInvoiceInterval() + await updateHistoryEntry(entry, { ...entry, isPending: false }) + navigation.navigate('success', { amount, mint: formatMintUrl(mintUrl) }) + } + + const handlePayment = async (entry: IHistoryEntry) => { try { - const allInvoices = (await getAllInvoices()).map(i => i.pr) const { success } = await requestToken(mintUrl, amount, hash) - /* - it is possible that success is false but invoice has - been paid and token have been issued due to the double - check in the background...(requestTokenLoop()) - So we check if the invoice is in the db and if it is - not then we check if the invoice has expired and if - it has not then we assume that the invoice has been - paid and token have been issued. - TODO we need a global context that handles invoices - payments so the frontend can handle updates accordingly - */ - if (success || (!allInvoices.includes(paymentRequest) && expire > 0)) { - // add as history entry - await addToHistory({ - amount, - type: 2, - value: paymentRequest, - mints: [mintUrl], - }) - clearInvoiceInterval() - navigation.navigate('success', { amount, mint: formatMintUrl(mintUrl) }) + if (success) { + await handlePaidInvoice(entry) } } catch (e) { if (isErr(e) && e.message === 'tokens already issued for this invoice.') { - await addToHistory({ - amount, - type: 2, - value: paymentRequest, - mints: [mintUrl], - }) - clearInvoiceInterval() - navigation.navigate('success', { amount, mint: formatMintUrl(mintUrl) }) - return + await handlePaidInvoice(entry) } setPaid('unpaid') - if (isCancelling) { - navigation.navigate('dashboard') - } } } @@ -87,8 +66,7 @@ export default function InvoiceScreen({ navigation, route }: TMintInvoicePagePro useEffect(() => { const timeLeft = Math.ceil((expiryTime - Date.now()) / 1000) if (timeLeft < 0 || paid === 'paid') { - setExpire(0) - return + return setExpire(0) } if (expire && expire > 0) { setTimeout(() => setExpire(timeLeft - 1), 1000) @@ -98,9 +76,22 @@ export default function InvoiceScreen({ navigation, route }: TMintInvoicePagePro // auto check payment in intervals useEffect(() => { - intervalRef.current = setInterval(() => { - void handlePayment() - }, 3000) + void (async () => { + // add as pending history entry + const entry = await addHistoryEntry({ + amount, + type: 2, + value: paymentRequest, + mints: [mintUrl], + isPending: true, + isExpired: false, + }) + // start checking for payment in intervals + intervalRef.current = setInterval(() => { + l('checking pending invoices in invoice screen') + void handlePayment(entry) + }, 20_000) + })() return () => clearInvoiceInterval() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -110,7 +101,10 @@ export default function InvoiceScreen({ navigation, route }: TMintInvoicePagePro void handlePayment(true)} + handlePress={() => { + clearInvoiceInterval() + navigation.navigate('dashboard') + }} /> () const { trustModal, setTrustModal } = useCashuToken() const { loading, startLoading, stopLoading } = useLoading() + const { addHistoryEntry } = useHistoryContext() const handleStoreRedeemed = async () => { await updateNostrRedeemed(id) @@ -89,7 +90,7 @@ export default function Token({ sender, token, id, dms, setDms, mints }: ITokenP return stopLoading() } // add as history entry (receive ecash from nostr) - await addToHistory({ + await addHistoryEntry({ amount: info.value, type: 1, value: token, diff --git a/src/screens/Payment/Send/CoinSelection.tsx b/src/screens/Payment/Send/CoinSelection.tsx index b4cc0b99..ace2ffba 100644 --- a/src/screens/Payment/Send/CoinSelection.tsx +++ b/src/screens/Payment/Send/CoinSelection.tsx @@ -17,7 +17,7 @@ import { isLightningAddress } from '@util/lnurl' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { ScrollView, View } from 'react-native' -import { ScaledSheet } from 'react-native-size-matters' +import { s, ScaledSheet } from 'react-native-size-matters' import { CoinSelectionModal, CoinSelectionResume, OverviewRow } from './ProofList' @@ -141,10 +141,17 @@ export default function CoinSelectionScreen({ navigation, route }: TCoinSelectio {isNum(estFee) && !nostr && !isSendEcash && } - 0 ? `${formatInt(balance - amount - estFee)} ${t('to')} ${formatSatStr(balance - amount)}` : `${formatSatStr(balance - amount)}`} - /> + + + 0 ? `${formatInt(balance - amount - estFee)} ${t('to')} ${formatSatStr(balance - amount)}` : `${formatSatStr(balance - amount)}`} + styles={[{ color: color.TEXT_SECONDARY }]} + /> + + {memo && memo.length > 0 && } diff --git a/src/screens/Payment/Send/EncodedToken.tsx b/src/screens/Payment/Send/EncodedToken.tsx index 4cd9e153..983ebe0d 100644 --- a/src/screens/Payment/Send/EncodedToken.tsx +++ b/src/screens/Payment/Send/EncodedToken.tsx @@ -7,9 +7,11 @@ import type { TBeforeRemoveEvent, TEncodedTokenPageProps } from '@model/nav' import TopNav from '@nav/TopNav' import { preventBack } from '@nav/utils' import { isIOS } from '@src/consts' +import { useBalanceContext } from '@src/context/Balance' +import { useHistoryContext } from '@src/context/History' import { useThemeContext } from '@src/context/Theme' import { NS } from '@src/i18n' -import { historyStore, store } from '@store' +import { store } from '@store' import { STORE_KEYS } from '@store/consts' import { globals, highlight as hi, mainColors } from '@styles' import { formatInt, formatSatStr, share, vib } from '@util' @@ -27,6 +29,8 @@ export default function EncodedTokenPage({ navigation, route }: TEncodedTokenPag const { value, amount } = route.params.entry const { t } = useTranslation([NS.common]) const { color, highlight } = useThemeContext() + const { updateHistoryEntry } = useHistoryContext() + const { updateBalance } = useBalanceContext() const [error, setError] = useState({ msg: '', open: false }) const [spent, setSpent] = useState(false) const { copied, copy } = useCopy() @@ -43,8 +47,7 @@ export default function EncodedTokenPage({ navigation, route }: TEncodedTokenPag setSpent(!isSpendable) if (!isSpendable) { clearTokenInterval() - // update history item - await historyStore.updateHistoryEntry(route.params.entry, { ...route.params.entry, isSpent: true }) + await updateHistoryEntry(route.params.entry, { ...route.params.entry, isSpent: true }) } } @@ -63,6 +66,7 @@ export default function EncodedTokenPage({ navigation, route }: TEncodedTokenPag // auto check payment in intervals useEffect(() => { + void updateBalance() intervalRef.current = setInterval(() => { void checkPayment() }, 3000) diff --git a/src/screens/Payment/Success.tsx b/src/screens/Payment/Success.tsx index 76b7ff3b..f4acefbf 100644 --- a/src/screens/Payment/Success.tsx +++ b/src/screens/Payment/Success.tsx @@ -5,9 +5,9 @@ import { isIOS } from '@consts' import type { TBeforeRemoveEvent, TSuccessPageProps } from '@model/nav' import { preventBack } from '@nav/utils' import ProfilePic from '@screens/Addressbook/ProfilePic' +import { useBalanceContext } from '@src/context/Balance' import { useThemeContext } from '@src/context/Theme' import { NS } from '@src/i18n' -import { l } from '@src/logger' import { formatSatStr, isNum, vib } from '@util' import LottieView from 'lottie-react-native' import { useEffect } from 'react' @@ -33,9 +33,14 @@ export default function SuccessPage({ navigation, route }: TSuccessPageProps) { } = route.params const { t } = useTranslation([NS.common]) const { color } = useThemeContext() + const { updateBalance } = useBalanceContext() const insets = useSafeAreaInsets() - useEffect(() => vib(400), []) + useEffect(() => { + vib(400) + void updateBalance() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) // prevent back navigation - https://reactnavigation.org/docs/preventing-going-back/ useEffect(() => { @@ -44,8 +49,6 @@ export default function SuccessPage({ navigation, route }: TSuccessPageProps) { return () => navigation.removeListener('beforeRemove', backHandler) }, [navigation]) - l({ amount, memo, fee, mint, isClaim, isMelt, nostr, isScanned }) - return ( {nostr && nostr.contact && nostr.contact.picture ? diff --git a/src/screens/QRScan/QRProcessing.tsx b/src/screens/QRScan/QRProcessing.tsx index e65dbb43..5d292bd2 100644 --- a/src/screens/QRScan/QRProcessing.tsx +++ b/src/screens/QRScan/QRProcessing.tsx @@ -4,11 +4,11 @@ import { getMintsBalances } from '@db' import { l } from '@log' import type { TBeforeRemoveEvent, TQRProcessingPageProps } from '@model/nav' import { preventBack } from '@nav/utils' +import { useHistoryContext } from '@src/context/History' import { useThemeContext } from '@src/context/Theme' import { NS } from '@src/i18n' import { isErr } from '@src/util' import { getLnurlData } from '@src/util/lnurl' -import { addToHistory } from '@store/latestHistoryEntries' import { getCustomMintNames } from '@store/mintStore' import { globals } from '@styles' import { checkFees, claimToken } from '@wallet' @@ -18,9 +18,10 @@ import { View } from 'react-native' import { ScaledSheet } from 'react-native-size-matters' export default function QRProcessingScreen({ navigation, route }: TQRProcessingPageProps) { + const { tokenInfo, token, ln, lnurl, scanned } = route.params const { t } = useTranslation([NS.mints]) const { color } = useThemeContext() - const { tokenInfo, token, ln, lnurl, scanned } = route.params + const { addHistoryEntry } = useHistoryContext() const getProcessingtxt = () => { if (token && tokenInfo) { return 'claiming' } @@ -42,7 +43,7 @@ export default function QRProcessingScreen({ navigation, route }: TQRProcessingP return } // add as history entry (receive ecash) - await addToHistory({ + await addHistoryEntry({ amount: tokenInfo.value, type: 1, value: token, diff --git a/src/storage/db/index.ts b/src/storage/db/index.ts index 7d552817..cd3e60fc 100644 --- a/src/storage/db/index.ts +++ b/src/storage/db/index.ts @@ -318,7 +318,7 @@ export async function setPreferences(p: IPreferences) { // ################################ Invoices ################################ export async function addInvoice({ pr, hash, amount, mintUrl }: Omit) { const result = await db.execInsert( - 'INSERT OR IGNORE INTO invoices (amount,pr,hash,mintUrl) VALUES (?, ?, ?,?)', + 'INSERT OR IGNORE INTO invoices (amount,pr,hash,mintUrl) VALUES (?, ?, ?, ?)', [amount, pr, hash, mintUrl] ) l('[addInvoice]', result, { pr, hash, amount, mintUrl }) @@ -347,6 +347,14 @@ export async function getInvoice(hash: string) { l('[getInvoice]', result, { hash }) return result?.item?.(0) } +export async function getInvoiceByPr(pr: string) { + const result = await db.execSelect( + 'SELECT * from invoices Where pr = ?', + [pr] + ) + l('[getInvoice]', result, { pr }) + return result?.item?.(0) +} // ################################ Contacts ################################ export async function getContacts(): Promise { diff --git a/src/storage/store/HistoryStore.ts b/src/storage/store/HistoryStore.ts index 71d131cf..e1ee304f 100644 --- a/src/storage/store/HistoryStore.ts +++ b/src/storage/store/HistoryStore.ts @@ -1,6 +1,5 @@ -import type { IHistoryEntry, IKeyValuePair } from '@model' +import type { IHistoryEntry, IInvoice, IKeyValuePair } from '@model' import { getHistoryGroupDate } from '@util' -import type { payLnInvoice } from '@wallet' import { type ISelectParams, StoreBase } from './StoreBase' import { getDb } from './utils' @@ -83,27 +82,21 @@ class HistoryStore extends StoreBase { export const historyStore = new HistoryStore() -export async function addLnPaymentToHistory( - payResp: Awaited>, - mints: string[], - amount: number, - invoice: string -) { - await historyStore.add({ - amount, - type: 2, - value: invoice, - mints, - fee: payResp?.realFee, - timestamp: Math.ceil(Date.now() / 1000) - }) -} - export async function getHistory({ order = 'DESC', start = 0, count = -1, orderBy = 'insertionOrder' }: ISelectParams = {}) { const history = await historyStore.getHistory({ order, start, count, orderBy }) return groupEntries(history) } +export async function getHistoryEntryByInvoice(invoice: string) { + const history = await historyStore.getHistory() + return history.find(i => i.value === invoice) +} + +export async function getHistoryEntriesByInvoices(invoices: IInvoice[]) { + const history = await historyStore.getHistory() + return history.filter(h => invoices.map(i => i.pr).includes(h.value)) +} + function groupEntries(history: IHistoryEntry[]) { return groupBy(history, i => getHistoryGroupDate(new Date(i.timestamp * 1000))) } @@ -114,4 +107,4 @@ function groupBy(arr: IHistoryEntry[], key: (i: IHistoryEntry) => string) { (groups[key(item)] ??= []).push(item) return groups }, {} as Record) -} +} \ No newline at end of file diff --git a/src/storage/store/latestHistoryEntries.ts b/src/storage/store/latestHistoryEntries.ts index e8e74e9b..57f48cef 100644 --- a/src/storage/store/latestHistoryEntries.ts +++ b/src/storage/store/latestHistoryEntries.ts @@ -32,4 +32,13 @@ export async function addToHistory(entry: Omit) { // latest 3 history entries // TODO provide a historyStore method to retreive the 3 latest entries await updateLatestHistory(item) return item +} + +export async function updateHistory(oldEntry: IHistoryEntry, newEntry: IHistoryEntry) { + await historyStore.updateHistoryEntry(oldEntry, newEntry) + const stored = await getLatestHistory() + const idx = stored.findIndex(i => i.value === oldEntry.value) + if (idx === -1) { return } + stored[idx] = newEntry + await store.setObj(STORE_KEYS.latestHistory, stored) } \ No newline at end of file diff --git a/src/wallet/index.ts b/src/wallet/index.ts index 33950626..7e4334f5 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -308,8 +308,6 @@ export async function getCounterByMintUrl(mintUrl: string) { return 0 } l('[getCounterByMintUrl] ', { mintUrl, keysetId, storedCounter: counter }) - // await store.set(storeKey, counter) - l('[getCounterByMintUrl] ', { keysetId, counter: counter }) return +counter } catch (e) { l('[getCounterByMintUrl] Error while getCounter: ', e) diff --git a/test/components/Balance.test.tsx b/test/components/Balance.test.tsx index aa276d6e..39dda406 100644 --- a/test/components/Balance.test.tsx +++ b/test/components/Balance.test.tsx @@ -15,15 +15,15 @@ describe('Basic test of the Txt.tsx component', () => { beforeEach(() => jest.clearAllMocks()) // Start tests it('renders the expected string', () => { - render() - const textElement = screen.getByText('69') + render() + const textElement = screen.getByText('0') expect(textElement).toBeDefined() }) it('updates the balance format state on press', () => { - render() - const touchableElement = screen.getByText('69') + render() + const touchableElement = screen.getByText('0') // Simulate press event fireEvent.press(touchableElement) - expect(touchableElement.props.children).toBe('0.00000069') + expect(screen.getByText('0.00000000')).toBeDefined() }) -}) +}) \ No newline at end of file