From c0d4b4713a5f1bd023631a1115281224fc6064dd Mon Sep 17 00:00:00 2001 From: Blake Kaufman Date: Wed, 17 Dec 2025 16:49:52 -0500 Subject: [PATCH 01/28] creating add contact page, expanded contact page, and contacts page --- db/index.js | 90 ++- locales/en/translation.json | 583 +++++++++++----- package-lock.json | 16 +- package.json | 1 + src/components/customInput/style.css | 2 +- src/components/customSettingsNavbar/style.css | 2 +- src/components/navBar/profileImage.css | 12 + src/components/navBar/profileImage.jsx | 32 + .../customSendAndRequestBTN.css | 17 + .../customSendAndRequsetButton.jsx | 54 ++ src/constants/index.js | 73 +- src/constants/theme.js | 4 + src/contexts/globalContacts.jsx | 526 +++++++++++--- src/contexts/imageCacheContext.jsx | 270 ++++--- src/functions/cachedImage.js | 126 ++++ src/functions/contacts/index.js | 96 ++- src/functions/images/storage.js | 364 ++++++++++ src/functions/messaging/cachedMessages.js | 7 +- src/functions/timeFormatter.js | 34 + src/hooks/useDebounce.js | 17 + src/main.jsx | 35 +- .../expandedContactsPage.css | 147 ++++ .../expandedContactsPage.jsx | 412 +++++++++++ .../addContactPage/addContactPage.jsx | 63 ++ .../components/addContactPage/style.css | 78 +++ .../addContactsHalfModal.jsx | 255 +++++++ .../components/addContactsHalfModal/style.css | 172 +++++ .../expandedAddContactPage.jsx | 176 +++++ .../expandedAddContactPage/style.css | 102 +++ .../components/profileImage/profileImage.jsx | 5 +- src/pages/contacts/contacts.css | 146 ++++ src/pages/contacts/contacts.jsx | 659 ++++++++++++++---- .../contactsTransactionItem.css | 88 +++ .../contactsTransactions.jsx | 528 ++++++++++++++ .../giftCardTxItem/giftCardTxItem.css | 52 ++ .../giftCardTxItem/giftCardTxItem.jsx | 122 ++++ .../contacts/utils/formatListDisplayName.js | 17 + .../getReceiveAddressAndKindForPayment.js | 55 ++ src/pages/contacts/utils/hooks.js | 43 ++ src/pages/contacts/utils/imageComparison.js | 23 + src/pages/contacts/utils/transactionText.js | 29 + src/pages/contacts/utils/useExpandedNavbar.js | 88 +++ src/pages/contacts/utils/useProfileImage.js | 224 ++++++ src/pages/contacts/utils/utilityFunctions.js | 66 ++ src/pages/customHalfModal/index.jsx | 14 + src/pages/receiveAmount/style.css | 2 +- src/pages/receiveQRPage/receiveQRPage.jsx | 3 - src/pages/wallet/components/nav/nav.css | 5 - src/pages/wallet/components/nav/nav.jsx | 20 +- src/tabs/tabs.jsx | 1 + src/workers/messageWorker.js | 67 ++ 51 files changed, 5446 insertions(+), 577 deletions(-) create mode 100644 src/components/navBar/profileImage.css create mode 100644 src/components/navBar/profileImage.jsx create mode 100644 src/components/sendAndRequsetButton/customSendAndRequestBTN.css create mode 100644 src/components/sendAndRequsetButton/customSendAndRequsetButton.jsx create mode 100644 src/functions/cachedImage.js create mode 100644 src/functions/images/storage.js create mode 100644 src/functions/timeFormatter.js create mode 100644 src/hooks/useDebounce.js create mode 100644 src/pages/contacts/components/ExpandedContactsPage/expandedContactsPage.css create mode 100644 src/pages/contacts/components/ExpandedContactsPage/expandedContactsPage.jsx create mode 100644 src/pages/contacts/components/addContactPage/addContactPage.jsx create mode 100644 src/pages/contacts/components/addContactPage/style.css create mode 100644 src/pages/contacts/components/addContactsHalfModal/addContactsHalfModal.jsx create mode 100644 src/pages/contacts/components/addContactsHalfModal/style.css create mode 100644 src/pages/contacts/components/expandedAddContactPage/expandedAddContactPage.jsx create mode 100644 src/pages/contacts/components/expandedAddContactPage/style.css create mode 100644 src/pages/contacts/internalComponents/contactTransactions/contactsTransactionItem.css create mode 100644 src/pages/contacts/internalComponents/contactTransactions/contactsTransactions.jsx create mode 100644 src/pages/contacts/internalComponents/giftCardTxItem/giftCardTxItem.css create mode 100644 src/pages/contacts/internalComponents/giftCardTxItem/giftCardTxItem.jsx create mode 100644 src/pages/contacts/utils/formatListDisplayName.js create mode 100644 src/pages/contacts/utils/getReceiveAddressAndKindForPayment.js create mode 100644 src/pages/contacts/utils/hooks.js create mode 100644 src/pages/contacts/utils/imageComparison.js create mode 100644 src/pages/contacts/utils/transactionText.js create mode 100644 src/pages/contacts/utils/useExpandedNavbar.js create mode 100644 src/pages/contacts/utils/useProfileImage.js create mode 100644 src/pages/contacts/utils/utilityFunctions.js create mode 100644 src/workers/messageWorker.js diff --git a/db/index.js b/db/index.js index c94bc2d..e329ca8 100644 --- a/db/index.js +++ b/db/index.js @@ -1,11 +1,14 @@ import { addDoc, + and, collection, doc, getDoc, getDocs, getFirestore, limit, + or, + orderBy, query, setDoc, where, @@ -320,51 +323,78 @@ export async function updateMessage({ } } -export async function syncDatabasePayment( - myPubKey, - updatedCachedMessagesStateFunction -) { +export async function syncDatabasePayment(myPubKey, privateKey) { try { const cachedConversations = await getCachedMessages(); const savedMillis = cachedConversations.lastMessageTimestamp; console.log("Retrieving docs from timestamp:", savedMillis); const messagesRef = collection(db, "contactMessages"); - const receivedMessagesQuery = query( - messagesRef, - where("toPubKey", "==", myPubKey), - where("timestamp", ">", savedMillis) - ); - - const sentMessagesQuery = query( + const combinedQuery = query( messagesRef, - where("fromPubKey", "==", myPubKey), - where("timestamp", ">", savedMillis) + and( + where("timestamp", ">", savedMillis), + or( + where("toPubKey", "==", myPubKey), + where("fromPubKey", "==", myPubKey) + ) + ), + orderBy("timestamp") ); - const [receivedSnapshot, sentSnapshot] = await Promise.all([ - getDocs(receivedMessagesQuery), - getDocs(sentMessagesQuery), - ]); - - const receivedMessages = receivedSnapshot.docs.map((doc) => doc.data()); - const sentMessages = sentSnapshot.docs.map((doc) => doc.data()); - const allMessages = [...receivedMessages, ...sentMessages]; - - if (allMessages.length === 0) { - updatedCachedMessagesStateFunction(); - return; - } + const snapshot = await getDocs(combinedQuery); + const allMessages = snapshot.docs.map((doc) => doc.data()); + if (allMessages.length === 0) return []; + console.log(allMessages); console.log(`${allMessages.length} messages received from history`); - queueSetCashedMessages({ - newMessagesList: allMessages, + const processedMessages = await processWithRAF( + allMessages, myPubKey, - }); + privateKey + ); + + return processedMessages; } catch (err) { console.error("Error syncing database payments:", err); // Consider adding error handling callback if needed - updatedCachedMessagesStateFunction(); + return []; } } + +function processWithRAF(allMessages, myPubKey, privateKey, onProgress) { + return new Promise((resolve, reject) => { + // Create worker + const worker = new Worker( + new URL("../src/workers/messageWorker.js", import.meta.url), + { type: "module" } + ); + + worker.onmessage = function (e) { + const { type, data, current, total } = e.data; + + if (type === "PROGRESS") { + console.log(`Processing: ${current}/${total}`); + if (onProgress) { + onProgress(current, total); + } + } else if (type === "COMPLETE") { + worker.terminate(); + resolve(data); + } + }; + + worker.onerror = function (error) { + console.error("Worker error:", error); + worker.terminate(); + reject(error); + }; + + // Send data to worker + worker.postMessage({ + type: "PROCESS_MESSAGES", + data: { allMessages, myPubKey, privateKey }, + }); + }); +} diff --git a/locales/en/translation.json b/locales/en/translation.json index 9bc3c5f..5d40733 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -71,7 +71,6 @@ "hide": "Hide", "show": "Show", "understandText": "I understand", - "retyr": "Retry", "reportError": "Report Error", "address": "Address", "string": "String", @@ -103,17 +102,33 @@ "selectOption": "Select an option", "slideToConfirm": "Slide to confirm", "token": "Token", - "veriable": "Veriable", + "veriable": "Variable", "instant": "Instant", "andLower": "and", "onLower": "on", - "offLower": "off" + "offLower": "off", + "recover": "Recover", + "search": "Search", + "review": "Review", + "loadMore": "Load More", + "invoice": "Invoice", + "annonName": "Anonymous User", + "claim": "Claim", + "manage": "Manage", + "reclaim": "Expired", + "optionalFlag": "(Optional)", + "gift": "Gift" }, "languages": { "english": "English", "spanish": "Español", - "italian": "Italiano" + "italian": "Italiano", + "portuguese_br": "Português (Brasil)", + "german": "Deutsch", + "french": "Française", + "swedish": "Svenska", + "russian": "русский" }, "weekdays": { @@ -178,7 +193,7 @@ "date": "Date", "time": "Time", "memo": "Memo", - "roostockSwap": "Rootstock to Spark Swap" + "roostockSwap": "Rootstock to Spark Transfer" }, "createAccount": { @@ -194,7 +209,7 @@ }, "disclaimerPage": { "header": "Self-custodial", - "subHeader": "Blitz cannot access your funds or help recover them if lost. By continuing, you agree to Blitz Wallet's terms and conditions.", + "subHeader": "Blitz cannot access your funds or help recover them if lost. By continuing, you agree to Blitz Wallet's Terms and Conditions.", "imgCaption": "'With great power comes great responsibility' - Uncle Ben", "acceptPrefix": "I accept the ", "terms&Conditions": "Terms and Conditions", @@ -202,18 +217,18 @@ "continueBTN": "Next" }, "verifyKeyPage": { - "header": "Let's confirm your backup!" + "header": "Let's confirm your seed phrase!" }, "keySetup": { "generateKey": { "header": "This is your password to your money, if you lose it you lose your money!", - "subHeader": "Write it down with pen and paper and keep it safe!", + "subHeader": "Write it down with a pen and paper and keep it safe!", "disclaimer": "WE CAN NOT HELP YOU IF YOU LOSE IT", - "errorText": "Error Fetching recovery phrase", - "keyGenError": "Not able to generate valid seed", + "errorText": "Error fetching seed phrase", + "keyGenError": "Unable to generate a valid seed phrase", "seedPrivacyMessage": "Make sure no one is around who can see your screen.", "showIt": "Show it", - "invalidSeedError": "Not able to generate valid seed" + "invalidSeedError": "Unable to generate a valid seed phrase" }, "pin": { "savePinError": "Unable to save pin", @@ -226,16 +241,16 @@ }, "restoreWallet": { "home": { - "header": "Enter your recovery phrase", + "header": "Enter your seed phrase", "continueBTN": "Restore wallet", - "error1": "Please enter all of your keys.", - "error2": "This is not a valid mnemonic.", + "error1": "Please enter all of your seed phrase words.", + "error2": "This is not a valid seed phrase.", "error3": "Your words are not the same as the generated words", - "noSeedInString": "Unable to find seed in string", - "invalidWordLen": "Not every word is of valid length", - "cannotFind12Words": "Unable to find 12 words from copied recovery phrase.", - "noSeedInNumberArray": "Unable to find seed in number array", - "noSeedInQr": "Unable to get seed from QR code", + "noSeedInString": "Unable to find a seed phrase in the string", + "invalidWordLen": "Not all words are of valid length", + "cannotFind12Words": "Unable to find 12 words from copied seed phrase.", + "noSeedInNumberArray": "Unable to find a seed phrase in number array", + "noSeedInQr": "Unable to get a seed phrase from QR code", "scanQr": "Scan QR" } }, @@ -248,17 +263,18 @@ "adminLogin": { "pinPage": { - "isBiometricEnabledConfirmAction": "Since biometric setting are enabled you cannot use the default pin login method. Would you like to terminate your account?", + "isBiometricEnabledConfirmAction": "We couldn’t verify your biometrics.\n\nDo you want to delete all wallet data? Only continue if your seed phrase is securely backed up.", "wrongPinError": "Wrong PIN, try again", "enterPinMessage": "Enter PIN", - "attemptsText": "{{attempts}} attempts left" + "attemptsText": "{{attempts}} attempts left", + "biometricsHeader": "Open with Biometrics" } }, "apps": { "appList": { "AI": "AI", - "AIDescription": "Chat with the latest generative Ai models", + "AIDescription": "Chat with the latest generative AI models", "SMS": "SMS", "SMSDescription": "Send and receive SMS messages worldwide using Bitcoin", "VPN": "VPN", @@ -266,11 +282,11 @@ "onlineListings": "Shops", "onlineListingsDescription": "Directory of online businesses accepting Bitcoin payments", "Soon": "Soon", - "SoonDescription": "More apps coming soon" + "SoonDescription": "More integrations coming soon" }, "VPN": { "VPNPlanPage": { - "backupLoadingMessage": "Retriving invoice", + "backupLoadingMessage": "Retrieving invoice", "countrySearchPlaceholder": "Search by country", "createVPNBTN": "Buy VPN", "noLocationError": "Please select a country for the VPN to be located in.", @@ -278,8 +294,8 @@ "backupPaymentError": "Error paying invoice.", "createInvoiceError": "Error creating invoice.", "payingInvoiveError": "Error paying invoice.", - "runningTries": "Running {{runCount}} for {{maxTries}} tries", - "configError": "Not able to get VPN config, please reach out to LNVPN.", + "runningTries": "Attempt {{runCount}} of {{maxTries}} attempts", + "configError": "Unable to get VPN config. Please reach out to LNVPN.", "paymentMemo": "Store - VPN" }, "confirmationSlideUp": { @@ -302,17 +318,17 @@ "30": "year" }, "historicalPurchasesPage": { - "deleteVPNConfirmMessage": "Are you sure you want to remove this VPN.", + "deleteVPNConfirmMessage": "Are you sure you want to remove this VPN?", "country": "Country:", "createdAt": "Created at:", "duration": "Duration:", "paymentHash": "Payment Hash:", "title": "Purchases", - "retryClaim": "Trying to create file", - "noPurchases": "You have no VPN configuations", + "retryClaim": "Attempting to create file", + "noPurchases": "You have no VPN configurations", "assistanceText": "For assistance, reach out to LNVPN", "noValidCountryCodeError": "Not able to get valid country code", - "claimConfigError": "Not able to get VPN config, please reach out to LNVPN.", + "claimConfigError": "Unable to get VPN config. Please reach out to LNVPN.", "contact": "Contact" }, "home": { @@ -324,8 +340,8 @@ }, "generatedFile": { "title": "Wiregurard Config File", - "iosDownloadInstructions": "When dowloading, click save to files.", - "androidDownloadInstructions": "When dowloading, you will need to give permission to a location where we can save the config file to." + "iosDownloadInstructions": "When downloading, click save to Files.", + "androidDownloadInstructions": "When downloading, you will need to give permission for a location to save the config file to." } }, "chatGPT": { @@ -333,15 +349,16 @@ "loadingMessage": "Loading previous chats", "addCreditsPage": { "title": "Add Credits", - "description": "In order to use the latest generative AI models, you must buy credits. Choose an option below to begin.", + "description": "In order to use the latest generative AI models, you must purchase credits. Choose an option below to begin.", "supportedModels": "Supported Models", - "feeInfo": "Depending on the length of your question and response, the number of searches you get might be different. Blitz adds a 150 sat fee + 0.5% of purchase price onto all purchases.", + "feeInfo": "Depending on the length of your question and response, the number of searches you get might differ. Blitz adds a 150 sat fee + 0.5% of purchase price onto all purchases.", "price": "Price: ", "estSearches": "Est. searches: {{num}}", "casualPlanTitle": "Casual Plan", "proPlanTitle": "Pro Plan", "powerPlanTitle": "Power Plan", - "paymentMemo": "Store - chatGPT" + "paymentMemo": "Store - chatGPT", + "notAvailableMessage": "Unfortunately, due to Apple’s in-app purchase guidelines, this feature is not currently available on iOS. We’re exploring ways to bring it to iOS in the future." }, "countrySearch": { "inputPlaceholder": "Search for a country" @@ -702,7 +719,7 @@ "termsAndConditions4": "Privacy policy" }, "expandedGiftCardPage": { - "purchasingCardMessage": "Purchasing gift card, do not leave the page.", + "purchasingCardMessage": "Purchasing gift card. Do not leave the page.", "selectamount": "Select an amount", "sendingto": "Sending to: ", "minMaxPurchaseAmount": "You can buy a {{min}} amount of {{max}} {{currency}}", @@ -712,7 +729,7 @@ "terms": "Terms", "cardDescription": "Card Description", "cardTerms": "Card terms", - "dailyPurchaseAmountError": "You have hit your daily purchase limit", + "dailyPurchaseAmountError": "You have reached your daily purchase limit", "giftMessage": "Add a note to your gift", "giftMessagePlaceholder": "Type your message here", "selectForContactBTN": "Select Gift Card", @@ -735,7 +752,7 @@ "sms4sats": { "home": { - "pageDescription": "Send and Receive sms messages without giving away your personal phone number.", + "pageDescription": "Send and receive SMS messages without giving away your personal phone number.", "pricingError": "Unable to get SMS pricing" }, "confirmationSlideUp": { @@ -755,8 +772,8 @@ "invalidInputError": "Must have a {{errorTyoe}}", "invalidCountryError": "Not a valid country", "payingMessage": "Paying...", - "runningTries": "Running {{runCount}} for {{maxTries}} tries", - "notAbleToSettleInvoice": "Not able to settle invoice.", + "runningTries": "Attempt {{runCount}} of {{maxTries}} attempts", + "notAbleToSettleInvoice": "Unable to settle invoice.", "paymentMemo": "Store - SMS Send" }, "sentPayments": { @@ -772,14 +789,14 @@ "fetchOrderError": "Failed to fetch order status", "failedUpdate": "Failed to update order status", "refundedOrder": "Order has been refunded, funds will automatically return to your wallet after 21 minutes", - "reclaimComplete": "Rety complete. If no code is generated, you will be auto-refunded after 21 minutes.", + "reclaimComplete": "Retry complete. If no code is generated, you will be auto-refunded after 21 minutes.", "noCode": "No code", "rateLimitError": "Too many requests. You can only make 5 requests every 30 seconds. Please wait before trying again." }, "receivePage": { "paymentMemo": "Store - SMS Receive", "selectCountry": "Select Country", - "autoSelectInfoMessage": "Choose the country of the phone number you want to use. For the best chance of receiving your code, we recommend keeping it on Auto Select so the system picks the most reliable option.", + "autoSelectInfoMessage": "Choose the country of the phone number you want to use. For the best chance of receiving your code, we recommend keeping it on 'Auto Select' so the system picks the most reliable option.", "autoSelect": "Auto Select", "inputPlaceholder": "Search for a service", "loacationLoadingMessage": "Getting location-based services", @@ -840,7 +857,7 @@ "settingsText": "Go to settings to let Blitz Wallet access your camera", "noCameraDevice": "You do not have a camera device.", "noFlash": " Device does not have a torch", - "sanningResponse": "Only QRcodes are accepted.", + "sanningResponse": "Only QR codes are accepted.", "qrDecodeError": "Error decoding QR code" }, "confirmClipboard": { @@ -851,10 +868,10 @@ "see_all_txs": "See all transactions", "no_transaction_history": "Send or receive a transaction for it to show up here", "contactsPage": { - "header": "Select recipient", + "header": "Select contact", "inputTextPlaceholder": "Search username...", "subHeader": "All Contacts", - "giftsText": "My Gifts" + "giftsText": "Gifts from Contacts" }, "cameraPage": { "noCameraAccess": "No access to camera", @@ -866,23 +883,28 @@ "receivePages": { "editPaymentInfo": { "receive_amount": "receive amount", - "descriptionInputPlaceholder": "Description..." + "descriptionInputPlaceholder": "Description...", + "editAmount": "Edit amount" }, "buttonContainer": { - "format": "Choose Network", + "format": "Other receiving methods", "infoMessage": "Custom amounts aren’t supported on Spark or Rootstock payments." }, "switchReceiveOptionPage": { - "title": "Choose Network", + "title": "Receiving methods", "actionBTN": "Show {{action}}", "sparkWarningMessage": "Receiving directly via Spark will expose your balance to the person paying and is not considered private.", - "swapWarning": "{{swapType}} payments will be swapped into Spark. Payments below {{amount}} won’t be swapped. Funds will only be swapped after the {{swapType}} payment is confirmed.", - "unalignedSeeds": "Warning: You’re not using your main wallet account. All Liquid swaps will be sent to your main wallet.", - "rootstockWarningText": "Rootstock payments will be swapped into Spark. Payments below {{amount}} won’t be swapped. Funds will only be swapped after the Rootsock payment is confirmed.", + "swapWarning": "{{swapType}} payments will be received into Spark. Payments below {{amount}} won’t be transferred. Funds will only be transferred after the {{swapType}} payment is confirmed.", "oneMinute": "~ {{numMins}} minute", "tenMinutes": "~ {{numMins}} minutes", - "notUsingMainAccountWarning": "Warning: You’re not using your main wallet account. All {{swapType}} swaps will be sent to your main wallet." + "notUsingMainAccountWarning": "Warning: You’re not using your main wallet account. All {{swapType}} transfers will be sent to your main wallet.", + "sparkDesc": "Instant, Supports Tokens", + "sparkTitle": "Spark Address", + "bitcoinTitle": "Bitcoin Address", + "lightningTitle": "Lightning Invoice", + "liquidTitle": "Liquid Address", + "rootstockTitle": "Rootstock Address" }, "editLNURLContact": { "informationMessage": "Changing your username updates how others find you in Blitz Contacts and your Lightning address (username @blitzwalletapp.com).\n\nA Lightning address lets others easily send you Bitcoin. Just share your address (username@blitzwalletapp.com) to get paid.", @@ -894,26 +916,30 @@ "initialLoadingMessage": "Preparing invoice details", "alreadyPaidInvoiceError": "You have already paid this invoice", "descriptionPlaceholder": "Payment description...", - "fallbackErrorMessage": "Error decoding invoice" + "fallbackErrorMessage": "Error decoding invoice", + "connectToSparkMessage": "Connecting to your wallet.", + "switchTokenText": "Use Different Token" }, "handlingAddressErrors": { - "invlidFormat": "Addresses should be text only. Please check and try again.", - "parseError": "We couldn’t recognize the payment type. Please enter a valid Spark, on-chain, LNURL, or BOLT11 invoice.", + "invlidFormat": "Invalid format: Addresses should be text only. Please check and try again.", + "parseError": "We couldn’t recognize the payment type. Please enter a valid Spark address, Bitcoin address, or a Lightning address/invoice.", "paymentProcessingError": "Error processing payment info", "tooLowSendingAmount": "The sending amount is too high to cover the payment and fees. Maximum send amount is {{amount}}", "processInputError": "Unable to process input", "unkonwDecodeError": "Unknown decoding error occurred", "invalidInputType": "Not a valid address type", - "expiredLightningInvoice": "This lightning invoice has expired", + "expiredLightningInvoice": "This Lightning invoice has expired", "lnurlAuthStartMeessage": "Starting LNURL auth", "lnurlFailedAuthMessage": "Failed to authenticate LNURL", "lnurlConfirmMessage": "LNURL successfully authenticated", - "lnurlPayInvoiceError": "Unable to retrive invoice from LNURL, please try again.", - "lnurlWithdrawlStart": "Generating invoice for withdrawl", - "lnurlWithdrawlInvoiceError": "Unable to generate invoice for lnurl withdrawl", + "lnurlPayInvoiceError": "Unable to retrieve invoice from LNURL. Please try again.", + "lnurlWithdrawlStart": "Generating invoice for withdrawal", + "lnurlWithdrawlInvoiceError": "Unable to generate invoice for LNURL withdrawal", "waitingForLnurlWithdrawl": "Waiting for payment...", "lnurlWithdrawlSuccess": "Withdrawal successful! Your payment is on its way.", - "invoiceDetails": "Loading details" + "invoiceDetails": "Loading details", + "timeoutError": "The process took too long to complete. Please try again.", + "payingToSameAddress": "Sending to your own {{addressType}} address is not allowed." }, "errorScreen": { @@ -938,7 +964,7 @@ "acceptButton": { "noSendAmountError": "Please enter a send amount", "liquidError": "{{overFlowType}} send amount {{amount}}", - "onchainError": "Minimum on-chain send amount is {{amount}}", + "onchainError": "Minimum payment to a Bitcoin address is {{amount}}", "lnurlPayError": "{{overFlowType}} send amount {{amount}}", "lrc20FeeError": "You need {{amount}} to send tokens. Your balance is {{balance}}. Please receive some Bitcoin first.", "balanceError": "Not enough funds to cover sending amount and fees" @@ -962,11 +988,17 @@ }, "manualEnterSendAddress": { "title": "Enter in destination", - "paymentTypesDesc": "Blitz wallet can send to spark, on-chain, LNURL and BOLT 11 addresses" + "paymentTypesDesc": "Blitz Wallet can send to Spark addresses, Bitcoin addresses, and Lightning addresses/invoices" }, "sparkErrorScreen": { "connectionError": "We’re having trouble connecting to your wallet. Please let us know about this issue.\n\nRemember, your seed phrase keeps your funds safe.", "retry": "We couldn’t load your wallet right now. Please try again. \n\nYour funds are safe as long as you have your seed phrase." + }, + "backupSeedWarning": { + "header": "Backup Your Seed Phrase", + "description": "You have money in your wallet, but you haven’t saved your seed phrase. Your seed phrase is a list of words that unlock your wallet. If you lose this device and don’t have it saved, your money could be gone forever.", + "backupBTN": "Backup Now", + "laterBTN": "I'll do it later" } }, "exportTransactions": { @@ -976,7 +1008,7 @@ "txFees": "Transaction Fees (SAT)", "amount": "Amount (SAT)", "sent/received": "Sent/Received", - "title": "Export your payment history in CSV (comma seperated value) format.", + "title": "Export your payment history in CSV (comma separated value) format.", "paymentsCounter": "{{number}} payments", "paymentsCounterTracker": "{{number}} of {{total}}", "loadingMessage": "Loading saved transactions" @@ -985,12 +1017,16 @@ "contacts": { "contactsPage": { + "contactsHeader": "Contacts", + "addContactsText": "Add Contact", "searchContactsPlaceholder": "Search added contacts", "noContactsMessage": "You have no contacts", "editContactProfileMessage": "Edit your profile to begin using contacts.", "addContactButton": "Add Contact", "editContactButton": "Edit Profile", - "unknownSender": "Unknown sender" + "unknownSender": "Unknown sender", + "noContactSearch": "This contact isn't in your list yet.", + "notFound": "not found." }, "addContactsHalfModal": { "noContactsMessage": "Not able to find contact.", @@ -1011,7 +1047,7 @@ "noDescription": "No description" }, "editMyProfilePage": { - "navTitle": "Edit Contact Profile", + "navTitle": "Edit Profile", "nameInputDesc": "Name", "nameInputPlaceholder": "Enter your name", "lnurlInputDesc": "LNURL Address", @@ -1021,17 +1057,18 @@ "bioInputDesc": "Bio", "bioInputPlaceholder": "Enter a short bio", "addContactBTN": " Add Contact", - "unqiueNameRegexError": "You can only have letters, numbers, or underscores in your username, and must contain at least 1 letter.", + "unqiueNameRegexError": "You can only have letters, numbers, or underscores in your username, and it must contain at least 1 letter.", "usernameAlreadyExistsError": "This username already exists, please choose another.", "unableToSaveError": "Unable to save profile image, please try again.", - "deleteProfileImageError": "Unable to delete profile image, please try again." + "deleteProfileImageError": "Unable to delete profile image, please try again.", + "deleateWarning": "Are you sure you want to delete this contact? Messages older than a week cannot be restored." }, "sendAndRequestPage": { "descriptionPlaceholder": "Payment description", "profileMessage": "Paying {{name}}", "contactMessage": "{{name}} paid you", - "giftCardappVersionError": "You cannot send a gift card to this contact. They have not updated to the latest verison of Blitz Wallet.", + "giftCardappVersionError": "You cannot send a gift card to this contact. They have not updated to the latest version of Blitz Wallet.", "giftCardDescription": "You sent {{name}} a {{giftCardName}} Gift Card", "cardDetailsError": "We couldn’t load your gift card right now. Please try again.", "giftCardRange": "Send {{amount}} - {{amount2}}", @@ -1072,11 +1109,12 @@ "pushNotificationUpdateMessage": "{{name}} {{option}} your request", "acceptProfileMessage": "Paid {{name}}'s request", "acceptPayingContactMessage": "{{name}} paid your request", - "requestTitle": "Received request for " + "requestTitle": "Received request for {{amount}}", + "send": "Send" }, "viewAllGiftCards": { "cardNamePlaceH": "Gift Card", - "noCardsText": "Your gift cards will appear here once you receive them", + "noCardsText": "Your gift cards will appear here when a contact sends you one.", "header": "Your Gift Cards", "cardsLenghSingular": "{{num}} card", "cardsLenghPlurl": "{{num}} cards" @@ -1084,34 +1122,78 @@ "editGiftCard": { "header": "What would you like to do with your gift?" } + }, + "showProfileQr": { + "header": "Share Profile", + "lnAddress": "Lightning Address", + "blitzContact": "Blitz Contact", + "copyMessage": "Copied!", + "lnurlCopy": "Copy Address", + "blitzCopy": "Copy Link", + "scanProfile": "Scan Profile" } }, "settings": { + "index": { + "editProfile": "Edit Profile", + "showQR": "Show QR", + "profileHead": "Profile", + "settingsHead": "Settings", + "seedPopup": "A seed phrase is a list of words that lets you recover your Bitcoin if your device is lost or damaged. Write it down and store it safely. Never give it to anyone—if someone gets it, they can take all your money." + }, "about": { "header1": "Software", "text2": "Blitz is a free and open source app under the ", "text3": "Apache License", "text4": " Version 2.0", - "text5": "This is a self-custodial Bitcoin Lightning wallet. Blitz does not have access to your funds, if you lose your backup phrase it may result in a loss of funds.", + "text5": "This is a self-custodial Bitcoin Spark wallet compatible with the Lightning Network. Blitz does not have access to your funds, if you lose your seed phrase it may result in a loss of funds.", "text6": "Blitz uses ", "text7": "Breez Liquid SDK, ", "text8": "Spark, ", "text9": "and ", "text10": "Boltz API.", "header2": "Good to know", - "text12": "Blitz is a powered by the Spark protocol. Spark is an ", + "text12": "Blitz is powered by the Spark protocol. Spark is an ", "text13": "off-chain protocol ", "text14": "where Spark Operators update the state of Bitcoin ownership off-chain ", "text15": "allowing for fast, low-fee, and non-custodial transfers without touching the blockchain.", "text16": "Creator", "text17": "Version:", - "learnMore": "Learn More" + "learnMore": "Learn More", + "webpackVerified": "Webpack Verified", + "webpackUnvarified": "Webpack Unverified", + "showTechnicals": "Show Technical Details", + "hideTechnicals": "Hide Technical Details", + "backendHash": "Backend Hash", + "expectedHash": "Expected Hash" }, "language": { "inputPlaceHolder": "" }, "displayOptions": { + "appearance": "Appearance", + "theme": "Theme", + "light": "Light", + "lightsOut": "Lights out", + "dim": "Dim", + "balDisplay": "Balance Display", + "prev": "Preview", + "denomination": "Denomination", + "displayForm": "Display Format", + "homepage": "Homepage", + "txToDisplay": "Transactions to Display", + "transactionsLabel_15": "15 Transactions", + "transactionsLabel_20": "20 Transactions", + "transactionsLabel_25": "25 Transactions", + "transactionsLabel_30": "30 Transactions", + "transactionsLabel_35": "35 Transactions", + "transactionsLabel_40": "40 Transactions", + "privFeat": "Privacy & Features", + "cameraSwipe": "Swipe for camera", + "cameraSwipeDesc": "Swipe right on any main screen to open the camera.", + "unkownSenders": "Hide Unknown Senders", + "unknownSenderDesc": "Hide transactions from unknown contacts", "text1": "Dark Mode Style", "text2": "Lights out", "text3": "Dim", @@ -1119,17 +1201,25 @@ "text5": "Current denomination", "text6": "Please reconnect to the internet to switch your denomination", "text7": "How to display", - "text8": "fiat", + "fiat": "Fiat", + "hidden": "Hidden", + "symbol": "Symbol", + "word": "Word", "text9": "Example", "text10": "Home Screen", "text11": "Swipe for camera", "text12": "Swipe right from the main screen—whether you're on Contacts, Wallet, or the Home page—to quickly open the camera for scanning.", "text13": "Displayed Transactions", "text14": "Contacts Page", - "text15": "Hide Unknown Senders" + "text15": "Hide Unknown Senders", + "sparkTitle": "BTKN Spark Tokens", + "sliderTitle_enabled": "Show BTKN tokens", + "thousandsSeperator": "Thousands Separator", + "space": "Space", + "local": "Local Number Format" }, "fastPay": { - "text1": "Error adding inputed value, plase try again.", + "text1": "Error adding input value. Please try again.", "text2": "Amount cannot be 0", "text3": "Enable Fast Pay", "text4": "Fast pay threshold (SAT)", @@ -1140,46 +1230,46 @@ "text2": "Security Type", "text3": "Pin", "text4": "Biometric", - "text5": "Device does not have a Biometric profile. Create one in settings to continue.", + "text5": "Device does not have a Biometric profile. Create one in Settings to continue.", "text6": "Error logging in with Biometrics", "text7": "Device does not support Biometric login", "unsuccesfullLoginSwitch": "Unable to switch login type", "toggleSecurityModeError": "Something went wrong while switching your security settings. Please try again.", "noBiometricsError": "Device does not support Biometric login", - "noBiometricProfileError": "Device does not have a Biometric profile. Create one in settings to continue.", + "noBiometricProfileError": "Device does not have a Biometric profile. Create one in Settings to continue.", "biometricSignInError": "Error logging in with Biometrics", "migratingStorageMessgae": "Migrating storage to new security.", "createPinPageHeader": "{{type}} your Blitz PIN", - "invalidPinconfirmation": "PIN's do not match. Try again!" + "invalidPinconfirmation": "PINs do not match. Try again!", + "randomPinKeyboardToggle": "Shuffle Keypad", + "randomPinKeyboardInfo": "Enable this feature to shuffle the keypad numbers every time you log in. It adds an extra layer of security by making it harder for anyone nearby to learn your PIN pattern." }, "seedPhrase": { - "header": "Keep this phrase in a secure and safe place", + "header": "Keep this seed phrase in a secure and safe place", "headerDesc": "Do not share it with anyone!", - "qrWarning": "Are you sure you want to show this QR Code?\n\nScanning your seed is convenient, but be sure you're using a secure and trusted device. This helps keep your wallet safe.", + "qrWarning": "Are you sure you want to show this QR Code?\n\nScanning your seed phrase is convenient but be sure you're using a secure and trusted device. This helps keep your wallet safe.", "wordsText": "Words", "qrText": "QR Code", - "showSeedWarning": "Are you sure you want to show your recovery phrase?" + "showSeedWarning": "Are you sure you want to show your seed phrase?" }, "crashReporting": { - "enabled": "Enabled", - "disabled": "Disabled", - "crashreporting": "crash reporting", + "crashreporting_enabled": "Enabled crash reporting", + "crashreporting_disabled": "Disabled crash reporting", "descriptionText": "Crash data helps us improve the stability and performance of our application.\n\nWhen a crash occurs, the device information that is automatically recorded includes:\n\n• Operating System: OS version, device orientation, and jailbreak status\n• Device Details: Model, orientation, and available RAM\n• Crash Information: Date of the crash and the app version" }, "accounts": { - "inputPlaceholder": "Account name", - "buttonCTA": "Swap Funds" + "inputPlaceholder": "Search existing account" }, "resetWallet": { "header": "Are you sure?", "dataDeleteHeader": "Select data to delete from this device.", - "dataDeleteDesc": "Any option that is selected will be removed forever. If your seed is forgotten, you WILL lose your funds.", - "seedAndPinOpt": "Delete seed phrase and pin from my device", - "localData": "Delete locally stored data from my device", + "dataDeleteDesc": "This will delete all wallet data from your device. If you don’t have a copy of your seed phrase saved, you WILL lose access to your funds.", + "boxNotChecked": "Please check the box to confirm you want to reset your wallet before resetting.", + "seedAndPinOpt": "Delete all data from my device", "balanceText": "Your balance is", - "localStorageError": "Not able to delete local stored information", - "secureStorageError": "Not able to delete secure stored information" + "localStorageError": "Unable to delete locally stored information", + "secureStorageError": "Unable to delete securely stored information" }, "sparkInfo": { "title": "Wallet Info", @@ -1189,48 +1279,52 @@ "sparkLrc20": { "balanceError": "Your current wallet balance is {{balance}}. Blitz adds a {{fee}} fee to all BTKN payments. Make sure you have some Bitcoin in your wallet to send tokens.", "title": "Spark Settings", - "sliderTitle": "{{switchType}} BTKN", - "sliderDesc": "BTKN is Spark’s native token. Enabling BTKN allows you send and receive tokens on the Spark network.\n\nBlitz is a Bitcoin focused wallet. We do not promote or endorse the use of tokens. This feature exists because we believe users should have the freedom to use the technology they want to use.\n\nBlitz also applies a {{fee}} fee to all token transactions, with 20% of that fee donated to support open-source Bitcoin development." + "sliderTitle_enabled": "Enabled BTKN", + "sliderTitle_disabled": "Disabled BTKN", + "sliderDesc": "BTKN is Spark’s native token. Enabling BTKN allows you to send and receive tokens on the Spark network.\n\nBlitz is a Bitcoin-focused wallet. We do not promote or endorse the use of tokens. This feature exists because we believe users should have the freedom to use the technology they want to use.", + "selectTokenHeader": "Default Token", + "selectTokenInformationPopup": "Choose which token you’d like to use first when sending payments. Don’t worry—you can still change it on the payment page anytime." }, "viewSwapsHome": { - "swaps": "Swaps", + "swaps": "Network Transfers", "selectionTitle": "Choose Network", "liquid": "Liquid", "rootstock": "Rootstock", - "pageTitle": "{{type}} Swaps" + "pageTitle": "{{type}} Transfers" }, "viewRoostockSwaps": { - "loadingMessage": "Loading saved Roostock swaps", - "noSwapsMessage": "You have no saved Roostock swaps" + "loadingMessage": "Loading saved Roostock transfers", + "noSwapsMessage": "You have no saved Roostock transfers" }, "rootstockSwapInfo": { "title": "Submarine Swap", "id": "ID", - "swapDetails": "Swap Details", + "swapDetails": "Transfer Details", "claimAddress": "Claim Address", "timeoutBHeight": "Timeout Block Height", "expAmount": "Expected Amount", "invoice": "Invoice", - "refundSwap": "Refund swap" + "refundSwap": "Refund transfer" }, "viewAllLiquidSwaps": { "rescanComplete": "Rescan complete", - "swapStartedMessage": "The swap has started. It may take 10–20 seconds for the payment to show up.", - "balanceError": "Current liquid balance is {{balance}} but the minimum swap amount is {{swapAmount}}", - "loadingMessage": "Getting liquid info", + "swapStartedMessage": "The transfer has started. It may take 10–20 seconds for the payment to show up.", + "balanceError": "Current Liquid balance is {{balance}} but the minimum transfer amount is {{swapAmount}}", + "loadingMessage": "Getting Liquid info", "breakdownHead": "Balance Breakdown", "incoming": "{{amount}} pending incoming", "outgoing": "{{amount}} pending outgoing", - "balance": "{{amount}} liquid balance", + "balance": "{{amount}} Liquid balance", "rescan": "Rescan", "totalBalance": "Total Balance", - "swapMessage": "Blitz Wallet will try to swap your Liquid funds into Spark when you first load the app or when you receive Liquid payments.\n\nHowever, in some cases a swap might be missed. To move these funds into Spark manually, just click the Swap button below.", - "swap": "Swap" + "swapMessage": "Blitz Wallet will try to transfer your Liquid funds into Spark when you first load the app or when you receive Liquid payments.\n\nHowever, in some cases a transfer might be missed. To move these funds into Spark manually, just click the Transfer button below.", + "swap": "Transfer" }, "notifications": { - "mainToggle": "{{state}} notifications", + "mainToggle_enabled": "Enabled notifications", + "mainToggle_disabled": "Disabled notifications", "mainToggleDesc": "Notifications let you stay informed about important events and updates happening in the app.", "optionsTitle": "Notification options", "contact": "Contact", @@ -1246,13 +1340,13 @@ "nwcDesc": "Connect your Blitz Wallet to apps using Nostr." }, "fiatCurrency": { - "title": "Display Currency", + "title": "Currency", "placeholderText": "Search currency", "saveCurrencyError": "We were unable to save the selected currency, please try again." }, "feeInformation": { "title": "", - "description": "Blitz Wallet offers free and open‑source products for the Bitcoin community. \n\nTo help keep the project sustainable, we’ve added a small transaction fee to each payment.\n\nHere’s how those fees are distributed:", + "description": "Blitz Wallet provides free and open-source tools for the Bitcoin community.\n\nTo support the long-term sustainability of the project, a small transaction fee will be applied to each payment.\n\nOnce fees are active, they will be displayed here.", "upTo": "Up To", "fixedFee": "Fixed Fee", "percent": "Percent", @@ -1266,9 +1360,9 @@ "regexError": "Name can only include letters or numbers.", "takenNameError": "Name already taken", "nameConfirmationMessage": "NIP-05 added successfully! Please note that it may take up to 24 hours to appear, as the list is updated once per day.", - "title": "Nip5 Verification", - "desc": "Nip5 turns your long Npub into a small email-like address, similar to a lightning address.", - "dataIsInvalid": "Something went wrong, please try again,", + "title": "NIP-05 Verification", + "desc": "NIP-05 turns your long Npub into a small, email-like address, similar to a Lightning address.", + "dataIsInvalid": "Something went wrong. Please try again.", "usernameInputLabel": "Username", "usernameInputPlaceholder": "Satoshi...", "publicKeyLabel": "Username", @@ -1277,7 +1371,7 @@ "invalidPubKey": "Invalid pubkey: must be a non-empty string", "invalidNpub": "Invalid npub format", "decodeNpubError": "Failed to decode npub", - "invlaidFormat": "Invalid pubkey format: must be either 64-character hex string or npub" + "invlaidFormat": "Invalid pubkey format: must be either a 64-character hex string or npub" } }, "nwc": { @@ -1309,30 +1403,38 @@ "wanringMessage": "To send money from your Nostr Connect wallet, you’ll first need to add funds—either by receiving money or transferring from your main wallet.\n\n You can transfer funds from your main wallet to NWC in the account settings page." }, "noNotifications": { - "wanringMessage": "In order to use Nostr Wallet Connect you need to have push notification for Nostr Wallet Connect enabled.\n\nPlease enable push notifications in the settings and try again." + "wanringMessage": "In order to use Nostr Wallet Connect you need to have push notifications for Nostr Wallet Connect enabled.\n\nPlease enable push notifications in the settings and try again." }, "showSeedPage": { - "loadingMessage": "Generating Mnemonic", - "wanringMessage": "To keep your wallet safe, Nostr Wallet Connect creates a separate seed phrase from your main wallet's seed.\n\nThis wallet uses the second derivation path and can always be recovered using your main wallet's seed.\n\nIf you're not tech-savvy and unsure about recovering the wallet address, please write down this seed phrase.", - "viewSeed": "View seed" + "loadingMessage": "Generating seed phrase", + "wanringMessage": "To keep your wallet safe, Nostr Wallet Connect creates a separate seed phrase from your main wallet's seed phrase.\n\nThis wallet uses the second derivation path and can always be recovered using your main wallet's seed phrase.\n\nIf you're not tech-savvy and unsure about recovering the wallet address, please write down this seed phrase.", + "viewSeed": "View seed phrase" } }, "accountComponents": { + "homepage": { + "addNewAccount": "Add", + "swap": "Transfer", + "viewSeed": "View seed phrase", + "editAccount": "Edit account", + "swapAccountError": "You need at least two accounts to make a transfer.", + "noAccountsFound": "No accounts match your search." + }, "accountPaymentPage": { - "noAmountError": "Please enter an amount to be swapped", - "noAccountError": "Please select an account for funds to be swapped from", - "noAccountToError": "Please select an account for funds to be swapped to", - "loadingFeeError": "Cannot start swap when fee is being calculated", + "noAmountError": "Please enter an amount to be transferred", + "noAccountError": "Please select an account for funds to be transferred from", + "noAccountToError": "Please select an account for funds to be transferred to", + "loadingFeeError": "Cannot start transfer when fee is being calculated", "alreadyStartedTransferError": "A transfer has already been started", "balanceError": "Sending amount is greater than your balance and fees", - "noSendAddressError": "Not able to get transfer address", - "noAccountInformation": "Not able to get account information", - "title": "Swap", + "noSendAddressError": "Unable to get transfer address", + "noAccountInformation": "Unable to get account information", + "title": "Transfer", "selectAccount": "Select Account", - "inputPlaceHolderText": "Account Swap" + "inputPlaceHolderText": "Account Transfer" }, "createAccountPage": { - "alreadyUsingSeedError": "This seed already exists for another account", + "alreadyUsingSeedError": "This seed phrase already exists for another account", "editTitle": "Edit Account", "createTitle": "Create Account", "updateTitle": "Update Account", @@ -1341,10 +1443,10 @@ "inputDesc": "Account Name", "nameTakenError": "This name is currently in use. Please use a differnt account name.", "nameInputPlaceholder": "Name...", - "seedHeader": "Account Seed" + "seedHeader": "Account seed phrase" }, "viewAccountPage": { - "informationMessage": "Are you sure you want to show this QR Code?\n\nScanning your seed is convenient, but be sure you're using a secure and trusted device. This helps keep your wallet safe." + "informationMessage": "Are you sure you want to show this QR Code?\n\nScanning your seed phrase is convenient, but be sure you're using a secure and trusted device. This helps keep your wallet safe." } }, "posPath": { @@ -1375,10 +1477,10 @@ "updateContactMessage": "The recipient needs to update their Blitz app to get paid.", "payingMessage": "Paying...", "notifyngMessage": "Notifiying...", - "errorPaying": "Unable to pay blitz contact. Try again later.", + "errorPaying": "Unable to pay Blitz contact. Try again later.", "invalidName": "Name is not an LNURL or a Blitz user.", "removeEmployeeWarning": "Are you sure you want to remove this employee?", - "startingPaymentProcessMessage": "'Begining payment process, please do not leave this page or the app.", + "startingPaymentProcessMessage": "Begining payment process, please do not leave this page or the app.", "goBack": "Go back", "lastSale": "Last sale:", "numTipsPaid": "Tips paid:", @@ -1390,6 +1492,7 @@ "unpaidtips": "Tips: {{number}}", "title": "Tips to pay", "empNamePlaceholder": "Employee name", + "noEmployees": "You currently don’t have any employees with saved tips. Once an employee uses the point-of-sale, their tips will appear here so you can pay them.", "noTips": "Employee has no tips" }, "items": { @@ -1438,21 +1541,28 @@ "shopTitle": "Shop with Bitcoin", "shopDescription": "Buy gift cards from thousands of different merchants around the world", "callToAction": "Anything you want here?", - "featuredApps": "Anything you want here?" + "featuredApps": "Anything you want here?", + "comingSoon": "This feature is temporarily unavailable. We're working to restore it as soon as possible." }, "confirmTxPage": { "failedToSend": "Failed to send", - "confirmMessage": "{{direction}} succesfully", + "confirmMessage": "{{direction}} successfully", "paymentErrorMessage": "There was an issue sending this payment, please try again.", "sendReport": "Send report to developer", - "lnurlAuthSuccess": "Wallet authentication successful! You’re now logged in." + "lnurlAuthSuccess": "Wallet authentication successful! You’re now logged in.", + "emailReport": "Send via Email", + "copyReport": "Copy to Clipboard", + "confirmMessage_sent": "Sent successfully", + "confirmMessage_received": "Received successfully" }, "expandedTxPage": { - "confirmMessage": "{{direction}} amount", + "confirmMessage_sent": "Sent amount", + "confirmMessage_received": "Received amount", "paymentStatus": "Payment status", "confReqired": "Confs required", - "detailsBTN": "Technical details" + "detailsBTN": "Technical details", + "contactPaymentType": "Contact" }, "explorePage": { "timeLeft": "({{time}} left)", @@ -1465,24 +1575,38 @@ "loadingText": "We're setting things up. Hang tight! This could take up to a minute.", "dbInitError": "Unable to open the database. Please try again.", "userSettingsError": "Unable to load user settings. Please try again.", - "liquidWalletError": "Wallet information was not set properly, please try again.", - "liquidWalletError2": "We were unable to set up your wallet. Please try again." + "liquidWalletError": "Wallet information was not set properly. Please try again.", + "liquidWalletError2": "We were unable to set up your wallet. Please try again.", + "dbInitError1": "We’re unable to set up your app.", + "dbInitError2": "Please try again. If this issue continues, you can safely recover your funds using your seed phrase." }, "receiveBtcPage": { - "onchainFeeMessage": "On-chain payments have a network fee and 0.1% Spark fee.\n\nIf you send money to yourself, you’ll pay the network fee twice — once to send it and once to claim it.\n\nIf someone else sends you money, you’ll only pay the network fee once to claim it.", - "liquidFeeMessage": "Liquid payments need to be swapped into Spark.\n\nThis process includes a lockup fee of about {{fee}}, a claim fee of around {{claimFee}}, and a 0.1% service fee from Boltz based on the amount you’re sending.", - "rootstockFeeMessage": "Rootstock payments need to be swapped into Spark.\n\nThis process includes a lockup fee of about {{fee}}, and a 0.1% service fee from Boltz based on the amount you’re sending.", - "generatingInvoice": "Generating Invoice" + "onchainFeeMessage": "Payments received to Bitcoin addresses have a network fee and a 0.1% Spark fee.\n\nIf you send money to yourself, you'll pay the network fee twice — once to send it and once to claim it.\n\nIf someone else sends you money, you'll only pay the network fee once to claim it.", + "liquidFeeMessage": "Payments received to Liquid addresses need to be transferred into Spark.\n\nThis process includes a lockup fee of about {{fee}}, a claim fee of around {{claimFee}}, and a 0.1% service fee from Boltz based on the amount you're sending.", + "rootstockFeeMessage": "Payments received to Rootstock addresses need to be transferred into Spark.\n\nThis process includes a lockup fee of about {{fee}}, and a 0.1% service fee from Boltz based on the amount you're sending.", + "generatingInvoice": "Generating Invoice", + "amountPlaceholder": "Any amount", + "invoiceDescription_lightningInvoice": "Lightning invoice", + "invoiceDescription_lightningAddress": "Lightning address", + "invoiceDescription_bitcoinAddress": "Bitcoin address", + "invoiceDescription_liquidAddress": "Liquid address", + "invoiceDescription_sparkAddress": "Spark address", + "invoiceDescription_rootstockAddress": "Rootstock address", + "header_lightning": "Bitcoin via Lightning", + "header_bitcoin": "Bitcoin", + "header_spark": "Bitcoin/Tokens via Spark", + "header_liquid": "Bitcoin via Liquid", + "header_rootstock": "Bitcoin via Rootstock" }, "settingsContent": { "about": "About", "language": "Language", - "display currency": "Display Currency", + "display currency": "Currency", "display options": "Display Options", "edit contact profile": "Edit Contact Profile", - "view all swaps": "Swaps", + "view all swaps": "Network Transfers", "fast pay": "Fast Pay", "blitz fee details": "Blitz Fee Details", "crash reports": "Crash Reports", @@ -1495,6 +1619,7 @@ "spark info": "Spark Info", "delete wallet": "Delete Wallet", "general": "General", + "preferences": "Preferences", "security": "Security", "technical settings": "Technical Settings", "experimental features": "Experimental Features", @@ -1504,7 +1629,7 @@ }, "technicalTransactionDetails": { "txHash": "Transaction Hash", - "paymentId": "Payment Id", + "paymentId": "Payment ID", "preimage": "Payment Preimage", "address": "Payment Address", "bitcoinTxId": "Bitcoin Txid", @@ -1528,6 +1653,120 @@ "maxSupply": "Max Supply", "tokenTicker": "Token Ticker", "tokenPubKey": "Token Public Key" + }, + "giftPages": { + "expired": "Expired", + "lessThanMin": "Less than a minute left", + "timeLeft": { + "days_one": "{{count}} day left", + "days_other": "{{count}} days left", + "hours_one": "{{count}} hour left", + "hours_other": "{{count}} hours left", + "minutes_one": "{{count}} minute left", + "minutes_other": "{{count}} minutes left" + }, + "shareMessage": "You've received a {{icon}}{{amount}} gift! Claim it here:\n{{link}}", + "fundGiftMessage": "Creating Gift", + "reclaimGiftMessage": "Reclaiming Gift", + + "giftsHome": { + "title": "Bitcoin Gifts" + }, + + "giftsOverview": { + "noGiftsHead": "No gifts yet", + "noGiftsDesc": "Create your first gift to share Bitcoin with friends and family", + "createGift": "Create Gift", + "claimed": "Claimed", + "expired": "Expired", + "unclaimed": "Unclaimed", + "reclaimed": "Reclaimed" + }, + + "createGift": { + "noAmountError": "Please enter an amount to create your gift.", + "connectError": "We couldn’t set up your gift wallet. Please try again.", + "addressError": "We couldn’t get the address for your gift wallet.", + "saveError": "We couldn’t save your gift. Please try again.", + "fundError": "We couldn’t add funds to your gift.", + "startProcess1": "Preparing your gift…", + "startProcess2": "Setting things up…", + "startProcess3": "Adding funds to your gift…", + "header": "Create Gift", + "inputPlaceholder": "Add a message or note...", + "button": "Create Gift", + "disclaimer": "When you create a gift, the money stays locked for 7 days. If no one claims it by then, you can go to the reclaim page to get it back." + }, + + "giftConfirmation": { + "header": "Gift Created!", + "whatToDo": "Share this gift with anyone using the QR code or link below", + "details": "Gift Details", + "expire": "Expires", + "giftLink": "Gift Link", + "shareGift": "Share Gift", + "createAnother": "Create Another", + "done": "Done" + }, + "claimHome": { + "header": "Enter gift link", + "desc": "Paste the gift link you received or scan the QR code to claim your gift", + "inputPlaceholder": "Enter gift link", + "claim": "Claim" + }, + "reclaimPage": { + "header": "Reclaim your gift", + "desc": "Enter or choose the gift ID to return an unclaimed, expired gift to your wallet.", + "inputPlaceholder": "Enter gift ID", + "dropdownPlaceHolder": "Select Expired Gift", + "button": "Reclaim Gift", + "noReclaimsMessage": "You have no expired gifts right now. Come back after a gift has expired to reclaim it.", + "advancedModeBTN": "Advanced Mode" + }, + "advancedMode": { + "header": "Advanced Mode", + "currentIndexHead": "Current Gift Index", + "infoHead": "How Bitcoin Gifts Work", + "infoDesc1": "Blitz gifts use separate wallets created from your main wallet. This lets you recover unclaimed funds while keeping your main wallet protected.", + "infoDesc2": "Most expired gifts can be reclaimed on the regular reclaim page. If a gift wasn’t fully received or the normal reclaim option doesn’t work, you can use this page to select a specific gift and check if any money is still left.", + "inputHead": "Enter Gift Number", + "invalidGiftNum": "Please enter a valid gift number between 1 and {{currentGiftIndex}}", + "restoreGiftBTN": "Restore Gift", + "advancedWarning": "This advanced feature should only be used when the standard reclaim process doesn't work. If you're unsure, try the normal reclaim page first." + }, + "claimPage": { + "parseError": "We couldn’t read the gift details from this link.", + "noGiftForReclaim": "We couldn’t find this gift. It may have already been claimed.", + "expiredOrClaimed": "This gift has already been claimed or has expired.", + "noGiftSeed": "We couldn’t reach the gift right now. Please try again later.", + "giftWalletInitError": "We were unable to reach the gift. Please try again later.", + "noBalanceErrorReclaim": "There are no funds left to reclaim from this gift.", + "notExpired": "You cannot reclaim this gift yet.", + "nobalanceError": "We couldn’t find an amount for this gift. Please try again later, or try restoring the gift.", + "nobalanceErrorExpert": "There’s no amount available to restore for this gift.", + "balanceMismatchError": "There was an issue getting the gift amount. Please try again.", + "paymentError": "We couldn’t claim the gift. Please try again.", + "claimHead": "Accept Gift", + "reclaimHead": "Refund Gift", + "claimLoading": "Claiming your gift… please stay in the app.", + "reclaimLoading": "Returning your funds… please stay in the app.", + "claimAmountHead": "Receving amount", + "reclaimAmountHead": "Amount Restored", + "reclaimAmountHeadExpert": "Gift Number Restored", + "networkFeeWarningExpert": "Any amount found, minus network fees, will be added to your balance", + "networkFeeWarning": "This amount, minus network fees, will be added to your balance", + "buttonTextClaiming": "Claiming", + "reclaimButton": "Reclaim Gift", + "claimButton": "Accept Gift", + "confirmMessage": "Your gift has been claimed, and you will receive your funds shortly", + "defaultDesc": "Blitz Gift", + "claimingGiftMessage1": "Preparing Gift...", + "giftBalanceMessage_0": "Just a moment while we get things ready...", + "giftBalanceMessage_1": "We’re gathering your gift information...", + "giftBalanceMessage_2": "Almost there—please keep the app open...", + "giftBalanceMessage_3": "Finishing up the last steps...", + "claimingGiftMessage4": "Claiming Gift..." + } } } }, @@ -1550,42 +1789,45 @@ "requestError": "There was an error with the request, please try again.", "invoiceRetrivalError": "There was an error retrieving the invoice, please try again.", "payingInvoiceError": "There was an error paying the invoice, please try again.", - "noMailAppsFoundError": " No mail apps found on your device. Please install a mail app to use this feature.", - "retriveingPhotoesError": " There was an error retrieving photos, please try again.", - "clipboardContentError": "No data in clipboard", + "noMailAppsFoundError": "No mail apps found on your device. Please install a mail app to use this feature.", + "retriveingPhotoesError": "There was an error retrieving photos, please try again.", + "clipboardContentError": "No data in the clipboard", "userDataFetchError": "Unable to get user from database.", - "updateContactMessageError": "There was a problem updating this message, please try again.", - "declinePaymentError": "There was a problem declining this payment, please try again.", - "payingContactError": "We were not able to get the contacts address, please try again.", - "noUserFoundDeeplinkError": "Not able to find any user for this username.", + "updateContactMessageError": "There was a problem updating this message. Please try again.", + "declinePaymentError": "There was a problem declining this payment. Please try again.", + "payingContactError": "We were unable to get the contact's address. Please try again.", + "noUserFoundDeeplinkError": "Unable to find any user for this username.", "cannotAddSelfError": "You cannot add yourself as a contact.", - "fullDeeplinkError": "Unable to retrive contact information, please try again.", + "fullDeeplinkError": "Unable to retrieve contact information. Please try again.", "processingDeepLinkError": "Failed to process link: {{error}}", - "legacyContactError": "Contact has not updated thier wallet yet. Please ask them to update their wallet to send this.", + "legacyContactError": "Contact has not updated their wallet yet. Please ask them to update their wallet to send this.", "contactInvoiceGenerationError": "Error generating invoice. Make sure this is a valid LNURL address.", - "explorePagenoDataError": "We were unable to retrive Blitz user count.", + "explorePagenoDataError": "We were unable to retrieve Blitz user count.", "savingFileError": "Error saving file to document", "savingFilePermissionsError": "Error getting permissions", "writtingFileError": "Error writing file to filesystem", "createTransactionsFileError": "Unable to create transaction file", - "noQrInScanError": "Not able to get find QRcode from image.", - "noDataInQRError": "Not able to get find data from image.", - "noInvoiceInImageError": "Not able to get invoice from image.", - "savingImageError": "Unable to save image, pleae try again.", - "invalidSeedError": "Did not enter a valid seed", - "invalidSeedWordLengthErorr": "Not every word is of valid length", - "invalidSeedLengthError": "Unable to find 12 words from copied recovery phrase.", + "noQrInScanError": "Unable to find QR code in image.", + "noDataInQRError": "Unable to find data in image.", + "noInvoiceInImageError": "Unable to get invoice from image.", + "savingImageError": "Unable to save image. Please try again.", + "invalidSeedError": "Did not enter a valid seed phrase", + "invalidSeedWordLengthErorr": "Not all words are of valid length", + "invalidSeedLengthError": "Unable to find 12 words from copied seed phrase.", "noGooglePlay": "Google Play Services are required to receive notifications.", - "noNotificationPermission": "Blitz doesn’t have notification permissions. Enable them in settings to use notifications.", + "noNotificationPermission": "Blitz doesn’t have notification permissions. Enable them in Settings to use notifications.", "rootstockInvoiceError": "There was a problem generating your Rootstock address. Please try again.", "liquidInvoiceError": "There was a problem generating your Liquid address. Please try again.", - "bitcoinInvioceError": "There was a problem generating your Bitcoin address. Please try again.", + "bitcoinInvoiceError": "There was a problem generating your Bitcoin address. Please try again.", "sparkInvioceError": "There was a problem generating your Spark address. Please try again.", - "lightningInvioceError": "There was a problem generating your Lightning address. Please try again.", + "lightningInvoiceError": "There was a problem generating your Lightning invoice. Please try again.", "sparkConnectionError": "Spark connection failed. Please try again.", - "giftCardExpiration": "Gift cards can only be recovered for 7 days after they are sent. If this app is deleted or local storage is cleared after that time, your gift cards will be lost. Be sure to save the claim links or gift card codes somewhere safe.", + "giftCardExpiration": "Gift cards can only be recovered for 7 days after they are sent. If this app is deleted or local storage is cleared after that time, your gift cards will be lost. Be sure to save the claim links or gift card codes in a safe place.", "receivedRootstock": "Rootstock payment received — your swap is starting. It may take up to a minute, so please keep the app open.", - "receivedLiquid": "Liquid payment received — your swap is starting. It may take up to a minute, so please keep the app open." + "receivedLiquid": "Liquid payment received — your swap is starting. It may take up to a minute, so please keep the app open.", + "invalidData": "The data you entered isn’t valid", + "paymentError": "Something went wrong while processing your payment. Please try again. If it still doesn’t work, close the app and reopen it before trying again.", + "paymentFeeError": "We couldn’t retrieve the payment fee. Please try again. If the issue continues, close the app and reopen it before trying again." }, "loadingScreen": { @@ -1599,9 +1841,12 @@ "depositLabel": "Deposit address payment" } }, + "share": { + "contact": "Hi, add me on Blitz!" + }, "swapMessages": { - "liquid": "Liquid to Spark Swap" + "liquid": "Liquid to Spark Transfer" }, "tabs": { "home": "Wallet", @@ -1609,6 +1854,10 @@ "contacts": "Contacts" }, "pushNotifications": { + "paymentReceived": { + "title": "Payment received", + "body": "You received {{totalAmount}}" + }, "LNURL": { "zapped": "You were zapped {{totalAmount}}", "regular": "You just received {{totalAmount}}" diff --git a/package-lock.json b/package-lock.json index 6ab38d8..dc53f6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "events": "^3.3.0", "firebase": "^11.9.0", "framer-motion": "^12.10.0", + "i18next": "^25.7.3", "idb": "^8.0.3", "intl-pluralrules": "^2.0.1", "js-sha256": "^0.11.1", @@ -1591,9 +1592,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", - "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -8143,9 +8144,9 @@ "license": "MIT" }, "node_modules/i18next": { - "version": "25.3.0", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.0.tgz", - "integrity": "sha512-ZSQIiNGfqSG6yoLHaCvrkPp16UejHI8PCDxFYaNG/1qxtmqNmqEg4JlWKlxkrUmrin2sEjsy+Mjy1TRozBhOgw==", + "version": "25.7.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.3.tgz", + "integrity": "sha512-2XaT+HpYGuc2uTExq9TVRhLsso+Dxym6PWaKpn36wfBmTI779OQ7iP/XaZHzrnGyzU4SHpFrTYLKfVyBfAhVNA==", "funding": [ { "type": "individual", @@ -8161,9 +8162,8 @@ } ], "license": "MIT", - "peer": true, "dependencies": { - "@babel/runtime": "^7.27.6" + "@babel/runtime": "^7.28.4" }, "peerDependencies": { "typescript": "^5" diff --git a/package.json b/package.json index 61da2e2..9e9dc08 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "events": "^3.3.0", "firebase": "^11.9.0", "framer-motion": "^12.10.0", + "i18next": "^25.7.3", "idb": "^8.0.3", "intl-pluralrules": "^2.0.1", "js-sha256": "^0.11.1", diff --git a/src/components/customInput/style.css b/src/components/customInput/style.css index c9ca5b9..c41db47 100644 --- a/src/components/customInput/style.css +++ b/src/components/customInput/style.css @@ -6,7 +6,7 @@ } .custom-description-input-container .description-input { width: 100%; - padding: 15px; + padding: 10px; border: unset; border-radius: 8px; } diff --git a/src/components/customSettingsNavbar/style.css b/src/components/customSettingsNavbar/style.css index c1f5098..aad79d7 100644 --- a/src/components/customSettingsNavbar/style.css +++ b/src/components/customSettingsNavbar/style.css @@ -11,7 +11,7 @@ z-index: 1; } .pageNavBar .pageHeaderText { - font-size: 1.5rem; + font-size: 1.25rem; text-align: center; white-space: nowrap; /* Prevent line break */ overflow: hidden; /* Hide overflow */ diff --git a/src/components/navBar/profileImage.css b/src/components/navBar/profileImage.css new file mode 100644 index 0000000..374259c --- /dev/null +++ b/src/components/navBar/profileImage.css @@ -0,0 +1,12 @@ +.navbarProfileImageContainer { + width: 35px; + height: 35px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + position: relative; + z-index: 99; + overflow: hidden; +} diff --git a/src/components/navBar/profileImage.jsx b/src/components/navBar/profileImage.jsx new file mode 100644 index 0000000..054d82c --- /dev/null +++ b/src/components/navBar/profileImage.jsx @@ -0,0 +1,32 @@ +import { useNavigate } from "react-router-dom"; +import useThemeColors from "../../hooks/useThemeColors"; + +import { useImageCache } from "../../contexts/imageCacheContext"; +import { useGlobalContextProvider } from "../../contexts/masterInfoObject"; +import { useThemeContext } from "../../contexts/themeContext"; +import ContactProfileImage from "../../pages/contacts/components/profileImage/profileImage"; +import "./profileImage.css"; + +export default function NavBarProfileImage() { + const { backgroundOffset } = useThemeColors(); + const { cache } = useImageCache(); + const { masterInfoObject } = useGlobalContextProvider(); + const { theme, darkModeType } = useThemeContext(); + const navigate = useNavigate(); + return ( +
{ + navigate("/settings"); + }} + > + +
+ ); +} diff --git a/src/components/sendAndRequsetButton/customSendAndRequestBTN.css b/src/components/sendAndRequsetButton/customSendAndRequestBTN.css new file mode 100644 index 0000000..fa86b7c --- /dev/null +++ b/src/components/sendAndRequsetButton/customSendAndRequestBTN.css @@ -0,0 +1,17 @@ +/* Send and Request Button */ +.send-request-button { + background: none; + border: none; + padding: 0; + cursor: pointer; +} + +/* Scan QR Icon Container */ +.scan-qr-icon { + width: 70px; + height: 70px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 35px; +} diff --git a/src/components/sendAndRequsetButton/customSendAndRequsetButton.jsx b/src/components/sendAndRequsetButton/customSendAndRequsetButton.jsx new file mode 100644 index 0000000..92a560b --- /dev/null +++ b/src/components/sendAndRequsetButton/customSendAndRequsetButton.jsx @@ -0,0 +1,54 @@ +import { ArrowDown, ArrowUp } from "lucide-react"; +import "./customSendAndRequestBTN.css"; + +import { useThemeContext } from "../../contexts/themeContext"; +import { Colors } from "../../constants/theme"; + +export default function CustomSendAndRequsetBTN({ + btnType, + btnFunction, + arrowColor, + containerBackgroundColor, + height = 40, + width = 40, + containerStyles, + activeOpacity = 0.2, +}) { + const { theme, darkModeType } = useThemeContext(); + return ( + + ); +} diff --git a/src/constants/index.js b/src/constants/index.js index 8409eb7..a795dfb 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -1,4 +1,5 @@ import { SATSPERBITCOIN } from "./math"; +import { HIDDEN_OPACITY, INSET_WINDOW_WIDTH, WINDOWWIDTH } from "./theme"; const WEBSITE_REGEX = /^(https?:\/\/|www\.)[a-z\d]([a-z\d-]*[a-z\d])*(\.[a-z]{2,})+/i; @@ -7,6 +8,9 @@ const hasSpace = /\s/; const VALID_URL_REGEX = /^(https?:\/\/)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(\/[^\s]*)?$/; +const IS_BLITZ_URL_REGEX = + /^(https?:\/\/)?(www\.)?(blitz-wallet\.com|blitzwalletapp\.com|blitzwallet\.app)(\/[^\s]*)?$/; + const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; const VALID_USERNAME_REGEX = /^(?=.*\p{L})[\p{L}\p{N}_]+$/u; @@ -18,6 +22,12 @@ const IS_SPARK_REQUEST_ID = /^SparkLightning(?:Receive|Send)Request:[0-9a-fA-F\-]+$/; const IS_BITCOIN_REQUEST_ID = /^SparkCoopExitRequest:[0-9a-fA-F\-]+$/; +const GIFT_DEEPLINK_REGEX = + /^(?:blitz-wallet:\/\/gift\/|https:\/\/(?:blitz-wallet\.com|blitzwalletapp\.com|blitzwallet\.app)\/gift\/)[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}(#[A-Za-z0-9_-]+)?$/; + +const CONTACT_UNIVERSAL_LINK_REGEX = + /^https?:\/\/(blitzwalletapp\.com|blitzwallet\.app|blitz-wallet\.com)\/u\/[A-Za-z0-9_-]+\/?$/; + const NOSTR_NAME_REGEX = /^[a-zA-Z0-9]+$/; const NOSTR_RELAY_URL = "wss://relay.getalbypro.com/blitz"; @@ -26,7 +36,7 @@ const WHITE_FILTER = const IS_LETTER_REGEX = /^[A-Za-z]$/; const BITCOIN_SATS_ICON = "\u20BF"; -const HIDDEN_BALANCE_TEXT = `* * * * *`; +const HIDDEN_BALANCE_TEXT = `E`; const BITCOIN_SAT_TEXT = "SAT"; const INFINITY_SYMBOL = "\u221E"; @@ -50,12 +60,31 @@ const BIOMETRIC_KEY = "biometricEncryptionKey"; const LOGIN_SECURITY_MODE_TYPE_KEY = "LOGIN_SECURITY_MODE_TYPE"; const SPARK_CACHED_BALANCE_KEY = "CACHED_SPARK_BALANCE"; const NWC_IDENTITY_PUB_KEY = "NWC_WALLET_PUB_KEY"; +const GENERATED_BITCOIN_ADDRESSES = "GENERATED_BITCOIN_ADDRESSES"; +const SHOPS_DIRECTORY_KEY = "SHOPS_CURRENCY_LOCATION"; +const RANDOM_LOGIN_KEYBOARD_LAYOUT_KEY = "RANDOM_KEYBOARD_LAYOUT_KEY"; +const PERSISTED_LOGIN_COUNT_KEY = "PERSISTED_LOGIN_COUNT_KEY"; +const GIFT_DERIVE_PATH_CUTOFF = 1763650239108; const BLITZ_SUPPORT_DEFAULT_PAYMENT_DESCRIPTION = "Blitz support"; const CUSTODY_ACCOUNTS_STORAGE_KEY = "CUSTODY_ACCOUNTS"; +const BLITZ_PAYMENT_DEEP_LINK_SCHEMES = [ + "bitcoin", + "BITCOIN", + "lightning", + "LIGHTNING", + "lnurl", + "LNURL", + "lnurlp", + "LNURLP", + "lnurlw", + "LNURLW", + "keyauth", +]; const CHATGPT_INPUT_COST = 10 / 1000000; const CHATGPT_OUTPUT_COST = 30 / 1000000; +const STARTING_INDEX_FOR_GIFTS_DERIVE = 1000; const MIN_CHANNEL_OPEN_FEE = 500_000; const MAX_CHANNEL_OPEN_FEE = 1_000_000; @@ -70,11 +99,15 @@ const LIQUID_NON_BITCOIN_DRAIN_LIMIT = 10; const CONTENT_KEYBOARD_OFFSET = 10; const BLITZ_GOAL_USER_COUNT = 1_000_000; + const IS_DONTATION_PAYMENT_BUFFER = 10_000; const SKELETON_ANIMATION_SPEED = 1200; const TOKEN_TICKER_MAX_LENGTH = 10; -const INSET_WINDOW_WIDTH = "95%"; +const BACKGROUND_THRESHOLD_MS = 2 * 60 * 1000; +const FORCE_RESET_SPAARK_STATE_MS = 6 * 60 * 1000; + +const DEFAULT_PAYMENT_EXPIRY_SEC = 60 * 60 * 12; export { // COLORS, @@ -118,27 +151,43 @@ export { CUSTODY_ACCOUNTS_STORAGE_KEY, BLITZ_SUPPORT_DEFAULT_PAYMENT_DESCRIPTION, IS_DONTATION_PAYMENT_BUFFER, - THEME_LOCAL_STORAGE_KEY, + LAST_LOADED_BLITZ_LOCAL_STOREAGE_KEY, BLITZ_PROFILE_IMG_STORAGE_REF, + SKELETON_ANIMATION_SPEED, BIOMETRIC_KEY, - BITCOIN_SAT_TEXT, IS_LETTER_REGEX, - INSET_WINDOW_WIDTH, - THEME_DARK_MODE_KEY, - IS_SPARK_ID, - IS_BITCOIN_REQUEST_ID, + BITCOIN_SAT_TEXT, + LOGIN_SECURITY_MODE_TYPE_KEY, IS_SPARK_REQUEST_ID, NOSTR_NAME_REGEX, - LAST_LOADED_BLITZ_LOCAL_STOREAGE_KEY, SPARK_CACHED_BALANCE_KEY, - SKELETON_ANIMATION_SPEED, TOKEN_TICKER_MAX_LENGTH, SMALLEST_ONCHAIN_SPARK_SEND_AMOUNT, + IS_BITCOIN_REQUEST_ID, NWC_SECURE_STORE_KEY, NWC_SECURE_STORE_MNEMOINC, NWC_LOACAL_STORE_KEY, - LOGIN_SECURITY_MODE_TYPE_KEY, + IS_SPARK_ID, + NOSTR_RELAY_URL, NWC_IDENTITY_PUB_KEY, - WHITE_FILTER, + SHOPS_DIRECTORY_KEY, INFINITY_SYMBOL, + GENERATED_BITCOIN_ADDRESSES, + RANDOM_LOGIN_KEYBOARD_LAYOUT_KEY, + PERSISTED_LOGIN_COUNT_KEY, + BLITZ_PAYMENT_DEEP_LINK_SCHEMES, + IS_BLITZ_URL_REGEX, + STARTING_INDEX_FOR_GIFTS_DERIVE, + GIFT_DEEPLINK_REGEX, + GIFT_DERIVE_PATH_CUTOFF, + CONTACT_UNIVERSAL_LINK_REGEX, + BACKGROUND_THRESHOLD_MS, + FORCE_RESET_SPAARK_STATE_MS, + DEFAULT_PAYMENT_EXPIRY_SEC, + WHITE_FILTER, + THEME_LOCAL_STORAGE_KEY, + THEME_DARK_MODE_KEY, + WINDOWWIDTH, + HIDDEN_OPACITY, + INSET_WINDOW_WIDTH, }; diff --git a/src/constants/theme.js b/src/constants/theme.js index e73d362..fb0a804 100644 --- a/src/constants/theme.js +++ b/src/constants/theme.js @@ -59,3 +59,7 @@ export const Colors = { expandedTxReceitBackground: "#1B1B1B", }, }; +export const WINDOWWIDTH = "95%"; +export const INSET_WINDOW_WIDTH = "90%"; +export const MAX_CONTENT_WIDTH = 600; +export const HIDDEN_OPACITY = 0.5; diff --git a/src/contexts/globalContacts.jsx b/src/contexts/globalContacts.jsx index 1123648..6782878 100644 --- a/src/contexts/globalContacts.jsx +++ b/src/contexts/globalContacts.jsx @@ -19,14 +19,17 @@ import { import { CONTACTS_TRANSACTION_UPDATE_NAME, contactsSQLEventEmitter, + deleteCachedMessages, getCachedMessages, queueSetCashedMessages, } from "../functions/messaging/cachedMessages"; import { db } from "../../db/initializeFirebase"; import { useKeysContext } from "./keysContext"; import { + and, collection, onSnapshot, + or, orderBy, query, startAfter, @@ -45,39 +48,39 @@ export const GlobalContactsList = ({ children }) => { const [decodedAddedContacts, setDecodedAddedContacts] = useState([]); const didTryToUpdate = useRef(false); - const lookForNewMessages = useRef(false); + const lookForNewMessages = useRef(true); const unsubscribeMessagesRef = useRef(null); const unsubscribeSentMessagesRef = useRef(null); const pendingWrite = useRef(null); + const globalContactsInformationRef = useRef(globalContactsInformation); + const decodedAddedContactsRef = useRef([]); const addedContacts = globalContactsInformation.addedContacts; - const toggleGlobalContactsInformation = useCallback((newData, writeToDB) => { - console.log("WRITING TO DATABASE TWICE (should only see once)"); - - setGlobalContactsInformation((prev) => { - const newContacts = { ...prev, ...newData }; - - if (writeToDB) { - // Store the data we want to write outside the updater - pendingWrite.current = newContacts; - } + const toggleGlobalContactsInformation = useCallback( + (newData, writeToDB) => { + setGlobalContactsInformation((prev) => { + const newContacts = { ...prev, ...newData }; + if (writeToDB) { + addDataToCollection( + { contacts: newContacts }, + "blitzWalletUsers", + publicKey + ); + } + return newContacts; + }); + }, + [publicKey] + ); - return newContacts; - }); - }, []); + useEffect(() => { + globalContactsInformationRef.current = globalContactsInformation; + }, [globalContactsInformation]); useEffect(() => { - if (pendingWrite.current) { - console.log("RUNNING IN WRITE TO DB (should only see once)"); - addDataToCollection( - { contacts: pendingWrite.current }, - "blitzWalletUsers", - publicKey - ); - pendingWrite.current = null; - } - }, [globalContactsInformation, publicKey]); + decodedAddedContactsRef.current = decodedAddedContacts; + }, [decodedAddedContacts]); useEffect(() => { if (!publicKey || !addedContacts) return; @@ -114,7 +117,7 @@ export const GlobalContactsList = ({ children }) => { .filter((key) => key !== "lastMessageTimestamp") .filter( (contact) => - !decodedAddedContacts.find( + !decodedAddedContactsRef.current.find( (contactElement) => contactElement.uuid === contact ) && contact !== globalContactsInformation.myProfile.uuid ) @@ -142,16 +145,16 @@ export const GlobalContactsList = ({ children }) => { toggleGlobalContactsInformation( { myProfile: { ...globalContactsInformation.myProfile }, - addedContacts: encryptMessage( + addedContacts: await encryptMessage( contactsPrivateKey, globalContactsInformation.myProfile.uuid, - JSON.stringify(decodedAddedContacts.concat(newContats)) + JSON.stringify(decodedAddedContactsRef.current.concat(newContats)) ), }, true ); } - }, [globalContactsInformation, decodedAddedContacts, contactsPrivateKey]); + }, [globalContactsInformation, contactsPrivateKey]); useEffect(() => { async function handleUpdate(updateType) { @@ -174,92 +177,447 @@ export const GlobalContactsList = ({ children }) => { }; }, [updatedCachedMessagesStateFunction]); + const updateContactUniqueName = useCallback( + async (newUniqueNames) => { + try { + if (newUniqueNames.size === 0) { + return; + } + let newValue = null; + try { + // Validate prerequisites + if (!contactsPrivateKey || !publicKey) { + console.warn("Missing required data for contact update"); + return; + } + + let currentContacts; + try { + const decryptedData = await decryptMessage( + contactsPrivateKey, + publicKey, + globalContactsInformationRef.current.addedContacts + ); + + if (!decryptedData) { + console.warn("Decryption returned empty data"); + return; + } + + currentContacts = JSON.parse(decryptedData); + + // Validate parsed data + if (!Array.isArray(currentContacts)) { + console.warn("Decrypted contacts is not an array"); + return; + } + } catch (decryptError) { + console.error( + "Failed to decode contacts for update:", + decryptError + ); + return; + } + + let hasChanges = false; + const updatedContacts = currentContacts.map((contact) => { + try { + const newUniqueName = newUniqueNames.get(contact.uuid); + + if ( + newUniqueName && + typeof newUniqueName === "string" && + newUniqueName.trim() !== "" && + newUniqueName !== contact.uniqueName + ) { + hasChanges = true; + return { + ...contact, + uniqueName: newUniqueName, + }; + } + + return contact; + } catch (mapError) { + console.error("Error processing contact:", mapError); + return contact; + } + }); + + if (!hasChanges) { + return; + } + + try { + const newEncryptedContacts = await encryptMessage( + contactsPrivateKey, + publicKey, + JSON.stringify(updatedContacts) + ); + + if (!newEncryptedContacts) { + console.error("Encryption failed, aborting update"); + return; + } + + addDataToCollection( + { + contacts: { + ...globalContactsInformationRef.current, + addedContacts: newEncryptedContacts, + }, + }, + "blitzWalletUsers", + publicKey + ).catch((dbError) => { + console.error("Failed to save contacts to database:", dbError); + }); + + newValue = { + ...globalContactsInformationRef.current, + addedContacts: newEncryptedContacts, + }; + } catch (encryptError) { + console.error("Failed to encrypt updated contacts:", encryptError); + return; + } + } catch (stateError) { + console.error("Error in state update function:", stateError); + return; + } + + if (newValue) { + setGlobalContactsInformation(newValue); + } + } catch (outerError) { + console.error("Critical error in updateContactUniqueName:", outerError); + } + }, + [contactsPrivateKey, publicKey] + ); + useEffect(() => { - return; if (!Object.keys(globalContactsInformation).length) return; const now = new Date().getTime(); // Unsubscribe from previous listeners before setting new ones if (unsubscribeMessagesRef.current) { unsubscribeMessagesRef.current(); } - if (unsubscribeSentMessagesRef.current) { - unsubscribeSentMessagesRef.current(); - } - const inboundMessageQuery = query( - collection(db, "contactMessages"), - where("toPubKey", "==", globalContactsInformation.myProfile.uuid), - orderBy("timestamp"), - startAfter(now) - ); - const outbounddMessageQuery = query( + + const combinedMessageQuery = query( collection(db, "contactMessages"), - where("fromPubKey", "==", globalContactsInformation.myProfile.uuid), - orderBy("timestamp"), - startAfter(now) + and( + where("timestamp", ">", now), + or( + where("toPubKey", "==", globalContactsInformation.myProfile.uuid), + where("fromPubKey", "==", globalContactsInformation.myProfile.uuid) + ) + ), + orderBy("timestamp") ); + // Set up the realtime listener unsubscribeMessagesRef.current = onSnapshot( - inboundMessageQuery, + combinedMessageQuery, (snapshot) => { if (!snapshot?.docChanges()?.length) return; - snapshot.docChanges().forEach((change) => { + let newMessages = []; + let newUniqueIds = new Map(); + snapshot.docChanges().forEach(async (change) => { console.log("received a new message", change.type); if (change.type === "added") { const newMessage = change.doc.data(); - queueSetCashedMessages({ - newMessagesList: [newMessage], - myPubKey: globalContactsInformation.myProfile.uuid, - }); - } - }); - } - ); - unsubscribeSentMessagesRef.current = onSnapshot( - outbounddMessageQuery, - (snapshot) => { - if (!snapshot?.docChanges()?.length) return; - snapshot.docChanges().forEach((change) => { - console.log("sent a new message", change.type); - if (change.type === "added") { - const newMessage = change.doc.data(); - queueSetCashedMessages({ - newMessagesList: [newMessage], - myPubKey: globalContactsInformation.myProfile.uuid, - }); + + const isReceived = + newMessage.toPubKey === globalContactsInformation.myProfile.uuid; + console.log( + `${isReceived ? "received" : "sent"} a new message`, + newMessage + ); + + if (typeof newMessage.message === "string") { + const sendersPubkey = + newMessage.toPubKey === globalContactsInformation.myProfile.uuid + ? newMessage.fromPubKey + : newMessage.toPubKey; + const decoded = await decryptMessage( + contactsPrivateKey, + sendersPubkey, + newMessage.message + ); + + if (!decoded) return; + let parsedMessage; + try { + parsedMessage = JSON.parse(decoded); + } catch (err) { + console.log("error parsing decoded message", err); + return; + } + + if (parsedMessage?.senderProfileSnapshot && isReceived) { + newUniqueIds.set( + sendersPubkey, + parsedMessage.senderProfileSnapshot?.uniqueName + ); + } + + newMessages.push({ + ...newMessage, + message: parsedMessage, + sendersPubkey, + isReceived, + }); + } else newMessages.push(newMessage); } }); + updateContactUniqueName(newUniqueIds); + if (newMessages.length > 0) { + queueSetCashedMessages({ + newMessagesList: newMessages, + myPubKey: globalContactsInformation.myProfile.uuid, + }); + } } ); + return () => { if (unsubscribeMessagesRef.current) { unsubscribeMessagesRef.current(); } - if (unsubscribeSentMessagesRef.current) { - unsubscribeSentMessagesRef.current(); - } }; - }, [globalContactsInformation?.myProfile?.uuid]); + }, [globalContactsInformation?.myProfile?.uuid, contactsPrivateKey]); + + const addContact = useCallback( + async (contact) => { + try { + const newContact = { + name: contact.name || "", + nameLower: contact.nameLower || "", + bio: contact.bio, + unlookedTransactions: 0, + isLNURL: contact.isLNURL, + uniqueName: contact.uniqueName || "", + uuid: contact.uuid, + isAdded: true, + isFavorite: false, + profileImage: contact.profileImage, + receiveAddress: contact.receiveAddress, + transactions: [], + }; + + let newAddedContacts = JSON.parse(JSON.stringify(decodedAddedContacts)); + + const isContactInAddedContacts = newAddedContacts.filter( + (addedContact) => addedContact.uuid === newContact.uuid + ).length; + + if (isContactInAddedContacts) { + newAddedContacts = newAddedContacts.map((addedContact) => { + if (addedContact.uuid === newContact.uuid) { + return { + ...addedContact, + name: newContact.name, + nameLower: newContact.nameLower, + bio: newContact.bio, + unlookedTransactions: 0, + isAdded: true, + }; + } else return addedContact; + }); + } else newAddedContacts.push(newContact); + + toggleGlobalContactsInformation( + { + myProfile: { + ...globalContactsInformation.myProfile, + didEditProfile: true, + }, + addedContacts: await encryptMessage( + contactsPrivateKey, + publicKey, + JSON.stringify(newAddedContacts) + ), + }, + true + ); + } catch (err) { + console.log("Error adding contact", err); + } + }, + [ + decodedAddedContacts, + contactsPrivateKey, + publicKey, + globalContactsInformation, + ] + ); + + const deleteContact = useCallback( + async (contact) => { + try { + const newAddedContacts = decodedAddedContacts + .map((savedContacts) => { + if (savedContacts.uuid === contact.uuid) { + return null; + } else return savedContacts; + }) + .filter((contact) => contact); + + await deleteCachedMessages(contact.uuid); + + toggleGlobalContactsInformation( + { + addedContacts: await encryptMessage( + contactsPrivateKey, + publicKey, + JSON.stringify(newAddedContacts) + ), + myProfile: { ...globalContactsInformation.myProfile }, + }, + true + ); + } catch (err) { + console.log("Error deleating contact", err); + } + }, + [ + decodedAddedContacts, + contactsPrivateKey, + publicKey, + globalContactsInformation, + ] + ); useEffect(() => { if (!Object.keys(globalContactsInformation).length) return; - if (lookForNewMessages.current) return; - lookForNewMessages.current = true; - // syncDatabasePayment( - // globalContactsInformation.myProfile.uuid, - // updatedCachedMessagesStateFunction - // ); - }, [globalContactsInformation, updatedCachedMessagesStateFunction]); + if (!contactsPrivateKey) return; + if (!addedContacts) return; + + if (lookForNewMessages.current) { + lookForNewMessages.current = false; + async function handleOfflineMessageSync() { + console.log("RUNNING SYNC DATABAE"); + const restoredPayments = await syncDatabasePayment( + globalContactsInformation.myProfile.uuid, + contactsPrivateKey + ); + + if (restoredPayments.length === 0) { + updatedCachedMessagesStateFunction(); + } + + const contactDataMap = new Map( + restoredPayments + .filter( + (item) => + item.isReceived && + item?.message?.senderProfileSnapshot?.uniqueName + ) + .map((item) => [ + item.sendersPubkey, + item.message.senderProfileSnapshot.uniqueName, + ]) + ); + updateContactUniqueName(contactDataMap); + + queueSetCashedMessages({ + newMessagesList: restoredPayments, + myPubKey: globalContactsInformation.myProfile.uuid, + }); + } + handleOfflineMessageSync(); + } + }, [ + globalContactsInformation, + updatedCachedMessagesStateFunction, + contactsPrivateKey, + decodedAddedContacts, + addedContacts, + ]); + + const giftCardsList = useMemo(() => { + if (!contactsMessags) return []; + + const actualContacts = Object.keys(contactsMessags); + const lastMessageTimestampIndex = actualContacts.indexOf( + "lastMessageTimestamp" + ); + + // Remove lastMessageTimestamp efficiently + if (lastMessageTimestampIndex > -1) { + actualContacts.splice(lastMessageTimestampIndex, 1); + } + + if (actualContacts.length === 0) return []; + + const giftCards = []; + + // Process contacts efficiently + for (const contact of actualContacts) { + const contactData = contactsMessags[contact]; + if (!contactData?.messages?.length) continue; + + // Use for loop for better performance than filter + push + const messages = contactData.messages; + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + if (message.message?.giftCardInfo && !message.message.didSend) { + giftCards.push(message); + } + } + } + + // Sort in-place for memory efficiency + giftCards.sort((a, b) => { + const timeA = a.serverTimestamp || a.timestamp; + const timeB = b.serverTimestamp || b.timestamp; + return timeB - timeA; + }); + + return giftCards; + }, [contactsMessags]); + + const hasUnlookedTransactions = useMemo(() => { + return Object.keys(contactsMessags).some((contactUUID) => { + if ( + contactUUID === "lastMessageTimestamp" || + contactUUID === globalContactsInformation?.myProfile?.uuid + ) { + return false; + } + const messages = contactsMessags[contactUUID]?.messages; + return messages?.some((message) => !message.message.wasSeen) || false; + }); + }, [contactsMessags, globalContactsInformation?.myProfile?.uuid]); + + const contextValue = useMemo( + () => ({ + decodedAddedContacts, + globalContactsInformation, + toggleGlobalContactsInformation, + contactsMessags, + updatedCachedMessagesStateFunction, + giftCardsList, + hasUnlookedTransactions, + deleteContact, + addContact, + }), + [ + decodedAddedContacts, + globalContactsInformation, + toggleGlobalContactsInformation, + contactsMessags, + updatedCachedMessagesStateFunction, + giftCardsList, + hasUnlookedTransactions, + deleteContact, + addContact, + ] + ); return ( - + {children} ); diff --git a/src/contexts/imageCacheContext.jsx b/src/contexts/imageCacheContext.jsx index 2dceae2..ae68ae6 100644 --- a/src/contexts/imageCacheContext.jsx +++ b/src/contexts/imageCacheContext.jsx @@ -4,129 +4,231 @@ import React, { useState, useEffect, useRef, + useCallback, + useMemo, } from "react"; -import { getStorage } from "firebase/storage"; +import { getDownloadURL, getMetadata, getStorage, ref } from "firebase/storage"; import { useGlobalContacts } from "./globalContacts"; import { useAppStatus } from "./appStatus"; import { BLITZ_PROFILE_IMG_STORAGE_REF } from "../constants"; -import { ImageCacheDB } from "../functions/contacts/imageCacheDb"; +import { + clearAllCachedImages, + deleteCachedImage, + downloadAndCacheImage, + getAllImageKeys, + getCachedImage, +} from "../functions/images/storage"; +import { useGlobalContextProvider } from "./masterInfoObject"; const ImageCacheContext = createContext(); export function ImageCacheProvider({ children }) { const [cache, setCache] = useState({}); + const { masterInfoObject } = useGlobalContextProvider(); const { didGetToHomepage } = useAppStatus(); const { decodedAddedContacts } = useGlobalContacts(); const didRunContextCacheCheck = useRef(null); + const cachedImagesRef = useRef(cache); - useEffect(() => { - (async () => { - try { - const keys = await ImageCacheDB.getAllKeys(); - const initialCache = {}; - - for (const key of keys) { - const value = await ImageCacheDB.getItem(key); - if (value && value.blob) { - const uuid = key.replace(BLITZ_PROFILE_IMG_STORAGE_REF + "/", ""); - const blobUrl = URL.createObjectURL(value.blob); - initialCache[uuid] = { ...value, uri: blobUrl }; - } - } + const inFlightRequests = useRef(new Map()); - setCache(initialCache); - } catch (e) { - console.error("Error loading image cache from IndexedDB", e); + const refreshCacheObject = useCallback(async () => { + try { + const keys = await getAllImageKeys(); + + const initialCache = {}; + + for (const key of keys) { + const value = await getCachedImage(key); + if (value) { + const uuid = key.replace(BLITZ_PROFILE_IMG_STORAGE_REF + "/", ""); + initialCache[uuid] = { ...value, localUri: value.uri }; + } } - })(); + + setCache(initialCache); + } catch (e) { + console.error("Error loading image cache from storage", e); + } }, []); useEffect(() => { - return; - if (!didGetToHomepage) return; - if (didRunContextCacheCheck.current) return; - didRunContextCacheCheck.current = true; - console.log(decodedAddedContacts, "DECIN FUNC"); - async function refreshContactsImages() { - for (let index = 0; index < decodedAddedContacts.length; index++) { - const element = decodedAddedContacts[index]; - await refreshCache(element.uuid); + cachedImagesRef.current = cache; + }, [cache]); + + useEffect(() => { + refreshCacheObject(); + }, [decodedAddedContacts, refreshCacheObject]); //rerun the cache when adding or removing contacts + + const refreshCache = useCallback( + async (uuid, hasdownloadURL, skipCacheUpdate = false) => { + if (inFlightRequests.current.has(uuid)) { + console.log("Request already in flight for", uuid); + return inFlightRequests.current.get(uuid); } - } - refreshContactsImages(); - }, [decodedAddedContacts, didGetToHomepage]); + const requestPromise = (async () => { + try { + console.log("Refreshing image for", uuid); + const key = `${BLITZ_PROFILE_IMG_STORAGE_REF}/${uuid}`; + let url; + let metadata; + let updated; + + if (!hasdownloadURL) { + const storageInstance = getStorage(); + + const reference = ref( + storageInstance, + `${BLITZ_PROFILE_IMG_STORAGE_REF}/${uuid}.jpg` + ); + + metadata = await getMetadata(reference); + updated = metadata.updated; + + const cached = cache[uuid]; + if (cached && cached.updated === updated) { + const cachedImage = await getCachedImage(key); + if (cachedImage) { + return { + uri: cachedImage.uri, + localUri: cachedImage.uri, + updated: cachedMetadata.updated, + isObjectURL: cachedImage.isObjectURL, + }; + } + } + + url = await getDownloadURL(reference); + } else { + url = hasdownloadURL; + updated = new Date().toISOString(); + } - async function refreshCache(uuid, hasDownloadURL) { - try { - const key = `${BLITZ_PROFILE_IMG_STORAGE_REF}/${uuid}`; - let url; - let metadata; - let updated; - - if (!hasDownloadURL) { - const reference = getStorage().ref( - `${BLITZ_PROFILE_IMG_STORAGE_REF}/${uuid}.jpg` - ); - metadata = await reference.getMetadata(); - updated = metadata.updated; - - const cached = await ImageCacheDB.getItem(key); - if (cached && cached.updated === updated) { - const blobUrl = URL.createObjectURL(cached.blob); - const newCache = { ...cached, uri: blobUrl }; - setCache((prev) => ({ ...prev, [uuid]: newCache })); - return newCache; - } + const blob = await downloadAndCacheImage(key, url, { updated }); - url = await reference.getDownloadURL(); - } else { - url = hasDownloadURL; - updated = new Date().toISOString(); - } + if (!blob) { + throw new Error("Failed to download image"); + } - const response = await fetch(url); - const blob = await response.blob(); - const blobUrl = URL.createObjectURL(blob); + // Get the newly cached image + const cachedImage = await getCachedImage(key); - const newCacheEntry = { - updated, - blob, - }; + if (!cachedImage) { + throw new Error("Failed to retrieve cached image"); + } - await ImageCacheDB.setItem(key, newCacheEntry); + const newCacheEntry = { + uri: cachedImage.uri, + localUri: cachedImage.uri, + updated, + isObjectURL: cachedImage.isObjectURL, + }; - const newDisplayEntry = { - ...newCacheEntry, - uri: blobUrl, - }; + if (!skipCacheUpdate) { + setCache((prev) => ({ ...prev, [uuid]: newCacheEntry })); + } - setCache((prev) => ({ ...prev, [uuid]: newDisplayEntry })); + return newCacheEntry; + } catch (err) { + console.log("Error refreshing image cache", err); + throw err; + } finally { + inFlightRequests.current.delete(uuid); + } + })(); - return newDisplayEntry; - } catch (err) { - console.error("Error refreshing image cache", err); - } - } + inFlightRequests.current.set(uuid, requestPromise); - async function removeProfileImageFromCache(uuid) { + return requestPromise; + }, + [cache] + ); + + const removeProfileImageFromCache = useCallback(async (uuid) => { try { + console.log("Deleting profile image", uuid); const key = `${BLITZ_PROFILE_IMG_STORAGE_REF}/${uuid}`; - await ImageCacheDB.deleteItem(key); + const newCacheEntry = { uri: null, - updated: new Date().toISOString(), + localUri: null, + updated: new Date().getTime(), }; + + deleteCachedImage(key); setCache((prev) => ({ ...prev, [uuid]: newCacheEntry })); return newCacheEntry; } catch (err) { - console.error("Error deleting image from cache", err); + console.log("Error refreshing image cache", err); } - } + }, []); + + useEffect(() => { + if (!didGetToHomepage) return; + if (didRunContextCacheCheck.current) return; + if (!masterInfoObject.uuid) return; + didRunContextCacheCheck.current = true; + + async function refreshContactsImages() { + // allways check all images, will return cahced image if its already cached. But this prevents against stale images + let refreshArray = [ + ...decodedAddedContacts, + { uuid: masterInfoObject.uuid }, + ]; + + const validContacts = refreshArray.filter((element) => !element.isLNURL); + + const results = await Promise.allSettled( + validContacts.map((element) => refreshCache(element.uuid, null, true)) + ); + + const cacheUpdates = {}; + + results.forEach((result, index) => { + if (result.status === "fulfilled" && result.value) { + try { + const uuid = validContacts[index].uuid; + const newEntry = result.value; + const existingEntry = cachedImagesRef.current[uuid]; + + // Only add to updates if the entry is new or has changed + if ( + !existingEntry || + existingEntry.updated !== newEntry.updated || + existingEntry.localUri !== newEntry.localUri + ) { + cacheUpdates[uuid] = newEntry; + } + } catch (err) { + console.error("Error updating response", err); + } + } + }); + + if (Object.keys(cacheUpdates).length > 0) { + setCache((prev) => ({ ...prev, ...cacheUpdates })); + } + } + refreshContactsImages(); + }, [ + decodedAddedContacts, + didGetToHomepage, + masterInfoObject?.uuid, + refreshCache, + ]); + + const contextValue = useMemo( + () => ({ + cache, + refreshCache, + removeProfileImageFromCache, + refreshCacheObject, + }), + [cache, refreshCache, removeProfileImageFromCache, refreshCacheObject] + ); return ( - + {children} ); diff --git a/src/functions/cachedImage.js b/src/functions/cachedImage.js new file mode 100644 index 0000000..bab3867 --- /dev/null +++ b/src/functions/cachedImage.js @@ -0,0 +1,126 @@ +/** + * getCachedProfileImage.js + * Browser-compatible profile image caching using storage.js + */ + +import { getStorage, ref, getDownloadURL, getMetadata } from "firebase/storage"; +import { + deleteCachedImage, + downloadAndCacheImage, + getCachedImage, + getCachedImageMetadata, +} from "./images/storage"; +import { BLITZ_PROFILE_IMG_STORAGE_REF } from "../constants"; + +// Helper to generate cache key +const CACHE_KEY = (uuid) => `profile_${uuid}`; + +/** + * Get cached profile image from Firebase Storage + * Checks cache first, validates against Firebase metadata, downloads if needed + * + * @param {string} uuid - User unique identifier + * @param {Object} storage - Firebase storage instance (optional, will use default if not provided) + * @param {boolean} asDataURL - If true, returns data URL; if false, returns object URL + * @returns {Promise<{localUri: string, updated: string, isObjectURL?: boolean}|null>} + */ +export async function getCachedProfileImage( + uuid, + storage = null, + asDataURL = true +) { + try { + // Use provided storage or get default + const storageInstance = storage || getStorage(); + + const key = CACHE_KEY(uuid); + + // Get Firebase Storage reference + const reference = ref( + storageInstance, + `${BLITZ_PROFILE_IMG_STORAGE_REF}/${uuid}.jpg` + ); + + // Get metadata from Firebase to check if image has been updated + const metadata = await getMetadata(reference); + const updated = metadata.updated; + + // Check for cached image and its metadata + const cachedMetadata = await getCachedImageMetadata(key); + + // If cached version matches current version, return cached image + if (cachedMetadata?.updated === updated) { + const cachedImage = await getCachedImage(key, asDataURL); + + if (cachedImage) { + return { + localUri: cachedImage.uri, + updated: cachedMetadata.updated, + isObjectURL: cachedImage.isObjectURL, + }; + } + } + + // Download new image if cache is stale or doesn't exist + const url = await getDownloadURL(reference); + + // Download and cache the image with metadata + const blob = await downloadAndCacheImage(key, url, { updated }); + + if (!blob) { + throw new Error("Failed to download image"); + } + + // Get the newly cached image + const cachedImage = await getCachedImage(key, asDataURL); + + if (!cachedImage) { + throw new Error("Failed to retrieve cached image"); + } + + return { + localUri: cachedImage.uri, + updated, + isObjectURL: cachedImage.isObjectURL, + }; + } catch (e) { + console.log("Error caching profile image:", e); + return null; + } +} + +/** + * Preload multiple profile images into cache + * Useful for loading images in bulk (e.g., contact list) + * + * @param {string[]} uuids - Array of user UUIDs + * @param {Object} storage - Firebase storage instance + * @returns {Promise} Object with success/failure counts + */ +export async function preloadProfileImages(uuids, storage = null) { + const storageInstance = storage || getStorage(); + const results = { + success: 0, + failed: 0, + errors: [], + }; + + await Promise.all( + uuids.map(async (uuid) => { + try { + const result = await getCachedProfileImage(uuid, storageInstance); + if (result) { + results.success++; + } else { + results.failed++; + results.errors.push({ uuid, error: "Failed to cache" }); + } + } catch (e) { + results.failed++; + results.errors.push({ uuid, error: e.message }); + } + }) + ); + + return results; +} diff --git a/src/functions/contacts/index.js b/src/functions/contacts/index.js index 1eb76d7..c42edb3 100644 --- a/src/functions/contacts/index.js +++ b/src/functions/contacts/index.js @@ -1,10 +1,100 @@ -import {uniqueNamesGenerator, animals, names} from 'unique-names-generator'; +import { uniqueNamesGenerator, animals, names } from "unique-names-generator"; +import i18next from "i18next"; export function generateRandomContact() { const randomName = uniqueNamesGenerator({ dictionaries: [names, animals], - separator: '', + separator: "", }); // big_red_donkey - return {uniqueName: randomName + Math.ceil(Math.random() * 99)}; + return { uniqueName: randomName + Math.ceil(Math.random() * 99) }; +} + +export async function getBolt11InvoiceForContact( + contactUniqueName, + sendingValue, + description, + useBlitzContact = true, + domain = "blitzwalletapp.com", + sendingUUID +) { + try { + let runCount = 0; + let maxRunCount = 2; + let invoice = null; + + while (runCount < maxRunCount) { + try { + const url = `https://${domain}/.well-known/lnurlp/${contactUniqueName}?amount=${ + sendingValue * 1000 + }&isBlitzContact=${useBlitzContact ? true : false}${ + !!description + ? `&comment=${encodeURIComponent(description || "")}` + : "" + }&sendingUUID=${sendingUUID}`; + console.log(url); + const response = await fetch(url); + const data = await response.json(); + if (data.status !== "OK" && !data?.pr) + throw new Error("Not able to get invoice"); + invoice = data.pr; + break; + } catch (err) { + console.log("Error getting invoice trying again", err); + await new Promise((res) => setTimeout(res, 1000)); + } + runCount += 1; + } + + return invoice; + } catch (err) { + console.log("get ln address for liquid payment error", err); + return false; + } +} + +export function getTimeDisplay( + timeDifferenceMinutes, + timeDifferenceHours, + timeDifferenceDays, + timeDifferenceYears +) { + const timeValue = + timeDifferenceMinutes <= 60 + ? timeDifferenceMinutes < 1 + ? "" + : Math.round(timeDifferenceMinutes) + : timeDifferenceHours <= 24 + ? Math.round(timeDifferenceHours) + : timeDifferenceDays <= 365 + ? Math.round(timeDifferenceDays) + : Math.round(timeDifferenceYears); + + const timeUnit = + timeDifferenceMinutes <= 60 + ? timeDifferenceMinutes < 1 + ? i18next.t("transactionLabelText.txTime_just_now") + : Math.round(timeDifferenceMinutes) === 1 + ? i18next.t("timeLabels.minute") + : i18next.t("timeLabels.minutes") + : timeDifferenceHours <= 24 + ? Math.round(timeDifferenceHours) === 1 + ? i18next.t("timeLabels.hour") + : i18next.t("timeLabels.hours") + : timeDifferenceDays <= 365 + ? Math.round(timeDifferenceDays) === 1 + ? i18next.t("timeLabels.day") + : i18next.t("timeLabels.days") + : Math.round(timeDifferenceYears) === 1 + ? i18next.t("timeLabels.year") + : i18next.t("timeLabels.years"); + + const suffix = + timeDifferenceMinutes > 1 + ? ` ${i18next.t("transactionLabelText.ago")}` + : ""; + + return `${timeValue}${ + timeUnit === i18next.t("transactionLabelText.txTime_just_now") ? "" : " " + }${timeUnit}${suffix}`; } diff --git a/src/functions/images/storage.js b/src/functions/images/storage.js new file mode 100644 index 0000000..f286882 --- /dev/null +++ b/src/functions/images/storage.js @@ -0,0 +1,364 @@ +/** + * storage.js - Local Image Cache Manager + * Handles caching of images using IndexedDB and localStorage + */ + +const DB_NAME = "ImageCacheDB"; +const STORE_NAME = "images"; +const METADATA_STORE_NAME = "metadata"; +const DB_VERSION = 2; + +/** + * Open or create IndexedDB database + */ +function openDatabase() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + + request.onupgradeneeded = (event) => { + const db = event.target.result; + + // Create images store if it doesn't exist + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + + // Create metadata store if it doesn't exist + if (!db.objectStoreNames.contains(METADATA_STORE_NAME)) { + db.createObjectStore(METADATA_STORE_NAME); + } + }; + }); +} + +/** + * Get item from IndexedDB + */ +async function getIndexedDBItem(key, storeName = STORE_NAME) { + const db = await openDatabase(); + return new Promise((resolve, reject) => { + const transaction = db.transaction([storeName], "readonly"); + const store = transaction.objectStore(storeName); + const request = store.get(key); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + }); +} + +/** + * Set item in IndexedDB + */ +async function setIndexedDBItem(key, value, storeName = STORE_NAME) { + const db = await openDatabase(); + return new Promise((resolve, reject) => { + const transaction = db.transaction([storeName], "readwrite"); + const store = transaction.objectStore(storeName); + const request = store.put(value, key); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + }); +} + +/** + * Delete item from IndexedDB + */ +async function deleteIndexedDBItem(key, storeName = STORE_NAME) { + const db = await openDatabase(); + return new Promise((resolve, reject) => { + const transaction = db.transaction([storeName], "readwrite"); + const store = transaction.objectStore(storeName); + const request = store.delete(key); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(true); + }); +} + +/** + * Get all keys from IndexedDB store + */ +async function getAllImageKeys(storeName = STORE_NAME) { + const db = await openDatabase(); + return new Promise((resolve, reject) => { + const transaction = db.transaction([storeName], "readonly"); + const store = transaction.objectStore(storeName); + const request = store.getAllKeys(); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + }); +} + +/** + * Clear all items from a store + */ +async function clearStore(storeName = STORE_NAME) { + const db = await openDatabase(); + return new Promise((resolve, reject) => { + const transaction = db.transaction([storeName], "readwrite"); + const store = transaction.objectStore(storeName); + const request = store.clear(); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(true); + }); +} + +// ============================================================================ +// Image Utility Functions +// ============================================================================ + +/** + * Convert blob to data URL + */ +function blobToDataURL(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +} + +/** + * Download image from URL and return as blob + */ +async function downloadImageBlob(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download image: ${response.statusText}`); + } + return await response.blob(); +} + +/** + * Create object URL from blob (remember to revoke when done) + */ +function createObjectURL(blob) { + return URL.createObjectURL(blob); +} + +/** + * Revoke object URL to free memory + */ +function revokeObjectURL(url) { + try { + URL.revokeObjectURL(url); + } catch (e) { + console.error("Error revoking object URL:", e); + } +} + +// ============================================================================ +// High-Level Image Cache Functions +// ============================================================================ + +/** + * Store image in cache with metadata + * @param {string} key - Unique identifier for the image + * @param {Blob} blob - Image blob data + * @param {Object} metadata - Additional metadata (e.g., { updated: timestamp }) + */ +export async function cacheImage(key, blob, metadata = {}) { + try { + // Store the blob in IndexedDB + await setIndexedDBItem(key, blob, STORE_NAME); + + // Store metadata in IndexedDB metadata store + const metadataWithTimestamp = { + ...metadata, + cachedAt: new Date().toISOString(), + }; + await setIndexedDBItem(key, metadataWithTimestamp, METADATA_STORE_NAME); + + return true; + } catch (e) { + console.error("Error caching image:", e); + return false; + } +} + +/** + * Get cached image and its metadata + * @param {string} key - Unique identifier for the image + * @param {boolean} asDataURL - If true, returns data URL; if false, returns blob + */ +export async function getCachedImage(key, asDataURL = true) { + try { + const blob = await getIndexedDBItem(key, STORE_NAME); + const metadata = await getIndexedDBItem(key, METADATA_STORE_NAME); + + if (!blob) { + return null; + } + + const uri = asDataURL ? await blobToDataURL(blob) : createObjectURL(blob); + + return { + uri, + blob, + metadata, + isObjectURL: !asDataURL, + }; + } catch (e) { + console.error("Error getting cached image:", e); + return null; + } +} + +/** + * Download and cache image from URL + * @param {string} key - Unique identifier for the image + * @param {string} url - URL to download from + * @param {Object} metadata - Additional metadata + */ +export async function downloadAndCacheImage(key, url, metadata = {}) { + try { + const blob = await downloadImageBlob(url); + await cacheImage(key, blob, metadata); + return blob; + } catch (e) { + console.error("Error downloading and caching image:", e); + return null; + } +} + +/** + * Check if image exists in cache + * @param {string} key - Unique identifier for the image + */ +export async function isCached(key) { + try { + const blob = await getIndexedDBItem(key, STORE_NAME); + return blob !== undefined && blob !== null; + } catch (e) { + console.error("Error checking cache:", e); + return false; + } +} + +/** + * Delete cached image and its metadata + * @param {string} key - Unique identifier for the image + */ +export async function deleteCachedImage(key) { + try { + await deleteIndexedDBItem(key, STORE_NAME); + await deleteIndexedDBItem(key, METADATA_STORE_NAME); + return true; + } catch (e) { + console.error("Error deleting cached image:", e); + return false; + } +} + +/** + * Get metadata for a cached image + * @param {string} key - Unique identifier for the image + */ +export async function getCachedImageMetadata(key) { + try { + return await getIndexedDBItem(key, METADATA_STORE_NAME); + } catch (e) { + console.error("Error getting cached image metadata:", e); + return null; + } +} + +/** + * Update metadata for a cached image + * @param {string} key - Unique identifier for the image + * @param {Object} metadata - Metadata to update + */ +export async function updateCachedImageMetadata(key, metadata) { + try { + const existing = await getIndexedDBItem(key, METADATA_STORE_NAME); + const updated = { ...existing, ...metadata }; + await setIndexedDBItem(key, updated, METADATA_STORE_NAME); + return true; + } catch (e) { + console.error("Error updating cached image metadata:", e); + return false; + } +} + +/** + * Get all cached image keys + */ +export async function getAllCachedImageKeys() { + try { + return await getAllKeys(STORE_NAME); + } catch (e) { + console.error("Error getting all cached image keys:", e); + return []; + } +} + +/** + * Clear all cached images + */ +export async function clearAllCachedImages() { + try { + await clearStore(STORE_NAME); + await clearStore(METADATA_STORE_NAME); + return true; + } catch (e) { + console.error("Error clearing all cached images:", e); + return false; + } +} + +/** + * Get cache statistics + */ +export async function getCacheStats() { + try { + const keys = await getAllKeys(STORE_NAME); + const db = await openDatabase(); + + // Estimate size (not precise but gives an idea) + let totalSize = 0; + const transaction = db.transaction([STORE_NAME], "readonly"); + const store = transaction.objectStore(STORE_NAME); + + const sizes = await Promise.all( + keys.map((key) => { + return new Promise((resolve) => { + const request = store.get(key); + request.onsuccess = () => { + const blob = request.result; + resolve(blob ? blob.size : 0); + }; + request.onerror = () => resolve(0); + }); + }) + ); + + totalSize = sizes.reduce((sum, size) => sum + size, 0); + + return { + totalImages: keys.length, + totalSize, + totalSizeMB: (totalSize / (1024 * 1024)).toFixed(2), + }; + } catch (e) { + console.error("Error getting cache stats:", e); + return null; + } +} + +export { + blobToDataURL, + createObjectURL, + revokeObjectURL, + downloadImageBlob, + getIndexedDBItem, + getAllImageKeys, + setIndexedDBItem, + deleteIndexedDBItem, +}; diff --git a/src/functions/messaging/cachedMessages.js b/src/functions/messaging/cachedMessages.js index 444f02c..7a35d25 100644 --- a/src/functions/messaging/cachedMessages.js +++ b/src/functions/messaging/cachedMessages.js @@ -117,6 +117,7 @@ const setCashedMessages = async ({ newMessagesList, myPubKey }) => { const store = tx.objectStore(STORE_NAME_CONTACT_MESSAGES); try { + if (!newMessagesList.length) return; for (const newMessage of newMessagesList) { const existing = await store.get(newMessage.message.uuid); const parsedMessage = existing ? JSON.parse(existing.message) : null; @@ -166,7 +167,11 @@ const setCashedMessages = async ({ newMessagesList, myPubKey }) => { } await tx.done; - + console.log(newMessagesList, "sourted timestamps"); + const sortedTimestamps = newMessagesList.sort( + (a, b) => b.timestamp - a.timestamp + ); + console.log(sortedTimestamps, "sourted timestamps"); const newTimestamp = newMessagesList.sort( (a, b) => b.timestamp - a.timestamp )[0].timestamp; diff --git a/src/functions/timeFormatter.js b/src/functions/timeFormatter.js new file mode 100644 index 0000000..a5dc844 --- /dev/null +++ b/src/functions/timeFormatter.js @@ -0,0 +1,34 @@ +import i18next from 'i18next'; + +export function formatLocalTimeShort(date) { + try { + return date.toLocaleDateString(i18next.language, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } catch (err) { + console.log('error formatting local time', err.message); + } +} +export function formatLocalTimeNumeric(date) { + try { + return date.toLocaleDateString(i18next.language, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + }); + } catch (err) { + console.log('error formatting local time', err.message); + } +} +export function formatLocalTimeNumericMonthDay(date) { + try { + return date.toLocaleDateString(i18next.language, { + month: 'numeric', + day: 'numeric', + }); + } catch (err) { + console.log('error formatting local time', err.message); + } +} diff --git a/src/hooks/useDebounce.js b/src/hooks/useDebounce.js new file mode 100644 index 0000000..be9da59 --- /dev/null +++ b/src/hooks/useDebounce.js @@ -0,0 +1,17 @@ +import {useCallback, useRef} from 'react'; + +function useDebounce(func, wait) { + const debounceTimeout = useRef(null); + + const debouncedFunction = useCallback( + (...args) => { + clearTimeout(debounceTimeout.current); + debounceTimeout.current = setTimeout(() => func(...args), wait); + }, + [func, wait], + ); + + return debouncedFunction; +} + +export default useDebounce; diff --git a/src/main.jsx b/src/main.jsx index 77e9748..ad9fc8a 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,6 +1,7 @@ +import "../i18n.js"; + import "./fonts.css"; import "./index.css"; -import "../i18n"; // for translation option import App from "./App.jsx"; import "../pollyfills.js"; import { @@ -59,6 +60,16 @@ const SendPage = lazy(() => import("./pages/sendPage/sendPage.jsx")); const SparkSettingsPage = lazy(() => import("./pages/settings/pages/sparkSettingsPage/index.jsx") ); +const ExpandedAddContactsPage = lazy(() => + import( + "./pages/contacts/components/expandedAddContactPage/expandedAddContactPage.jsx" + ) +); +const ExpandedContactsPage = lazy(() => + import( + "./pages/contacts/components/ExpandedContactsPage/ExpandedContactsPage.jsx" + ) +); import ConfirmPayment from "./pages/confirmPayment/confirmPaymentScreen.jsx"; import { @@ -104,6 +115,7 @@ import CustomHalfModal from "./pages/customHalfModal/index.jsx"; import InformationPopup from "./pages/informationPopup/index.jsx"; import FullLoadingScreen from "./components/fullLoadingScreen/fullLoadingScreen.jsx"; import { GlobalServerTimeProvider } from "./contexts/serverTime.jsx"; + const ViewAllTxsPage = lazy(() => import("./pages/viewAllTx/viewAllTxPage.jsx") ); @@ -290,6 +302,27 @@ function Root() { } /> + + + + } + /> + + + + } + /> + + decodedAddedContacts.filter((contact) => contact?.uuid === selectedUUID), + [decodedAddedContacts, selectedUUID] + ); + console.log(selectedContact); + const imageData = cache[selectedContact.uuid]; + const contactTransactions = contactsMessags[selectedUUID]?.messages || []; + + useEffect(() => { + //listening for messages when you're on the contact + async function updateSeenTransactions() { + const newMessagesList = []; + let consecutiveSeenCount = 0; + const REQUIRED_CONSECUTIVE_SEEN = 100; + + for (let i = 0; i < contactTransactions.length; i++) { + const msg = contactTransactions[i]; + + if (msg.message.wasSeen) { + consecutiveSeenCount++; + if (consecutiveSeenCount >= REQUIRED_CONSECUTIVE_SEEN) { + break; + } + } else { + consecutiveSeenCount = 0; + newMessagesList.push({ + ...msg, + message: { ...msg.message, wasSeen: true }, + }); + } + } + + if (!newMessagesList.length) return; + + queueSetCashedMessages({ + newMessagesList, + myPubKey: globalContactsInformation.myProfile.uuid, + }); + } + + updateSeenTransactions(); + }, [contactTransactions]); + + const handleShare = () => { + if (selectedContact?.isLNURL || !selectedContact?.uniqueName) return; + + const shareText = `${t("share.contact")}\nhttps://blitzwalletapp.com/u/${ + selectedContact?.uniqueName + }`; + + if (navigator.share) { + navigator + .share({ + text: shareText, + }) + .catch((err) => console.log("Share failed:", err)); + } else { + navigator.clipboard.writeText(shareText); + // Optionally show a toast notification + } + }; + + // Header component for the list + const ListHeaderComponent = useCallback( + () => ( + <> + {!hideProfileImage && ( + + )} + + + + {selectedContact.uniqueName && ( + + )} + +
+ { + if (!isConnectedToTheInternet) { + navigate("/error", { + state: { errorMessage: t("errormessages.nointernet") }, + }); + return; + } + navigate("/send-request", { + state: { + selectedContact: selectedContact, + paymentType: "send", + imageData, + }, + }); + }} + arrowColor={ + theme + ? darkModeType + ? Colors.lightsout.background + : Colors.dark.background + : Colors.constants.blue + } + containerBackgroundColor={Colors.dark.text} + containerStyles={{ marginRight: 30 }} + /> + + { + if (selectedContact.isLNURL) { + navigate("/error", { + state: { + errorMessage: t( + "contacts.expandedContactPage.requestLNURLError" + ), + }, + }); + return; + } + if (!isConnectedToTheInternet) { + navigate("/error", { + state: { errorMessage: t("errormessages.nointernet") }, + }); + return; + } + navigate("/send-request", { + state: { + selectedContact: selectedContact, + paymentType: "request", + imageData, + }, + }); + }} + arrowColor={ + theme + ? darkModeType + ? Colors.lightsout.background + : Colors.dark.background + : Colors.constants.blue + } + containerBackgroundColor={Colors.dark.text} + containerStyles={{ + opacity: selectedContact.isLNURL ? HIDDEN_OPACITY : 1, + }} + /> +
+ + {!!selectedContact?.bio?.trim() && ( +
+
+ +
+
+ )} + + ), + [ + theme, + darkModeType, + selectedContact?.name, + selectedContact?.uniqueName, + selectedContact?.bio, + selectedContact?.isLNURL, + imageData?.updated, + imageData?.localUri, + isConnectedToTheInternet, + hideProfileImage, + contactTransactions.length, + ] + ); + + if (hideProfileImage) { + return ( +
+ {!selectedContact ? ( + + ) : contactTransactions.length !== 0 ? ( + <> + +
+ {contactTransactions.slice(0, 50).map((item, index) => ( + + ))} +
+ + ) : ( +
+ + +
+ )} +
+ ); + } + console.log(selectedContact); + return ( +
+
+ + {selectedContact && ( + handleFavortie({ selectedContact })} + style={{ marginRight: "10px" }} + fill={ + selectedContact?.isFavorite + ? theme && darkModeType + ? Colors.dark.text + : Colors.constants.blue + : "transparent" + } + color={ + theme && darkModeType ? Colors.dark.text : Colors.constants.blue + } + /> + )} + {selectedContact && ( + handleSettings({ selectedContact })} + color={ + theme && darkModeType ? Colors.dark.text : Colors.constants.blue + } + /> + )} +
+ + {!selectedContact ? ( + + ) : contactTransactions.length !== 0 ? ( +
+ +
+ {contactTransactions.slice(0, 50).map((item, index) => ( + + ))} +
+
+ ) : ( +
+ + +
+ )} +
+ ); +} diff --git a/src/pages/contacts/components/addContactPage/addContactPage.jsx b/src/pages/contacts/components/addContactPage/addContactPage.jsx new file mode 100644 index 0000000..e627053 --- /dev/null +++ b/src/pages/contacts/components/addContactPage/addContactPage.jsx @@ -0,0 +1,63 @@ +import React from "react"; +import "./style.css"; +import useThemeColors from "../../../../hooks/useThemeColors"; +import { useGlobalContacts } from "../../../../contexts/globalContacts"; +import CustomButton from "../../../../components/customButton/customButton"; +import { useTranslation } from "react-i18next"; +import ThemeText from "../../../../components/themeText/themeText"; + +export default function AddContactPage({ selectedContact }) { + const newContact = selectedContact; + + const { textInputBackground, textInputColor } = useThemeColors(); + const { addContact } = useGlobalContacts(); + const { t } = useTranslation(); + + const name = newContact?.name?.trim() || t("constants.annonName"); + const username = newContact?.uniqueName; + const lnurl = newContact?.isLNURL ? newContact?.receiveAddress : null; + const bio = newContact?.bio?.trim() || t("constants.noBioSet"); + + return ( +
+ + + {!!username && ( + + )} + + {!!lnurl && ( +
+ + +
+ )} + +
+ +
+ + { + addContact(newContact); + }} + buttonStyles={{ marginTop: "auto" }} + textContent={t("contacts.editMyProfilePage.addContactBTN")} + /> +
+ ); +} diff --git a/src/pages/contacts/components/addContactPage/style.css b/src/pages/contacts/components/addContactPage/style.css new file mode 100644 index 0000000..13ec908 --- /dev/null +++ b/src/pages/contacts/components/addContactPage/style.css @@ -0,0 +1,78 @@ +.container { + flex-grow: 1; + display: flex; + flex-direction: column; + align-items: center; +} + +.name-text { + font-size: 24px; + text-align: center; + opacity: 0.6; + margin-bottom: 5px; +} + +.username-text { + font-size: 18px; + text-align: center; + margin-bottom: 15px; +} + +.info-container { + width: 90%; + max-width: 500px; + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 12px; +} + +.info-label { + font-size: 16px; + opacity: 0.6; + margin-bottom: 4px; +} + +.info-value { + margin-bottom: 10px; + word-break: break-all; + text-align: center; +} + +.bio-container { + width: 90%; + max-width: 500px; + min-height: 60px; + max-height: 80px; + border-radius: 8px; + padding: 10px; + display: flex; + align-items: center; + justify-content: center; + overflow: auto; + margin-bottom: 20px; +} + +.bio-text { + text-align: center; +} + +.custom-button { + padding: 12px 24px; + background-color: #007aff; + color: white; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s; +} + +.custom-button:hover { + background-color: #0056b3; +} + +.custom-button:active { + background-color: #004494; +} diff --git a/src/pages/contacts/components/addContactsHalfModal/addContactsHalfModal.jsx b/src/pages/contacts/components/addContactsHalfModal/addContactsHalfModal.jsx new file mode 100644 index 0000000..c8e3b8e --- /dev/null +++ b/src/pages/contacts/components/addContactsHalfModal/addContactsHalfModal.jsx @@ -0,0 +1,255 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import "./style.css"; +import customUUID from "../../../../functions/customUUID"; +import useDebounce from "../../../../hooks/useDebounce"; +import { + EMAIL_REGEX, + VALID_URL_REGEX, + VALID_USERNAME_REGEX, +} from "../../../../constants"; +import { searchUsers } from "../../../../../db"; +import { getCachedProfileImage } from "../../../../functions/cachedImage"; +import ContactProfileImage from "../profileImage/profileImage"; +import { useThemeContext } from "../../../../contexts/themeContext"; +import useThemeColors from "../../../../hooks/useThemeColors"; +import ThemeText from "../../../../components/themeText/themeText"; +import { useNavigate } from "react-router-dom"; + +// Main Component +export default function AddContactsModal({ onClose }) { + const navigate = useNavigate(); + const [searchInput, setSearchInput] = useState(""); + const [users, setUsers] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [isKeyboardActive, setIsKeyboardActive] = useState(false); + const searchInputRef = useRef(null); + const searchTrackerRef = useRef(null); + const didClickCamera = useRef(null); + const { theme, darkModeType } = useThemeContext(); + + // Mock context values + const globalContactsInformation = { + myProfile: { uniqueName: "currentUser" }, + }; + + const isUsingLNURL = + searchInput?.includes("@") && searchInput?.indexOf("@") !== 0; + + useEffect(() => { + searchInputRef.current?.focus(); + }, []); + + const handleSearchTrackerRef = () => { + const requestUUID = customUUID(); + searchTrackerRef.current = requestUUID; + return requestUUID; + }; + + const debouncedSearch = useDebounce(async (term, requestUUID) => { + if (searchTrackerRef.current !== requestUUID) { + return; + } + + const searchTerm = term.replace(/@/g, ""); + if (searchTerm && VALID_USERNAME_REGEX.test(searchTerm)) { + const results = await searchUsers(searchTerm); + const newUsers = ( + await Promise.all( + results.map(async (savedContact) => { + if (!savedContact) return false; + if ( + savedContact.uniqueName === + globalContactsInformation.myProfile.uniqueName + ) + return false; + if (!savedContact?.uuid) return false; + + let responseData; + if ( + savedContact.hasProfileImage || + typeof savedContact.hasProfileImage === "boolean" + ) { + responseData = await getCachedProfileImage(savedContact.uuid); + console.log(responseData); + } + + if (!responseData) return savedContact; + else return { ...savedContact, ...responseData }; + }) + ) + ).filter(Boolean); + + setIsSearching(false); + setUsers(newUsers); + } else { + setIsSearching(false); + } + }, 650); + + const handleSearch = (term) => { + setSearchInput(term); + + if (isUsingLNURL) { + searchTrackerRef.current = null; + setIsSearching(false); + return; + } + + if (term.length === 0 || term === "@") { + searchTrackerRef.current = null; + setUsers([]); + setIsSearching(false); + return; + } + + if (term.length > 0) { + const requestUUID = handleSearchTrackerRef(); + setIsSearching(true); + debouncedSearch(term, requestUUID); + } + }; + + const clearHalfModalForLNURL = () => { + if (!EMAIL_REGEX.test(searchInput)) return; + + const newContact = { + name: searchInput.split("@")[0], + bio: "", + uniqueName: "", + isFavorite: false, + transactions: [], + unlookedTransactions: 0, + receiveAddress: searchInput, + isAdded: true, + isLNURL: true, + profileImage: "", + uuid: customUUID(), + }; + + console.log("Navigate to expanded page with LNURL:", newContact); + + if (onClose) { + onClose(); + } + + navigate("/expandedAddContactsPage", { + state: newContact, + }); + }; + + const handleBlur = () => { + setIsKeyboardActive(false); + if (!searchInput && !didClickCamera.current) { + console.log("Close modal"); + } + didClickCamera.current = false; + }; + + return ( +
+
+ + {isSearching &&
} +
+ +
+ handleSearch(e.target.value)} + onFocus={() => setIsKeyboardActive(true)} + onBlur={handleBlur} + onKeyPress={(e) => { + if (e.key === "Enter") { + clearHalfModalForLNURL(); + } + }} + placeholder="Find a contact" + className="search-input" + /> +
+ + {isUsingLNURL ? ( +
+

Add Lightning Address:

+

{searchInput}

+ +
+ ) : ( +
+ {users.length > 0 ? ( + users.map((item) => ( + + )) + ) : ( +

+ {isSearching && searchInput.length > 0 + ? "" + : searchInput.length > 0 && searchInput !== "@" + ? "No profiles found" + : "Search by LNURL (e.g. name@service.com) or Blitz username"} +

+ )} +
+ )} +
+ ); +} + +function ContactListItem({ savedContact, theme, darkModeType, onClose }) { + const { backgroundOffset } = useThemeColors(); + const navigate = useNavigate(); + const newContact = { + ...savedContact, + isFavorite: false, + transactions: [], + unlookedTransactions: 0, + isAdded: true, + }; + + const handleClick = () => { + console.log("Navigate to expanded page with:", newContact); + if (onClose) { + onClose(); + } + + navigate("/expandedAddContactsPage", { + state: newContact, + }); + }; + + return ( + + ); +} diff --git a/src/pages/contacts/components/addContactsHalfModal/style.css b/src/pages/contacts/components/addContactsHalfModal/style.css new file mode 100644 index 0000000..46fe63b --- /dev/null +++ b/src/pages/contacts/components/addContactsHalfModal/style.css @@ -0,0 +1,172 @@ +.modal-container { + display: flex; + flex-direction: column; + align-items: center; + width: 90%; + margin: 10px auto 0; +} + +.title-container { + display: flex; + align-items: center; + margin-bottom: 10px; + width: 100%; + margin-top: 10px; +} + +.title-text { + font-size: 20px; + font-weight: 400; + margin: 0; + margin-right: 10px; +} + +.spinner { + width: 20px; + height: 20px; + border: 2px solid #3b82f6; + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.search-container { + position: relative; + width: 100%; +} + +.search-input { + width: 100%; + padding: 12px 48px 12px 16px; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 16px; + outline: none; + transition: border-color 0.2s; +} + +.search-input:focus { + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.camera-button { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: #3b82f6; + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; +} + +.camera-icon { + width: 24px; + height: 24px; +} + +.lnurl-container { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; +} + +.lnurl-text { + text-align: center; + margin-bottom: 8px; + color: #374151; +} + +.lnurl-address { + text-align: center; + margin-bottom: 24px; + font-weight: 600; + color: #111827; +} + +.continue-button { + padding: 10px 24px; + background-color: #3b82f6; + color: white; + border: none; + border-radius: 8px; + font-size: 16px; + cursor: pointer; + transition: background-color 0.2s; +} + +.users-list { + width: 100%; + max-height: 384px; + overflow-y: auto; +} + +.empty-message { + text-align: center; + color: #6b7280; + margin-top: 16px; +} + +.contact-item { + width: 100%; + display: flex; + align-items: center; + padding: 8px 0; + background: none; + border: none; + cursor: pointer; + border-radius: 8px; + margin-bottom: 8px; +} + +.contact-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-right: 12px; + flex-shrink: 0; + overflow: hidden; +} + +.contact-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.contact-initial { + color: white; + font-weight: 600; + font-size: 18px; +} + +.contact-info { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.contact-username { + margin: 0; +} + +.contact-name { + font-size: 14px; + opacity: 0.6; + margin: 0; +} diff --git a/src/pages/contacts/components/expandedAddContactPage/expandedAddContactPage.jsx b/src/pages/contacts/components/expandedAddContactPage/expandedAddContactPage.jsx new file mode 100644 index 0000000..765ff4b --- /dev/null +++ b/src/pages/contacts/components/expandedAddContactPage/expandedAddContactPage.jsx @@ -0,0 +1,176 @@ +import React, { memo, useEffect, useMemo, useState } from "react"; +import "./style.css"; +import { ArrowLeft, Settings, Star } from "lucide-react"; +import { Colors } from "../../../../constants/theme"; +import { useThemeContext } from "../../../../contexts/themeContext"; +import useThemeColors from "../../../../hooks/useThemeColors"; +import { useImageCache } from "../../../../contexts/imageCacheContext"; +import { useGlobalContacts } from "../../../../contexts/globalContacts"; +import { useExpandedNavbar } from "../../utils/useExpandedNavbar"; +import ContactProfileImage from "../profileImage/profileImage"; +import { useLocation, useNavigate } from "react-router-dom"; +import AddContactPage from "../addContactPage/addContactPage"; +import ExpandedContactsPage from "../ExpandedContactsPage/ExpandedContactsPage"; + +// Memoized shared header component +const SharedHeader = memo( + ({ + selectedContact, + imageData, + theme, + darkModeType, + backgroundOffset, + isContactAdded, + isEditingMyProfile, + navigate, + }) => { + return ( +
+
+ +
+
+ ); + } +); + +// Memoized navbar +const MemoizedNavBar = memo( + ({ + onBack, + theme, + darkModeType, + selectedContact, + backgroundColor, + isContactAdded, + handleFavortie, + handleSettings, + }) => { + return ( +
+ + {selectedContact && isContactAdded && ( + handleFavortie({ selectedContact })} + style={{ marginRight: "10px" }} + fill={ + selectedContact?.isFavorite + ? theme && darkModeType + ? Colors.dark.text + : Colors.constants.blue + : "transparent" + } + color={ + theme && darkModeType ? Colors.dark.text : Colors.constants.blue + } + /> + )} + {selectedContact && isContactAdded && ( + handleSettings({ selectedContact })} + color={ + theme && darkModeType ? Colors.dark.text : Colors.constants.blue + } + /> + )} +
+ ); + } +); + +export default function ExpandedAddContactsPage({ route }) { + const { decodedAddedContacts, globalContactsInformation, contactsMessags } = + useGlobalContacts(); + const { theme, darkModeType } = useThemeContext(); + const { backgroundOffset, backgroundColor } = useThemeColors(); + const { cache, refreshCacheObject } = useImageCache(); + const { handleFavortie, handleSettings } = useExpandedNavbar(); + + const navigate = useNavigate(); + const location = useLocation(); + const props = location.state; + console.log(props, location); + const newContact = props; + + useEffect(() => { + refreshCacheObject(); + }, []); + + // Memoize contact lookup + const selectedContact = useMemo(() => { + return decodedAddedContacts.find( + (contact) => + (contact.uuid === newContact?.uuid && contact.isAdded) || + (contact.isLNURL && + contact.receiveAddress.toLowerCase() === + newContact.receiveAddress?.toLowerCase()) + ); + }, [decodedAddedContacts, newContact]); + + const isSelf = useMemo(() => { + return ( + newContact.uniqueName?.toLowerCase() === + globalContactsInformation?.myProfile?.uniqueName?.toLowerCase() + ); + }, [newContact.uniqueName, globalContactsInformation?.myProfile?.uniqueName]); + + const isContactAdded = !!selectedContact; + const imageData = cache[newContact?.uuid]; + const contactTransactions = contactsMessags[newContact?.uuid]?.messages || []; + + // Memoize back handler + const handleBack = useMemo(() => { + return () => navigate(-1); + }, [navigate]); + + return ( +
+ + +
+ + + {isContactAdded ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/src/pages/contacts/components/expandedAddContactPage/style.css b/src/pages/contacts/components/expandedAddContactPage/style.css new file mode 100644 index 0000000..b4638ff --- /dev/null +++ b/src/pages/contacts/components/expandedAddContactPage/style.css @@ -0,0 +1,102 @@ +.global-theme-view { + width: 100%; + margin: 0 auto; + display: flex; + flex-direction: column; + flex: 1; +} + +.top-bar { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + margin-bottom: 15px; +} + +.back-button-container { + margin-right: auto; + cursor: pointer; + background-color: unset; +} + +.star-container { + margin-right: 5px; + background: none; + border: none; + color: #ffd700; + font-size: 24px; + cursor: pointer; + padding: 4px 8px; + transition: transform 0.2s; +} + +.star-container:hover { + transform: scale(1.2); +} + +.settings-button { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + padding: 4px 8px; + transition: transform 0.2s; +} + +.settings-button:hover { + transform: rotate(45deg); +} + +.profile-image-container { + display: flex; + align-items: center; + justify-content: center; + width: 100%; +} + +.profile-image { + width: 150px; + height: 150px; + border-radius: 75px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + margin-bottom: 10px; +} + +.profile-image-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background-color: #333; +} + +.scroll-view { + flex-grow: 1; + overflow-y: auto; + max-height: calc(100vh - 100px); + display: flex; + flex-direction: column; +} + +.scroll-view::-webkit-scrollbar { + width: 8px; +} + +.scroll-view::-webkit-scrollbar-track { + background: #2a2a2a; + border-radius: 4px; +} + +.scroll-view::-webkit-scrollbar-thumb { + background: #555; + border-radius: 4px; +} + +.scroll-view::-webkit-scrollbar-thumb:hover { + background: #777; +} diff --git a/src/pages/contacts/components/profileImage/profileImage.jsx b/src/pages/contacts/components/profileImage/profileImage.jsx index 34e4fec..4f392c2 100644 --- a/src/pages/contacts/components/profileImage/profileImage.jsx +++ b/src/pages/contacts/components/profileImage/profileImage.jsx @@ -1,5 +1,4 @@ import React, { useState } from "react"; -import customUUID from "../../../../functions/customUUID"; import { userIcon, userWhite } from "../../../../constants/icons"; export default function ContactProfileImage({ @@ -13,9 +12,7 @@ export default function ContactProfileImage({ const [isLoading, setIsLoading] = useState(true); const fallbackIcon = darkModeType && theme ? userWhite : userIcon; - const customURI = uri - ? `${uri}?v=${updated ? new Date(updated).getTime() : customUUID()}` - : null; + const customURI = uri; const source = !loadError && uri && !isLoading ? customURI : fallbackIcon; const isProfile = !loadError && uri && !isLoading; diff --git a/src/pages/contacts/contacts.css b/src/pages/contacts/contacts.css index 41f0d50..56be8e1 100644 --- a/src/pages/contacts/contacts.css +++ b/src/pages/contacts/contacts.css @@ -8,6 +8,21 @@ #contactsPage .contactsPageTopBar { display: flex; justify-content: end; + align-items: center; + position: relative; +} +#contactsPage .contactsPageTopBar .pageHeaderText { + width: 100%; + position: absolute; + font-size: 1.25rem; + text-align: center; + white-space: nowrap; /* Prevent line break */ + overflow: hidden; /* Hide overflow */ + text-overflow: ellipsis; /* Add "..." at end */ + max-width: 100%; /* Optional: constrain width */ + flex: 1; /* Fill available space if needed */ + padding: 0 35px; + margin: 0; } #contactsPage .contactsPageTopBar .myContactContainer { width: 35px; @@ -28,3 +43,134 @@ justify-content: center; padding: 20px 0; } + +/* row items */ +#contactsPage .pinned-contact { + display: flex; + flex-direction: column; + align-items: center; + padding: 10px 0; + cursor: pointer; +} +#contactsPage .pinnedContactScrollview { + display: flex; + flex-direction: row; + column-gap: 15px; + overflow-x: scroll; +} +#contactsPage .pinned-contact-image-wrapper { + width: 100%; + margin-bottom: 5px; +} + +#contactsPage .pinned-contact-image-container { + width: 100%; + aspect-ratio: 1; + border-radius: 9999px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +#contactsPage .pinned-contact-footer { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; +} + +#contactsPage .pinned-contact-name { + width: 100%; + font-size: 12px; + margin: 5px 0 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; +} + +#contactsPage .contact-row { + width: 95%; + display: flex; + align-items: center; + padding: 15px 0; + cursor: pointer; +} + +#contactsPage .contact-image-container { + width: 35px; + height: 35px; + border-radius: 30px; + margin-right: 10px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +#contactsPage .contact-content { + flex: 1; + display: flex; + flex-direction: column; +} + +#contactsPage .contact-row-inline { + display: flex; + align-items: center; + width: 100%; + margin-bottom: 5px; +} + +#contactsPage .contact-name-block { + display: flex; + flex-direction: column; + flex-grow: 1; + margin-right: 5px; +} + +#contactsPage .unknown-sender { + font-size: 12px; + opacity: 0.8; + margin: 0; +} + +#contactsPage .contact-date { + font-size: 12px; + margin: 0; + margin-right: 5px; +} + +#contactsPage .contact-arrow { + width: 20px; + height: 20px; + transform: rotate(180deg); +} + +#contactsPage .contact-preview { + font-size: 12px; + opacity: 0.85; + margin: 0; +} + +#contactsPage .notification-dot { + width: 10px; + height: 10px; + margin: 0; + border-radius: 50%; + margin-right: 5px; + position: relative; +} + +#contactsPage .add-contact-icon { + width: 25px; + height: 25px; + transform: rotate(45deg); +} + +#contactsPage .add-contact-text { + font-weight: 500; + margin: 0; +} diff --git a/src/pages/contacts/contacts.jsx b/src/pages/contacts/contacts.jsx index 6bb1dcf..4a10eb4 100644 --- a/src/pages/contacts/contacts.jsx +++ b/src/pages/contacts/contacts.jsx @@ -1,11 +1,9 @@ -import { useMemo, useState } from "react"; -import PinnedContactElement from "./components/pinnedContact/pinnedContact"; +import { memo, useCallback, useMemo, useState } from "react"; import { useGlobalContacts } from "../../contexts/globalContacts"; import { useImageCache } from "../../contexts/imageCacheContext"; import { useGlobalContextProvider } from "../../contexts/masterInfoObject"; import { useAppStatus } from "../../contexts/appStatus"; import { useLocation, useNavigate } from "react-router-dom"; -import { ContactElement } from "./components/contactElement/contactElement"; import CustomInput from "../../components/customInput/customInput"; import ThemeText from "../../components/themeText/themeText"; import CustomButton from "../../components/customButton/customButton"; @@ -17,195 +15,546 @@ import { useThemeContext } from "../../contexts/themeContext"; import useThemeColors from "../../hooks/useThemeColors"; import { questionMarkSVG } from "../../constants/icons"; import ThemeImage from "../../components/ThemeImage/themeImage"; +import NavBarProfileImage from "../../components/navBar/profileImage"; +import { useKeysContext } from "../../contexts/keysContext"; +import { useServerTime, useServerTimeOnly } from "../../contexts/serverTime"; +import { useTranslation } from "react-i18next"; +import { useFilteredContacts, useProcessedContacts } from "./utils/hooks"; +import { encryptMessage } from "../../functions/encodingAndDecoding"; +import { ChevronRight, PlusIcon } from "lucide-react"; +import { createFormattedDate, formatMessage } from "./utils/utilityFunctions"; +import { formatDisplayName } from "./utils/formatListDisplayName"; export default function Contacts({ openOverlay }) { + const { contactsPrivateKey, publicKey } = useKeysContext(); const { masterInfoObject } = useGlobalContextProvider(); - const { decodedAddedContacts, globalContactsInformation, contactsMessags } = - useGlobalContacts(); + const { cache } = useImageCache(); const { theme, darkModeType } = useThemeContext(); - const { backgroundOffset } = useThemeColors(); + const { + decodedAddedContacts, + globalContactsInformation, + contactsMessags, + toggleGlobalContactsInformation, + giftCardsList, + } = useGlobalContacts(); + const { serverTimeOffset } = useServerTime(); + const getServerTime = useServerTimeOnly(); + const { backgroundOffset, backgroundColor, textColor } = useThemeColors(); const { isConnectedToTheInternet } = useAppStatus(); - const { cache } = useImageCache(); + const { t } = useTranslation(); + const [inputText, setInputText] = useState(""); const hideUnknownContacts = masterInfoObject.hideUnknownContacts; - const myProfile = globalContactsInformation?.myProfile; - const didEditProfile = globalContactsInformation?.myProfile?.didEditProfile; const navigate = useNavigate(); - const location = useLocation(); - const [inputText, setInputText] = useState(""); + const myProfile = globalContactsInformation.myProfile; + const didEditProfile = myProfile?.didEditProfile; + + const contactInfoList = useProcessedContacts( + decodedAddedContacts, + contactsMessags + ); + + const filteredContacts = + useFilteredContacts( + contactInfoList, + inputText.trim(), + hideUnknownContacts + ) ?? []; + const profileContainerStyle = useMemo( + () => ({ + backgroundColor: backgroundOffset, + }), + [backgroundOffset] + ); - console.log("test", import.meta.env.MODE); + const searchInputStyle = useMemo( + () => ({ + width: "100%", + paddingBottom: "10px", + backgroundColor, + }), + [backgroundColor] + ); + + const scrollContentStyle = useMemo( + () => ({ + paddingTop: contactInfoList.some((c) => c.contact.isFavorite) ? 0 : 10, + }), + [contactInfoList] + ); + + const showAddContactRowItem = + !contactInfoList?.length || + filteredContacts?.length || + (contactInfoList?.length && + !filteredContacts?.length && + !inputText?.trim()?.length); + + const showHighlightedGifts = useMemo(() => { + return giftCardsList && !!giftCardsList?.length; + }, [giftCardsList]); + + const navigateToExpandedContact = useCallback( + async (contact) => { + try { + if (!contact.isAdded) { + let newAddedContacts = [...decodedAddedContacts]; + const indexOfContact = decodedAddedContacts.findIndex( + (obj) => obj.uuid === contact.uuid + ); + + let newContact = newAddedContacts[indexOfContact]; + newContact["isAdded"] = true; + + toggleGlobalContactsInformation( + { + myProfile: { ...globalContactsInformation.myProfile }, + addedContacts: await encryptMessage( + contactsPrivateKey, + publicKey, + JSON.stringify(newAddedContacts) + ), + }, + true + ); + } + navigate("/expandedContactsPage", { + state: { uuid: contact.uuid }, + }); + } catch (err) { + console.log("error navigating to expanded contact", err); + navigate("/expandedContactsPage", { + state: { uuid: contact.uuid }, + }); + } + }, + [ + decodedAddedContacts, + globalContactsInformation, + toggleGlobalContactsInformation, + contactsPrivateKey, + publicKey, + navigate, + ] + ); const pinnedContacts = useMemo(() => { - return decodedAddedContacts - .filter((contact) => contact.isFavorite) - .map((contact, id) => { - return ( - - ); - }); - }, [decodedAddedContacts, contactsMessags, cache]); + return contactInfoList + .filter((contact) => contact.contact.isFavorite) + .map((contact) => ( + + )); + }, [ + contactInfoList, + cache, + darkModeType, + theme, + backgroundOffset, + navigateToExpandedContact, + // dimensions, + navigate, + openOverlay, + ]); const contactElements = useMemo(() => { - return decodedAddedContacts - .filter((contact) => { - return ( - (contact.name?.toLowerCase()?.startsWith(inputText.toLowerCase()) || - contact?.uniqueName - ?.toLowerCase() - ?.startsWith(inputText.toLowerCase())) && - !contact.isFavorite && - (!hideUnknownContacts || contact.isAdded) - ); - }) - .sort((a, b) => { - const earliset_A = contactsMessags[a.uuid]?.lastUpdated; - const earliset_B = contactsMessags[b.uuid]?.lastUpdated; - return (earliset_B || 0) - (earliset_A || 0); - }) - .map((contact, id) => { - return ( - - ); - }); + const currentTime = getServerTime(); + + let contacts = filteredContacts.map((item, index) => ( + + )); + if (showAddContactRowItem) { + contacts.unshift( + + ); + } + return contacts; }, [ - decodedAddedContacts, - inputText, - hideUnknownContacts, - contactsMessags, + filteredContacts, cache, + darkModeType, + theme, + backgroundOffset, + navigateToExpandedContact, + isConnectedToTheInternet, + navigate, + getServerTime, + serverTimeOffset, + showAddContactRowItem, + t, + openOverlay, ]); + const handleButtonPress = useCallback(() => { + if (!isConnectedToTheInternet) { + openOverlay({ + for: "error", + errorMessage: "Not connected to the internet", + }); + return; + } + if (didEditProfile) { + openOverlay({ + for: "halfModal", + contentType: "addContactsHalfModal", + }); + } else { + navigate("/edit-profile", { + state: { pageType: "myProfile", fromSettings: false }, + }); + } + }, [isConnectedToTheInternet, didEditProfile, navigate]); + + const hasContacts = + decodedAddedContacts.filter( + (contact) => !hideUnknownContacts || contact.isAdded + ).length !== 0; + + const stickyHeaderIndicesValue = useMemo(() => { + return [pinnedContacts.length ? 1 : 0]; + }, [pinnedContacts]); + return (
- {myProfile?.didEditProfile && ( + {(didEditProfile || hasContacts) && (
- {/*
{ - keyboardNavigate(() => { - if (!isConnectedToTheInternet) { - navigate.navigate("ErrorScreen", { - errorMessage: - "Please connect to the internet to use this feature", - }); - return; - } - navigate.navigate("CustomHalfModal", { - wantedContent: "addContacts", - sliderHight: 0.4, - }); - }); - }} - > - -
*/} -
{ - navigate("/my-profile"); - }} - > - + +
+
)} - {/* {decodedAddedContacts.filter( - (contact) => !hideUnknownContacts || contact.isAdded - ).length !== 0 && myProfile.didEditProfile ? ( + {hasContacts && didEditProfile ? (
{pinnedContacts.length != 0 && ( -
+
{pinnedContacts}
)} {contactElements}
- ) : ( */} -
- - - - { - if (!isConnectedToTheInternet) { - openOverlay({ - for: "error", - errorMessage: - "Please connect to the internet to use this feature.", - }); - return; - } - if (didEditProfile) { - //navigate to add contacts popup - openOverlay({ - for: "error", - errorMessage: "Feature coming soon...", - }); - } else { - navigate("/edit-profile", { - state: { pageType: "myProfile", fromSettings: false }, - }); + ) : ( +
+ + -
- {/* )} */} + /> + + +
+ )}
); } + +export const PinnedContactElement = memo( + ({ + contact, + hasUnlookedTransaction, + cache, + darkModeType, + theme, + backgroundOffset, + navigateToExpandedContact, + navigate, + }) => { + const [textWidth, setTextWidth] = useState(0); + + const containerSize = useMemo(() => "calc(95% / 4 - 15px)", []); + + const handleLongPress = useCallback( + (e) => { + e.preventDefault(); + if (!contact.isAdded) return; + navigate("ContactsPageLongPressActions", { contact }); + }, + [contact, navigate] + ); + + const handlePress = useCallback(() => { + navigateToExpandedContact(contact); + }, [contact, navigateToExpandedContact]); + + return ( +
+
+ +
+ +
+ {hasUnlookedTransaction && ( + + )} + + el && setTextWidth(el.offsetWidth)} + textStyles={{ + width: `calc(100% - ${hasUnlookedTransaction ? "25px" : "0px"})`, + }} + className="pinned-contact-name" + textContent={formatDisplayName(contact)} + /> +
+
+ ); + } +); + +export const ContactElement = memo( + ({ + contact, + hasUnlookedTransaction, + lastUpdated, + firstMessage, + cache, + darkModeType, + theme, + backgroundOffset, + navigateToExpandedContact, + isConnectedToTheInternet, + navigate, + currentTime, + serverTimeOffset, + t, + isLastElement, + openOverlay, + }) => { + const handlePress = useCallback(() => { + navigateToExpandedContact(contact); + }, [contact, navigateToExpandedContact]); + + const formattedDate = lastUpdated + ? createFormattedDate( + lastUpdated - serverTimeOffset, + currentTime - serverTimeOffset, + t + ) + : ""; + console.log(cache[contact.uuid]); + return ( +
+
+ +
+ +
+
+
+ + {!contact.isAdded && ( + + )} +
+ + {hasUnlookedTransaction && ( + + )} + + + +
+ + {!!formatMessage(firstMessage) && contact.isAdded && ( +
+ +
+ )} +
+
+ ); + } +); +export const AddContactRowItem = memo( + ({ + darkModeType, + theme, + backgroundOffset, + t, + isConnectedToTheInternet, + navigate, + numberOfContacts, + openOverlay, + }) => { + const goToAddContact = useCallback(() => { + if (!isConnectedToTheInternet) { + navigate("ErrorScreen", { + errorMessage: t("errormessages.nointernet"), + }); + } else { + openOverlay({ + for: "halfModal", + contentType: "addContactsHalfModal", + }); + } + }, [isConnectedToTheInternet, navigate, t]); + + return ( +
+
+ +
+
+ +
+
+ ); + } +); diff --git a/src/pages/contacts/internalComponents/contactTransactions/contactsTransactionItem.css b/src/pages/contacts/internalComponents/contactTransactions/contactsTransactionItem.css new file mode 100644 index 0000000..e7653b9 --- /dev/null +++ b/src/pages/contacts/internalComponents/contactTransactions/contactsTransactionItem.css @@ -0,0 +1,88 @@ +/* Transaction Item Button */ +.transaction-item-button { + width: 100%; + background: none; + border: none; + padding: 0; + cursor: pointer; + text-align: left; +} + +.transaction-item-button:active { + opacity: 1; +} + +/* Transaction Container */ +.transaction-container { + width: 95%; + display: flex; + flex-direction: row; + align-items: flex-start; + margin: 0 auto; +} + +.transaction-container.centered { + align-items: center; +} + +/* Icon Styles */ +.icon { + width: 30px; + height: 30px; + margin-right: 5px; +} + +/* Transaction Content */ +.transaction-content { + width: 100%; + flex: 1; + display: flex; + flex-direction: column; + margin-left: 10px; +} +.transaction-content > * { + margin: 0; +} +.transaction-content p:last-child { + font-size: 0.8em; + opacity: 0.6; +} + +/* Accept or Pay Button Base Styles */ +.accept-or-pay-btn { + width: 100%; + overflow: hidden; + border-radius: 15px; + display: flex; + align-items: center; + justify-content: center; + padding: 10px; + font-size: 16px; + cursor: pointer; + transition: opacity 0.2s ease; + border: none; +} + +.accept-or-pay-btn:hover:not(:disabled) { + opacity: 0.9; +} + +.accept-or-pay-btn:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +/* Text Ellipsis Support */ +.ellipsis-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ellipsis-text-multiline { + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} diff --git a/src/pages/contacts/internalComponents/contactTransactions/contactsTransactions.jsx b/src/pages/contacts/internalComponents/contactTransactions/contactsTransactions.jsx new file mode 100644 index 0000000..d39ab4f --- /dev/null +++ b/src/pages/contacts/internalComponents/contactTransactions/contactsTransactions.jsx @@ -0,0 +1,528 @@ +import React, { useCallback, useState, useMemo } from "react"; + +import { useNavigate } from "react-router-dom"; + +// import { sendPushNotification } from "../../../../../functions/messaging/publishMessage"; + +import { useTranslation } from "react-i18next"; + +import { useGlobalContextProvider } from "../../../../contexts/masterInfoObject"; +import FormattedSatText from "../../../../components/formattedSatText/formattedSatText"; +import useThemeColors from "../../../../hooks/useThemeColors"; +import ThemeImage from "../../../../components/ThemeImage/themeImage"; +import ThemeText from "../../../../components/themeText/themeText"; +import { getDataFromCollection, updateMessage } from "../../../../../db"; +import { useThemeContext } from "../../../../contexts/themeContext"; +import { useKeysContext } from "../../../../contexts/keysContext"; +import CustomButton from "../../../../components/customButton/customButton"; +import { useServerTimeOnly } from "../../../../contexts/serverTime"; +import GiftCardTxItem from "../giftCardTxItem/giftCardTxItem"; +import { getTimeDisplay } from "../../../../functions/contacts"; +import getReceiveAddressAndContactForContactsPayment from "../../utils/getReceiveAddressAndKindForPayment"; +import { useGlobalContacts } from "../../../../contexts/globalContacts"; +import { getTransactionContent } from "../../utils/transactionText"; +import displayCorrectDenomination from "../../../../functions/displayCorrectDenomination"; +import { useNodeContext } from "../../../../contexts/nodeContext"; + +import "./contactsTransactionItem.css"; +import { ArrowDown, ArrowUp, CircleX } from "lucide-react"; +import { Colors } from "../../../../constants/theme"; + +function ConfirmedOrSentTransaction({ + txParsed, + paymentDescription, + timeDifferenceMinutes, + timeDifferenceHours, + timeDifferenceDays, + timeDifferenceYears, + props, + navigate, +}) { + const { t } = useTranslation(); + const { theme, darkModeType } = useThemeContext(); + const { masterInfoObject } = useGlobalContextProvider(); + const { textColor, backgroundOffset } = useThemeColors(); + + const didDeclinePayment = txParsed.isRedeemed != null && !txParsed.isRedeemed; + + const isOutgoingPayment = + (txParsed.didSend && !txParsed.isRequest) || + (txParsed.isRequest && txParsed.isRedeemed && !txParsed.didSend); + + if (!!txParsed.giftCardInfo) { + return ( + + ); + } + + return ( +
+ {didDeclinePayment ? ( + + ) : isOutgoingPayment ? ( + + ) : ( + + )} + +
+ + +
+ + +
+ ); +} + +export default function ContactsTransactionItem(props) { + const { selectedContact, transaction, myProfile, currentTime, imageData } = + props; + const { t } = useTranslation(); + const { fiatStats } = useNodeContext(); + const { masterInfoObject } = useGlobalContextProvider(); + const { contactsPrivateKey, publicKey } = useKeysContext(); + const { theme, darkModeType } = useThemeContext(); + const { textColor, backgroundColor } = useThemeColors(); + const navigate = useNavigate(); + const getServerTime = useServerTimeOnly(); + const { globalContactsInformation } = useGlobalContacts(); + const [isLoading, setIsLoading] = useState({ + sendBTN: false, + declineBTN: false, + }); + + // Memoized calculations + const timeCalculations = useMemo(() => { + const endDate = currentTime; + const startDate = transaction.timestamp; + + const timeDifferenceMs = Math.abs(endDate - startDate); + + return { + timeDifferenceMinutes: timeDifferenceMs / (1000 * 60), + timeDifferenceHours: timeDifferenceMs / (1000 * 60 * 60), + timeDifferenceDays: timeDifferenceMs / (1000 * 60 * 60 * 24), + timeDifferenceYears: timeDifferenceMs / (1000 * 60 * 60 * 24 * 365), + }; + }, [currentTime, transaction.serverTimestamp, transaction.timestamp]); + + const { + timeDifferenceMinutes, + timeDifferenceHours, + timeDifferenceDays, + timeDifferenceYears, + } = timeCalculations; + + const txParsed = transaction.message; + const paymentDescription = txParsed?.description || ""; + + const updatePaymentStatus = useCallback( + async (transaction, usingOnPage, didPay, txid) => { + try { + usingOnPage && + setIsLoading((prev) => ({ + ...prev, + [didPay ? "sendBTN" : "declineBTN"]: true, + })); + let newMessage = { + ...transaction.message, + isRedeemed: didPay, + txid, + name: + globalContactsInformation.myProfile.name || + globalContactsInformation.myProfile.uniqueName, + }; + // Need to switch unique name since the original receiver is now the sender + if (newMessage.senderProfileSnapshot) { + newMessage.senderProfileSnapshot.uniqueName = + globalContactsInformation.myProfile.uniqueName; + } + delete newMessage.didSend; + delete newMessage.wasSeen; + const [retrivedContact] = await Promise.all([ + getDataFromCollection("blitzWalletUsers", selectedContact.uuid), + ]); + if (!retrivedContact) + throw new Error(t("errormessages.userDataFetchError")); + + const currentTime = getServerTime(); + + const useNewNotifications = !!retrivedContact.isUsingNewNotifications; + + const [didPublishNotification, didUpdateMessage] = await Promise.all([ + sendPushNotification({ + selectedContactUsername: selectedContact.uniqueName, + myProfile: myProfile, + data: { + isUpdate: true, + [useNewNotifications ? "option" : "message"]: useNewNotifications + ? didPay + ? "paid" + : "declined" + : t( + "contacts.internalComponents.contactsTransactions.pushNotificationUpdateMessage", + { + name: myProfile.name || myProfile.uniqueName, + option: didPay + ? t("transactionLabelText.paidLower") + : t("transactionLabelText.declinedLower"), + } + ), + }, + privateKey: contactsPrivateKey, + retrivedContact, + masterInfoObject, + }), + + retrivedContact.isUsingEncriptedMessaging + ? updateMessage({ + newMessage, + fromPubKey: publicKey, + toPubKey: selectedContact.uuid, + retrivedContact, + privateKey: contactsPrivateKey, + currentTime, + }) + : updateMessage({ + newMessage, + fromPubKey: transaction.fromPubKey, + toPubKey: transaction.toPubKey, + retrivedContact, + privateKey: contactsPrivateKey, + currentTime, + }), + ]); + if (!didUpdateMessage && usingOnPage) { + navigate("/error", { + state: { + errorMessage: t("errormessages.updateContactMessageError"), + }, + }); + } + } catch (err) { + console.log(err); + if (!usingOnPage) return; + navigate("/error", { + state: { + errorMessage: t("errormessages.declinePaymentError"), + }, + }); + } finally { + if (!usingOnPage) return; + setIsLoading((prev) => ({ + ...prev, + [didPay ? "sendBTN" : "declineBTN"]: false, + })); + } + }, + [ + selectedContact, + myProfile, + contactsPrivateKey, + publicKey, + getServerTime, + navigate, + masterInfoObject, + globalContactsInformation, + ] + ); + + const acceptPayRequest = useCallback( + async (transaction, selectedContact) => { + setIsLoading((prev) => ({ + ...prev, + sendBTN: true, + })); + const sendingAmount = transaction.message.amountMsat / 1000; + + const myProfileMessage = t( + "contacts.internalComponents.contactsTransactions.acceptProfileMessage", + { + name: selectedContact.name || selectedContact.uniqueName, + } + ); + const payingContactMessage = t( + "contacts.internalComponents.contactsTransactions.acceptPayingContactMessage", + { + name: + globalContactsInformation.myProfile.name || + globalContactsInformation.myProfile.uniqueName, + } + ); + + const { + receiveAddress, + didWork, + error, + formattedPayingContactMessage, + retrivedContact, + } = await getReceiveAddressAndContactForContactsPayment({ + sendingAmountSat: sendingAmount, + selectedContact, + myProfileMessage, + payingContactMessage, + }); + + if (!didWork) { + navigate("/error", { + state: { + errorMessage: error, + useTranslationString: true, + }, + }); + return; + } + + setIsLoading((prev) => ({ + ...prev, + sendBTN: false, + })); + + navigate("/confirm-payment", { + state: { + btcAdress: receiveAddress, + comingFromAccept: true, + enteredPaymentInfo: { + amount: sendingAmount, + description: myProfileMessage, + }, + contactInfo: { + imageData, + name: selectedContact.name || selectedContact.uniqueName, + payingContactMessage: formattedPayingContactMessage, + uniqueName: retrivedContact?.contacts?.myProfile?.uniqueName, + uuid: retrivedContact?.uuid, + }, + fromPage: "contacts", + publishMessageFunc: (txid) => + updatePaymentStatus(transaction, false, true, txid), + }, + }); + return; + }, + [ + myProfile, + navigate, + updatePaymentStatus, + globalContactsInformation, + imageData, + selectedContact, + ] + ); + + const handleDescriptionClick = () => { + if (!paymentDescription) return; + navigate("/modal", { + state: { + wantedContent: "expandedContactMessage", + sliderHeight: 0.3, + message: paymentDescription, + }, + }); + }; + + if (txParsed === undefined) return null; + + const isCompletedTransaction = + txParsed.didSend || + !txParsed.isRequest || + (txParsed.isRequest && txParsed.isRedeemed != null); + + return ( + + ); +} diff --git a/src/pages/contacts/internalComponents/giftCardTxItem/giftCardTxItem.css b/src/pages/contacts/internalComponents/giftCardTxItem/giftCardTxItem.css new file mode 100644 index 0000000..4e5b25f --- /dev/null +++ b/src/pages/contacts/internalComponents/giftCardTxItem/giftCardTxItem.css @@ -0,0 +1,52 @@ +/* Gift Card Transaction Container */ +.gift-card-transaction-container { + width: 95%; + display: flex; + flex-direction: row; + align-items: center; + padding: 12.5px 0; + margin: 0 auto; + background: none; + border: none; + cursor: pointer; + text-align: left; + transition: opacity 0.2s ease; +} + +.gift-card-transaction-container:hover:not(:disabled) { + opacity: 0.9; +} + +.gift-card-transaction-container:disabled { + cursor: default; +} + +/* Gift Card Logo Container */ +.gift-card-logo-container { + width: 50px; + height: 50px; + aspect-ratio: 1; + display: flex; + justify-content: center; + align-items: center; + border-radius: 12px; + padding: 6px; + overflow: visible; + margin-right: 15px; + position: relative; +} + +.gift-card-logo { + width: 100%; + height: 100%; + border-radius: 8px; + object-fit: contain; +} + +/* Gift Card Content */ +.gift-card-content { + width: 100%; + flex: 1; + display: flex; + flex-direction: column; +} diff --git a/src/pages/contacts/internalComponents/giftCardTxItem/giftCardTxItem.jsx b/src/pages/contacts/internalComponents/giftCardTxItem/giftCardTxItem.jsx new file mode 100644 index 0000000..595000f --- /dev/null +++ b/src/pages/contacts/internalComponents/giftCardTxItem/giftCardTxItem.jsx @@ -0,0 +1,122 @@ +import FormattedSatText from "../../../../components/formattedSatText/formattedSatText"; +import ThemeText from "../../../../components/themeText/themeText"; +import { Colors } from "../../../../constants/theme"; +import "./giftCardTxItem.css"; + +export default function GiftCardTxItem({ + txParsed, + isOutgoingPayment, + theme, + darkModeType, + backgroundOffset, + textColor, + t, + timeDifference, + isFromProfile, + navigate, + masterInfoObject, +}) { + const giftCardName = txParsed.giftCardInfo?.name; + + const handleClick = () => { + if (isFromProfile || !navigate) return; + + navigate("/modal", { + state: { + wantedContent: "viewContactsGiftInfo", + giftCardInfo: txParsed.giftCardInfo, + message: txParsed.description, + from: "txItem", + sliderHeight: 1, + isOutgoingPayment, + }, + }); + }; + + return ( + + ); +} diff --git a/src/pages/contacts/utils/formatListDisplayName.js b/src/pages/contacts/utils/formatListDisplayName.js new file mode 100644 index 0000000..9cb52f4 --- /dev/null +++ b/src/pages/contacts/utils/formatListDisplayName.js @@ -0,0 +1,17 @@ +export function formatDisplayName(contact) { + try { + if (contact.name?.length) { + return contact.name?.trim(); + } else if (contact.uniqueName?.length) { + return contact.uniqueName?.trim(); + } else if (contact.isLNURL) { + const [prefix, suffix] = contact.receiveAddress.split('@'); + console.log(prefix, suffix); + return prefix; + } + return ''; + } catch (err) { + console.log('error formatting display name', err); + return ''; + } +} diff --git a/src/pages/contacts/utils/getReceiveAddressAndKindForPayment.js b/src/pages/contacts/utils/getReceiveAddressAndKindForPayment.js new file mode 100644 index 0000000..850172c --- /dev/null +++ b/src/pages/contacts/utils/getReceiveAddressAndKindForPayment.js @@ -0,0 +1,55 @@ +import { getDataFromCollection } from "../../../../db"; + +export default async function getReceiveAddressAndContactForContactsPayment({ + sendingAmountSat, + selectedContact, + myProfileMessage = "", + payingContactMessage = "", + onlyGetContact = false, +}) { + try { + let receiveAddress; + let retrivedContact; + let message = ""; + + if (selectedContact.isLNURL) { + receiveAddress = selectedContact.receiveAddress; + retrivedContact = selectedContact; + } else { + retrivedContact = await getDataFromCollection( + "blitzWalletUsers", + selectedContact.uuid + ); + + if (!retrivedContact) throw new Error("errormessages.fullDeeplinkError"); + + if (onlyGetContact) + return { didWork: true, receiveAddress: "", retrivedContact }; + + if (retrivedContact?.contacts?.myProfile?.sparkAddress) { + if (payingContactMessage?.usingTranslation) { + message = retrivedContact.isUsingNewNotifications + ? JSON.stringify({ + name: payingContactMessage.name, + translation: "contacts.sendAndRequestPage.contactMessage", + }) + : `${payingContactMessage.name} paid you`; + } else { + message = payingContactMessage; + } + + receiveAddress = retrivedContact?.contacts?.myProfile?.sparkAddress; + } else throw new Error("errormessages.legacyContactError"); + } + + return { + didWork: true, + receiveAddress, + retrivedContact, + formattedPayingContactMessage: message, + }; + } catch (err) { + console.log("error getting receive address for contact payment"); + return { didWork: false, error: err.message }; + } +} diff --git a/src/pages/contacts/utils/hooks.js b/src/pages/contacts/utils/hooks.js new file mode 100644 index 0000000..3d8c549 --- /dev/null +++ b/src/pages/contacts/utils/hooks.js @@ -0,0 +1,43 @@ +import { useMemo } from 'react'; + +export const useProcessedContacts = (decodedAddedContacts, contactsMessags) => { + return useMemo(() => { + return decodedAddedContacts.map(contact => { + const info = contactsMessags[contact.uuid] || {}; + return { + contact, + hasUnlookedTransaction: !!info.messages?.some(m => !m.message.wasSeen), + lastUpdated: info.lastUpdated, + firstMessage: info.messages?.[0], + }; + }); + }, [decodedAddedContacts, contactsMessags]); +}; + +export const useFilteredContacts = ( + contactInfoList, + inputText, + hideUnknownContacts, +) => { + return useMemo(() => { + const searchTerm = inputText.toLowerCase(); + return contactInfoList + .filter(item => { + const matchesSearch = + item.contact.name?.toLowerCase().startsWith(searchTerm) || + item.contact.uniqueName?.toLowerCase().startsWith(searchTerm); + const isNotFavorite = !item.contact.isFavorite; + const shouldShow = !hideUnknownContacts || item.contact.isAdded; + + return matchesSearch && isNotFavorite && shouldShow; + }) + .sort((a, b) => { + const timeDiff = (b.lastUpdated || 0) - (a.lastUpdated || 0); + if (timeDiff !== 0) return timeDiff; + + const nameA = a.contact.name || a.contact.uniqueName || ''; + const nameB = b.contact.name || b.contact.uniqueName || ''; + return nameA.localeCompare(nameB); + }); + }, [contactInfoList, inputText, hideUnknownContacts]); +}; diff --git a/src/pages/contacts/utils/imageComparison.js b/src/pages/contacts/utils/imageComparison.js new file mode 100644 index 0000000..a163ace --- /dev/null +++ b/src/pages/contacts/utils/imageComparison.js @@ -0,0 +1,23 @@ +import * as Crypto from 'expo-crypto'; +import * as FileSystem from 'expo-file-system/legacy'; + +async function getImageHash(imageUri) { + if (!imageUri) return ''; + const base64 = await FileSystem.readAsStringAsync(imageUri, { + encoding: FileSystem.EncodingType.Base64, + }); + + const hash = await Crypto.digestStringAsync( + Crypto.CryptoDigestAlgorithm.MD5, + base64, + ); + + return hash; +} + +export async function areImagesSame(uri1, uri2) { + const hash1 = await getImageHash(uri1); + const hash2 = await getImageHash(uri2); + + return hash1 === hash2; +} diff --git a/src/pages/contacts/utils/transactionText.js b/src/pages/contacts/utils/transactionText.js new file mode 100644 index 0000000..41b152b --- /dev/null +++ b/src/pages/contacts/utils/transactionText.js @@ -0,0 +1,29 @@ +export const getTransactionContent = ({ + paymentDescription, + didDeclinePayment, + txParsed, + t, +}) => { + if (paymentDescription) { + return paymentDescription; + } + + if (didDeclinePayment) { + return txParsed.didSend + ? t('transactionLabelText.requestDeclined') + : t('transactionLabelText.declinedRequest'); + } + + if (txParsed.isRequest) { + if (txParsed.didSend) { + return txParsed.isRedeemed === null + ? t('transactionLabelText.requestSent') + : t('transactionLabelText.requestPaid'); + } + return t('transactionLabelText.paidRequest'); + } + + return txParsed.didSend + ? t('transactionLabelText.sent') + : t('transactionLabelText.received'); +}; diff --git a/src/pages/contacts/utils/useExpandedNavbar.js b/src/pages/contacts/utils/useExpandedNavbar.js new file mode 100644 index 0000000..55d2d7b --- /dev/null +++ b/src/pages/contacts/utils/useExpandedNavbar.js @@ -0,0 +1,88 @@ +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { useGlobalContacts } from "../../../contexts/globalContacts"; +import { useAppStatus } from "../../../contexts/appStatus"; +import { + decryptMessage, + encryptMessage, +} from "../../../functions/encodingAndDecoding"; +import { useKeysContext } from "../../../contexts/keysContext"; + +/** + * Custom hook for managing profile image operations + * Handles adding, uploading, and deleting profile images for contacts + */ +export function useExpandedNavbar() { + const navigate = useNavigate(); + const { t } = useTranslation(); + const { contactsPrivateKey, publicKey } = useKeysContext(); + const { isConnectedToTheInternet } = useAppStatus(); + const { globalContactsInformation, toggleGlobalContactsInformation } = + useGlobalContacts(); + + /** + * toggle whether a contact is a favorite + * @param {object} selectedContact - The contact object (only needed if not editing own profile) + */ + const handleFavortie = async ({ selectedContact }) => { + if (!isConnectedToTheInternet) { + navigate.navigate("ErrorScreen", { + errorMessage: t("errormessages.nointernet"), + }); + return; + } + if (!selectedContact) return; + toggleGlobalContactsInformation( + { + myProfile: { ...globalContactsInformation.myProfile }, + addedContacts: await encryptMessage( + contactsPrivateKey, + publicKey, + JSON.stringify( + [ + ...JSON.parse( + await decryptMessage( + contactsPrivateKey, + publicKey, + globalContactsInformation.addedContacts + ) + ), + ].map((savedContact) => { + if (savedContact.uuid === selectedContact.uuid) { + return { + ...savedContact, + isFavorite: !savedContact.isFavorite, + }; + } else return savedContact; + }) + ) + ), + }, + true + ); + }; + + /** + * navigate to settings page + * @param {object} params - Upload parameters + * @param {object} selectedContact - The contact object (only needed if not editing own profile) + */ + const handleSettings = ({ selectedContact }) => { + if (!isConnectedToTheInternet) { + navigate("ErrorScreen", { + errorMessage: t("errormessages.nointernet"), + }); + return; + } + if (!selectedContact) return; + navigate.navigate("EditMyProfilePage", { + pageType: "addedContact", + selectedAddedContact: selectedContact, + }); + }; + + return { + handleFavortie, + handleSettings, + }; +} diff --git a/src/pages/contacts/utils/useProfileImage.js b/src/pages/contacts/utils/useProfileImage.js new file mode 100644 index 0000000..af01455 --- /dev/null +++ b/src/pages/contacts/utils/useProfileImage.js @@ -0,0 +1,224 @@ +import { useState } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { useTranslation } from 'react-i18next'; +import * as ImageManipulator from 'expo-image-manipulator'; +import { getImageFromLibrary } from '../../../../../functions/imagePickerWrapper'; +import { useGlobalContacts } from '../../../../../../context-store/globalContacts'; +import { useImageCache } from '../../../../../../context-store/imageCache'; +import { + deleteDatabaseImage, + setDatabaseIMG, +} from '../../../../../../db/photoStorage'; + +export function useProfileImage() { + const navigate = useNavigation(); + const { t } = useTranslation(); + const { globalContactsInformation, toggleGlobalContactsInformation } = + useGlobalContacts(); + const { refreshCache, removeProfileImageFromCache } = useImageCache(); + + const [isAddingImage, setIsAddingImage] = useState(false); + + /** + * gets a profile picture for a contact + */ + const getProfileImage = async () => { + try { + const imagePickerResponse = await getImageFromLibrary({ quality: 1 }); + const { didRun, error, imgURL } = imagePickerResponse; + + if (!didRun) return; + + if (error) { + navigate.navigate('ErrorScreen', { errorMessage: t(error) }); + return; + } + const startTime = Date.now(); + setIsAddingImage(true); + + const savedImage = await resizeImage({ imgURL }); + + if (!savedImage.uri) return; + const offsetTime = Date.now() - startTime; + const remainingTime = Math.max(0, 700 - offsetTime); + + if (remainingTime > 0) { + console.log(`Waiting ${remainingTime}ms to reach minimum 1s duration`); + await new Promise(resolve => setTimeout(resolve, remainingTime)); + } + + return { comparison: savedImage, imgURL }; + } catch (err) { + console.log('error getting profile iamge', err); + } finally { + setIsAddingImage(false); + } + }; + + /** + * Saves a profile picture for a contact + * @param {string} imgURL - Imgae URI + * @param {boolean} isEditingMyProfile - Whether editing own profile + */ + const saveProfileImage = async ( + imgData, + isEditingMyProfile, + selectedContact, + ) => { + try { + if (isEditingMyProfile) { + const response = await uploadProfileImage({ + imgURL: imgData.comparison.uri, + uuid: globalContactsInformation.myProfile.uuid, + }); + + if (!response) return; + + toggleGlobalContactsInformation( + { + myProfile: { + ...globalContactsInformation.myProfile, + hasProfileImage: true, + }, + addedContacts: globalContactsInformation.addedContacts, + }, + true, + ); + return; + } + + // For other contacts, just refresh cache + if (selectedContact) { + await refreshCache(selectedContact.uuid, imgData.comparison.uri, false); + } + } catch (err) { + console.log('error saving profile image', err); + } + }; + + /** + * Resizes and crops an image to a circle for profile pictures + * @param {object} params - Upload parameters + * @param {object} params.imgURL - Image URL object + */ + const resizeImage = async ({ imgURL }) => { + try { + const { width: originalWidth, height: originalHeight } = imgURL; + const photoWidth = originalWidth * 0.95; + const photoHeight = originalHeight * 0.95; + const targetSize = 250; // Match your largest display size + + const smallerDimension = Math.min(photoWidth, photoHeight); + const cropSize = smallerDimension; + const cropX = (photoWidth - cropSize) / 2; + const cropY = (photoHeight - cropSize) / 2; + + // Get image dimensions to calculate center crop + const manipulator = ImageManipulator.ImageManipulator.manipulate( + imgURL.uri, + ); + + const cropped = manipulator.crop({ + originX: cropX, + originY: cropY, + width: cropSize, + height: cropSize, + }); + + const resized = cropped.resize({ + width: targetSize, + height: targetSize, + }); + + const image = await resized.renderAsync(); + const savedImage = await image.saveAsync({ + compress: 0.4, + format: ImageManipulator.SaveFormat.WEBP, + }); + + return savedImage; + } catch (err) { + console.log('Error resizing image', err); + return {}; + } + }; + + /** + * Uploads and processes a profile image + * @param {object} params - Upload parameters + * @param {object} params.imgURL - Image URL object + * @param {string} params.uuid - UUID of the profile + * @param {boolean} params.removeImage - Whether to remove the image + */ + const uploadProfileImage = async ({ imgURL, uuid, removeImage }) => { + try { + if (!removeImage) { + const response = await setDatabaseIMG(uuid, { uri: imgURL }); + + if (response) { + await refreshCache(uuid, imgURL, false); + return true; + } else { + throw new Error(t('contacts.editMyProfilePage.unableToSaveError')); + } + } else { + await deleteDatabaseImage(uuid); + await removeProfileImageFromCache(uuid); + return true; + } + } catch (err) { + console.log(err); + navigate.navigate('ErrorScreen', { errorMessage: err.message }); + return false; + } + }; + + /** + * Deletes a profile picture + * @param {boolean} isEditingMyProfile - Whether editing own profile + * @param {object} selectedContact - The contact object (only needed if not editing own profile) + */ + const deleteProfilePicture = async ( + isEditingMyProfile, + selectedContact = null, + ) => { + try { + if (isEditingMyProfile) { + const response = await uploadProfileImage({ + removeImage: true, + uuid: globalContactsInformation.myProfile.uuid, + }); + + if (!response) return; + + toggleGlobalContactsInformation( + { + myProfile: { + ...globalContactsInformation.myProfile, + hasProfileImage: false, + }, + addedContacts: globalContactsInformation.addedContacts, + }, + true, + ); + return; + } + + if (selectedContact) { + await removeProfileImageFromCache(selectedContact.uuid); + } + } catch (err) { + navigate.navigate('ErrorScreen', { + errorMessage: t('contacts.editMyProfilePage.deleteProfileImageError'), + }); + console.log(err); + } + }; + + return { + isAddingImage, + deleteProfilePicture, + getProfileImage, + saveProfileImage, + }; +} diff --git a/src/pages/contacts/utils/utilityFunctions.js b/src/pages/contacts/utils/utilityFunctions.js new file mode 100644 index 0000000..cc4f50d --- /dev/null +++ b/src/pages/contacts/utils/utilityFunctions.js @@ -0,0 +1,66 @@ +import { formatLocalTimeNumeric } from "../../../functions/timeFormatter"; + +function isSameDay(date1, date2) { + return ( + date1.getFullYear() === date2.getFullYear() && + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate() + ); +} + +function getDaysDifference(laterDate, earlierDate) { + const later = new Date( + laterDate.getFullYear(), + laterDate.getMonth(), + laterDate.getDate() + ); + const earlier = new Date( + earlierDate.getFullYear(), + earlierDate.getMonth(), + earlierDate.getDate() + ); + + const differenceMs = later - earlier; + return Math.floor(differenceMs / (1000 * 60 * 60 * 24)); +} + +export function createFormattedDate(time, currentTime, t) { + const date = new Date(time); + const currentDate = new Date(currentTime); + + const daysOfWeek = [ + t("weekdays.Sun"), + t("weekdays.Mon"), + t("weekdays.Tue"), + t("weekdays.Wed"), + t("weekdays.Thu"), + t("weekdays.Fri"), + t("weekdays.Sat"), + ]; + + let formattedTime; + + if (isSameDay(date, currentDate)) { + const hours = date.getHours(); + const minutes = date.getMinutes(); + const ampm = hours >= 12 ? "PM" : "AM"; + const formattedHours = hours % 12 === 0 ? 12 : hours % 12; + const formattedMinutes = minutes < 10 ? "0" + minutes : minutes; + formattedTime = `${formattedHours}:${formattedMinutes} ${ampm}`; + } else { + const daysDiff = getDaysDifference(currentDate, date); + if (daysDiff === 1) { + formattedTime = t("constants.yesterday"); + } else if (daysDiff <= 7) { + formattedTime = daysOfWeek[date.getDay()]; + } else { + formattedTime = formatLocalTimeNumeric(date); + } + } + + return formattedTime; +} + +export function formatMessage(message) { + return message?.message?.description; +} diff --git a/src/pages/customHalfModal/index.jsx b/src/pages/customHalfModal/index.jsx index 60c7860..ae2a041 100644 --- a/src/pages/customHalfModal/index.jsx +++ b/src/pages/customHalfModal/index.jsx @@ -8,6 +8,7 @@ import ManualEnterSendAddress from "../wallet/components/sendOptions/manualEnter import SwitchReceiveOption from "../switchReceiveOption/switchReceiveOption"; import EditLNURLContactOnReceivePage from "./components/editLNURLOnReceive"; import LRC20TokenInformation from "../../functions/lrc20/lrc20TokenDataHalfModal"; +import AddContactsModal from "../contacts/components/addContactsHalfModal/addContactsHalfModal"; export default function CustomHalfModal({ onClose, @@ -81,6 +82,19 @@ export default function CustomHalfModal({ params={params} /> ); + case "addContactsHalfModal": + return ( + + ); case "confirmSMS": return
Confirm SMS: {params?.message}
; default: diff --git a/src/pages/receiveAmount/style.css b/src/pages/receiveAmount/style.css index 54e29bb..a4a633c 100644 --- a/src/pages/receiveAmount/style.css +++ b/src/pages/receiveAmount/style.css @@ -42,7 +42,7 @@ .description-input { width: 100%; - padding: 15px; + padding: 10px; border: unset; border-radius: 8px; } diff --git a/src/pages/receiveQRPage/receiveQRPage.jsx b/src/pages/receiveQRPage/receiveQRPage.jsx index 990b543..fed87f1 100644 --- a/src/pages/receiveQRPage/receiveQRPage.jsx +++ b/src/pages/receiveQRPage/receiveQRPage.jsx @@ -200,9 +200,6 @@ function LNURLContainer({ for: "halfModal", contentType: "editLNURLOnReceive", }); - navigate.navigate("CustomHalfModal", { - wantedContent: "editLNURLOnReceive", - }); }} className="lnurlContainer" > diff --git a/src/pages/wallet/components/nav/nav.css b/src/pages/wallet/components/nav/nav.css index 7b624ca..08429d4 100644 --- a/src/pages/wallet/components/nav/nav.css +++ b/src/pages/wallet/components/nav/nav.css @@ -9,11 +9,6 @@ z-index: 99; align-items: center; } -.walletNavBar img { - width: 30px; - height: 30px; - cursor: pointer; -} .walletNavBar .themeContainer { display: flex; diff --git a/src/pages/wallet/components/nav/nav.jsx b/src/pages/wallet/components/nav/nav.jsx index 86a7b8c..75630b0 100644 --- a/src/pages/wallet/components/nav/nav.jsx +++ b/src/pages/wallet/components/nav/nav.jsx @@ -7,22 +7,12 @@ import { useThemeContext } from "../../../../contexts/themeContext"; import ThemeImage from "../../../../components/ThemeImage/themeImage"; import useThemeColors from "../../../../hooks/useThemeColors"; import { useActiveCustodyAccount } from "../../../../contexts/activeAccount"; -import { - darkMode, - lightMode, - lightModeWhite, - refresh, - refreshWhite, - settingsIcon, - settingsWhite, -} from "../../../../constants/icons"; import { Moon, Sun, RefreshCw, Settings } from "lucide-react"; +import NavBarProfileImage from "../../../../components/navBar/profileImage"; export default function WalletNavBar({ openOverlay, didEnabledLrc20 }) { - const navigate = useNavigate(); - const location = useLocation(); const { theme, toggleTheme, darkModeType } = useThemeContext(); - const { backgroundColor } = useThemeColors(); + const { backgroundColor, backgroundOffset } = useThemeColors(); const [isRefreshing, setIsRefreshing] = useState(false); const { sparkInformation } = useSpark(); const { currentWalletMnemoinc } = useActiveCustodyAccount(); @@ -64,11 +54,7 @@ export default function WalletNavBar({ openOverlay, didEnabledLrc20 }) { className={`${isRefreshing ? "spinningAnimation" : ""}`} />
- navigate("/settings")} - /> +
); } diff --git a/src/tabs/tabs.jsx b/src/tabs/tabs.jsx index 6a0f850..283c6f5 100644 --- a/src/tabs/tabs.jsx +++ b/src/tabs/tabs.jsx @@ -55,6 +55,7 @@ export default function BottomTabs({ setValue, value, Link }) { style={{ backgroundColor: "transparent", borderRadius: "20px", + height: "45px", }} sx={{ "& .MuiBottomNavigationAction-root": { diff --git a/src/workers/messageWorker.js b/src/workers/messageWorker.js new file mode 100644 index 0000000..989b4a7 --- /dev/null +++ b/src/workers/messageWorker.js @@ -0,0 +1,67 @@ +import { decryptMessage } from "../functions/encodingAndDecoding"; + +self.onmessage = async function (e) { + const { type, data } = e.data; + + if (type === "PROCESS_MESSAGES") { + const { allMessages, myPubKey, privateKey } = data; + const processedMessages = []; + + for (let i = 0; i < allMessages.length; i++) { + const message = allMessages[i]; + + // Send progress updates every 10 messages + if (i % 10 === 0) { + self.postMessage({ + type: "PROGRESS", + current: i, + total: allMessages.length, + }); + } + + try { + const isReceived = message.toPubKey === myPubKey; + + if (typeof message.message === "string") { + const sendersPubkey = + message.toPubKey === myPubKey + ? message.fromPubKey + : message.toPubKey; + + const decoded = await decryptMessage( + privateKey, + sendersPubkey, + message.message + ); + + if (!decoded) continue; + + let parsedMessage; + try { + parsedMessage = JSON.parse(decoded); + } catch (err) { + console.log("error parsing decoded message", err); + continue; + } + + processedMessages.push({ + ...message, + message: parsedMessage, + sendersPubkey, + isReceived, + }); + } else { + processedMessages.push(message); + } + } catch (err) { + console.log("error decoding incoming request from history"); + } + } + + // Send completed result + self.postMessage({ + type: "COMPLETE", + data: processedMessages, + }); + } +}; From c30c0fcace43e0a0cca325d958bd75e4b4cacf4e Mon Sep 17 00:00:00 2001 From: Blake Kaufman Date: Wed, 17 Dec 2025 17:00:06 -0500 Subject: [PATCH 02/28] fixing eslint errors --- src/contexts/imageCacheContext.jsx | 2 +- src/functions/contacts/index.js | 4 +- src/functions/images/storage.js | 51 ------------ .../contactsTransactions.jsx | 77 ++++++++++--------- 4 files changed, 43 insertions(+), 91 deletions(-) diff --git a/src/contexts/imageCacheContext.jsx b/src/contexts/imageCacheContext.jsx index ae68ae6..b0916a0 100644 --- a/src/contexts/imageCacheContext.jsx +++ b/src/contexts/imageCacheContext.jsx @@ -92,7 +92,7 @@ export function ImageCacheProvider({ children }) { return { uri: cachedImage.uri, localUri: cachedImage.uri, - updated: cachedMetadata.updated, + updated: cachedImage.metadata, isObjectURL: cachedImage.isObjectURL, }; } diff --git a/src/functions/contacts/index.js b/src/functions/contacts/index.js index c42edb3..0f0e6e7 100644 --- a/src/functions/contacts/index.js +++ b/src/functions/contacts/index.js @@ -28,9 +28,7 @@ export async function getBolt11InvoiceForContact( const url = `https://${domain}/.well-known/lnurlp/${contactUniqueName}?amount=${ sendingValue * 1000 }&isBlitzContact=${useBlitzContact ? true : false}${ - !!description - ? `&comment=${encodeURIComponent(description || "")}` - : "" + description ? `&comment=${encodeURIComponent(description || "")}` : "" }&sendingUUID=${sendingUUID}`; console.log(url); const response = await fetch(url); diff --git a/src/functions/images/storage.js b/src/functions/images/storage.js index f286882..9ad18f8 100644 --- a/src/functions/images/storage.js +++ b/src/functions/images/storage.js @@ -287,18 +287,6 @@ export async function updateCachedImageMetadata(key, metadata) { } } -/** - * Get all cached image keys - */ -export async function getAllCachedImageKeys() { - try { - return await getAllKeys(STORE_NAME); - } catch (e) { - console.error("Error getting all cached image keys:", e); - return []; - } -} - /** * Clear all cached images */ @@ -313,45 +301,6 @@ export async function clearAllCachedImages() { } } -/** - * Get cache statistics - */ -export async function getCacheStats() { - try { - const keys = await getAllKeys(STORE_NAME); - const db = await openDatabase(); - - // Estimate size (not precise but gives an idea) - let totalSize = 0; - const transaction = db.transaction([STORE_NAME], "readonly"); - const store = transaction.objectStore(STORE_NAME); - - const sizes = await Promise.all( - keys.map((key) => { - return new Promise((resolve) => { - const request = store.get(key); - request.onsuccess = () => { - const blob = request.result; - resolve(blob ? blob.size : 0); - }; - request.onerror = () => resolve(0); - }); - }) - ); - - totalSize = sizes.reduce((sum, size) => sum + size, 0); - - return { - totalImages: keys.length, - totalSize, - totalSizeMB: (totalSize / (1024 * 1024)).toFixed(2), - }; - } catch (e) { - console.error("Error getting cache stats:", e); - return null; - } -} - export { blobToDataURL, createObjectURL, diff --git a/src/pages/contacts/internalComponents/contactTransactions/contactsTransactions.jsx b/src/pages/contacts/internalComponents/contactTransactions/contactsTransactions.jsx index d39ab4f..2757ad9 100644 --- a/src/pages/contacts/internalComponents/contactTransactions/contactsTransactions.jsx +++ b/src/pages/contacts/internalComponents/contactTransactions/contactsTransactions.jsx @@ -49,7 +49,7 @@ function ConfirmedOrSentTransaction({ (txParsed.didSend && !txParsed.isRequest) || (txParsed.isRequest && txParsed.isRedeemed && !txParsed.didSend); - if (!!txParsed.giftCardInfo) { + if (txParsed.giftCardInfo) { return ( ({ - ...prev, - [didPay ? "sendBTN" : "declineBTN"]: false, - })); + if (usingOnPage) { + setIsLoading((prev) => ({ + ...prev, + [didPay ? "sendBTN" : "declineBTN"]: false, + })); + } } }, [ From 70dcdff975dedeab01e30247b3d666089e858c45 Mon Sep 17 00:00:00 2001 From: Blake Kaufman Date: Wed, 17 Dec 2025 19:58:23 -0500 Subject: [PATCH 03/28] fixing style inconsitancies --- src/components/backArrow/backArrow.jsx | 16 ++++++++++--- .../transactionContianer.jsx | 2 -- .../expandedContactsPage.jsx | 5 +++- .../addContactPage/addContactPage.jsx | 12 ++-------- .../components/addContactPage/style.css | 6 ++--- .../expandedAddContactPage.jsx | 3 +++ src/pages/contacts/contacts.css | 3 +++ .../editMyProfilePage/editMyProfilePage.jsx | 24 +++++++------------ .../screens/editMyProfilePage/style.css | 10 ++++++++ src/tabs/tabs.jsx | 15 ++++++++++-- 10 files changed, 60 insertions(+), 36 deletions(-) diff --git a/src/components/backArrow/backArrow.jsx b/src/components/backArrow/backArrow.jsx index 32c6b19..24ce012 100644 --- a/src/components/backArrow/backArrow.jsx +++ b/src/components/backArrow/backArrow.jsx @@ -3,8 +3,12 @@ import "./style.css"; import ThemeImage from "../ThemeImage/themeImage"; import { smallArrowLeft } from "../../constants/icons"; import { WHITE_FILTER } from "../../constants"; +import { ArrowLeft } from "lucide-react"; +import useThemeColors from "../../hooks/useThemeColors"; +import { Colors } from "../../constants/theme"; export default function BackArrow({ backFunction, showWhite = false }) { + const { theme, darkModeType } = useThemeColors(); const navigate = useNavigate(); return (
-
); diff --git a/src/components/transactionContainer/transactionContianer.jsx b/src/components/transactionContainer/transactionContianer.jsx index ac7baeb..f67235c 100644 --- a/src/components/transactionContainer/transactionContianer.jsx +++ b/src/components/transactionContainer/transactionContianer.jsx @@ -11,8 +11,6 @@ import { } from "../../constants"; import ThemeText from "../themeText/themeText"; import { useThemeContext } from "../../contexts/themeContext"; -import { pendingTx, smallArrowLeft } from "../../constants/icons"; -import ThemeImage from "../ThemeImage/themeImage"; import { useTranslation } from "react-i18next"; import { useMemo } from "react"; import { formatTokensNumber } from "../../functions/lrc20/formatTokensBalance"; diff --git a/src/pages/contacts/components/ExpandedContactsPage/expandedContactsPage.jsx b/src/pages/contacts/components/ExpandedContactsPage/expandedContactsPage.jsx index d266ed0..90f2741 100644 --- a/src/pages/contacts/components/ExpandedContactsPage/expandedContactsPage.jsx +++ b/src/pages/contacts/components/ExpandedContactsPage/expandedContactsPage.jsx @@ -335,7 +335,7 @@ export default function ExpandedContactsPage({ ); } - console.log(selectedContact); + return (
@@ -344,6 +344,7 @@ export default function ExpandedContactsPage({ color={ theme && darkModeType ? Colors.dark.text : Colors.constants.blue } + size={30} /> {selectedContact && ( @@ -360,6 +361,7 @@ export default function ExpandedContactsPage({ color={ theme && darkModeType ? Colors.dark.text : Colors.constants.blue } + size={30} /> )} {selectedContact && ( @@ -368,6 +370,7 @@ export default function ExpandedContactsPage({ color={ theme && darkModeType ? Colors.dark.text : Colors.constants.blue } + size={30} /> )}
diff --git a/src/pages/contacts/components/addContactPage/addContactPage.jsx b/src/pages/contacts/components/addContactPage/addContactPage.jsx index e627053..c01e3e2 100644 --- a/src/pages/contacts/components/addContactPage/addContactPage.jsx +++ b/src/pages/contacts/components/addContactPage/addContactPage.jsx @@ -20,18 +20,10 @@ export default function AddContactPage({ selectedContact }) { return (
- + {!!username && ( - + )} {!!lnurl && ( diff --git a/src/pages/contacts/components/addContactPage/style.css b/src/pages/contacts/components/addContactPage/style.css index 13ec908..2271408 100644 --- a/src/pages/contacts/components/addContactPage/style.css +++ b/src/pages/contacts/components/addContactPage/style.css @@ -6,16 +6,16 @@ } .name-text { - font-size: 24px; text-align: center; opacity: 0.6; + margin-top: 10px; margin-bottom: 5px; } .username-text { - font-size: 18px; text-align: center; - margin-bottom: 15px; + margin-top: 0px; + margin-bottom: 20px; } .info-container { diff --git a/src/pages/contacts/components/expandedAddContactPage/expandedAddContactPage.jsx b/src/pages/contacts/components/expandedAddContactPage/expandedAddContactPage.jsx index 765ff4b..6da95dd 100644 --- a/src/pages/contacts/components/expandedAddContactPage/expandedAddContactPage.jsx +++ b/src/pages/contacts/components/expandedAddContactPage/expandedAddContactPage.jsx @@ -61,6 +61,7 @@ const MemoizedNavBar = memo( color={ theme && darkModeType ? Colors.dark.text : Colors.constants.blue } + size={30} /> {selectedContact && isContactAdded && ( @@ -77,6 +78,7 @@ const MemoizedNavBar = memo( color={ theme && darkModeType ? Colors.dark.text : Colors.constants.blue } + size={30} /> )} {selectedContact && isContactAdded && ( @@ -85,6 +87,7 @@ const MemoizedNavBar = memo( color={ theme && darkModeType ? Colors.dark.text : Colors.constants.blue } + size={30} /> )}
diff --git a/src/pages/contacts/contacts.css b/src/pages/contacts/contacts.css index 56be8e1..8674208 100644 --- a/src/pages/contacts/contacts.css +++ b/src/pages/contacts/contacts.css @@ -51,6 +51,7 @@ align-items: center; padding: 10px 0; cursor: pointer; + max-width: 80px; } #contactsPage .pinnedContactScrollview { display: flex; @@ -65,6 +66,7 @@ #contactsPage .pinned-contact-image-container { width: 100%; + aspect-ratio: 1; border-radius: 9999px; display: flex; @@ -98,6 +100,7 @@ align-items: center; padding: 15px 0; cursor: pointer; + margin: 0 auto; } #contactsPage .contact-image-container { diff --git a/src/pages/contacts/screens/editMyProfilePage/editMyProfilePage.jsx b/src/pages/contacts/screens/editMyProfilePage/editMyProfilePage.jsx index 4ae4514..49aec3b 100644 --- a/src/pages/contacts/screens/editMyProfilePage/editMyProfilePage.jsx +++ b/src/pages/contacts/screens/editMyProfilePage/editMyProfilePage.jsx @@ -186,7 +186,7 @@ function InnerContent({ return (
- + { @@ -270,14 +270,12 @@ function InnerContent({ />
@@ -288,7 +286,7 @@ function InnerContent({ receiveAddressRef.current.focus(); }} > - + { @@ -305,14 +303,12 @@ function InnerContent({ />
@@ -326,7 +322,7 @@ function InnerContent({ uniquenameRef.current.focus(); }} > - + { @@ -343,14 +339,12 @@ function InnerContent({ />
@@ -361,7 +355,7 @@ function InnerContent({ bioRef.current.focus(); }} > - + { @@ -381,12 +375,12 @@ function InnerContent({
diff --git a/src/pages/contacts/screens/editMyProfilePage/style.css b/src/pages/contacts/screens/editMyProfilePage/style.css index 7d3328d..6c5fc09 100644 --- a/src/pages/contacts/screens/editMyProfilePage/style.css +++ b/src/pages/contacts/screens/editMyProfilePage/style.css @@ -20,6 +20,7 @@ position: relative; width: max-content; align-self: center; + margin-bottom: 25px; } #editMyProfile .profileImageBackground { width: 150px; @@ -58,3 +59,12 @@ margin: 0; max-width: unset; } + +#editMyProfile .input-label { + margin: 0; + margin-bottom: 5px; +} +#editMyProfile .charCount-label { + text-align: right; + margin: 10px 0 15px; +} diff --git a/src/tabs/tabs.jsx b/src/tabs/tabs.jsx index 283c6f5..0156247 100644 --- a/src/tabs/tabs.jsx +++ b/src/tabs/tabs.jsx @@ -29,10 +29,10 @@ export default function BottomTabs({ setValue, value, Link }) {
Date: Thu, 18 Dec 2025 14:23:53 -0500 Subject: [PATCH 04/28] adding send + requests + updating context logic + resotore --- db/index.js | 165 ++- .../activityIndicator/activityIndicator.jsx | 1 - .../customNumberKeyboard.jsx | 10 +- src/components/customNumberKeyboard/style.css | 1 + src/components/emojiBar/emojiQuickBar.css | 49 + src/components/emojiBar/emojiQuickBar.jsx | 392 ++++++ .../formattedBalanceInput.css | 72 ++ .../formattedBalanceInput.jsx | 184 +++ .../navBarWithBalance/navBarWithBalance.css | 31 + .../navBarWithBalance/navbarWithBalance.jsx | 75 ++ src/components/overlayHost.jsx | 47 + .../customSendAndRequsetButton.jsx | 4 +- src/components/themeText/themeText.jsx | 2 + .../transactionContianer.jsx | 19 +- src/constants/theme.js | 1 + src/contexts/activeAccount.jsx | 2 +- src/contexts/globalContacts.jsx | 88 +- src/contexts/navigationLogger.jsx | 2 - src/contexts/overlayContext.jsx | 45 + src/contexts/sparkContext.jsx | 1097 ++++++++++++----- src/functions/apps/giftCardPurchaseTracker.js | 57 + src/functions/initiateWalletConnection.js | 138 ++- .../lrc20/lrc20TokenDataHalfModal.jsx | 9 +- src/functions/messaging/publishMessage.js | 272 +++- src/functions/pollingManager.js | 208 ++++ src/functions/spark/handleBalanceCache.js | 62 + src/functions/spark/index.js | 30 + src/functions/spark/restore.js | 617 ++++++--- src/functions/spark/transactions.js | 105 ++ src/functions/spark/transformTxToPayment.js | 105 +- src/functions/textInputConvertValue.js | 26 + src/main.jsx | 708 +++++------ src/pages/camera/camera.jsx | 6 +- .../expandedContactsPage.css | 1 - .../expandedContactsPage.jsx | 7 +- .../sendAndRequestPage/sendAndRequestPage.css | 147 +++ .../sendAndRequestPage/sendAndRequsetPage.jsx | 778 ++++++++++++ src/pages/contacts/contacts.jsx | 4 +- .../contactsTransactionItem.css | 3 +- .../contactsTransactions.jsx | 134 +- .../editMyProfilePage/editMyProfilePage.jsx | 4 +- .../screens/myProfilePage/myProfilePage.jsx | 4 +- src/pages/createSeed/createSeed.jsx | 4 +- src/pages/customHalfModal/Modal.css | 2 +- .../components/editLNURLOnReceive/index.jsx | 3 +- src/pages/disclaimer/disclaimer.jsx | 5 +- src/pages/login/login.jsx | 4 +- .../components/buttonContainer.jsx | 3 +- src/pages/receiveQRPage/receiveQRPage.jsx | 4 +- src/pages/restoreWallet/restoreWallet.jsx | 4 +- src/pages/sendPage/sendPage.jsx | 68 +- .../pages/currency/displayCurrency.jsx | 4 +- src/pages/settings/pages/fastPay/fastPay.jsx | 4 +- .../settings/pages/sparkInfo/sparkInfo.jsx | 4 +- .../pages/sparkSettingsPage/index.jsx | 4 +- src/pages/settings/settings.jsx | 4 +- .../settings/settingsItem/settingsItem.jsx | 4 +- .../switchReceiveOption.jsx | 5 +- .../technicalDetails/technicalDetails.jsx | 4 +- src/pages/viewkey/viewKey.jsx | 4 +- .../wallet/components/lrc20Assets/index.jsx | 4 +- src/pages/wallet/components/nav/nav.jsx | 4 +- .../sendAndRequestBTNS/sendAndRequstBtns.jsx | 4 +- .../components/sendOptions/manualEnter.jsx | 6 +- src/pages/wallet/wallet.jsx | 4 +- 65 files changed, 4618 insertions(+), 1250 deletions(-) create mode 100644 src/components/emojiBar/emojiQuickBar.css create mode 100644 src/components/emojiBar/emojiQuickBar.jsx create mode 100644 src/components/formattedBalanceInput/formattedBalanceInput.css create mode 100644 src/components/formattedBalanceInput/formattedBalanceInput.jsx create mode 100644 src/components/navBarWithBalance/navBarWithBalance.css create mode 100644 src/components/navBarWithBalance/navbarWithBalance.jsx create mode 100644 src/components/overlayHost.jsx create mode 100644 src/contexts/overlayContext.jsx create mode 100644 src/functions/apps/giftCardPurchaseTracker.js create mode 100644 src/functions/pollingManager.js create mode 100644 src/functions/spark/handleBalanceCache.js create mode 100644 src/functions/textInputConvertValue.js create mode 100644 src/pages/contacts/components/sendAndRequestPage/sendAndRequestPage.css create mode 100644 src/pages/contacts/components/sendAndRequestPage/sendAndRequsetPage.jsx diff --git a/db/index.js b/db/index.js index e329ca8..ecfe50e 100644 --- a/db/index.js +++ b/db/index.js @@ -19,6 +19,7 @@ import { getCachedMessages, queueSetCashedMessages, } from "../src/functions/messaging/cachedMessages"; +import { encryptMessage } from "../src/functions/encodingAndDecoding"; export async function addDataToCollection(dataObject, collectionName, uuid) { try { @@ -294,16 +295,22 @@ export async function updateMessage({ fromPubKey, toPubKey, onlySaveToLocal, + retrivedContact, + privateKey, + currentTime, }) { try { const messagesRef = collection(db, "contactMessages"); const timestamp = new Date().getTime(); + const useEncription = retrivedContact.isUsingEncriptedMessaging; - const message = { + let message = { fromPubKey, toPubKey, message: newMessage, timestamp, + serverTimestamp: currentTime, + isGiftCard: !!newMessage?.giftCardInfo, }; if (onlySaveToLocal) { @@ -313,6 +320,14 @@ export async function updateMessage({ }); return true; } + if (useEncription) { + let messgae = + typeof message.message === "string" + ? message.message + : JSON.stringify(message.message); + const encripted = await encryptMessage(privateKey, toPubKey, messgae); + message.message = encripted; + } await addDoc(messagesRef, message); console.log("New message was published:", message); @@ -398,3 +413,151 @@ function processWithRAF(allMessages, myPubKey, privateKey, onProgress) { }); }); } + +export async function isValidNip5Name(wantedName) { + try { + crashlyticsLogReport("Seeing if the unique name exists"); + const usersRef = collection(db, "nip5Verification"); + const q = query( + usersRef, + where("nameLower", "==", wantedName.toLowerCase()) + ); + const querySnapshot = await getDocs(q); + return querySnapshot.empty; + } catch (error) { + console.error("Error checking unique name:", error); + crashlyticsRecordErrorReport(error.message); + return false; + } +} +export async function addNip5toCollection(dataObject, uuid) { + try { + if (!uuid) throw Error("Not authenticated"); + + const db = getFirestore(); + const docRef = doc(db, "nip5Verification", uuid); + + await setDoc(docRef, dataObject, { merge: true }); + + return true; + } catch (e) { + console.error("Error adding document: ", e); + return false; + } +} +export async function deleteNip5FromCollection(uuid) { + try { + if (!uuid) throw Error("Not authenticated"); + + const db = getFirestore(); + const docRef = doc(db, "nip5Verification", uuid); + + await deleteDoc(docRef); + + console.log("Document deleted"); + return true; + } catch (e) { + console.error("Error deleting document", e); + return false; + } +} + +export async function addGiftToDatabase(dataObject) { + try { + const db = getFirestore(); + const docRef = doc(db, "blitzGifts", dataObject.uuid); + + await setDoc(docRef, dataObject, { merge: false }); + + console.log("Document merged with ID: ", dataObject.uuid); + return true; + } catch (e) { + console.error("Error adding gift to database: ", e); + return false; + } +} + +export async function updateGiftInDatabase(dataObject) { + try { + const db = getFirestore(); + const docRef = doc(db, "blitzGifts", dataObject.uuid); + + await setDoc(docRef, dataObject, { merge: true }); + + console.log("Document merged with ID: ", dataObject.uuid); + return true; + } catch (e) { + console.error("Error adding gift to database: ", e); + return false; + } +} + +export async function getGiftCard(cardUUID) { + try { + const db = getFirestore(); + const docRef = doc(db, "blitzGifts", cardUUID); + + const docSnap = await getDoc(docRef); + + if (docSnap.exists()) { + const userData = docSnap.data(); + return userData; + } + } catch (e) { + console.error("Error adding gift to database: ", e); + return false; + } +} + +export async function deleteGift(uuid) { + try { + const db = getFirestore(); + const docRef = doc(db, "blitzGifts", uuid); + + await deleteDoc(docRef); + + console.log("Gift deleted:", uuid); + return true; + } catch (e) { + console.error("Error deleting gift:", e); + return false; + } +} + +export async function handleGiftCheck(cardUUID) { + try { + const db = getFirestore(); + const docRef = doc(db, "blitzGifts", cardUUID); + + const docSnap = await getDoc(docRef); + + if (docSnap.exists()) return { didWork: true, wasClaimed: false }; + else return { didWork: true, wasClaimed: true }; + } catch (e) { + console.error("Error adding gift to database: ", e); + return { didWork: false }; + } +} + +export async function reloadGiftsOnDomesday(uuid) { + try { + const db = getFirestore(); + + const q = query( + collection(db, "blitzGifts"), + where("createdBy", "==", uuid) + ); + + const snapshot = await getDocs(q); + + const results = snapshot.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + })); + + return results; + } catch (e) { + console.error("Error fetching gifts by creator:", e); + return []; + } +} diff --git a/src/components/activityIndicator/activityIndicator.jsx b/src/components/activityIndicator/activityIndicator.jsx index 7d66962..585d0e0 100644 --- a/src/components/activityIndicator/activityIndicator.jsx +++ b/src/components/activityIndicator/activityIndicator.jsx @@ -1,7 +1,6 @@ import { Colors } from "../../constants/theme"; import "./style.css"; export default function ActivityIndicator({ color, size = "small" }) { - console.log(color); return (
{ @@ -89,14 +90,7 @@ export default function CustomNumberKeyboard({ className={`keyboard-key ${keyClassName}`} onClick={() => addPin(num)} > - {num === "back" ? ( - - ) : ( - num - )} + {num === "back" ? : num} ))}
diff --git a/src/components/customNumberKeyboard/style.css b/src/components/customNumberKeyboard/style.css index feaa0b8..8e5ed3d 100644 --- a/src/components/customNumberKeyboard/style.css +++ b/src/components/customNumberKeyboard/style.css @@ -1,4 +1,5 @@ .keyboard-container { + width: 100%; display: flex; flex-direction: column; align-items: center; diff --git a/src/components/emojiBar/emojiQuickBar.css b/src/components/emojiBar/emojiQuickBar.css new file mode 100644 index 0000000..5fb2d39 --- /dev/null +++ b/src/components/emojiBar/emojiQuickBar.css @@ -0,0 +1,49 @@ +/* Emoji Quick Bar */ +.emoji-bar { + height: 50px; + width: 100%; + overflow: hidden; +} + +.emoji-scroll-content { + display: flex; + flex-direction: row; + align-items: center; + overflow-x: auto; + overflow-y: hidden; + height: 100%; + padding: 0 8px; +} + +.emoji-scroll-content::-webkit-scrollbar { + display: none; +} + +.emoji-scroll-content { + -ms-overflow-style: none; + scrollbar-width: none; +} + +.emoji-button { + min-width: 50px; + width: 50px; + height: 50px; + display: flex; + justify-content: center; + align-items: center; + background: none; + border: none; + cursor: pointer; + padding: 0; + transition: transform 0.2s ease, opacity 0.2s ease; + flex-shrink: 0; +} + +.emoji-button:hover { + transform: scale(1.2); +} + +.emoji-button:active { + transform: scale(0.95); + opacity: 0.7; +} diff --git a/src/components/emojiBar/emojiQuickBar.jsx b/src/components/emojiBar/emojiQuickBar.jsx new file mode 100644 index 0000000..93e8560 --- /dev/null +++ b/src/components/emojiBar/emojiQuickBar.jsx @@ -0,0 +1,392 @@ +import { useMemo, useCallback } from "react"; + +import i18next from "i18next"; +import "./emojiQuickBar.css"; +import useThemeColors from "../../hooks/useThemeColors"; +import ThemeText from "../themeText/themeText"; + +// Emojis mapped to keywords +const EMOJI_KEYWORDS = { + en: { + "🍕": ["pizza", "food", "dinner", "lunch"], + "🍔": ["burger", "food", "dinner", "lunch", "mcdonalds", "fast food"], + "🌮": ["taco", "food", "dinner", "lunch", "mexican"], + "🍜": ["ramen", "noodles", "food", "dinner", "lunch", "soup"], + "🍣": ["sushi", "food", "dinner", "lunch", "japanese"], + "🍺": ["beer", "drink", "bar", "drinks", "alcohol"], + "🍻": ["beers", "drinks", "bar", "cheers", "alcohol"], + "☕": ["coffee", "cafe", "starbucks", "drink", "breakfast"], + "🍷": ["wine", "drink", "drinks", "alcohol", "dinner"], + "🥂": ["champagne", "drinks", "celebrate", "cheers", "alcohol"], + "🎉": ["party", "celebrate", "birthday", "celebration"], + "🎂": ["cake", "birthday", "dessert", "celebration"], + "🎁": ["gift", "present", "birthday", "celebration"], + "🎮": ["game", "gaming", "xbox", "playstation", "video game"], + "🎬": ["movie", "movies", "film", "cinema", "theater"], + "🎵": ["music", "song", "concert", "spotify"], + "⚽": ["soccer", "football", "sport", "game"], + "🏀": ["basketball", "sport", "game", "nba"], + "🎸": ["guitar", "music", "concert", "band"], + "🎤": ["karaoke", "singing", "music", "concert"], + "✈️": ["flight", "airport", "travel", "trip", "vacation"], + "🚗": ["car", "drive", "uber", "lyft", "ride"], + "🚕": ["taxi", "cab", "uber", "lyft", "ride"], + "🏠": ["home", "house", "rent", "mortgage"], + "🏨": ["hotel", "stay", "travel", "vacation"], + "⛽": ["gas", "fuel", "gasoline", "petrol"], + "🚇": ["subway", "metro", "train", "transit"], + "🚲": ["bike", "bicycle", "cycling", "ride"], + "💰": ["money", "cash", "payment", "pay"], + "💵": ["dollar", "money", "cash", "bill"], + "💳": ["card", "credit", "payment", "pay"], + "🛒": ["groceries", "shopping", "grocery", "store", "supermarket"], + "🎫": ["ticket", "tickets", "concert", "event", "show"], + "🏪": ["store", "shop", "shopping", "convenience"], + "❤️": ["love", "thanks", "thank you", "heart"], + "😂": ["funny", "lol", "haha", "laugh"], + "😊": ["happy", "smile", "thanks"], + "🙏": ["thanks", "thank you", "please", "grateful"], + "👍": ["good", "yes", "ok", "thanks", "great"], + "💯": ["perfect", "100", "great", "excellent"], + "🔥": ["fire", "hot", "lit", "awesome"], + "✨": ["sparkle", "magic", "special", "awesome"], + }, + es: { + "🍕": ["pizza", "comida", "cena", "almuerzo"], + "🍔": [ + "hamburguesa", + "comida", + "cena", + "almuerzo", + "mcdonalds", + "comida rápida", + ], + "🌮": ["taco", "comida", "cena", "almuerzo", "mexicana"], + "🍜": ["ramen", "fideos", "comida", "cena", "almuerzo", "sopa"], + "🍣": ["sushi", "comida", "cena", "almuerzo", "japonés"], + "🍺": ["cerveza", "bebida", "bar", "alcohol"], + "🍻": ["cervezas", "bebidas", "bar", "salud", "alcohol"], + "☕": ["café", "cafetería", "starbucks", "bebida", "desayuno"], + "🍷": ["vino", "bebida", "alcohol", "cena"], + "🥂": ["champán", "brindis", "celebración", "alcohol"], + "🎉": ["fiesta", "celebrar", "cumpleaños", "celebración"], + "🎂": ["pastel", "cumpleaños", "postre", "celebración"], + "🎁": ["regalo", "presente", "cumpleaños", "celebración"], + "🎮": ["juego", "gaming", "xbox", "playstation", "videojuego"], + "🎬": ["película", "cine", "film", "teatro"], + "🎵": ["música", "canción", "concierto", "spotify"], + "⚽": ["fútbol", "deporte", "partido"], + "🏀": ["baloncesto", "deporte", "nba", "partido"], + "🎸": ["guitarra", "música", "concierto", "banda"], + "🎤": ["karaoke", "cantar", "música", "concierto"], + "✈️": ["vuelo", "aeropuerto", "viaje", "vacaciones"], + "🚗": ["auto", "coche", "conducir", "uber", "viaje"], + "🚕": ["taxi", "uber", "viaje"], + "🏠": ["casa", "hogar", "renta", "alquiler"], + "🏨": ["hotel", "estancia", "viaje", "vacaciones"], + "⛽": ["gasolina", "combustible"], + "🚇": ["metro", "subte", "tren", "transporte"], + "🚲": ["bicicleta", "bici", "ciclismo", "viaje"], + "💰": ["dinero", "efectivo", "pago", "pagar"], + "💵": ["dólar", "dinero", "efectivo", "billete"], + "💳": ["tarjeta", "crédito", "pago"], + "🛒": ["compras", "supermercado", "tienda"], + "🎫": ["ticket", "entrada", "concierto", "evento"], + "🏪": ["tienda", "comercio", "supermercado pequeño"], + "❤️": ["amor", "gracias", "corazón"], + "😂": ["gracioso", "risa", "jajaja"], + "😊": ["feliz", "sonrisa", "gracias"], + "🙏": ["gracias", "por favor", "agradecido"], + "👍": ["bien", "sí", "ok", "gracias", "genial"], + "💯": ["perfecto", "excelente", "100"], + "🔥": ["fuego", "caliente", "genial"], + "✨": ["brillo", "magia", "especial", "genial"], + }, + it: { + "🍕": ["pizza", "cibo", "cena", "pranzo"], + "🍔": [ + "burger", + "hamburger", + "cibo", + "cena", + "pranzo", + "mcdonalds", + "fast food", + ], + "🌮": ["taco", "cibo", "cena", "pranzo", "messicano"], + "🍜": ["ramen", "noodles", "cibo", "cena", "pranzo", "zuppa"], + "🍣": ["sushi", "cibo", "cena", "pranzo", "giapponese"], + "🍺": ["birra", "bevanda", "bar", "alcol"], + "🍻": ["birre", "bevande", "brindisi", "alcol"], + "☕": ["caffè", "bar", "starbucks", "bevanda", "colazione"], + "🍷": ["vino", "bevanda", "alcol", "cena"], + "🥂": ["champagne", "brindisi", "celebrare", "alcol"], + "🎉": ["festa", "celebrare", "compleanno"], + "🎂": ["torta", "compleanno", "dessert"], + "🎁": ["regalo", "presente", "compleanno"], + "🎮": ["gioco", "gaming", "xbox", "playstation", "videogioco"], + "🎬": ["film", "cinema", "teatro"], + "🎵": ["musica", "canzone", "concerto", "spotify"], + "⚽": ["calcio", "sport", "partita"], + "🏀": ["basket", "sport", "nba"], + "🎸": ["chitarra", "musica", "concerto", "band"], + "🎤": ["karaoke", "cantare", "musica"], + "✈️": ["volo", "aeroporto", "viaggio", "vacanza"], + "🚗": ["auto", "macchina", "guidare", "uber"], + "🚕": ["taxi", "uber", "corsa"], + "🏠": ["casa", "abitazione", "affitto", "mutuo"], + "🏨": ["hotel", "soggiorno", "viaggio"], + "⛽": ["benzina", "carburante"], + "🚇": ["metro", "sottopassaggio", "treno", "trasporto"], + "🚲": ["bici", "bicicletta", "ciclismo"], + "💰": ["soldi", "contanti", "pagamento"], + "💵": ["dollaro", "soldi", "contanti"], + "💳": ["carta", "credito", "pagamento"], + "🛒": ["spesa", "supermercato", "negozio"], + "🎫": ["biglietto", "evento", "concerto"], + "🏪": ["negozio", "minimarket"], + "❤️": ["amore", "grazie", "cuore"], + "😂": ["divertente", "risata", "ahah"], + "😊": ["felice", "sorriso", "grazie"], + "🙏": ["grazie", "per favore", "grato"], + "👍": ["bene", "ok", "sì", "grazie"], + "💯": ["perfetto", "eccellente"], + "🔥": ["fuoco", "caldo", "fantastico"], + "✨": ["brillare", "magia", "speciale"], + }, + "pt-BR": { + "🍕": ["pizza", "comida", "jantar", "almoço"], + "🍔": [ + "hambúrguer", + "comida", + "jantar", + "almoço", + "mcdonalds", + "fast food", + ], + "🌮": ["taco", "comida", "jantar", "almoço", "mexicano"], + "🍜": ["lamen", "macarrão", "comida", "jantar", "almoço", "sopa"], + "🍣": ["sushi", "comida", "jantar", "almoço", "japonês"], + "🍺": ["cerveja", "bebida", "bar", "álcool"], + "🍻": ["cervejas", "brinde", "bebidas", "álcool"], + "☕": ["café", "cafeteria", "starbucks", "bebida", "café da manhã"], + "🍷": ["vinho", "bebida", "álcool", "jantar"], + "🥂": ["champanhe", "brinde", "celebrar", "álcool"], + "🎉": ["festa", "celebração", "aniversário"], + "🎂": ["bolo", "aniversário", "sobremesa"], + "🎁": ["presente", "gift", "aniversário"], + "🎮": ["jogo", "gaming", "xbox", "playstation", "videogame"], + "🎬": ["filme", "cinema", "teatro"], + "🎵": ["música", "canção", "show", "spotify"], + "⚽": ["futebol", "esporte", "jogo"], + "🏀": ["basquete", "esporte", "nba"], + "🎸": ["guitarra", "música", "show", "banda"], + "🎤": ["karaokê", "cantar", "música"], + "✈️": ["voo", "aeroporto", "viagem", "férias"], + "🚗": ["carro", "dirigir", "uber"], + "🚕": ["táxi", "uber", "corrida"], + "🏠": ["casa", "lar", "aluguel"], + "🏨": ["hotel", "hospedagem", "viagem"], + "⛽": ["gasolina", "combustível"], + "🚇": ["metrô", "trem", "transporte"], + "🚲": ["bicicleta", "bike", "ciclismo"], + "💰": ["dinheiro", "pagamento", "pagar"], + "💵": ["dólar", "dinheiro", "nota"], + "💳": ["cartão", "crédito", "pagamento"], + "🛒": ["mercado", "compras", "supermercado"], + "🎫": ["ingresso", "evento", "show"], + "🏪": ["loja", "mercadinho", "conveniência"], + "❤️": ["amor", "obrigado", "coração"], + "😂": ["engraçado", "haha", "risada"], + "😊": ["feliz", "sorriso", "obrigado"], + "🙏": ["obrigado", "por favor", "gratidão"], + "👍": ["bom", "ok", "sim", "obrigado"], + "💯": ["perfeito", "excelente"], + "🔥": ["fogo", "quente", "incrível"], + "✨": ["brilho", "mágico", "especial"], + }, + "de-DE": { + "🍕": ["pizza", "essen", "abendessen", "mittagessen"], + "🍔": [ + "burger", + "essen", + "abendessen", + "mittagessen", + "mcdonalds", + "fast food", + ], + "🌮": ["taco", "essen", "abendessen", "mittagessen", "mexikanisch"], + "🍜": ["ramen", "nudeln", "essen", "suppe"], + "🍣": ["sushi", "essen", "japanisch"], + "🍺": ["bier", "getränk", "bar", "alkohol"], + "🍻": ["biere", "anstoßen", "getränke", "alkohol"], + "☕": ["kaffee", "café", "starbucks", "getränk", "frühstück"], + "🍷": ["wein", "getränk", "alkohol"], + "🥂": ["sekt", "champagner", "anstoßen", " feiern"], + "🎉": ["party", "feiern", "geburtstag"], + "🎂": ["kuchen", "geburtstag", "dessert"], + "🎁": ["geschenk", "präsent", "geburtstag"], + "🎮": ["spiel", "gaming", "xbox", "playstation", "videospiel"], + "🎬": ["film", "kino", "theater"], + "🎵": ["musik", "lied", "konzert", "spotify"], + "⚽": ["fußball", "sport", "spiel"], + "🏀": ["basketball", "sport", "nba"], + "🎸": ["gitarre", "musik", "konzert", "band"], + "🎤": ["karaoke", "singen", "musik"], + "✈️": ["flug", "reise", "urlaub", "flughafen"], + "🚗": ["auto", "fahren", "uber", "fahrt"], + "🚕": ["taxi", "fahrt"], + "🏠": ["haus", "heim", "miete"], + "🏨": ["hotel", "aufenthalt", "reise"], + "⛽": ["benzin", "kraftstoff"], + "🚇": ["u-bahn", "bahn", "zug", "verkehr"], + "🚲": ["fahrrad", "radfahren"], + "💰": ["geld", "zahlung"], + "💵": ["dollar", "geld", "schein"], + "💳": ["karte", "kreditkarte", "zahlung"], + "🛒": ["einkauf", "supermarkt", "laden"], + "🎫": ["ticket", "eintritt", "event"], + "🏪": ["laden", "geschäft", "kiosk"], + "❤️": ["liebe", "danke", "herz"], + "😂": ["lustig", "lol", "lachen"], + "😊": ["glücklich", "lächeln", "danke"], + "🙏": ["danke", "bitte", "dankbar"], + "👍": ["gut", "ok", "ja", "danke"], + "💯": ["perfekt", "super"], + "🔥": ["feuer", "heiß", "cool"], + "✨": ["glitzer", "magisch", "besonders"], + }, +}; + +const ALL_EMOJIS = [ + "🍕", + "🍔", + "☕", + "🍺", + "🚗", + "⛽", + "🏠", + "💰", + "🎉", + "❤️", + "🌮", + "🍜", + "🍣", + "🍻", + "🍷", + "🥂", + "🎂", + "🎁", + "🎮", + "🎬", + "🎵", + "⚽", + "🏀", + "🎸", + "🎤", + "✈️", + "🚕", + "🏨", + "🚇", + "🚲", + "💵", + "💳", + "🛒", + "🎫", + "🏪", + "😂", + "😊", + "🙏", + "👍", + "💯", + "🔥", + "✨", +]; + +// Default emoji order (most common first) +const DEFAULT_EMOJI_ORDER = ["💵", "🏠", "⛽", "🍕", "☕", "🎁", "🎉", "🎫"]; + +const EmojiQuickBar = ({ description = "", onEmojiSelect }) => { + const { backgroundOffset } = useThemeColors(); + + const defalutItems = useMemo(() => { + return DEFAULT_EMOJI_ORDER.map((item) => ({ + emoji: item, + shouldReplace: false, + score: 0, + })); + }, []); + + const sortedEmojis = useMemo(() => { + const splitString = description.split(" "); + const currentWord = splitString[splitString.length - 1] || ""; + if (!currentWord.trim()) { + return defalutItems; + } + + const lowerDescription = currentWord.toLowerCase(); + const scored = ALL_EMOJIS.map((emoji) => { + const keywords = EMOJI_KEYWORDS[i18next.language][emoji] || []; + + const shouldReplace = keywords[0] + ?.toLowerCase() + .startsWith(lowerDescription); + const score = keywords.reduce((count, keyword) => { + return count + (keyword.includes(lowerDescription) ? 1 : 0); + }, 0); + if (score === 0) return false; + return { emoji, score, shouldReplace }; + }).filter(Boolean); + + if (!scored.length) return defalutItems; + + return scored + .sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return ALL_EMOJIS.indexOf(a.emoji) - ALL_EMOJIS.indexOf(b.emoji); + }) + .map((item) => item); + }, [description, defalutItems]); + + const createDescription = useCallback( + (emoji) => { + let newDescription = ""; + if (emoji.shouldReplace) { + let prevDescription = description.split(" "); + prevDescription.pop(); + newDescription = prevDescription.join(" ") + emoji.emoji; + } else { + newDescription = + description.trim() + + (description.trim().length ? " " : "") + + emoji.emoji; + } + + onEmojiSelect(newDescription + " "); + }, + [description, onEmojiSelect] + ); + + return ( +
+
+ {sortedEmojis.map((emoji, index) => ( + + ))} +
+
+ ); +}; + +export default EmojiQuickBar; diff --git a/src/components/formattedBalanceInput/formattedBalanceInput.css b/src/components/formattedBalanceInput/formattedBalanceInput.css new file mode 100644 index 0000000..62b6d54 --- /dev/null +++ b/src/components/formattedBalanceInput/formattedBalanceInput.css @@ -0,0 +1,72 @@ +/* Formatted Balance Input Container */ +.formatted-balance-input-container { + width: 90%; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + position: relative; + margin: 0 auto; + cursor: pointer; +} +.formatted-balance-input-container p { + margin: 0; + text-transform: uppercase; +} + +.input-wrapper { + overflow: hidden; + display: flex; + align-items: center; + margin-right: 3px; +} + +.scroll-container { + width: 100%; + overflow-x: auto; + overflow-y: hidden; + display: flex; +} + +.scroll-container::-webkit-scrollbar { + display: none; +} + +.scroll-container { + flex-shrink: 1; + -ms-overflow-style: none; + scrollbar-width: none; + overflow-x: auto; + overflow-y: hidden; + display: flex; + white-space: nowrap; +} + +/* Balance Text Input */ +.balance-text-input { + font-size: 40px; + background: none; + border: none; + outline: none; + padding: 0; + margin: 0; + pointer-events: none; + user-select: none; + min-width: fit-content; + text-align: center; +} + +.balance-text-input:focus { + outline: none; +} + +/* Hidden Text for Measurement */ +.hidden-text { + position: absolute; + z-index: -1; + font-size: 40px; + opacity: 0; + pointer-events: none; + white-space: nowrap; + visibility: hidden; +} diff --git a/src/components/formattedBalanceInput/formattedBalanceInput.jsx b/src/components/formattedBalanceInput/formattedBalanceInput.jsx new file mode 100644 index 0000000..eaf6c09 --- /dev/null +++ b/src/components/formattedBalanceInput/formattedBalanceInput.jsx @@ -0,0 +1,184 @@ +import { useMemo, useState, useRef, useEffect } from "react"; +import { + BITCOIN_SAT_TEXT, + BITCOIN_SATS_ICON, + HIDDEN_OPACITY, +} from "../../constants"; +import "./formattedBalanceInput.css"; +import { useNodeContext } from "../../contexts/nodeContext"; +import { formatCurrency } from "../../functions/formatCurrency"; +import ThemeText from "../themeText/themeText"; +import formatBalanceAmount from "../../functions/formatNumber"; +import { useGlobalContextProvider } from "../../contexts/masterInfoObject"; +import useThemeColors from "../../hooks/useThemeColors"; + +export default function FormattedBalanceInput({ + amountValue = 0, + containerFunction, + inputDenomination, + customTextInputContainerStyles, + customTextInputStyles, + activeOpacity = 0.2, + maxWidth = 0.95, + customCurrencyCode = "", +}) { + const containerRef = useRef(null); + const amountRef = useRef(null); + const labelRef = useRef(null); + + const { masterInfoObject } = useGlobalContextProvider(); + const { textColor } = useThemeColors(); + const { fiatStats } = useNodeContext(); + + const currencyText = fiatStats.coin || "USD"; + const showSymbol = masterInfoObject.satDisplay !== "word"; + + const formattedAmount = formatBalanceAmount( + amountValue, + false, + masterInfoObject + ); + + const currencyInfo = useMemo( + () => formatCurrency({ amount: 0, code: currencyText }), + [currencyText] + ); + + const isSymbolInFront = currencyInfo[3]; + const currencySymbol = currencyInfo[2]; + + const showSats = + inputDenomination === "sats" || inputDenomination === "hidden"; + + const displayText = useMemo(() => { + if (customCurrencyCode) return formattedAmount; + return formattedAmount; + }, [formattedAmount, customCurrencyCode]); + + const fontSize = useAutoScaleCompositeFont({ + containerRef, + amountRef, + labelRef, + baseFontSize: 40, + minFontSize: 24, + padding: 12, + displayText, + }); + + return ( +
+ {isSymbolInFront && !showSats && showSymbol && ( + + )} + + {showSats && showSymbol && ( + + )} + + + + {!isSymbolInFront && !showSats && showSymbol && ( + + )} + + {!showSymbol && !showSats && ( + + )} + + {!showSymbol && showSats && ( + + )} +
+ ); +} +function useAutoScaleCompositeFont({ + containerRef, + amountRef, + labelRef, + baseFontSize = 40, + minFontSize = 24, + padding = 16, + displayText, +}) { + const [fontSize, setFontSize] = useState(baseFontSize); + + useEffect(() => { + const container = containerRef.current; + const amountEl = amountRef.current; + const labelEl = labelRef.current; + + if (!container || !amountEl) return; + + const containerWidth = container.offsetWidth; + if (!containerWidth) return; + + // Force base size for measurement + amountEl.style.fontSize = baseFontSize + "px"; + if (labelEl) labelEl.style.fontSize = baseFontSize + "px"; + + const amountWidth = amountEl.offsetWidth; + const labelWidth = labelEl ? labelEl.offsetWidth : 0; + + const totalWidth = amountWidth + labelWidth; + + const available = containerWidth - padding * 2; + + let nextSize = baseFontSize; + + if (totalWidth > available) { + const ratio = available / totalWidth; + nextSize = Math.max(minFontSize, Math.floor(baseFontSize * ratio)); + } + + setFontSize(nextSize); + }, [ + containerRef, + amountRef, + labelRef, + baseFontSize, + minFontSize, + padding, + displayText, + ]); + + return fontSize; +} diff --git a/src/components/navBarWithBalance/navBarWithBalance.css b/src/components/navBarWithBalance/navBarWithBalance.css new file mode 100644 index 0000000..281fab6 --- /dev/null +++ b/src/components/navBarWithBalance/navBarWithBalance.css @@ -0,0 +1,31 @@ +/* Navigation Bar with Balance */ +.nav-bar-top-bar { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + min-height: 30px; + position: relative; +} + +.nav-bar-back-arrow { + position: absolute; + z-index: 99; + left: 0; + background: none; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.nav-bar-container { + display: flex; + flex-direction: row; + align-items: center; + flex-grow: 1; + padding: 0 35px; + justify-content: center; +} diff --git a/src/components/navBarWithBalance/navbarWithBalance.jsx b/src/components/navBarWithBalance/navbarWithBalance.jsx new file mode 100644 index 0000000..a0e9001 --- /dev/null +++ b/src/components/navBarWithBalance/navbarWithBalance.jsx @@ -0,0 +1,75 @@ +import { useNavigate } from "react-router-dom"; +import "./navBarWithBalance.css"; +import { ArrowLeft, Wallet2 } from "lucide-react"; +import { useThemeContext } from "../../contexts/themeContext"; +import { Colors } from "../../constants/theme"; +import FormattedSatText from "../formattedSatText/formattedSatText"; +import { useSpark } from "../../contexts/sparkContext"; +import { formatTokensNumber } from "../../functions/lrc20/formatTokensBalance"; + +export default function NavBarWithBalance({ + backFunction, + seletctedToken, + selectedLRC20Asset = "Bitcoin", + showBalance = true, +}) { + const { theme, darkModeType } = useThemeContext(); + const navigate = useNavigate(); + const { sparkInformation } = useSpark(); + const balance = seletctedToken?.balance || sparkInformation.balance; + + const formattedTokensBalance = + selectedLRC20Asset !== "Bitcoin" + ? formatTokensNumber(balance, seletctedToken?.tokenMetadata?.decimals) + : balance; + + const handleBack = () => { + if (backFunction) { + backFunction(); + } else { + navigate(-1); + } + }; + + return ( +
+ + + {showBalance && ( +
+ + +
+ )} +
+ ); +} diff --git a/src/components/overlayHost.jsx b/src/components/overlayHost.jsx new file mode 100644 index 0000000..5b2e678 --- /dev/null +++ b/src/components/overlayHost.jsx @@ -0,0 +1,47 @@ +import { useMemo } from "react"; +import { AnimatePresence } from "framer-motion"; + +import { useOverlay } from "../contexts/overlayContext.jsx"; +import InformationPopup from "../pages/informationPopup/index.jsx"; +import CustomHalfModal from "../pages/customHalfModal/index.jsx"; +import ErrorScreen from "../pages/error/error.jsx"; +import ConfirmActionPage from "./confirmActionPage/confirmActionPage.jsx"; + +export default function OverlayHost() { + const { overlays, closeOverlay, openOverlay } = useOverlay(); + + const overlayElements = useMemo( + () => + overlays.map((overlay, index) => ( +
+ {overlay.for === "confirm-action" && ( + + )} + + {overlay.for === "error" && ( + + )} + + {overlay.for === "halfModal" && ( + + )} + + {overlay.for === "informationPopup" && ( + + )} +
+ )), + [overlays, closeOverlay, openOverlay] + ); + + return {overlayElements}; +} diff --git a/src/components/sendAndRequsetButton/customSendAndRequsetButton.jsx b/src/components/sendAndRequsetButton/customSendAndRequsetButton.jsx index 92a560b..415bb65 100644 --- a/src/components/sendAndRequsetButton/customSendAndRequsetButton.jsx +++ b/src/components/sendAndRequsetButton/customSendAndRequsetButton.jsx @@ -36,14 +36,14 @@ export default function CustomSendAndRequsetBTN({ {btnType === "send" ? ( ) : ( diff --git a/src/components/themeText/themeText.jsx b/src/components/themeText/themeText.jsx index 2651608..240c7a6 100644 --- a/src/components/themeText/themeText.jsx +++ b/src/components/themeText/themeText.jsx @@ -9,6 +9,7 @@ export default function ThemeText({ className, reversed, clickFunction, + ref, }) { const { theme } = useThemeContext(); @@ -27,6 +28,7 @@ export default function ThemeText({ ); return (

{ if (clickFunction) { clickFunction(); diff --git a/src/components/transactionContainer/transactionContianer.jsx b/src/components/transactionContainer/transactionContianer.jsx index f67235c..724689e 100644 --- a/src/components/transactionContainer/transactionContianer.jsx +++ b/src/components/transactionContainer/transactionContianer.jsx @@ -17,6 +17,7 @@ import { formatTokensNumber } from "../../functions/lrc20/formatTokensBalance"; import { ArrowDown, ArrowUp, Clock } from "lucide-react"; import { Colors } from "../../constants/theme"; import useThemeColors from "../../hooks/useThemeColors"; +import { useAppStatus } from "../../contexts/appStatus"; const TRANSACTION_CONSTANTS = { VIEW_ALL_PAGE: "viewAllTx", SPARK_WALLET: "sparkWallet", @@ -31,6 +32,7 @@ const TRANSACTION_CONSTANTS = { export default function TransactionContanier({ frompage }) { const { t } = useTranslation(); + const { didGetToHomepage } = useAppStatus(); const { sparkInformation } = useSpark(); const { masterInfoObject } = useGlobalContextProvider(); const currentTime = new Date(); @@ -40,7 +42,11 @@ export default function TransactionContanier({ frompage }) { const userBalanceDenomination = masterInfoObject?.userBalanceDenomination; const homepageTxPreferance = masterInfoObject?.homepageTxPreferance; - if (frompage === "home" && sparkInformation.didConnect === null) { + if ( + !sparkInformation.didConnect || + !sparkInformation.identityPubKey || + !didGetToHomepage + ) { return (

@@ -100,14 +106,7 @@ export default function TransactionContanier({ frompage }) { const transactionPaymentType = currentTransaction.paymentType; const paymentStatus = currentTransaction.paymentStatus; - const paymentDetails = - frompage === TRANSACTION_CONSTANTS.SPARK_WALLET - ? { - time: currentTransaction.createdTime, - direction: currentTransaction.transferDirection, - amount: currentTransaction.totalValue, - } - : JSON.parse(currentTransaction.details); + const paymentDetails = JSON.parse(currentTransaction.details); const isLRC20Payment = paymentDetails.isLRC20Payment; @@ -160,7 +159,7 @@ export default function TransactionContanier({ frompage }) { { } } - console.log("Initializing accounts...."); if (!accountMnemoinc) return; + console.log("Initializing accounts...."); initializeAccouts(); }, [accountMnemoinc]); diff --git a/src/contexts/globalContacts.jsx b/src/contexts/globalContacts.jsx index 6782878..4b5f1df 100644 --- a/src/contexts/globalContacts.jsx +++ b/src/contexts/globalContacts.jsx @@ -319,15 +319,18 @@ export const GlobalContactsList = ({ children }) => { // Set up the realtime listener unsubscribeMessagesRef.current = onSnapshot( combinedMessageQuery, - (snapshot) => { - if (!snapshot?.docChanges()?.length) return; + async (snapshot) => { + const changes = snapshot?.docChanges(); + if (!changes?.length) return; + let newMessages = []; let newUniqueIds = new Map(); - snapshot.docChanges().forEach(async (change) => { - console.log("received a new message", change.type); - if (change.type === "added") { - const newMessage = change.doc.data(); + await Promise.all( + changes.map(async (change) => { + if (change.type !== "added") return; + + const newMessage = change.doc.data(); const isReceived = newMessage.toPubKey === globalContactsInformation.myProfile.uuid; console.log( @@ -335,44 +338,51 @@ export const GlobalContactsList = ({ children }) => { newMessage ); - if (typeof newMessage.message === "string") { - const sendersPubkey = - newMessage.toPubKey === globalContactsInformation.myProfile.uuid - ? newMessage.fromPubKey - : newMessage.toPubKey; - const decoded = await decryptMessage( - contactsPrivateKey, + if (typeof newMessage.message !== "string") { + newMessages.push(newMessage); + return; + } + + const sendersPubkey = isReceived + ? newMessage.fromPubKey + : newMessage.toPubKey; + + const decoded = await decryptMessage( + contactsPrivateKey, + sendersPubkey, + newMessage.message + ); + + if (!decoded) return; + + let parsedMessage; + try { + parsedMessage = JSON.parse(decoded); + } catch { + return; + } + + if (parsedMessage?.senderProfileSnapshot && isReceived) { + newUniqueIds.set( sendersPubkey, - newMessage.message + parsedMessage.senderProfileSnapshot.uniqueName ); + } - if (!decoded) return; - let parsedMessage; - try { - parsedMessage = JSON.parse(decoded); - } catch (err) { - console.log("error parsing decoded message", err); - return; - } + newMessages.push({ + ...newMessage, + message: parsedMessage, + sendersPubkey, + isReceived, + }); + }) + ); - if (parsedMessage?.senderProfileSnapshot && isReceived) { - newUniqueIds.set( - sendersPubkey, - parsedMessage.senderProfileSnapshot?.uniqueName - ); - } + if (newUniqueIds.size) { + updateContactUniqueName(newUniqueIds); + } - newMessages.push({ - ...newMessage, - message: parsedMessage, - sendersPubkey, - isReceived, - }); - } else newMessages.push(newMessage); - } - }); - updateContactUniqueName(newUniqueIds); - if (newMessages.length > 0) { + if (newMessages.length) { queueSetCashedMessages({ newMessagesList: newMessages, myPubKey: globalContactsInformation.myProfile.uuid, diff --git a/src/contexts/navigationLogger.jsx b/src/contexts/navigationLogger.jsx index f58d912..7189135 100644 --- a/src/contexts/navigationLogger.jsx +++ b/src/contexts/navigationLogger.jsx @@ -9,8 +9,6 @@ export function NavigationStackProvider({ children }) { const [stack, setStack] = useState([]); const initialized = useRef(false); - console.log(stack); - useEffect(() => { if (!initialized.current) { initialized.current = true; diff --git a/src/contexts/overlayContext.jsx b/src/contexts/overlayContext.jsx new file mode 100644 index 0000000..ffb5e88 --- /dev/null +++ b/src/contexts/overlayContext.jsx @@ -0,0 +1,45 @@ +import { createContext, useCallback, useContext, useState } from "react"; + +const OverlayContext = createContext(null); + +export function OverlayProvider({ children }) { + const [overlays, setOverlays] = useState([]); + + const openOverlay = useCallback((overlay) => { + setOverlays((prev) => { + if (overlay.for === "halfModal") { + return prev.slice(0, -1).concat(overlay); + } + return [...prev, overlay]; + }); + }, []); + + const closeOverlay = useCallback(() => { + setOverlays((prev) => prev.slice(0, -1)); + }, []); + + const clearOverlays = useCallback(() => { + setOverlays([]); + }, []); + + return ( + + {children} + + ); +} + +export function useOverlay() { + const ctx = useContext(OverlayContext); + if (!ctx) { + throw new Error("useOverlay must be used inside OverlayProvider"); + } + return ctx; +} diff --git a/src/contexts/sparkContext.jsx b/src/contexts/sparkContext.jsx index dce8f24..4a95933 100644 --- a/src/contexts/sparkContext.jsx +++ b/src/contexts/sparkContext.jsx @@ -7,11 +7,10 @@ import { useRef, useCallback, } from "react"; - -import { sparkReceivePaymentWrapper } from "../functions/spark/payments"; -import { breezLiquidPaymentWrapper } from "../functions/breezLiquid"; import { claimnSparkStaticDepositAddress, + getCachedSparkTransactions, + getSingleTxDetails, getSparkBalance, getSparkLightningPaymentStatus, getSparkStaticBitcoinL1AddressQuote, @@ -24,13 +23,13 @@ import { addSingleSparkTransaction, bulkUpdateSparkTransactions, deleteUnpaidSparkLightningTransaction, + getAllSparkContactInvoices, getAllSparkTransactions, getAllUnpaidSparkLightningInvoices, SPARK_TX_UPDATE_ENVENT_NAME, sparkTransactionsEventEmitter, } from "../functions/spark/transactions"; import { - findSignleTxFromHistory, fullRestoreSparkState, restoreSparkTxState, updateSparkTxStatus, @@ -52,15 +51,34 @@ import liquidToSparkSwap from "../functions/spark/liquidToSparkSwap"; import { useActiveCustodyAccount } from "./activeAccount"; import sha256Hash from "../functions/hash"; import { getLRC20Transactions } from "../functions/lrc20"; +import { useGlobalContextProvider } from "./masterInfoObject"; +import handleBalanceCache from "../functions/spark/handleBalanceCache"; +import { + createBalancePoller, + createRestorePoller, +} from "../functions/pollingManager"; +import i18next from "i18next"; export const isSendingPayingEventEmiiter = new EventEmitter(); export const SENDING_PAYMENT_EVENT_NAME = "SENDING_PAYMENT_EVENT"; +if (!global.blitzWalletSparkIntervalState) { + global.blitzWalletSparkIntervalState = { + intervalTracker: new Map(), + listenerLock: new Map(), + allIntervalIds: new Set(), + depositIntervalIds: new Set(), + }; +} +const { intervalTracker, listenerLock, allIntervalIds, depositIntervalIds } = + global.blitzWalletSparkIntervalState; + // Initiate context const SparkWalletManager = createContext(null); const sessionTime = new Date().getTime(); const SparkWalletProvider = ({ children, navigate }) => { + const { masterInfoObject } = useGlobalContextProvider(); const { accountMnemoinc, contactsPrivateKey, publicKey } = useKeysContext(); const { currentWalletMnemoinc } = useActiveCustodyAccount(); const { didGetToHomepage, minMaxLiquidSwapAmounts, appState } = @@ -78,128 +96,256 @@ const SparkWalletProvider = ({ children, navigate }) => { sparkAddress: "", didConnect: null, }); + const [tokensImageCache, setTokensImageCache] = useState({}); const [pendingNavigation, setPendingNavigation] = useState(null); + const hasRestoreCompleted = useRef(false); + const [restoreCompleted, setRestoreCompleted] = useState(false); + const [reloadNewestPaymentTimestamp, setReloadNewestPaymentTimestamp] = + useState(0); const [pendingLiquidPayment, setPendingLiquidPayment] = useState(null); const depositAddressIntervalRef = useRef(null); const sparkDBaddress = useRef(null); const updatePendingPaymentsIntervalRef = useRef(null); const isInitialRestore = useRef(true); const isInitialLRC20Run = useRef(true); + const sparkInfoRef = useRef({ + balance: 0, + tokens: {}, + identityPubKey: "", + sparkAddress: "", + }); + const sessionTimeRef = useRef(Date.now()); + const newestPaymentTimeRef = useRef(Date.now()); + const handledTransfers = useRef(new Set()); + const usedSavedTxIds = useRef(new Set()); + const prevAccountId = useRef(null); + const isSendingPaymentRef = useRef(false); + const balancePollingTimeoutRef = useRef(null); + const balancePollingAbortControllerRef = useRef(null); + const txPollingTimeoutRef = useRef(null); + const txPollingAbortControllerRef = useRef(null); + const currentPollingMnemonicRef = useRef(null); + const isInitialRender = useRef(true); const didInitializeSendingPaymentEvent = useRef(false); const initialBitcoinIntervalRun = useRef(null); - const currentWalletMnemoincRef = useRef(currentWalletMnemoinc); - const prevAppState = useRef(null); + const prevAppState = useRef(appState); const prevListenerType = useRef(null); const [numberOfCachedTxs, setNumberOfCachedTxs] = useState(0); - // dont need any more velow + + const showTokensInformation = + masterInfoObject.enabledBTKNTokens === null + ? !!Object.keys(sparkInformation.tokens || {}).length + : masterInfoObject.enabledBTKNTokens; + + const didRunInitialRestore = useRef(false); + + const handledNavigatedTxs = useRef(new Set()); + + const [didRunNormalConnection, setDidRunNormalConnection] = useState(false); + const [normalConnectionTimeout, setNormalConnectionTimeout] = useState(false); + const shouldRunNormalConnection = + didRunNormalConnection || normalConnectionTimeout; + const currentMnemonicRef = useRef(currentWalletMnemoinc); const [numberOfIncomingLNURLPayments, setNumberOfIncomingLNURLPayments] = useState(0); const [numberOfConnectionTries, setNumberOfConnectionTries] = useState(0); const [startConnectingToSpark, setStartConnectingToSpark] = useState(false); + const cleanStatusAndLRC20Intervals = () => { + try { + for (const intervalId of allIntervalIds) { + console.log("Clearing stored interval ID:", intervalId); + clearInterval(intervalId); + } + + intervalTracker.clear(); + allIntervalIds.clear(); + } catch (err) { + console.log("Error cleaning lrc20 intervals", err); + } + }; + + const clearAllDepositIntervals = () => { + console.log( + "Clearing all deposit address intervals. Counts:", + depositIntervalIds.size + ); + + for (const intervalId of depositIntervalIds) { + console.log("Clearing deposit interval ID:", intervalId); + clearInterval(intervalId); + } + + depositIntervalIds.clear(); + console.log("All deposit intervals cleared"); + }; + + useEffect(() => { + sparkInfoRef.current = { + balance: sparkInformation.balance, + tokens: sparkInformation.tokens, + identityPubKey: sparkInformation.identityPubKey, + sparkAddress: sparkInformation.sparkAddress, + }; + }, [ + sparkInformation.balance, + sparkInformation.tokens, + sparkInformation.identityPubKey, + sparkInformation.sparkAddress, + ]); + const sessionTime = useMemo(() => { - console.log("Updating wallet session time"); + console.log("Updating wallet session time", currentWalletMnemoinc); return Date.now(); }, [currentWalletMnemoinc]); useEffect(() => { - currentWalletMnemoincRef.current = currentWalletMnemoinc; + currentMnemonicRef.current = currentWalletMnemoinc; }, [currentWalletMnemoinc]); + useEffect(() => { + // Fixing race condition with new preloaded txs + sessionTimeRef.current = Date.now() + 5 * 1000; + }, [currentWalletMnemoinc]); + + useEffect(() => { + newestPaymentTimeRef.current = Date.now(); + }, [reloadNewestPaymentTimestamp]); + + useEffect(() => { + if (!sparkInfoRef.current?.tokens) return; + + async function updateTokensImageCache() { + const availableAssets = Object.entries(sparkInfoRef.current.tokens); + const extensions = ["jpg", "png"]; + const newCache = {}; + + for (const [tokenId] of availableAssets) { + newCache[tokenId] = null; + + for (const ext of extensions) { + const url = `https://tokens.sparkscan.io/${tokenId}.${ext}`; + try { + const response = await fetch(url, { method: "HEAD" }); + if (response.ok) { + newCache[tokenId] = url; + break; + } + } catch (err) { + console.log("Image fetch error:", tokenId, err); + } + } + } + + setTokensImageCache(newCache); + } + + updateTokensImageCache(); + }, [Object.keys(sparkInformation.tokens || {}).length]); + // Debounce refs const debounceTimeoutRef = useRef(null); const pendingTransferIds = useRef(new Set()); - const toggleIsSendingPayment = (isSending) => { - setIsSendingPayment(isSending); + const toggleIsSendingPayment = useCallback((isSending) => { + console.log("Setting is sending payment", isSending); + if (isSending) { + if (txPollingAbortControllerRef.current) { + txPollingAbortControllerRef.current.abort(); + txPollingAbortControllerRef.current = null; + } + } + isSendingPaymentRef.current = isSending; + }, []); + + const toggleNewestPaymentTimestamp = () => { + setReloadNewestPaymentTimestamp((prev) => prev + 1); }; - useEffect(() => { - if (didInitializeSendingPaymentEvent.current) return; - didInitializeSendingPaymentEvent.current = true; - isSendingPayingEventEmiiter.addListener( - SENDING_PAYMENT_EVENT_NAME, - toggleIsSendingPayment - ); - }, []); + useEffect(() => { + if ( + !isSendingPayingEventEmiiter.listenerCount(SENDING_PAYMENT_EVENT_NAME) + ) { + isSendingPayingEventEmiiter.addListener( + SENDING_PAYMENT_EVENT_NAME, + toggleIsSendingPayment + ); + } - // This is a function that handles incoming transactions and formmataes it to reqirued formation - const handleTransactionUpdate = async ( - recevedTxId, - transactions, - balance - ) => { - try { - // First we need to get recent spark transfers - if (!transactions) - throw new Error("Unable to get transactions from spark"); - const { transfers } = transactions; - let selectedSparkTransaction = transfers.find( - (tx) => tx.id === recevedTxId + return () => { + console.log("clearning up toggle send pament"); + isSendingPayingEventEmiiter.removeListener( + SENDING_PAYMENT_EVENT_NAME, + toggleIsSendingPayment ); + }; + }, [toggleIsSendingPayment]); - if (!selectedSparkTransaction) { - console.log("Running full history sweep"); - const singleTxResponse = await findSignleTxFromHistory( - recevedTxId, - 50, - currentWalletMnemoincRef.current + const debouncedHandleIncomingPayment = useCallback(async (balance) => { + if (pendingTransferIds.current.size === 0) return; + + const transferIdsToProcess = Array.from(pendingTransferIds.current); + pendingTransferIds.current.clear(); + + console.log( + "Processing debounced incoming payments:", + transferIdsToProcess + ); + // let transfersOffset = 0; + let cachedTransfers = []; + + for (const transferId of transferIdsToProcess) { + try { + const transfer = await getSingleTxDetails( + currentMnemonicRef.current, + transferId ); - if (!singleTxResponse.tx) - throw new Error("Unable to find tx in all of history"); - selectedSparkTransaction = singleTxResponse.tx; + + if (!transfer) continue; + cachedTransfers.push(transfer); + } catch (error) { + console.error("Error processing incoming payment:", transferId, error); } + } - console.log( - selectedSparkTransaction, - "received transaction from spark tx list" - ); - if (!selectedSparkTransaction) - throw new Error("Not able to get recent transfer"); + const paymentObjects = []; + + const [unpaidInvoices, unpaidContactInvoices] = await Promise.all([ + getAllUnpaidSparkLightningInvoices(), + getAllSparkContactInvoices(), + ]); - const unpaidInvoices = await getAllUnpaidSparkLightningInvoices(); - const paymentObject = await transformTxToPaymentObject( - selectedSparkTransaction, - sparkInformation.sparkAddress, + for (const transferId of transferIdsToProcess) { + const tx = cachedTransfers.find((t) => t.id === transferId); + if (!tx) continue; + + // Skip UTXO_SWAP handling here — old logic kept + if (tx.type === "UTXO_SWAP") continue; + + const paymentObj = await transformTxToPaymentObject( + tx, + sparkInfoRef.current.sparkAddress, undefined, false, unpaidInvoices, - sparkInformation.identityPubKey + sparkInfoRef.current.identityPubKey, + 1, + undefined, + unpaidContactInvoices ); - if (paymentObject) { - await bulkUpdateSparkTransactions( - [paymentObject], - "incomingPayment", - 0, - balance - ); + if (paymentObj) { + paymentObjects.push(paymentObj); } - const savedTxs = await getAllSparkTransactions( - 5, - sparkInformation.identityPubKey - ); - return { - txs: savedTxs, - paymentObject: paymentObject || {}, - paymentCreatedTime: new Date( - selectedSparkTransaction.createdTime - ).getTime(), - }; - } catch (err) { - console.log("Handle incoming transaction error", err); } - }; - console.log(sparkInformation); - const handleIncomingPayment = async (transferId, transactions, balance) => { - let storedTransaction = await handleTransactionUpdate( - transferId, - transactions, - balance - ); - if (!storedTransaction) { + if (!paymentObjects.length) { + handleBalanceCache({ + isCheck: false, + passedBalance: balance, + mnemonic: currentMnemonicRef.current, + }); setSparkInformation((prev) => ({ ...prev, balance: balance, @@ -207,135 +353,271 @@ const SparkWalletProvider = ({ children, navigate }) => { return; } - const selectedStoredPayment = storedTransaction.txs.find( - (tx) => tx.sparkID === transferId - ); - if (!selectedStoredPayment) return; - console.log(selectedStoredPayment, "seleceted stored transaction"); - - const details = JSON.parse(selectedStoredPayment.details); - - if (details?.shouldNavigate && !details.isLNURL) return; - if (details.isLNURL && !details.isBlitzContactPayment) return; - if (details.isRestore) return; - if (storedTransaction.paymentCreatedTime < sessionTime) return; - // Handle confirm animation here - navigate("/confirm-page", { - state: { - for: "invoicePaid", - transaction: { ...selectedStoredPayment, details }, - }, - }); - }; - - const debouncedHandleIncomingPayment = useCallback( - async (balance) => { - if (pendingTransferIds.current.size === 0) return; - - const transferIdsToProcess = Array.from(pendingTransferIds.current); - pendingTransferIds.current.clear(); - - console.log( - "Processing debounced incoming payments:", - transferIdsToProcess - ); - const transactions = await getSparkTransactions( - 1, - undefined, - currentWalletMnemoincRef.current + try { + await bulkUpdateSparkTransactions( + paymentObjects, + "incomingPayment", + 0, + balance ); - // Process all pending transfer IDs - for (const transferId of transferIdsToProcess) { - try { - await handleIncomingPayment(transferId, transactions, balance); - } catch (error) { - console.error( - "Error processing incoming payment:", - transferId, - error - ); - } - } - }, - [sparkInformation.identityPubKey] - ); + } catch (error) { + console.error("bulkUpdateSparkTransactions failed:", error); + } + }, []); - const handleUpdate = async (...args) => { + const handleUpdate = useCallback(async (...args) => { try { const [updateType = "transactions", fee = 0, passedBalance = 0] = args; + const mnemonic = currentMnemonicRef.current; + const { identityPubKey, balance: prevBalance } = sparkInfoRef.current; + console.log( "running update in spark context from db changes", updateType ); - const txs = await getAllSparkTransactions( - null, - sparkInformation.identityPubKey - ); - const balance = await getSparkBalance(currentWalletMnemoincRef.current); + if (!identityPubKey) { + console.warn( + "handleUpdate called but identityPubKey is not available yet" + ); + return; + } + + const txs = await getCachedSparkTransactions(null, identityPubKey); - setSparkInformation((prev) => { - return { - ...prev, - transactions: txs || prev.transactions, - balance: Math.round( - (passedBalance - ? passedBalance - : balance.didWork - ? Number(balance.balance) - : prev.balance) - fee - ), - tokens: balance.tokensObj, - }; - }); - return; if ( - updateType === "supportTx" || - updateType === "restoreTxs" || + updateType === "lrc20Payments" || + updateType === "txStatusUpdate" || updateType === "transactions" ) { setSparkInformation((prev) => ({ ...prev, transactions: txs || prev.transactions, })); - return; - } - if (updateType === "incomingPayment") { + } else if (updateType === "incomingPayment") { + handleBalanceCache({ + isCheck: false, + passedBalance: Number(passedBalance), + mnemonic, + }); setSparkInformation((prev) => ({ ...prev, transactions: txs || prev.transactions, balance: Number(passedBalance), })); - return; - } + } else if (updateType === "fullUpdate-waitBalance") { + if (balancePollingAbortControllerRef.current) { + balancePollingAbortControllerRef.current.abort(); + } + + balancePollingAbortControllerRef.current = new AbortController(); + currentPollingMnemonicRef.current = mnemonic; - if (updateType === "paymentWrapperTx") { - setSparkInformation((prev) => { - return { + const pollingMnemonic = currentPollingMnemonicRef.current; + + setSparkInformation((prev) => ({ + ...prev, + transactions: txs || prev.transactions, + })); + + const poller = createBalancePoller( + mnemonic, + currentMnemonicRef, + balancePollingAbortControllerRef.current, + (newBalance) => { + setSparkInformation((prev) => { + if (pollingMnemonic !== currentMnemonicRef.current) { + return prev; + } + handleBalanceCache({ + isCheck: false, + passedBalance: newBalance, + mnemonic: pollingMnemonic, + }); + return { + ...prev, + balance: newBalance, + }; + }); + }, + prevBalance + ); + + balancePollingTimeoutRef.current = poller; + poller.start(); + } else { + const balanceResponse = await getSparkBalance(mnemonic); + + const newBalance = balanceResponse.didWork + ? Number(balanceResponse.balance) + : prevBalance; + + if (updateType === "paymentWrapperTx") { + const updatedBalance = Math.round(newBalance - fee); + + handleBalanceCache({ + isCheck: false, + passedBalance: updatedBalance, + mnemonic, + }); + + setSparkInformation((prev) => ({ ...prev, transactions: txs || prev.transactions, - balance: Math.round( - (balance.didWork ? Number(balance.balance) : prev.balance) - fee - ), - tokens: balance.tokensObj, - }; - }); - } else if (updateType === "fullUpdate") { - setSparkInformation((prev) => { - return { + balance: updatedBalance, + tokens: balanceResponse.didWork + ? balanceResponse.tokensObj + : prev.tokens, + })); + } else if (updateType === "fullUpdate-tokens") { + setSparkInformation((prev) => ({ ...prev, - balance: balance.didWork ? Number(balance.balance) : prev.balance, transactions: txs || prev.transactions, - tokens: balance.tokensObj, - }; - }); + tokens: balanceResponse.didWork + ? balanceResponse.tokensObj + : prev.tokens, + })); + } else if (updateType === "fullUpdate") { + handleBalanceCache({ + isCheck: false, + passedBalance: newBalance, + mnemonic, + }); + + setSparkInformation((prev) => ({ + ...prev, + balance: newBalance, + transactions: txs || prev.transactions, + tokens: balanceResponse.didWork + ? balanceResponse.tokensObj + : prev.tokens, + })); + } } + + if ( + updateType === "paymentWrapperTx" || + updateType === "transactions" || + updateType === "txStatusUpdate" || + updateType === "lrc20Payments" + ) { + console.log( + "Payment type is send payment, transaction, lrc20 first render, or txstatus update, skipping confirm tx page navigation" + ); + return; + } + const [lastAddedTx] = await getAllSparkTransactions({ + accountId: identityPubKey, + limit: 1, + }); + + console.log(lastAddedTx, "testing"); + + if (!lastAddedTx) { + console.log( + "No transaction found, skipping confirm tx page navigation" + ); + + return; + } + + const parsedTx = { + ...lastAddedTx, + details: JSON.parse(lastAddedTx.details), + }; + + if (handledNavigatedTxs.current.has(parsedTx.sparkID)) { + console.log( + "Already handled transaction, skipping confirm tx page navigation" + ); + return; + } + handledNavigatedTxs.current.add(parsedTx.sparkID); + + const details = parsedTx?.details; + + if (new Date(details.time).getTime() < sessionTimeRef.current) { + console.log( + "created before session time was set, skipping confirm tx page navigation" + ); + return; + } + + if (isSendingPaymentRef.current) { + console.log("Is sending payment, skipping confirm tx page navigation"); + return; + } + + if (details.direction === "OUTGOING") { + console.log( + "Only incoming payments navigate here, skipping confirm tx page navigation" + ); + return; + } + + const isOnReceivePage = + navigationRef + .getRootState() + .routes?.filter((item) => item.name === "ReceiveBTC").length === 1; + + const isNewestPayment = + !!details?.createdTime || !!details?.time + ? new Date(details.createdTime || details?.time).getTime() > + newestPaymentTimeRef.current + : false; + + let shouldShowConfirm = false; + + if ( + (lastAddedTx.paymentType?.toLowerCase() === "lightning" && + !details.isLNURL && + !details?.shouldNavigate && + isOnReceivePage && + isNewestPayment) || + (lastAddedTx.paymentType?.toLowerCase() === "spark" && + !details.isLRC20Payment && + isOnReceivePage && + isNewestPayment) + ) { + if (lastAddedTx.paymentType?.toLowerCase() === "spark") { + const upaidLNInvoices = await getAllUnpaidSparkLightningInvoices(); + const lastMatch = upaidLNInvoices.findLast((invoice) => { + const savedInvoiceDetails = JSON.parse(invoice.details); + return ( + !savedInvoiceDetails.sendingUUID && + !savedInvoiceDetails.isLNURL && + invoice.amount === details.amount + ); + }); + + if (lastMatch && !usedSavedTxIds.current.has(lastMatch.id)) { + usedSavedTxIds.current.add(lastMatch.id); + const lastInvoiceDetails = JSON.parse(lastMatch.details); + if (details.time - lastInvoiceDetails.createdTime < 60 * 1000) { + shouldShowConfirm = true; + } + } + } else { + shouldShowConfirm = true; + } + } + + // Handle confirm animation here + setPendingNavigation({ + tx: parsedTx, + amount: details.amount, + LRC20Token: details.LRC20Token, + isLRC20Payment: !!details.LRC20Token, + showFullAnimation: shouldShowConfirm, + }); } catch (err) { - console.error("Error in handleUpdate:", err); + console.log("error in spark handle db update function", err); } - }; + }, []); - const transferHandler = (transferId, balance) => { + const transferHandler = useCallback((transferId, balance) => { + if (handledTransfers.current.has(transferId)) return; + handledTransfers.current.add(transferId); console.log(`Transfer ${transferId} claimed. New balance: ${balance}`); // Add transferId to pending set @@ -350,93 +632,150 @@ const SparkWalletProvider = ({ children, navigate }) => { debounceTimeoutRef.current = setTimeout(() => { debouncedHandleIncomingPayment(balance); }, 500); - }; + }, []); + + useEffect(() => { + if (!sparkInformation.identityPubKey) { + console.log("Skipping listener setup - no identity pub key yet"); + return; + } + + console.log("adding web view listeners"); + + sparkTransactionsEventEmitter.removeAllListeners( + SPARK_TX_UPDATE_ENVENT_NAME + ); + + sparkTransactionsEventEmitter.on(SPARK_TX_UPDATE_ENVENT_NAME, handleUpdate); + + return () => { + console.log("Cleaning up spark event listeners"); + sparkTransactionsEventEmitter.removeListener( + SPARK_TX_UPDATE_ENVENT_NAME, + handleUpdate + ); + }; + }, [sparkInformation.identityPubKey, handleUpdate, transferHandler]); const addListeners = async (mode) => { console.log("Adding Spark listeners..."); - const walletHash = sha256Hash(currentWalletMnemoincRef.current); - if (mode === "full") { - if (!sparkTransactionsEventEmitter.listenerCount()) { - sparkTransactionsEventEmitter.on( - SPARK_TX_UPDATE_ENVENT_NAME, - handleUpdate - ); - } - if (!sparkWallet[walletHash].listenerCount()) { - sparkWallet[walletHash].on("transfer:claimed", transferHandler); - } + const walletHash = sha256Hash(currentMnemonicRef.current); - if (isInitialRestore.current) { - isInitialRestore.current = false; - } + try { + if (mode === "full") { + if (!sparkWallet[walletHash]?.listenerCount()) { + sparkWallet[walletHash].on("transfer:claimed", transferHandler); + } - await fullRestoreSparkState({ - sparkAddress: sparkInformation.sparkAddress, - batchSize: isInitialRestore.current ? 15 : 5, - isSendingPayment: isSendingPayment, - mnemonic: currentWalletMnemoincRef.current, - identityPubKey: sparkInformation.identityPubKey, - }); - await updateSparkTxStatus( - currentWalletMnemoincRef.current, - sparkInformation.identityPubKey - ); + if (!isInitialRestore.current) { + if (txPollingAbortControllerRef.current) { + txPollingAbortControllerRef.current.abort(); + } - if (updatePendingPaymentsIntervalRef.current) { - console.log("BLOCKING TRYING TO SET INTERVAL AGAIN"); - clearInterval(updatePendingPaymentsIntervalRef.current); - } - updatePendingPaymentsIntervalRef.current = setInterval(async () => { - try { - await updateSparkTxStatus( - currentWalletMnemoincRef.current, - sparkInformation.identityPubKey + txPollingAbortControllerRef.current = new AbortController(); + const restorePoller = createRestorePoller( + currentMnemonicRef.current, + isSendingPaymentRef.current, + currentMnemonicRef, + txPollingAbortControllerRef.current, + (result) => { + console.log("RESTORE COMPLETE"); + }, + sparkInfoRef.current ); - await getLRC20Transactions({ - ownerPublicKeys: [sparkInformation.identityPubKey], - sparkAddress: sparkInformation.sparkAddress, - isInitialRun: isInitialLRC20Run.current, - mnemonic: currentWalletMnemoincRef.current, - }); - } catch (err) { - console.error("Error during periodic restore:", err); + + restorePoller.start(); } - }, 10 * 1000); + + updateSparkTxStatus( + currentMnemonicRef.current, + sparkInfoRef.current.identityPubKey + ); + + if (updatePendingPaymentsIntervalRef.current) { + console.log("BLOCKING TRYING TO SET INTERVAL AGAIN"); + clearInterval(updatePendingPaymentsIntervalRef.current); + updatePendingPaymentsIntervalRef.current = null; + } + + const capturedMnemonic = currentMnemonicRef.current; + const capturedWalletHash = walletHash; + + const intervalId = setInterval(async () => { + try { + if (capturedMnemonic !== currentMnemonicRef.current) { + console.log("Mnemonic changed. Aborting interval."); + clearInterval(intervalId); + intervalTracker.delete(capturedWalletHash); + allIntervalIds.delete(intervalId); + return; + } + + await updateSparkTxStatus( + currentMnemonicRef.current, + sparkInfoRef.current.identityPubKey + ); + + if (capturedMnemonic !== currentMnemonicRef.current) { + console.log( + "Context changed during updateSparkTxStatus. Aborting getLRC20Transactions." + ); + clearInterval(intervalId); + intervalTracker.delete(capturedWalletHash); + allIntervalIds.delete(intervalId); + return; + } + + await getLRC20Transactions({ + ownerPublicKeys: [sparkInfoRef.current.identityPubKey], + sparkAddress: sparkInfoRef.current.sparkAddress, + isInitialRun: isInitialLRC20Run.current, + mnemonic: currentMnemonicRef.current, + }); + if (isInitialLRC20Run.current) { + isInitialLRC20Run.current = false; + } + } catch (err) { + console.error("Error during periodic restore:", err); + } + }, 10 * 1000); + + if (isInitialRestore.current) { + isInitialRestore.current = false; + } + + updatePendingPaymentsIntervalRef.current = intervalId; + intervalTracker.set(walletHash, intervalId); + allIntervalIds.add(intervalId); + } + } catch (error) { + console.error("Error in addListeners:", error); + } finally { + listenerLock.set(walletHash, false); + console.log("Lock released for wallet:", walletHash); } }; - const removeListeners = () => { + const removeListeners = (onlyClearIntervals = false) => { console.log("Removing spark listeners"); - console.log( - sparkTransactionsEventEmitter.listenerCount(SPARK_TX_UPDATE_ENVENT_NAME), - "Nymber of event emiitter litsenrs" - ); - console.log( - sparkWallet[sha256Hash(prevAccountMnemoincRef.current)]?.listenerCount( - "transfer:claimed" - ), - "number of spark wallet listenre" - ); - if ( - sparkTransactionsEventEmitter.listenerCount(SPARK_TX_UPDATE_ENVENT_NAME) - ) { - sparkTransactionsEventEmitter.removeAllListeners( - SPARK_TX_UPDATE_ENVENT_NAME - ); - } - if ( - sparkWallet[sha256Hash(prevAccountMnemoincRef.current)]?.listenerCount( - "transfer:claimed" - ) - ) { - sparkWallet[ - sha256Hash(prevAccountMnemoincRef.current) - ]?.removeAllListeners("transfer:claimed"); - } + cleanStatusAndLRC20Intervals(); + if (!onlyClearIntervals) { + if (!prevAccountMnemoincRef.current) { + prevAccountMnemoincRef.current = currentMnemonicRef.current; + return; + } + const hashedMnemonic = sha256Hash(prevAccountMnemoincRef.current); + + if ( + prevAccountMnemoincRef.current && + sparkWallet[hashedMnemonic]?.listenerCount("transfer:claimed") + ) { + sparkWallet[hashedMnemonic]?.removeAllListeners("transfer:claimed"); + } - prevAccountMnemoincRef.current = currentWalletMnemoincRef.current; - // sparkWallet?.removeAllListeners('deposit:confirmed'); + prevAccountMnemoincRef.current = currentMnemonicRef.current; + } // Clear debounce timeout when removing listeners if (debounceTimeoutRef.current) { @@ -451,34 +790,56 @@ const SparkWalletProvider = ({ children, navigate }) => { clearInterval(updatePendingPaymentsIntervalRef.current); updatePendingPaymentsIntervalRef.current = null; } + //Clear balance polling + if (balancePollingTimeoutRef.current) { + clearTimeout(balancePollingTimeoutRef.current); + balancePollingTimeoutRef.current = null; + } + if (balancePollingAbortControllerRef.current) { + balancePollingAbortControllerRef.current.abort(); + balancePollingAbortControllerRef.current = null; + } + if (txPollingTimeoutRef.current) { + clearTimeout(txPollingTimeoutRef.current); + txPollingTimeoutRef.current = null; + } + if (txPollingAbortControllerRef.current) { + txPollingAbortControllerRef.current.abort(); + txPollingAbortControllerRef.current = null; + } + currentPollingMnemonicRef.current = null; }; // Add event listeners to listen for bitcoin and lightning or spark transfers when receiving does not handle sending useEffect(() => { + if (prevAppState.current !== appState && appState === "background") { + console.log("App moved to background — clearing listener type"); + prevListenerType.current = null; + } + const timeoutId = setTimeout(async () => { if (!didGetToHomepage) return; - if (!sparkInformation.didConnect) return; + if (!sparkInfoRef.current.identityPubKey) return; const getListenerType = () => { - if (appState === "active" && !isSendingPayment) return "full"; - if (appState === "active" && isSendingPayment) return "sparkOnly"; - return null; // No listeners in background + if (appState === "active") return "full"; + return null; }; const newType = getListenerType(); const prevType = prevListenerType.current; + const prevId = prevAccountId.current; - console.log(prevAppState.current, appState, "APP STATE CHANGE"); - - if (prevAppState.current !== appState && appState === "background") { - removeListeners(); - prevListenerType.current = null; - } - - if (newType !== prevType && appState === "active") { + // Only reconfigure listeners when becoming active + if ( + (newType !== prevType || + prevId !== sparkInfoRef.current.identityPubKey) && + appState === "active" + ) { removeListeners(); - if (newType) addListeners(newType); + if (newType) await addListeners(newType); prevListenerType.current = newType; + prevAccountId.current = sparkInfoRef.current.identityPubKey; } prevAppState.current = appState; @@ -488,6 +849,7 @@ const SparkWalletProvider = ({ children, navigate }) => { }, [ appState, sparkInformation.didConnect, + sparkInformation.identityPubKey, didGetToHomepage, isSendingPayment, ]); @@ -495,17 +857,21 @@ const SparkWalletProvider = ({ children, navigate }) => { useEffect(() => { if (!didGetToHomepage) return; if (!sparkInformation.didConnect) return; + if (!sparkInformation.identityPubKey) return; + // Interval to check deposit addresses to see if they were paid const handleDepositAddressCheck = async () => { try { console.log("l1Deposit check running...."); + if (isSendingPaymentRef.current) return; + if (!currentMnemonicRef.current) return; const allTxs = await getAllSparkTransactions( null, sparkInformation.identityPubKey ); const savedTxMap = new Map(allTxs.map((tx) => [tx.sparkID, tx])); const depoistAddress = await queryAllStaticDepositAddresses( - currentWalletMnemoincRef.current + currentMnemonicRef.current ); // Loop through deposit addresses and check if they have been paid @@ -521,16 +887,11 @@ const SparkWalletProvider = ({ children, navigate }) => { ); console.log("Deposit address txids:", txids); if (!txids || !txids.length) continue; + const unpaidTxids = txids.filter((txid) => !txid.didClaim); let claimedTxs = Storage.getItem("claimedBitcoinTxs") || []; for (const txid of unpaidTxids) { - // get quote for the txid - const { didwork, quote, error } = - await getSparkStaticBitcoinL1AddressQuote( - txid.txid, - currentWalletMnemoincRef.current - ); const hasAlreadySaved = savedTxMap.has(txid.txid); if (!txid.isConfirmed) { @@ -541,19 +902,36 @@ const SparkWalletProvider = ({ children, navigate }) => { creditAmountSats: txid.amount - txid.fee, }, address, - sparkInformation + sparkInfoRef.current ); } + continue; } + // get quote for the txid + const { + didwork: quoteDidWorkResponse, + quote, + error, + } = await getSparkStaticBitcoinL1AddressQuote( + txid.txid, + currentMnemonicRef.current + ); - console.log("Deposit address quote:", quote); - - if (!didwork || !quote) { + if (!quoteDidWorkResponse || !quote) { console.log(error, "Error getting deposit address quote"); if ( error.includes("UTXO is already claimed by the current user.") ) { handleTxIdState(txid, true, address); + } else if (!hasAlreadySaved) { + await addPendingTransaction( + { + transactionId: txid.txid, + creditAmountSats: txid.amount - txid.fee, + }, + address, + sparkInfoRef.current + ); } continue; } @@ -569,9 +947,14 @@ const SparkWalletProvider = ({ children, navigate }) => { } = await claimnSparkStaticDepositAddress({ ...quote, sspSignature: quote.signature, - mnemonic: currentWalletMnemoincRef.current, + mnemonic: currentMnemonicRef.current, }); + // Add pending transaction if not already saved (after successful claim) + if (!hasAlreadySaved) { + await addPendingTransaction(quote, address, sparkInfoRef.current); + } + if (!claimTx || !didWork) { console.log("Claim static deposit address error", claimError); if ( @@ -583,10 +966,6 @@ const SparkWalletProvider = ({ children, navigate }) => { continue; } - if (!hasAlreadySaved) { - await addPendingTransaction(quote, address, sparkInformation); - } - console.log("Claimed deposit address transaction:", claimTx); if (!claimedTxs?.includes(quote.signature)) { @@ -597,53 +976,56 @@ const SparkWalletProvider = ({ children, navigate }) => { await new Promise((res) => setTimeout(res, 2000)); - const findBitcoinTxResponse = await findSignleTxFromHistory( - claimTx.transferId, - 5, - currentWalletMnemoincRef.current + const bitcoinTransfer = await getSingleTxDetails( + currentMnemonicRef.current, + claimTx.transferId ); let updatedTx = {}; - if (!findBitcoinTxResponse.tx) { + if (!bitcoinTransfer) { updatedTx = { useTempId: true, id: claimTx.transferId, tempId: quote.transactionId, paymentStatus: "pending", paymentType: "bitcoin", - accountId: sparkInformation.identityPubKey, + accountId: sparkInfoRef.current.identityPubKey, }; } else { - const { tx: bitcoinTransfer } = findBitcoinTxResponse; - if (!bitcoinTransfer) { - updatedTx = { - useTempId: true, - id: claimTx.transferId, - tempId: quote.transactionId, - paymentStatus: "pending", - paymentType: "bitcoin", - accountId: sparkInformation.identityPubKey, - }; - } else { - updatedTx = { - useTempId: true, - tempId: quote.transactionId, - id: bitcoinTransfer.id, - paymentStatus: "completed", - paymentType: "bitcoin", - accountId: sparkInformation.identityPubKey, - details: { - amount: bitcoinTransfer.totalValue, - fee: Math.abs( - quote.creditAmountSats - bitcoinTransfer.totalValue - ), - }, - }; - } + updatedTx = { + useTempId: true, + tempId: quote.transactionId, + id: bitcoinTransfer.id, + paymentStatus: "completed", + paymentType: "bitcoin", + accountId: sparkInfoRef.current.identityPubKey, + details: { + amount: bitcoinTransfer.totalValue, + fee: Math.abs( + (txid.amount || quote.creditAmountSats) - + bitcoinTransfer.totalValue + ), + totalFee: Math.abs( + (txid.amount || quote.creditAmountSats) - + bitcoinTransfer.totalValue + ), + supportFee: 0, + }, + }; } - - await bulkUpdateSparkTransactions([updatedTx], "fullUpdate"); console.log("Updated bitcoin transaction:", updatedTx); + await bulkUpdateSparkTransactions([updatedTx], "fullUpdate"); + // If no details are provided do not show confirm screen + // Navigate here, since bulkUpdateSparkTransactions will default to transactions and get blocked in other path + if (updatedTx.details) { + if (handledNavigatedTxs.current.has(updatedTx.id)) return; + handledNavigatedTxs.current.add(updatedTx.id); + setPendingNavigation({ + tx: updatedTx, + amount: updatedTx.details.amount, + showFullAnimation: false, + }); + } } } } catch (err) { @@ -663,7 +1045,7 @@ const SparkWalletProvider = ({ children, navigate }) => { address: address, time: new Date().getTime(), direction: "INCOMING", - description: "Deposit address payment", + description: i18next.t("contexts.spark.depositLabel"), onChainTxid: quote.transactionId, isRestore: true, // This is a restore payment }, @@ -671,20 +1053,79 @@ const SparkWalletProvider = ({ children, navigate }) => { await addSingleSparkTransaction(pendingTx); }; + clearAllDepositIntervals(); if (depositAddressIntervalRef.current) { clearInterval(depositAddressIntervalRef.current); + depositAddressIntervalRef.current = null; } - if (isSendingPayment) return; + if (!initialBitcoinIntervalRun.current) { setTimeout(handleDepositAddressCheck, 1_000 * 5); initialBitcoinIntervalRun.current = true; } - depositAddressIntervalRef.current = setInterval( + + const depositIntervalId = setInterval( handleDepositAddressCheck, 1_000 * 60 ); + + depositAddressIntervalRef.current = depositIntervalId; + depositIntervalIds.add(depositIntervalId); + + return () => { + console.log("Cleaning up deposit interval on unmount/dependency change"); + if (depositIntervalId) { + clearInterval(depositIntervalId); + depositIntervalIds.delete(depositIntervalId); + } + if (depositAddressIntervalRef.current) { + clearInterval(depositAddressIntervalRef.current); + depositAddressIntervalRef.current = null; + } + }; }, [didGetToHomepage, sparkInformation.didConnect, isSendingPayment]); + // Run fullRestore when didConnect becomes true + useEffect(() => { + if (!sparkInformation.didConnect) return; + if (!sparkInformation.identityPubKey) return; + if (didRunInitialRestore.current) return; + didRunInitialRestore.current = true; + + async function runRestore() { + const restoreResponse = await fullRestoreSparkState({ + sparkAddress: sparkInfoRef.current.sparkAddress, + batchSize: isInitialRestore.current ? 5 : 2, + isSendingPayment: isSendingPaymentRef.current, + mnemonic: currentMnemonicRef.current, + identityPubKey: sparkInfoRef.current.identityPubKey, + isInitialRestore: isInitialRestore.current, + }); + + if (!restoreResponse) { + setRestoreCompleted(true); // This will get the transactions for the session + } + } + + runRestore(); + }, [sparkInformation.didConnect, sparkInformation.identityPubKey]); + + // Run transactions after BOTH restore completes + useEffect(() => { + if (!restoreCompleted) return; + + async function fetchTransactions() { + const transactions = await getCachedSparkTransactions( + null, + sparkInfoRef.current.identityPubKey + ); + setSparkInformation((prev) => ({ ...prev, transactions })); + hasRestoreCompleted.current = true; + } + + fetchTransactions(); + }, [restoreCompleted]); + // This function connects to the spark node and sets the session up const connectToSparkWallet = useCallback(async () => { @@ -694,6 +1135,7 @@ const SparkWalletProvider = ({ children, navigate }) => { // toggleGlobalContactsInformation, // globalContactsInformation, mnemonic: accountMnemoinc, + hasRestoreCompleted: hasRestoreCompleted.current, }); console.log(didWork, "did Connect to spark"); @@ -709,6 +1151,8 @@ const SparkWalletProvider = ({ children, navigate }) => { useEffect(() => { if (!sparkInformation.didConnect) return; if (!globalContactsInformation?.myProfile) return; + if (!sparkInformation.identityPubKey) return; + if (!sparkInformation.sparkAddress) return; if (sparkDBaddress.current) return; sparkDBaddress.current = true; @@ -728,7 +1172,22 @@ const SparkWalletProvider = ({ children, navigate }) => { true ); } - }, [globalContactsInformation.myProfile, sparkInformation]); + }, [ + globalContactsInformation.myProfile, + sparkInformation.didConnect, + sparkInformation.identityPubKey, + sparkInformation.sparkAddress, + ]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + pendingTransferIds.current.clear(); + }; + }, []); // This function checks to see if there are any liquid funds that need to be sent to spark useEffect(() => { diff --git a/src/functions/apps/giftCardPurchaseTracker.js b/src/functions/apps/giftCardPurchaseTracker.js new file mode 100644 index 0000000..c3dbd11 --- /dev/null +++ b/src/functions/apps/giftCardPurchaseTracker.js @@ -0,0 +1,57 @@ +import i18next from "i18next"; +import { SATSPERBITCOIN } from "../../constants"; +import { isMoreThanADayOld } from "../rotateAddressDateChecker"; +import Storage from "../localStorage"; + +export default async function giftCardPurchaseAmountTracker({ + sendingAmountSat, + USDBTCValue, + testOnly = false, +}) { + const currentTime = new Date(); + try { + Storage.getItem; + const dailyPurchaseAmount = Storage.getItem("dailyPurchaeAmount"); + + if (dailyPurchaseAmount) { + if (isMoreThanADayOld(dailyPurchaseAmount.date)) { + if (!testOnly) { + Storage.setItem("dailyPurchaeAmount", { + date: currentTime, + amount: sendingAmountSat, + }); + } + } else { + const totalPurchaseAmount = Math.round( + ((dailyPurchaseAmount.amount + sendingAmountSat) / SATSPERBITCOIN) * + USDBTCValue.value + ); + + if (totalPurchaseAmount > 9000) + throw new Error( + i18next.t( + "apps.giftCards.expandedGiftCardPage.dailyPurchaseAmountError" + ) + ); + + if (!testOnly) { + Storage.setItem("dailyPurchaeAmount", { + date: dailyPurchaseAmount.date, + amount: dailyPurchaseAmount.amount + sendingAmountSat, + }); + } + } + } else { + if (!testOnly) { + Storage.setItem("dailyPurchaeAmount", { + date: currentTime, + amount: sendingAmountSat, + }); + } + } + return { shouldBlock: false }; + } catch (err) { + console.log(err); + return { shouldBlock: true, reason: err.message }; + } +} diff --git a/src/functions/initiateWalletConnection.js b/src/functions/initiateWalletConnection.js index cc65144..ddcd034 100644 --- a/src/functions/initiateWalletConnection.js +++ b/src/functions/initiateWalletConnection.js @@ -5,7 +5,9 @@ import { getSparkIdentityPubKey, getSparkTransactions, initializeSparkWallet, + setPrivacyEnabled, } from "./spark"; +import handleBalanceCache from "./spark/handleBalanceCache"; import { cleanStalePendingSparkLightningTransactions } from "./spark/transactions"; export async function initWallet({ @@ -13,11 +15,29 @@ export async function initWallet({ // toggleGlobalContactsInformation, // globalContactsInformation, mnemonic, + hasRestoreCompleted, }) { console.log("HOME RENDER BREEZ EVENT FIRST LOAD"); try { - const didConnectToSpark = await initializeSparkWallet(mnemonic); + const [didConnectToSpark, balance] = await Promise.all([ + initializeSparkWallet(mnemonic), + handleBalanceCache({ + isCheck: false, + mnemonic: mnemonic, + returnBalanceOnly: true, + }), + ]); + + console.log(didConnectToSpark, balance); + + if (balance) { + setSparkInformation((prev) => ({ + ...prev, + didConnect: true, + balance: balance, + })); + } if (didConnectToSpark.isConnected) { const didSetSpark = await initializeSparkSession({ @@ -25,6 +45,7 @@ export async function initWallet({ // globalContactsInformation, // toggleGlobalContactsInformation, mnemonic, + hasRestoreCompleted, }); if (!didSetSpark) @@ -48,6 +69,7 @@ async function initializeSparkSession({ // globalContactsInformation, // toggleGlobalContactsInformation, mnemonic, + hasRestoreCompleted, }) { try { // Clean DB state but do not hold up process @@ -58,40 +80,114 @@ async function initializeSparkSession({ getSparkIdentityPubKey(mnemonic), ]); - if (!balance.didWork) - throw new Error("Unable to initialize spark from history"); + setPrivacyEnabled(mnemonic); const transactions = await getCachedSparkTransactions(null, identityPubKey); if (transactions === undefined) throw new Error("Unable to initialize spark from history"); - // if ( - // !globalContactsInformation.myProfile.sparkAddress || - // !globalContactsInformation.myProfile.sparkIdentityPubKey - // ) { - // toggleGlobalContactsInformation( - // { - // myProfile: { - // ...globalContactsInformation.myProfile, - // sparkAddress: sparkAddress.response, - // sparkIdentityPubKey: identityPubKey, - // }, - // }, - // true - // ); - // } + if (!balance.didWork) { + const storageObject = { + transactions: transactions, + identityPubKey, + sparkAddress: sparkAddress.response, + didConnect: true, + }; + await new Promise((res) => setTimeout(res, 500)); + setSparkInformation((prev) => ({ ...prev, ...storageObject })); + return storageObject; + } + + let didLoadCorrectBalance = false; + let runCount = 0; + let maxRunCount = 2; + let initialBalanceResponse = balance; + let correctBalance = 0; + + while (runCount < maxRunCount && !didLoadCorrectBalance) { + runCount += 1; + let currentBalance = 0; + + if (runCount === 1) { + currentBalance = Number(initialBalanceResponse.balance); + } else { + const retryResponse = await getSparkBalance( + mnemonic, + sendWebViewRequest + ); + currentBalance = Number(retryResponse.balance); + } + + const response = await handleBalanceCache({ + isCheck: true, + passedBalance: currentBalance, + mnemonic, + }); + + if (response.didWork) { + correctBalance = response.balance; + didLoadCorrectBalance = true; + } else { + console.log("Waiting for correct balance resposne"); + await new Promise((res) => setTimeout(res, 2000)); + } + } + + const finalBalanceToUse = didLoadCorrectBalance + ? correctBalance + : Number(initialBalanceResponse.balance); + console.log( + didLoadCorrectBalance, + runCount, + initialBalanceResponse, + correctBalance, + finalBalanceToUse, + "balancasldfkjasdlfkjasdf" + ); + if (!didLoadCorrectBalance) { + await handleBalanceCache({ + isCheck: false, + passedBalance: finalBalanceToUse, + mnemonic, + }); + } const storageObject = { - balance: Number(balance.balance), + balance: finalBalanceToUse, tokens: balance.tokensObj, - transactions: transactions, identityPubKey, sparkAddress: sparkAddress.response, didConnect: true, }; console.log("Spark storage object", storageObject); - setSparkInformation(storageObject); + await new Promise((res) => setTimeout(res, 500)); + + setSparkInformation((prev) => { + let txToUse; + + // Restore has not run yet: + if ( + !hasRestoreCompleted || + (prev.identityPubKey && prev.identityPubKey !== identityPubKey) + ) { + // We show cached transactions immediately to avoid blanks. + // But DO NOT overwrite later once restore writes. + // Fully overwrite if identityPubKey changed (new wallet). + txToUse = transactions; + } else { + // Restore has finished: + // Never insert fetchedTransactions (they may be stale) + // Use whatever DB restore already put in state. + txToUse = prev.transactions; + } + + return { + ...prev, + ...storageObject, + transactions: txToUse, + }; + }); return storageObject; } catch (err) { console.log("Set spark error", err); diff --git a/src/functions/lrc20/lrc20TokenDataHalfModal.jsx b/src/functions/lrc20/lrc20TokenDataHalfModal.jsx index 6504a8a..b376742 100644 --- a/src/functions/lrc20/lrc20TokenDataHalfModal.jsx +++ b/src/functions/lrc20/lrc20TokenDataHalfModal.jsx @@ -8,13 +8,10 @@ import useThemeColors from "../../hooks/useThemeColors"; import copyToClipboard from "../copyToClipboard"; import { formatTokensNumber } from "./formatTokensBalance"; import "./tokenHalfModalStyle.css"; +import { useOverlay } from "../../contexts/overlayContext"; -export default function LRC20TokenInformation({ - theme, - darkModeType, - openOverlay, - params, -}) { +export default function LRC20TokenInformation({ theme, darkModeType, params }) { + const { openOverlay } = useOverlay(); const { sparkInformation } = useSpark(); const selectedToken = sparkInformation.tokens?.[params?.tokenIdentifier]; const { balance, tokenMetadata } = selectedToken; diff --git a/src/functions/messaging/publishMessage.js b/src/functions/messaging/publishMessage.js index 82f26cd..38d18b5 100644 --- a/src/functions/messaging/publishMessage.js +++ b/src/functions/messaging/publishMessage.js @@ -1,7 +1,9 @@ import formatBalanceAmount from "../formatNumber"; -import { getSingleContact, updateMessage } from "../../../db"; +import { getDataFromCollection, updateMessage } from "../../../db"; import { BITCOIN_SAT_TEXT, SATSPERBITCOIN } from "../../constants"; import fetchBackend from "../../../db/handleBackend"; +import loadNewFiatData from "../saveAndUpdateFiatData"; +import i18next from "i18next"; export async function publishMessage({ toPubKey, @@ -9,9 +11,11 @@ export async function publishMessage({ data, globalContactsInformation, selectedContact, - fiatCurrencies, isLNURLPayment, privateKey, + retrivedContact, + currentTime, + masterInfoObject, }) { try { const sendingObj = data; @@ -20,15 +24,19 @@ export async function publishMessage({ fromPubKey, toPubKey, onlySaveToLocal: isLNURLPayment, + retrivedContact, + privateKey, + currentTime, }); if (isLNURLPayment) return; - await sendPushNotification({ + sendPushNotification({ selectedContactUsername: selectedContact.uniqueName, myProfile: globalContactsInformation.myProfile, data: data, - fiatCurrencies: fiatCurrencies, privateKey, + retrivedContact, + masterInfoObject, }); } catch (err) { console.log(err), "pubishing message to server error"; @@ -39,77 +47,132 @@ export async function sendPushNotification({ selectedContactUsername, myProfile, data, - fiatCurrencies, privateKey, + retrivedContact, + masterInfoObject, }) { try { console.log(selectedContactUsername); - const retrivedContact = await getSingleContact( - selectedContactUsername.toLowerCase() - ); - if (retrivedContact.length === 0) return; - const [selectedContact] = retrivedContact; + // Check if there is a selected contact + if (!retrivedContact) return; + const pushNotificationData = retrivedContact.pushNotifications; + + // check if the person has a push token saved + if (!pushNotificationData?.key?.encriptedText) return; + + // If a user has updated thier settings and they have chosen to not receive notification for contact payments + if ( + pushNotificationData?.enabledServices?.contactPayments !== undefined && + !pushNotificationData?.enabledServices?.contactPayments + ) + return; + const useNewNotifications = !!retrivedContact.isUsingNewNotifications; const devicePushKey = - selectedContact?.pushNotifications?.key?.encriptedText; - const deviceType = selectedContact?.pushNotifications?.platform; - const sendingContactFiatCurrency = selectedContact?.fiatCurrency || "USD"; + retrivedContact?.pushNotifications?.key?.encriptedText; + const deviceType = retrivedContact?.pushNotifications?.platform; + const sendingContactFiatCurrency = retrivedContact?.fiatCurrency || "USD"; const sendingContactDenominationType = - selectedContact?.userBalanceDenomination || "sats"; + retrivedContact?.userBalanceDenomination || "sats"; - const fiatValue = fiatCurrencies.filter( - (currency) => - currency.coin.toLowerCase() === sendingContactFiatCurrency.toLowerCase() - ); - const didFindCurrency = fiatValue.length >= 1; - const fiatAmount = - didFindCurrency && - ( - (fiatValue[0]?.value / SATSPERBITCOIN) * - (data.amountMsat / 1000) - ).toFixed(2); + if (!devicePushKey || !deviceType) return; - console.log(devicePushKey, deviceType); + let requestData = {}; - if (!devicePushKey || !deviceType) return; - let message; - if (data.isUpdate) { - message = data.message; - } else if (data.isRequest) { - message = `${ - myProfile.name || myProfile.uniqueName - } requested you ${formatBalanceAmount( - sendingContactDenominationType != "fiat" || !fiatAmount - ? data.amountMsat / 1000 - : fiatAmount - )} ${ - sendingContactDenominationType != "fiat" || !fiatAmount - ? BITCOIN_SAT_TEXT - : sendingContactFiatCurrency - }`; + if (useNewNotifications) { + let notificationData = { + name: myProfile.name || myProfile.uniqueName, + }; + + if (data.isUpdate) { + notificationData["option"] = + data.option === "paid" ? "paidLower" : "declinedLower"; + notificationData["type"] = "updateMessage"; + } else if (data.isRequest) { + notificationData["amountSat"] = data.amountMsat / 1000; + notificationData["type"] = "request"; + } else if (data.giftCardInfo) { + notificationData["giftCardName"] = data.giftCardInfo.name; + notificationData["type"] = "giftCard"; + } else { + notificationData["amountSat"] = data.amountMsat / 1000; + notificationData["type"] = "payment"; + } + + requestData = { + devicePushKey: devicePushKey, + deviceType: deviceType, + notificationData, + decryptPubKey: retrivedContact.uuid, + }; + } else if (data.giftCardInfo) { + const message = `${myProfile.name || myProfile.uniqueName} sent you a ${ + data.giftCardInfo.name + } Gift Card.`; + requestData = { + devicePushKey: devicePushKey, + deviceType: deviceType, + message, + decryptPubKey: retrivedContact.uuid, + }; } else { - message = `${ - myProfile.name || myProfile.uniqueName - } paid you ${formatBalanceAmount( - sendingContactDenominationType != "fiat" || !fiatAmount - ? data.amountMsat / 1000 - : fiatAmount - )} ${ - sendingContactDenominationType != "fiat" || !fiatAmount - ? BITCOIN_SAT_TEXT - : sendingContactFiatCurrency - }`; + const fiatValue = await loadNewFiatData( + sendingContactFiatCurrency, + privateKey, + myProfile.uuid, + masterInfoObject + ); + const didFindCurrency = fiatValue?.didWork; + const fiatAmount = + didFindCurrency && + ( + (fiatValue?.value / SATSPERBITCOIN) * + (data.amountMsat / 1000) + ).toFixed(2); + + let message = ""; + if (data.isUpdate) { + message = data.message; + } else if (data.isRequest) { + message = `${ + myProfile.name || myProfile.uniqueName + } requested you ${formatBalanceAmount( + sendingContactDenominationType != "fiat" || !fiatAmount + ? data.amountMsat / 1000 + : fiatAmount, + undefined, + { thousandsSeperator: "space" } + )} ${ + sendingContactDenominationType != "fiat" || !fiatAmount + ? BITCOIN_SAT_TEXT + : sendingContactFiatCurrency + }`; + } else { + message = `${ + myProfile.name || myProfile.uniqueName + } paid you ${formatBalanceAmount( + sendingContactDenominationType != "fiat" || !fiatAmount + ? data.amountMsat / 1000 + : fiatAmount, + undefined, + { thousandsSeperator: "space" } + )} ${ + sendingContactDenominationType != "fiat" || !fiatAmount + ? BITCOIN_SAT_TEXT + : sendingContactFiatCurrency + }`; + } + requestData = { + devicePushKey: devicePushKey, + deviceType: deviceType, + message, + decryptPubKey: retrivedContact.uuid, + }; } - const requestData = { - devicePushKey: devicePushKey, - deviceType: deviceType, - message: message, - decryptPubKey: selectedContact.uuid, - }; const response = await fetchBackend( - "contactsPushNotificationV3", + `contactsPushNotificationV${useNewNotifications ? "4" : "3"}`, requestData, privateKey, myProfile.uuid @@ -121,3 +184,92 @@ export async function sendPushNotification({ return false; } } + +export async function handlePaymentUpdate({ + transaction, + didPay, + txid, + globalContactsInformation, + selectedContact, + currentTime, + contactsPrivateKey, + publicKey, + masterInfoObject, +}) { + try { + let newMessage = { + ...transaction.message, + isRedeemed: didPay, + txid, + name: + globalContactsInformation.myProfile.name || + globalContactsInformation.myProfile.uniqueName, + }; + + // Need to switch unique name since the original receiver is now the sender + if (newMessage.senderProfileSnapshot) { + newMessage.senderProfileSnapshot.uniqueName = + globalContactsInformation.myProfile.uniqueName; + } + + delete newMessage.didSend; + delete newMessage.wasSeen; + + const [retrivedContact] = await Promise.all([ + getDataFromCollection("blitzWalletUsers", selectedContact.uuid), + ]); + if (!retrivedContact) + throw new Error(i18next.t("errormessages.userDataFetchError")); + + const useNewNotifications = !!retrivedContact.isUsingNewNotifications; + + const [didPublishNotification, didUpdateMessage] = await Promise.all([ + sendPushNotification({ + selectedContactUsername: selectedContact.uniqueName, + myProfile: globalContactsInformation.myProfile, + data: { + isUpdate: true, + [useNewNotifications ? "option" : "message"]: useNewNotifications + ? didPay + ? "paid" + : "declined" + : t( + "contacts.internalComponents.contactsTransactions.pushNotificationUpdateMessage", + { + name: + globalContactsInformation.myProfile.name || + globalContactsInformation.myProfile.uniqueName, + option: didPay + ? t("transactionLabelText.paidLower") + : t("transactionLabelText.declinedLower"), + } + ), + }, + privateKey: contactsPrivateKey, + retrivedContact, + masterInfoObject, + }), + + retrivedContact.isUsingEncriptedMessaging + ? updateMessage({ + newMessage, + fromPubKey: publicKey, + toPubKey: selectedContact.uuid, + retrivedContact, + privateKey: contactsPrivateKey, + currentTime, + }) + : updateMessage({ + newMessage, + fromPubKey: transaction.fromPubKey, + toPubKey: transaction.toPubKey, + retrivedContact, + privateKey: contactsPrivateKey, + currentTime, + }), + ]); + } catch (err) { + console.log("erro hanldling payment update", err.message); + return false; + } +} diff --git a/src/functions/pollingManager.js b/src/functions/pollingManager.js new file mode 100644 index 0000000..459774f --- /dev/null +++ b/src/functions/pollingManager.js @@ -0,0 +1,208 @@ +import { getSparkBalance } from "./spark"; +import { fullRestoreSparkState } from "./spark/restore"; + +/** + * Generic polling utility with exponential backoff + * @param {Object} config - Configuration object + * @param {Function} config.pollFn - Async function to poll (receives delayIndex) + * @param {Function} config.shouldContinue - Function that returns boolean to continue polling + * @param {Function} config.onUpdate - Callback when poll succeeds with new data + * @param {Array} config.delays - Array of delay intervals in ms + * @param {AbortController} config.abortController - Optional abort controller + * @param {Function} config.validateResult - Function to validate if result should stop polling + * @returns {Object} - Returns cleanup function and current state + */ +export const createPollingManager = ({ + pollFn, + shouldContinue, + onUpdate, + delays = [1000, 2000, 5000, 15000], + abortController, + validateResult = () => true, + initialBalance, +}) => { + let timeoutRef = null; + let previousResult = initialBalance || null; + + const cleanup = () => { + if (timeoutRef) { + clearTimeout(timeoutRef); + timeoutRef = null; + } + }; + + const poll = async (delayIndex = 0, resolve, reject) => { + try { + // Check abort conditions + if (abortController?.signal?.aborted || !shouldContinue()) { + console.log("Polling stopped:", { + aborted: abortController?.signal?.aborted, + shouldContinue: shouldContinue(), + }); + cleanup(); + resolve({ success: false, reason: "aborted" }); + return; + } + + // Check if we've exhausted all delays + if (delayIndex >= delays.length) { + console.log("Polling completed after all retries"); + cleanup(); + resolve({ + success: false, + reason: "max_retries", + result: previousResult, + }); + return; + } + + timeoutRef = setTimeout(async () => { + let globalResult; + try { + // Re-check abort conditions before executing + if (abortController?.signal?.aborted || !shouldContinue()) { + cleanup(); + resolve({ success: false, reason: "aborted" }); + return; + } + + // Execute the poll function + globalResult = await pollFn(delayIndex); + + // Validate if we should continue + if (validateResult(globalResult, previousResult)) { + // Call update callback + if (onUpdate) { + onUpdate(globalResult, delayIndex); + } + + // Success - stop polling + cleanup(); + resolve({ success: true, globalResult }); + return; + } + + // Continue to next delay + poll(delayIndex + 1, resolve, reject); + } catch (err) { + console.log("Error in polling iteration, continuing:", err); + poll(delayIndex + 1, resolve, reject); + } finally { + if ( + globalResult != null && + (previousResult == null || globalResult >= previousResult) + ) { + // Only update if same or higher + previousResult = globalResult; + } + } + }, delays[delayIndex]); + } catch (err) { + console.log("Error in poll setup:", err); + cleanup(); + reject(err); + } + }; + + return { + start: () => new Promise((resolve, reject) => poll(0, resolve, reject)), + cleanup, + }; +}; + +export const createBalancePoller = ( + mnemonic, + currentMnemonicRef, + abortController, + onBalanceUpdate, + initialBalance +) => { + let hasIncreasedAtLeastOnce = false; + let sameValueIndex = 0; + return createPollingManager({ + pollFn: async () => { + const balance = await getSparkBalance(mnemonic); + return balance.didWork ? Number(balance.balance) : null; + }, + shouldContinue: () => mnemonic === currentMnemonicRef.current, + validateResult: (newBalance, previousBalance) => { + console.log( + newBalance, + previousBalance, + hasIncreasedAtLeastOnce, + sameValueIndex + ); + + if (newBalance == null || previousBalance == null) return false; + + if (newBalance < previousBalance) { + console.log("Balance dropped — ignoring"); + sameValueIndex = 0; + return false; + } + + if (newBalance > previousBalance) { + hasIncreasedAtLeastOnce = true; + sameValueIndex = 0; + return false; + } + + sameValueIndex++; + + if ( + (hasIncreasedAtLeastOnce && sameValueIndex >= 2) || + sameValueIndex >= 3 + ) { + return true; + } + + return false; + }, + onUpdate: (newBalance, delayIndex) => { + console.log( + `Balance updated to ${newBalance} after ${delayIndex} attempts` + ); + onBalanceUpdate(newBalance); + }, + abortController, + delays: [1000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000], + initialBalance, + }); +}; + +export const createRestorePoller = ( + mnemonic, + isSendingPayment, + currentMnemonicRef, + abortController, + onRestoreComplete, + sparkInfo, + sendWebViewRequest +) => { + return createPollingManager({ + pollFn: async (delayIndex) => { + const result = await fullRestoreSparkState({ + sparkAddress: sparkInfo.sparkAddress, + batchSize: 2, + isSendingPayment: isSendingPayment, + mnemonic, + identityPubKey: sparkInfo.identityPubKey, + sendWebViewRequest, + isInitialRestore: false, + }); + return result; + }, + shouldContinue: () => mnemonic === currentMnemonicRef.current, + validateResult: (result) => { + return typeof result === "number" && result > 0; + }, + onUpdate: (result, delayIndex) => { + console.log( + `Restore completed after ${delayIndex + 1} attempts with ${result} txs` + ); + onRestoreComplete(result); + }, + abortController, + delays: [0, 2000, 5000], + }); +}; diff --git a/src/functions/spark/handleBalanceCache.js b/src/functions/spark/handleBalanceCache.js new file mode 100644 index 0000000..091af94 --- /dev/null +++ b/src/functions/spark/handleBalanceCache.js @@ -0,0 +1,62 @@ +import { SPARK_CACHED_BALANCE_KEY } from "../../constants"; +import sha256Hash from "../hash"; +import Storage from "../localStorage"; + +export default async function handleBalanceCache({ + isCheck, + passedBalance, + mnemonic, + returnBalanceOnly = false, +}) { + const mnemonicHash = sha256Hash(mnemonic); + console.log(mnemonicHash); + + const cachedBalances = await migrateCachedData(mnemonicHash); + console.log(cachedBalances); + + if (returnBalanceOnly) { + return cachedBalances[mnemonicHash] || 0; + } + + if (isCheck) { + const cachedBalance = cachedBalances[mnemonicHash] || null; + + if (!cachedBalance) { + cachedBalances[mnemonicHash] = passedBalance; + Storage.setItem(SPARK_CACHED_BALANCE_KEY, cachedBalances); + return { didWork: true, balance: passedBalance }; + } + if (passedBalance * 1.1 >= cachedBalance) { + cachedBalances[mnemonicHash] = passedBalance; + Storage.setItem(SPARK_CACHED_BALANCE_KEY, cachedBalances); + return { didWork: true, balance: passedBalance }; + } else { + return { didWork: false, balance: cachedBalance }; + } + } else { + // Set the balance for this mnemonic hash + cachedBalances[mnemonicHash] = passedBalance; + Storage.setItem(SPARK_CACHED_BALANCE_KEY, cachedBalances); + } +} + +async function migrateCachedData(mnemonicHash) { + const rawCachedData = Storage.getItem(SPARK_CACHED_BALANCE_KEY); + if (!rawCachedData) { + return {}; + } + + const parsedData = rawCachedData; + + if (typeof parsedData === "number") { + console.log("Migrating old balance cache format to new hash-based format"); + + const newFormat = { + [mnemonicHash]: parsedData, + }; + Storage.setItem(SPARK_CACHED_BALANCE_KEY, newFormat); + return newFormat; + } + + return parsedData; +} diff --git a/src/functions/spark/index.js b/src/functions/spark/index.js index 9a52488..f36f14c 100644 --- a/src/functions/spark/index.js +++ b/src/functions/spark/index.js @@ -15,6 +15,7 @@ import { migrateCachedTokens, saveCachedTokens, } from "../lrc20/cachedTokens"; +import Storage from "../localStorage"; export let sparkWallet = {}; @@ -411,6 +412,35 @@ export const getSparkTokenTransactions = async ({ } }; +export const getSingleTxDetails = async (mnemonic, id) => { + try { + const wallet = await getWallet(mnemonic); + return await wallet.getTransfer(id); + } catch (err) { + console.log("get single spark transaction error", err); + return undefined; + } +}; + +export const setPrivacyEnabled = async (mnemonic) => { + try { + const didSetPrivacySetting = Storage.getItem("didSetPrivacySetting"); + + if (didSetPrivacySetting) return; + + const wallet = await getWallet(mnemonic); + const walletSetings = await wallet.getWalletSettings(); + if (!walletSetings?.privateEnabled) { + wallet.setPrivacyEnabled(true); + } else { + Storage.setItem("didSetPrivacySetting", true); + } + return true; + } catch (err) { + console.log("Get spark balance error", err); + } +}; + export const getCachedSparkTransactions = async (limit, identifyPubKey) => { try { const txResponse = await getAllSparkTransactions(limit, identifyPubKey); diff --git a/src/functions/spark/restore.js b/src/functions/spark/restore.js index 3ddf1dd..82ae3e1 100644 --- a/src/functions/spark/restore.js +++ b/src/functions/spark/restore.js @@ -15,6 +15,7 @@ import { deleteSparkTransaction, deleteUnpaidSparkLightningTransaction, getAllPendingSparkPayments, + getAllSparkTransactions, getAllUnpaidSparkLightningInvoices, } from "./transactions"; import { transformTxToPaymentObject } from "./transformTxToPayment"; @@ -24,39 +25,172 @@ import { IS_SPARK_REQUEST_ID, } from "../../constants"; +const RESTORE_STATE_KEY = "spark_tx_restore_state"; +const MAX_BATCH_SIZE = 400; +const DEFAULT_BATCH_SIZE = 5; +const INCREMENTAL_SAVE_THRESHOLD = 200; + +/** + * Get the current restore state for an account + */ +async function getRestoreState(accountId, numSavedTxs) { + try { + const stateJson = Storage.getItem(`${RESTORE_STATE_KEY}_${accountId}`); + + if (!stateJson) { + // We assume if a user has over 400 saved txs, they are fully restored + return { + isFullyRestored: numSavedTxs > 400 ? true : false, + lastProcessedOffset: 0, + lastProcessedTxId: null, + restoredTxCount: 0, + }; + } + return stateJson; + } catch (error) { + console.error("Error getting restore state:", error); + return { + isFullyRestored: false, + lastProcessedOffset: 0, + lastProcessedTxId: null, + restoredTxCount: 0, + }; + } +} + +/** + * Update the restore state for an account + */ +async function updateRestoreState(accountId, state) { + try { + Storage.setItem(`${RESTORE_STATE_KEY}_${accountId}`, state); + } catch (error) { + console.error("Error updating restore state:", error); + } +} + +/** + * Mark restoration as complete for an account + */ +async function markRestoreComplete(accountId) { + await updateRestoreState(accountId, { + isFullyRestored: true, + lastProcessedOffset: 0, + lastProcessedTxId: null, + restoredTxCount: 0, + completedAt: Date.now(), + }); +} + export const restoreSparkTxState = async ( BATCH_SIZE, - savedTxs, + identityPubKey, isSendingPayment, mnemonic, - accountId + accountId, + onProgressSave = null ) => { const restoredTxs = []; try { + const [savedTxs, pendingTxs] = await Promise.all([ + getAllSparkTransactions({ accountId: identityPubKey, idsOnly: true }), + getAllPendingSparkPayments(accountId), + ]); + const savedIds = new Set(savedTxs?.map((tx) => tx.sparkID) || []); - const pendingTxs = await getAllPendingSparkPayments(accountId); const txsByType = { lightning: pendingTxs.filter((tx) => tx.paymentType === "lightning"), bitcoin: pendingTxs.filter((tx) => tx.paymentType === "bitcoin"), }; - let offset = 0; - let localBatchSize = !savedIds.size ? 100 : BATCH_SIZE; + const restoreState = await getRestoreState(accountId, savedIds.size); + + const isRestoring = !restoreState.isFullyRestored; + let offset = isRestoring ? restoreState.lastProcessedOffset : 0; + const localBatchSize = isRestoring ? MAX_BATCH_SIZE : BATCH_SIZE; + console.log( + `Restore mode: ${ + isRestoring ? "ACTIVE" : "NORMAL" + }, batch size: ${localBatchSize}` + ); + const donationPubKey = import.meta.env.VITE_BLITZ_SPARK_PUBKEY; + const newTxsAtFront = []; + + if (isRestoring && offset > 0) { + console.log("Checking for new transactions at the front..."); + try { + const recentTxs = await getSparkTransactions(BATCH_SIZE, 0, mnemonic); + const recentBatch = recentTxs.transfers || []; + + for (const tx of recentBatch) { + if (savedIds.has(tx.id)) break; + // Filter donations and active sends + if ( + tx.transferDirection === "OUTGOING" && + tx.receiverIdentityPublicKey === donationPubKey + ) { + continue; + } + if (tx.transferDirection === "OUTGOING" && isSendingPayment) continue; + + const type = sparkPaymentType(tx); + + // Check against pending transactions + if (type === "bitcoin") { + const duplicate = txsByType.bitcoin.find((item) => { + const details = JSON.parse(item.details); + return ( + tx.transferDirection === details.direction && + tx.totalValue === details.amount && + details.time - new Date(tx.createdTime) < 1000 * 60 * 10 + ); + }); + if (duplicate) continue; + } else if (type === "lightning") { + const duplicate = txsByType.lightning.find((item) => { + const details = JSON.parse(item.details); + return ( + tx.transferDirection === details.direction && + details?.createdAt - new Date(tx.createdTime) < 1000 * 30 + ); + }); + if (duplicate) continue; + } + + newTxsAtFront.push(tx); + } + + if (newTxsAtFront.length > 0) { + console.log( + `Found ${newTxsAtFront.length} new transactions at the front` + ); + restoredTxs.push(...newTxsAtFront); + // Add these new tx IDs to savedIds to avoid duplicates + newTxsAtFront.forEach((tx) => savedIds.add(tx.id)); + } + } catch (error) { + console.error("Error checking for new transactions:", error); + } + } + + let batchCounter = 0; + let foundOverlap = false; + while (true) { const txs = await getSparkTransactions(localBatchSize, offset, mnemonic); const batchTxs = txs.transfers || []; if (!batchTxs.length) { console.log("No more transactions found, ending restore."); + await markRestoreComplete(accountId); break; } // Process batch and check for overlap simultaneously - let foundOverlap = false; const newBatchTxs = []; for (const tx of batchTxs) { // Check for overlap first (most likely to break early) @@ -109,8 +243,27 @@ export const restoreSparkTxState = async ( // Add filtered transactions to result restoredTxs.push(...newBatchTxs); + batchCounter++; + + if (isRestoring && restoredTxs.length >= INCREMENTAL_SAVE_THRESHOLD) { + console.log(`Incremental save: ${restoredTxs.length} transactions`); + + await updateRestoreState(accountId, { + isFullyRestored: false, + lastProcessedOffset: offset + localBatchSize, + lastProcessedTxId: newBatchTxs[newBatchTxs.length - 1]?.id || null, + restoredTxCount: restoreState.restoredTxCount + restoredTxs.length, + }); + + if (onProgressSave) { + await onProgressSave(restoredTxs.slice()); + } + + restoredTxs.length = 0; + } if (foundOverlap) { + await markRestoreComplete(accountId); break; } @@ -119,7 +272,10 @@ export const restoreSparkTxState = async ( console.log(`Total restored transactions: ${restoredTxs.length}`); - return { txs: restoredTxs }; + return { + txs: restoredTxs, + isRestoreComplete: !isRestoring || foundOverlap, + }; } catch (error) { console.error("Error in spark restore history state:", error); return { txs: [] }; @@ -141,7 +297,8 @@ async function processTransactionChunk( sparkAddress, unpaidInvoices, identityPubKey, - numberOfRestoredTxs + numberOfRestoredTxs, + unpaidContactInvoices ) { const chunkPaymentObjects = []; @@ -154,7 +311,9 @@ async function processTransactionChunk( true, unpaidInvoices, identityPubKey, - numberOfRestoredTxs + numberOfRestoredTxs, + undefined, + unpaidContactInvoices ); if (paymentObject) { chunkPaymentObjects.push(paymentObject); @@ -167,9 +326,10 @@ async function processTransactionChunk( return chunkPaymentObjects; } +let isRestoringState = false; export async function fullRestoreSparkState({ sparkAddress, - batchSize = 50, + batchSize = DEFAULT_BATCH_SIZE, chunkSize = 100, maxConcurrentChunks = 3, // Reduced for better responsiveness yieldInterval = 50, // Yield every N milliseconds @@ -177,19 +337,71 @@ export async function fullRestoreSparkState({ isSendingPayment, mnemonic, identityPubKey, + isInitialRestore, }) { try { + if (isRestoringState) { + console.log("already restoring state"); + return; + } console.log("running"); - const savedTxs = await getCachedSparkTransactions(null, identityPubKey); + isRestoringState = true; + + const handleProgressSave = async (txBatch) => { + if (!txBatch.length) return; + + const [unpaidInvoices, unpaidContactInvoices] = await Promise.all([ + getAllUnpaidSparkLightningInvoices(), + getAllSparkContactInvoices(), + ]); + + const paymentObjects = []; + for (const tx of txBatch) { + try { + const paymentObject = await transformTxToPaymentObject( + tx, + sparkAddress, + undefined, + true, + unpaidInvoices, + identityPubKey, + txBatch.length, + undefined, + unpaidContactInvoices + ); + if (paymentObject) { + paymentObjects.push(paymentObject); + } + } catch (err) { + console.error( + "Error transforming tx during incremental save:", + tx.id, + err + ); + } + } + + if (paymentObjects.length) { + await bulkUpdateSparkTransactions(paymentObjects, "incrementalRestore"); + console.log( + `Incrementally saved ${paymentObjects.length} transactions` + ); + } + }; + const restored = await restoreSparkTxState( batchSize, - savedTxs, + identityPubKey, isSendingPayment, mnemonic, - identityPubKey + identityPubKey, + handleProgressSave ); - - const unpaidInvoices = await getAllUnpaidSparkLightningInvoices(); + if (!restored.txs.length) return; + const [unpaidInvoices, unpaidContactInvoices] = await Promise.all([ + getAllUnpaidSparkLightningInvoices(), + getAllSparkContactInvoices(), + ]); const txChunks = chunkArray(restored.txs, chunkSize); console.log( @@ -210,7 +422,8 @@ export async function fullRestoreSparkState({ sparkAddress, unpaidInvoices, identityPubKey, - restored.txs.length + restored.txs.length, + unpaidContactInvoices ) ); @@ -244,60 +457,19 @@ export async function fullRestoreSparkState({ ); if (allPaymentObjects.length) { - bulkUpdateSparkTransactions(allPaymentObjects, "fullUpdate"); + await bulkUpdateSparkTransactions(allPaymentObjects, "fullUpdate"); } return allPaymentObjects.length; } catch (err) { console.log("full restore spark state error", err); return false; + } finally { + isRestoringState = false; } } -export const findSignleTxFromHistory = async (txid, BATCH_SIZE, mnemonic) => { - let restoredTx; - try { - // here we do not want to save any tx to be shown, we only want to flag that it came from restore and then when we get the actual notification of it we can block the navigation - let start = 0; - - let foundOverlap = false; - - do { - const txs = await getSparkTransactions( - start + BATCH_SIZE, - start, - mnemonic - ); - const batchTxs = txs.transfers || []; - - if (!batchTxs.length) { - console.log("No more transactions found, ending restore."); - break; - } - - // Check for overlap with saved transactions - const overlap = batchTxs.find((tx) => tx.id === txid); - - if (overlap) { - console.log("Found overlap with saved transactions, stopping restore."); - foundOverlap = true; - restoredTx = overlap; - } - - start += BATCH_SIZE; - } while (!foundOverlap); - - // Filter out any already-saved txs or dontation payments - console.log(`Restored transaction`, restoredTx); - - return { tx: restoredTx }; - } catch (error) { - console.error("Error in spark restore history state:", error); - return { tx: null }; - } -}; let isUpdatingSparkTxStatus = false; - export const updateSparkTxStatus = async (mnemoninc, accountId) => { try { if (isUpdatingSparkTxStatus) { @@ -341,19 +513,22 @@ export const updateSparkTxStatus = async (mnemoninc, accountId) => { mnemoninc, accountId ), - processBitcoinTransactions(txsByType.bitcoin, mnemoninc), - processSparkTransactions(txsByType.spark), + processBitcoinTransactions(txsByType.bitcoin, mnemoninc, accountId), + processSparkTransactions(txsByType.spark, mnemoninc), ]); const updatedTxs = [ ...lightningUpdates, ...bitcoinUpdates, - ...sparkUpdates, + ...sparkUpdates.updatedTxs, ]; if (!updatedTxs.length) return { updated: [] }; - await bulkUpdateSparkTransactions(updatedTxs, "restoreTxs"); + await bulkUpdateSparkTransactions( + updatedTxs, + sparkUpdates.includesGift ? "fullUpdate-waitBalance" : "txStatusUpdate" + ); console.log(`Updated transactions:`, updatedTxs); return { updated: updatedTxs }; } catch (error) { @@ -402,30 +577,31 @@ async function processLightningTransactions( continue; } - const findTxResponse = await findTransactionTxFromTxHistory( - result.id, - transfersOffset, - cachedTransfers, - mnemonic - ); + const findTxResponse = await getSingleTxDetails(mnemonic, result.id); - if (findTxResponse.offset && findTxResponse.foundTransfers) { - transfersOffset = findTxResponse.offset; - cachedTransfers = findTxResponse.foundTransfers; + if (!findTxResponse) { + // If no transaction is found just call it completed + const details = JSON.parse(result.txStateUpdate.details); + newTxs.push({ + tempId: result.txStateUpdate.sparkID, + useTempId: true, + ...result.txStateUpdate, + details, + paymentStatus: "completed", + }); + continue; } - if (!findTxResponse.didWork || !findTxResponse.bitcoinTransfer) continue; - - const { offset, foundTransfers, bitcoinTransfer } = findTxResponse; - transfersOffset = offset; - cachedTransfers = foundTransfers; - - if (!bitcoinTransfer) continue; + const bitcoinTransfer = findTxResponse; const paymentStatus = getSparkPaymentStatus(bitcoinTransfer.status); const expiryDate = new Date(bitcoinTransfer.expiryTime); - if (paymentStatus === "pending" && expiryDate < Date.now()) { + if ( + (paymentStatus === "pending" && expiryDate < Date.now()) || + (bitcoinTransfer.transferDirection === "OUTGOING" && + bitcoinTransfer.status === "TRANSFER_STATUS_SENDER_KEY_TWEAK_PENDING") + ) { await deleteSparkTransaction(result.id); continue; } @@ -452,111 +628,112 @@ async function processLightningTransaction( mnemonic ) { const details = JSON.parse(txStateUpdate.details); + const possibleOptions = unpaidInvoicesByAmount.get(details.amount) || []; - if (txStateUpdate.paymentType === "lightning") { - const possibleOptions = unpaidInvoicesByAmount.get(details.amount) || []; - - if ( - !IS_SPARK_REQUEST_ID.test(txStateUpdate.sparkID) && - !possibleOptions.length - ) { - // goes to be handled later by transform tx to payment - return { - id: txStateUpdate.sparkID, - paymentStatus: "", - paymentType: "lightning", - accountId: txStateUpdate.accountId, - lookThroughTxHistory: true, - }; - } - - if (!IS_SPARK_REQUEST_ID.test(txStateUpdate.sparkID)) { - // Process invoice matching with retry logic - const matchResult = await findMatchingInvoice( - possibleOptions, - txStateUpdate.sparkID, - mnemonic - ); - - if (matchResult.savedInvoice) { - await deleteUnpaidSparkLightningTransaction( - matchResult.savedInvoice.sparkID - ); - } - - const savedDetails = matchResult.savedInvoice?.details - ? JSON.parse(matchResult.savedInvoice.details) - : {}; - - return { - useTempId: true, - tempId: txStateUpdate.sparkID, - id: matchResult.matchedUnpaidInvoice - ? matchResult.matchedUnpaidInvoice.transfer.sparkId - : txStateUpdate.sparkID, - paymentStatus: "completed", - // getSparkPaymentStatus( - // matchResult.matchedUnpaidInvoice.status, - // ), - paymentType: "lightning", - accountId: txStateUpdate.accountId, - details: { - ...savedDetails, - description: matchResult.savedInvoice?.description || "", - address: - matchResult.matchedUnpaidInvoice?.invoice?.encodedInvoice || "", - preimage: matchResult.matchedUnpaidInvoice?.paymentPreimage || "", - shouldNavigate: matchResult.savedInvoice?.shouldNavigate ?? 0, - isLNURL: savedDetails?.isLNURL || false, - }, - }; - } - - // Handle spark request IDs - const sparkResponse = - details.direction === "INCOMING" - ? await getSparkLightningPaymentStatus({ - lightningInvoiceId: txStateUpdate.sparkID, - mnemonic, - }) - : await getSparkLightningSendRequest(txStateUpdate.sparkID, mnemonic); + if ( + !IS_SPARK_REQUEST_ID.test(txStateUpdate.sparkID) && + !possibleOptions.length + ) { + // goes to be handled later by transform tx to payment + return { + id: txStateUpdate.sparkID, + paymentStatus: "", + paymentType: "lightning", + accountId: txStateUpdate.accountId, + lookThroughTxHistory: true, + txStateUpdate, + }; + } - if ( - details.direction === "OUTGOING" && - getSparkPaymentStatus(sparkResponse.status) === "failed" - ) - return { - ...txStateUpdate, - id: txStateUpdate.sparkID, - details: { - ...details, - }, - paymentStatus: "failed", - }; + if (!IS_SPARK_REQUEST_ID.test(txStateUpdate.sparkID)) { + // Process invoice matching with retry logic + const matchResult = await findMatchingInvoice( + possibleOptions, + txStateUpdate.sparkID, + mnemonic + ); - if (!sparkResponse?.transfer) return null; + // if (matchResult.savedInvoice) { + // await deleteUnpaidSparkLightningTransaction( + // matchResult.savedInvoice.sparkID + // ); + // } - // const fee = - // sparkResponse.fee.originalValue / - // (sparkResponse.fee.originalUnit === 'MILLISATOSHI' ? 1000 : 1); + const savedDetails = matchResult.savedInvoice?.details + ? JSON.parse(matchResult.savedInvoice.details) + : {}; return { useTempId: true, tempId: txStateUpdate.sparkID, - id: sparkResponse.transfer.sparkId, - paymentStatus: "completed", // getSparkPaymentStatus(sparkResponse.status) + id: matchResult.matchedUnpaidInvoice + ? matchResult.matchedUnpaidInvoice.transfer.sparkId + : txStateUpdate.sparkID, + paymentStatus: "completed", + // getSparkPaymentStatus( + // matchResult.matchedUnpaidInvoice.status, + // ), paymentType: "lightning", accountId: txStateUpdate.accountId, details: { - ...details, - // fee: Math.round(fee), - // totalFee: Math.round(fee) + (details.supportFee || 0), - preimage: sparkResponse.paymentPreimage || "", + ...savedDetails, + description: matchResult.savedInvoice?.description || "", + address: + matchResult.matchedUnpaidInvoice?.invoice?.encodedInvoice || "", + preimage: matchResult.matchedUnpaidInvoice?.paymentPreimage || "", + shouldNavigate: matchResult.savedInvoice?.shouldNavigate ?? 0, + isLNURL: savedDetails?.isLNURL || false, }, }; } - return null; + // Handle spark request IDs + const sparkResponse = + details.direction === "INCOMING" + ? await getSparkLightningPaymentStatus({ + lightningInvoiceId: txStateUpdate.sparkID, + mnemonic, + }) + : await getSparkLightningSendRequest(txStateUpdate.sparkID, mnemonic); + + if ( + details.direction === "OUTGOING" && + getSparkPaymentStatus(sparkResponse.status) === "failed" + ) + return { + ...txStateUpdate, + id: txStateUpdate.sparkID, + details: { + ...details, + }, + paymentStatus: "failed", + }; + + if (!sparkResponse?.transfer) return null; + + const preimage = sparkResponse.paymentPreimage || ""; + + if (!preimage) return null; + + // const fee = + // sparkResponse.fee.originalValue / + // (sparkResponse.fee.originalUnit === 'MILLISATOSHI' ? 1000 : 1); + + return { + useTempId: true, + tempId: txStateUpdate.sparkID, + id: sparkResponse.transfer.sparkId, + paymentStatus: + paymentStatus === "completed" || preimage ? "completed" : paymentStatus, + paymentType: "lightning", + accountId: txStateUpdate.accountId, + details: { + ...details, + // fee: Math.round(fee), + // totalFee: Math.round(fee) + (details.supportFee || 0), + preimage: preimage, + }, + }; } async function findMatchingInvoice(possibleOptions, sparkID, mnemonic) { @@ -633,9 +810,6 @@ async function processBitcoinTransactions(bitcoinTxs, mnemonic) { } const updatedTxs = []; - let transfersOffset = 0; - let cachedTransfers = []; - for (const txStateUpdate of bitcoinTxs) { const details = JSON.parse(txStateUpdate.details); @@ -643,31 +817,51 @@ async function processBitcoinTransactions(bitcoinTxs, mnemonic) { details.direction === "INCOMING" || !IS_BITCOIN_REQUEST_ID.test(txStateUpdate.sparkID) ) { - if (!IS_SPARK_ID.test(txStateUpdate.sparkID)) continue; - - const findTxResponse = await findTransactionTxFromTxHistory( - txStateUpdate.sparkID, - transfersOffset, - cachedTransfers, - mnemonic - ); + if (!IS_SPARK_ID.test(txStateUpdate.sparkID)) { + const allPayments = await getAllSparkTransactions({ accountId }); + const foundPayment = allPayments.find((payment) => { + if (payment.paymentType === "bitcoin") { + const details = JSON.parse(payment.details); + if (details.onChainTxid === txStateUpdate.sparkID) return true; + } + }); + if (foundPayment) { + const newDetails = JSON.parse(foundPayment.details); + const oldDetails = JSON.parse(txStateUpdate.details); + + if ( + sha256Hash(JSON.stringify(foundPayment)) === + sha256Hash(JSON.stringify(txStateUpdate)) + ) + continue; - if (findTxResponse.offset && findTxResponse.foundTransfers) { - transfersOffset = findTxResponse.offset; - cachedTransfers = findTxResponse.foundTransfers; + updatedTxs.push({ + useTempId: true, + tempId: txStateUpdate.sparkID, + id: foundPayment.sparkID, + paymentStatus: foundPayment.paymentStatus, + paymentType: "bitcoin", + accountId: foundPayment.accountId, + details: { + ...newDetails, + address: oldDetails.address || "", + description: oldDetails.description || "", + }, + }); + } + continue; } - if (!findTxResponse.didWork || !findTxResponse.bitcoinTransfer) continue; - - const { offset, foundTransfers, bitcoinTransfer } = findTxResponse; - transfersOffset = offset; - cachedTransfers = foundTransfers; + const transfer = await getSingleTxDetails( + mnemonic, + txStateUpdate.sparkID + ); - if (!bitcoinTransfer) continue; + if (!transfer) continue; updatedTxs.push({ id: txStateUpdate.sparkID, - paymentStatus: getSparkPaymentStatus(bitcoinTransfer.status), + paymentStatus: getSparkPaymentStatus(transfer.status), paymentType: "bitcoin", accountId: txStateUpdate.accountId, }); @@ -731,11 +925,36 @@ async function processBitcoinTransactions(bitcoinTxs, mnemonic) { return updatedTxs; } -async function processSparkTransactions(sparkTxs) { - return sparkTxs.map((txStateUpdate) => ({ - id: txStateUpdate.sparkID, - paymentStatus: "completed", - paymentType: "spark", - accountId: txStateUpdate.accountId, - })); +async function processSparkTransactions(sparkTxs, mnemonic) { + let includesGift = false; + let updatedTxs = []; + for (const txStateUpdate of sparkTxs) { + const details = JSON.parse(txStateUpdate.details); + + if (details.isGift) { + const findTxResponse = await getSingleTxDetails( + mnemonic, + txStateUpdate.sparkID + ); + + if (!findTxResponse) continue; + + includesGift = true; + updatedTxs.push({ + id: txStateUpdate.sparkID, + paymentStatus: getSparkPaymentStatus(findTxResponse.status), + paymentType: "spark", + accountId: txStateUpdate.accountId, + }); + } else { + updatedTxs.push({ + id: txStateUpdate.sparkID, + paymentStatus: "completed", + paymentType: "spark", + accountId: txStateUpdate.accountId, + }); + } + } + + return { updatedTxs, includesGift }; } diff --git a/src/functions/spark/transactions.js b/src/functions/spark/transactions.js index c69bfbd..5244393 100644 --- a/src/functions/spark/transactions.js +++ b/src/functions/spark/transactions.js @@ -4,6 +4,7 @@ import EventEmitter from "events"; export const SPARK_TRANSACTIONS_DATABASE_NAME = "spark-info-db"; export const SPARK_TRANSACTIONS_TABLE_NAME = "SPARK_TRANSACTIONS"; export const LIGHTNING_REQUEST_IDS_TABLE_NAME = "LIGHTNING_REQUEST_IDS"; +export const SPARK_REQUEST_IDS_TABLE_NAME = "SPARK_REQUEST_IDS"; export const sparkTransactionsEventEmitter = new EventEmitter(); export const SPARK_TX_UPDATE_ENVENT_NAME = "UPDATE_SPARK_STATE"; let bulkUpdateTransactionQueue = []; @@ -22,6 +23,11 @@ let dbPromise = openDB(SPARK_TRANSACTIONS_DATABASE_NAME, 1, { keyPath: "sparkID", }); } + if (!db.objectStoreNames.contains(SPARK_REQUEST_IDS_TABLE_NAME)) { + db.createObjectStore(SPARK_REQUEST_IDS_TABLE_NAME, { + keyPath: "sparkID", + }); + } }, }); @@ -113,6 +119,94 @@ export const getAllPendingSparkPayments = async (accountId) => { } }; +export const getAllSparkContactInvoices = async () => { + try { + const db = await dbPromise; + return await db.getAll(SPARK_TRANSACTIONS_TABLE_NAME); + } catch (error) { + console.error("Error fetching contacts saved transactions:", error); + } +}; + +export const addSingleUnpaidSparkTransaction = async (tx) => { + if (!tx || !tx.id) { + console.error("Invalid transaction object"); + return false; + } + + try { + const db = await dbPromise; + await db.put(SPARK_REQUEST_IDS_TABLE_NAME, { + sparkID: tx.id, + description: tx.description, + sendersPubkey: tx.sendersPubkey, + details: JSON.stringify(tx.details), + }); + return true; + } catch (error) { + console.error("Error adding spark transaction:", error); + return false; + } +}; + +export const addBulkUnpaidSparkContactTransactions = async (transactions) => { + if (!Array.isArray(transactions) || transactions.length === 0) { + console.error("Invalid transactions array"); + return { success: false, added: 0, failed: 0 }; + } + + const validTransactions = transactions.filter((tx) => tx && tx.id); + + if (validTransactions.length === 0) { + console.error("No valid transactions to add"); + return { success: false, added: 0, failed: transactions.length }; + } + + try { + const db = await dbPromise; + const tx = db.transaction(SPARK_REQUEST_IDS_TABLE_NAME, "readwrite"); + const store = tx.objectStore(SPARK_REQUEST_IDS_TABLE_NAME); + + // Add all valid transactions + for (const transaction of validTransactions) { + await store.put({ + sparkID: transaction.id, + description: transaction.description, + sendersPubkey: transaction.sendersPubkey, + details: JSON.stringify(transaction.details), + }); + } + + // Wait for the transaction to complete + await tx.done; + + console.log( + `Successfully added ${validTransactions.length} unpaid contact invoices` + ); + + return { + success: true, + added: validTransactions.length, + failed: transactions.length - validTransactions.length, + }; + } catch (error) { + console.error("Error adding bulk spark contact transactions:", error); + return { success: false, added: 0, failed: transactions.length }; + } +}; + +export const deleteSparkContactTransaction = async (sparkID) => { + try { + const db = await dbPromise; + await db.delete(SPARK_REQUEST_IDS_TABLE_NAME, sparkID); + + return true; + } catch (error) { + console.error(`Error deleting transaction ${sparkID}:`, error); + return false; + } +}; + export const getAllUnpaidSparkLightningInvoices = async () => { try { const db = await dbPromise; @@ -463,6 +557,17 @@ export const deleteUnpaidSparkLightningTransactionTable = async () => { db.deleteObjectStore(LIGHTNING_REQUEST_IDS_TABLE_NAME); }; +export const deleteSparkContactsTransactionsTable = async () => { + try { + const db = await dbPromise; + db.deleteObjectStore(SPARK_REQUEST_IDS_TABLE_NAME); + return true; + } catch (error) { + console.error("Error deleting spark_transactions table:", error); + return false; + } +}; + export const wipeEntireSparkDatabase = async () => { try { await deleteDB(SPARK_TRANSACTIONS_DATABASE_NAME); diff --git a/src/functions/spark/transformTxToPayment.js b/src/functions/spark/transformTxToPayment.js index e711093..5afcfd7 100644 --- a/src/functions/spark/transformTxToPayment.js +++ b/src/functions/spark/transformTxToPayment.js @@ -1,5 +1,7 @@ import { decode } from "bolt11"; import { getSparkPaymentStatus, sparkPaymentType } from "."; +import calculateProgressiveBracketFee from "./calculateSupportFee"; +import { deleteSparkContactTransaction } from "./transactions"; export async function transformTxToPaymentObject( tx, @@ -8,25 +10,30 @@ export async function transformTxToPaymentObject( isRestore, unpaidLNInvoices, identityPubKey, - numTxsBeingRestored = 1 + numTxsBeingRestored = 1, + forceOutgoing = false, + unpaidContactInvoices ) { // Defer all payments to the 10 second interval to be updated const paymentType = forcePaymentType ? forcePaymentType : sparkPaymentType(tx); + const paymentAmount = tx.totalValue; + + const accountId = forceOutgoing + ? tx.receiverIdentityPublicKey + : tx.transferDirection === "OUTGOING" + ? tx.senderIdentityPublicKey + : tx.receiverIdentityPublicKey; if (paymentType === "lightning") { - const foundInvoice = unpaidLNInvoices.find((item) => { - const details = JSON.parse(item.details); - return ( - item.amount === tx.totalValue && - Math.abs(details?.createdTime - new Date(tx.createdTime).getTime()) < - 1000 * 30 - ); - }); + const userRequest = tx.userRequest; + const userRequestId = userRequest?.id; + const foundInvoice = unpaidLNInvoices.find( + (item) => item.sparkID === userRequestId + ); const status = getSparkPaymentStatus(tx.status); - const userRequest = tx.userRequest; const isSendRequest = userRequest?.typename === "LightningSendRequest"; const invoice = userRequest ? isSendRequest @@ -34,6 +41,22 @@ export async function transformTxToPaymentObject( : userRequest.invoice?.encodedInvoice : ""; + const paymentFee = userRequest + ? isSendRequest + ? userRequest.fee.originalValue / + (userRequest.fee.originalUnit === "MILLISATOSHI" ? 1000 : 1) + : 0 + : 0; + const preimage = userRequest ? userRequest?.paymentPreimage || "" : ""; + const supportFee = await calculateProgressiveBracketFee( + paymentAmount, + "lightning" + ); + + const foundInvoiceDetails = foundInvoice + ? JSON.parse(foundInvoice.details) + : undefined; + const description = numTxsBeingRestored < 20 ? invoice @@ -46,49 +69,70 @@ export async function transformTxToPaymentObject( return { id: tx.transfer ? tx.transfer.sparkId : tx.id, - paymentStatus: status, + paymentStatus: status === "completed" || preimage ? "completed" : status, paymentType: "lightning", - accountId: identityPubKey, + accountId: accountId, details: { - fee: 0, - amount: tx.totalValue, + fee: paymentFee, + totalFee: paymentFee + supportFee, + supportFee: supportFee, + amount: paymentAmount - paymentFee, address: userRequest ? isSendRequest ? userRequest?.encodedInvoice : userRequest.invoice?.encodedInvoice : "", + createdTime: foundInvoiceDetails + ? foundInvoiceDetails.createdTime + : new Date(tx.createdTime).getTime(), time: tx.updatedTime ? new Date(tx.updatedTime).getTime() : new Date().getTime(), direction: tx.transferDirection, description: description, - preimage: userRequest ? userRequest?.paymentPreimage || "" : "", + preimage: preimage, isRestore, - isBlitzContactPayment: foundInvoice - ? JSON.parse(foundInvoice.details)?.isBlitzContactPayment + isBlitzContactPayment: foundInvoiceDetails + ? foundInvoiceDetails?.isBlitzContactPayment : undefined, shouldNavigate: foundInvoice ? foundInvoice?.shouldNavigate : undefined, - isLNURL: foundInvoice - ? JSON.parse(foundInvoice.details)?.isLNURL + isLNURL: foundInvoiceDetails ? foundInvoiceDetails?.isLNURL : undefined, + sendingUUID: foundInvoiceDetails + ? foundInvoiceDetails?.sendingUUID : undefined, }, }; } else if (paymentType === "spark") { + const foundInvoice = unpaidContactInvoices?.find( + (savedTx) => savedTx.sparkID === tx.id + ); + const paymentFee = tx.transferDirection === "OUTGOING" ? 0 : 0; + const supportFee = await (tx.transferDirection === "OUTGOING" + ? calculateProgressiveBracketFee(paymentAmount, "spark") + : Promise.resolve(0)); + + if (foundInvoice?.sparkID) { + deleteSparkContactTransaction(foundInvoice.sparkID); + } return { id: tx.id, paymentStatus: "completed", paymentType: "spark", - accountId: identityPubKey, + accountId: accountId, details: { - fee: 0, - amount: tx.totalValue, + sendingUUID: foundInvoice?.sendersPubkey, + fee: paymentFee, + totalFee: paymentFee + supportFee, + supportFee: supportFee, + amount: paymentAmount - paymentFee, address: sparkAddress, time: tx.updatedTime ? new Date(tx.updatedTime).getTime() : new Date().getTime(), direction: tx.transferDirection, senderIdentityPublicKey: tx.senderIdentityPublicKey, - description: "", + description: tx.description || foundInvoice?.description || "", + isGift: tx.isGift, isRestore, }, }; @@ -97,6 +141,7 @@ export async function transformTxToPaymentObject( const userRequest = tx.userRequest; let fee = 0; + let blitzFee = 0; if ( tx.transferDirection === "OUTGOING" && @@ -104,18 +149,24 @@ export async function transformTxToPaymentObject( userRequest?.l1BroadcastFee ) { fee = - userRequest.fee.originalValue + - userRequest.l1BroadcastFee.originalValue; + userRequest.fee.originalValue / + (userRequest.fee.originalUnit === "SATOSHI" ? 1 : 1000) + + userRequest.l1BroadcastFee.originalValue / + (userRequest.l1BroadcastFee.originalUnit === "SATOSHI" ? 1 : 1000); + + blitzFee = await calculateProgressiveBracketFee(paymentAmount, "bitcoin"); } return { id: tx.id, paymentStatus: status, paymentType: "bitcoin", - accountId: identityPubKey, + accountId: accountId, details: { fee, - amount: tx.totalValue, + totalFee: blitzFee + fee, + supportFee: blitzFee, + amount: paymentAmount - fee, address: tx.address || "", time: tx.updatedTime ? new Date(tx.updatedTime).getTime() diff --git a/src/functions/textInputConvertValue.js b/src/functions/textInputConvertValue.js new file mode 100644 index 0000000..1283a11 --- /dev/null +++ b/src/functions/textInputConvertValue.js @@ -0,0 +1,26 @@ +import { SATSPERBITCOIN } from "../constants"; + +const convertTextInputValue = (amountValue, fiatStats, inputDenomination) => { + try { + return !amountValue + ? "" + : inputDenomination === "fiat" + ? String( + Math.round( + (SATSPERBITCOIN / (fiatStats?.value || 80_000)) * + Number(amountValue) + ) + ) + : String( + ( + ((fiatStats?.value || 80_000) / SATSPERBITCOIN) * + Number(amountValue) + ).toFixed(2) + ); + } catch (err) { + console.log("Converting value erorr", err); + return ""; + } +}; + +export default convertTextInputValue; diff --git a/src/main.jsx b/src/main.jsx index ad9fc8a..af582f9 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -70,6 +70,11 @@ const ExpandedContactsPage = lazy(() => "./pages/contacts/components/ExpandedContactsPage/ExpandedContactsPage.jsx" ) ); +const SendAndRequestPage = lazy(() => + import( + "./pages/contacts/components/sendAndRequestPage/sendAndRequsetPage.jsx" + ) +); import ConfirmPayment from "./pages/confirmPayment/confirmPaymentScreen.jsx"; import { @@ -115,6 +120,8 @@ import CustomHalfModal from "./pages/customHalfModal/index.jsx"; import InformationPopup from "./pages/informationPopup/index.jsx"; import FullLoadingScreen from "./components/fullLoadingScreen/fullLoadingScreen.jsx"; import { GlobalServerTimeProvider } from "./contexts/serverTime.jsx"; +import { OverlayProvider } from "./contexts/overlayContext.jsx"; +import OverlayHost from "./components/overlayHost.jsx"; const ViewAllTxsPage = lazy(() => import("./pages/viewAllTx/viewAllTxPage.jsx") @@ -124,75 +131,13 @@ function Root() { const navigate = useNavigate(); const location = useLocation(); const [value, setValue] = useState(1); - const [overlays, setOverlays] = useState([]); - - const openOverlay = useCallback( - (type) => { - if (type.for !== "halfModal") { - setOverlays([...overlays, type]); - } else { - setOverlays(overlays.slice(0, -1).concat([type])); - } - }, - [overlays] - ); - - const closeOverlay = useCallback(() => { - setOverlays(overlays.slice(0, -1)); - }, [overlays]); // Define paths where the bottom navigation should be visible const showBottomTabsRoutes = ["/wallet", "/contacts", "/store"]; - const shouldShowBottomTabs = - showBottomTabsRoutes.includes(location.pathname) && !overlays.length; + const shouldShowBottomTabs = showBottomTabsRoutes.includes(location.pathname); const background = location.state && location.state.background; - console.log( - showBottomTabsRoutes, - shouldShowBottomTabs, - location.pathname, - overlays - ); - - const overlayElements = useMemo(() => { - return overlays.map((overlay, index) => ( -
- {overlay.for === "confirm-action" && ( - - )} - {overlay.for === "error" && ( - - )} - {overlay.for === "halfModal" && ( - - )} - {overlay.for === "informationPopup" && ( - - )} -
- )); - }, [ - overlays, - ConfirmActionPage, - ErrorScreen, - CustomHalfModal, - InformationPopup, - ]); + console.log(showBottomTabsRoutes, shouldShowBottomTabs, location.pathname); return ( @@ -211,351 +156,326 @@ function Root() { - - - - -
- -
- - } - > - - {/* Public Routes */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - - } - /> - + + + + +
+ +
+ + } + > + + {/* Public Routes */} + - + - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - + } + /> + - + - - } - /> - + } + /> + - + - - } - /> - + } + /> + - + - - } - /> - - } - /> - + } + /> + - + - - } - /> - + } + /> + + + + } + /> + - + - - } - /> - - - - } - /> - + } + /> + - + - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - {/* Render Overlays */} - {overlayElements} - - {/* {location.pathname === - "/confirm-action" && ( - - )} */} - {/* {location.pathname === "/error" && ( - - )} */} -
-
- {shouldShowBottomTabs && ( - - )} + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + } + /> + + + + + + } + /> + + + + + + } + /> + + + + } + /> + + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> +
+ +
+
+ {shouldShowBottomTabs && ( + + )} +
diff --git a/src/pages/camera/camera.jsx b/src/pages/camera/camera.jsx index 7155150..bdb7b7d 100644 --- a/src/pages/camera/camera.jsx +++ b/src/pages/camera/camera.jsx @@ -16,10 +16,10 @@ import { ImagesIcon, } from "../../constants/icons"; import ThemeImage from "../../components/ThemeImage/themeImage"; +import { useOverlay } from "../../contexts/overlayContext"; -// QrScanner. = "/qr-scanner-worker.min.js"; // Adjust if you move the file - -export default function Camera({ openOverlay }) { +export default function Camera() { + const { openOverlay } = useOverlay(); const { theme, darkModeType } = useThemeContext(); const navigate = useNavigate(); const location = useLocation(); diff --git a/src/pages/contacts/components/ExpandedContactsPage/expandedContactsPage.css b/src/pages/contacts/components/ExpandedContactsPage/expandedContactsPage.css index a80d508..8ffbe17 100644 --- a/src/pages/contacts/components/ExpandedContactsPage/expandedContactsPage.css +++ b/src/pages/contacts/components/ExpandedContactsPage/expandedContactsPage.css @@ -19,7 +19,6 @@ background: none; border: none; cursor: pointer; - padding: 8px; display: flex; align-items: center; justify-content: center; diff --git a/src/pages/contacts/components/ExpandedContactsPage/expandedContactsPage.jsx b/src/pages/contacts/components/ExpandedContactsPage/expandedContactsPage.jsx index 90f2741..d45cc79 100644 --- a/src/pages/contacts/components/ExpandedContactsPage/expandedContactsPage.jsx +++ b/src/pages/contacts/components/ExpandedContactsPage/expandedContactsPage.jsx @@ -95,6 +95,7 @@ export default function ExpandedContactsPage({ updateSeenTransactions(); }, [contactTransactions]); + console.log(contactTransactions); const handleShare = () => { if (selectedContact?.isLNURL || !selectedContact?.uniqueName) return; @@ -146,7 +147,7 @@ export default function ExpandedContactsPage({ size={20} color={ theme && darkModeType - ? Colors.dark.text + ? Colors.light.text : Colors.constants.blue } /> @@ -195,7 +196,7 @@ export default function ExpandedContactsPage({ }); return; } - navigate("/send-request", { + navigate("/sendAndRequestPage", { state: { selectedContact: selectedContact, paymentType: "send", @@ -234,7 +235,7 @@ export default function ExpandedContactsPage({ }); return; } - navigate("/send-request", { + navigate("/sendAndRequestPage", { state: { selectedContact: selectedContact, paymentType: "request", diff --git a/src/pages/contacts/components/sendAndRequestPage/sendAndRequestPage.css b/src/pages/contacts/components/sendAndRequestPage/sendAndRequestPage.css new file mode 100644 index 0000000..4b44c7a --- /dev/null +++ b/src/pages/contacts/components/sendAndRequestPage/sendAndRequestPage.css @@ -0,0 +1,147 @@ +/* Send and Request Page */ +.send-request-page { + flex-grow: 1; + display: flex; + flex-direction: column; +} + +.replacement-container { + flex-grow: 1; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.scroll-view-container { + width: 100%; + padding-bottom: 20px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex-grow: 1; + overflow-y: auto; +} + +.scroll-view-container::-webkit-scrollbar { + display: none; +} + +.scroll-view-container { + -ms-overflow-style: none; + scrollbar-width: none; +} + +/* Input and Gift Container */ +.input-and-gift-container { + width: 90%; + max-width: 350px; + align-self: center; +} + +/* Gift Amount Container */ +.gift-amount-container { + width: 90%; + max-width: 600px; + margin-top: 20px; +} + +/* Gift Container Button */ +.gift-container { + padding: 10px 16px; + border-radius: 20px; + display: flex; + flex-direction: row; + align-items: center; + background: none; + border: none; + cursor: pointer; + margin: 20px 0 0 auto; +} + +/* Pill Button for Gift Card */ +.pill { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + padding: 16px 20px; + border-radius: 24px; + gap: 16px; + border-width: 2px; + border-style: solid; + align-self: center; + position: relative; + background: none; + cursor: pointer; + transition: opacity 0.2s ease; +} + +.pill:hover { + opacity: 0.9; +} + +.logo-container { + width: 48px; + aspect-ratio: 1; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--dark-mode-text, #ffffff); + border-radius: 12px; + padding: 6px; +} + +.card-logo { + width: 100%; + height: 100%; + max-width: 80px; + max-height: 80px; + border-radius: 8px; + object-fit: contain; +} + +.edit-button { + width: 32px; + height: 32px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + position: absolute; + right: -8px; + top: -8px; + border-width: 3px; + border-style: solid; +} + +/* Memo Section */ +.memo-section { + margin-top: 28px; +} + +.memo-container { + padding: 18px; + border-radius: 16px; + border-width: 1px; + border-style: solid; + min-height: 60px; + display: flex; + justify-content: center; + align-items: center; +} + +/* Max and Accept Container */ +.max-and-accept-container { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + margin-top: 10px; +} +.send-request-page .sendRequetContainer { + max-width: 350px; +} diff --git a/src/pages/contacts/components/sendAndRequestPage/sendAndRequsetPage.jsx b/src/pages/contacts/components/sendAndRequestPage/sendAndRequsetPage.jsx new file mode 100644 index 0000000..61f8cc2 --- /dev/null +++ b/src/pages/contacts/components/sendAndRequestPage/sendAndRequsetPage.jsx @@ -0,0 +1,778 @@ +import { useNavigate, useLocation } from "react-router-dom"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { publishMessage } from "../../../../functions/messaging/publishMessage"; + +import { useTranslation } from "react-i18next"; +import { Colors, HIDDEN_OPACITY } from "../../../../constants/theme"; +import { + CONTENT_KEYBOARD_OFFSET, + QUICK_PAY_STORAGE_KEY, + SATSPERBITCOIN, +} from "../../../../constants"; +import { useGlobalContextProvider } from "../../../../contexts/masterInfoObject"; +import CustomNumberKeyboard from "../../../../components/customNumberKeyboard/customNumberKeyboard"; +import CustomButton from "../../../../components/customButton/customButton"; +import FormattedSatText from "../../../../components/formattedSatText/formattedSatText"; +import { useGlobalContacts } from "../../../../contexts/globalContacts"; +import customUUID from "../../../../functions/customUUID"; +import { useNodeContext } from "../../../../contexts/nodeContext"; +import { useAppStatus } from "../../../../contexts/appStatus"; +import { useKeysContext } from "../../../../contexts/keysContext"; +import convertTextInputValue from "../../../../functions/textInputConvertValue"; +import { useServerTimeOnly } from "../../../../contexts/serverTime"; +import useThemeColors from "../../../../hooks/useThemeColors"; +import { useThemeContext } from "../../../../contexts/themeContext"; +import { Edit, Gift } from "lucide-react"; +import fetchBackend from "../../../../../db/handleBackend"; +import { getDataFromCollection } from "../../../../../db"; +import loadNewFiatData from "../../../../functions/saveAndUpdateFiatData"; +import giftCardPurchaseAmountTracker from "../../../../functions/apps/giftCardPurchaseTracker"; +import { useSpark } from "../../../../contexts/sparkContext"; +import getReceiveAddressAndContactForContactsPayment from "../../utils/getReceiveAddressAndKindForPayment"; +import { useActiveCustodyAccount } from "../../../../contexts/activeAccount"; +import NavBarWithBalance from "../../../../components/navBarWithBalance/navbarWithBalance"; +import { sparkPaymenWrapper } from "../../../../functions/spark/payments"; +import { getBolt11InvoiceForContact } from "../../../../functions/contacts"; +import EmojiQuickBar from "../../../../components/emojiBar/emojiQuickBar"; + +import "./sendAndRequestPage.css"; +import CustomInput from "../../../../components/customInput/customInput"; +import ThemeText from "../../../../components/themeText/themeText"; +import FormattedBalanceInput from "../../../../components/formattedBalanceInput/formattedBalanceInput"; +import { useOverlay } from "../../../../contexts/overlayContext"; + +const MAX_SEND_OPTIONS = [ + { label: "25%", value: "25" }, + { label: "50%", value: "50" }, + { label: "75%", value: "75" }, + { label: "100%", value: "100" }, +]; + +export default function SendAndRequestPage(props) { + const { openOverlay } = useOverlay(); + const navigate = useNavigate(); + const location = useLocation(); + const { masterInfoObject } = useGlobalContextProvider(); + const { sparkInformation } = useSpark(); + const { contactsPrivateKey, publicKey } = useKeysContext(); + const { isConnectedToTheInternet } = useAppStatus(); + const { fiatStats } = useNodeContext(); + const { globalContactsInformation } = useGlobalContacts(); + const getServerTime = useServerTimeOnly(); + const [amountValue, setAmountValue] = useState(""); + const [isAmountFocused, setIsAmountFocused] = useState(true); + const [descriptionValue, setDescriptionValue] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [inputDenomination, setInputDenomination] = useState( + masterInfoObject.userBalanceDenomination + ); + const { currentWalletMnemoinc } = useActiveCustodyAccount(); + const { theme, darkModeType } = useThemeContext(); + const { backgroundOffset, textColor, backgroundColor } = useThemeColors(); + const { t } = useTranslation(); + const descriptionRef = useRef(null); + const [isGettingMax, setIsGettingMax] = useState(false); + + const selectedContact = + location.state?.selectedContact || props.route?.params?.selectedContact; + const paymentType = + location.state?.paymentType || props.route?.params?.paymentType; + const fromPage = location.state?.fromPage || props.route?.params?.fromPage; + const imageData = location.state?.imageData || props.route?.params?.imageData; + const giftOption = location.state?.cardInfo || props.route?.params?.cardInfo; + const useAltLayout = false; + + const isBTCdenominated = + inputDenomination == "hidden" || inputDenomination == "sats"; + + const convertedSendAmount = useMemo( + () => + (isBTCdenominated + ? Math.round(amountValue) + : Math.round( + (SATSPERBITCOIN / fiatStats?.value) * (amountValue / 100) + )) || 0, + [amountValue, fiatStats, isBTCdenominated] + ); + + console.log(amountValue, convertedSendAmount, "testing"); + + const canSendPayment = useMemo( + () => convertedSendAmount, + [convertedSendAmount, paymentType] + ); + + const switchTextToConfirm = useMemo(() => { + return ( + masterInfoObject[QUICK_PAY_STORAGE_KEY]?.isFastPayEnabled && + convertedSendAmount <= + masterInfoObject[QUICK_PAY_STORAGE_KEY].fastPayThresholdSats + ); + }, [convertedSendAmount]); + + const handleSelctProcesss = useCallback( + async (item) => { + try { + const balance = sparkInformation.balance; + const selectedPercent = !item ? 100 : Number(item.value); + + const sendingBalance = Math.floor(balance * (selectedPercent / 100)); + + setIsGettingMax(true); + await new Promise((res) => setTimeout(res, 250)); + + let maxAmountSats = 0; + + if (selectedContact.isLNURL) { + const [username, domain] = selectedContact.receiveAddress.split("@"); + const lnurlResposne = await getBolt11InvoiceForContact( + username, + sendingBalance, + undefined, + false, + domain + ); + if (!lnurlResposne) throw new Error("Unable to get invoice"); + const invoice = lnurlResposne; + const fee = await sparkPaymenWrapper({ + getFee: true, + address: invoice, + masterInfoObject, + paymentType: "lightning", + mnemonic: currentWalletMnemoinc, + }); + + if (!fee.didWork) throw new Error(fee.error); + + maxAmountSats = Math.max( + Number(sendingBalance) - fee.fee + fee.supportFee, + 0 + ); + } else { + const feeResponse = await sparkPaymenWrapper({ + getFee: true, + address: sparkInformation.sparkAddress, + masterInfoObject, + paymentType: "spark", + amountSats: sendingBalance, + mnemonic: currentWalletMnemoinc, + }); + if (!feeResponse.didWork) throw new Error("Unable to get invoice"); + maxAmountSats = Math.max( + Number(sendingBalance) - feeResponse.fee + feeResponse.supportFee, + 0 + ); + } + + const convertedMax = + inputDenomination != "fiat" + ? Math.floor(Number(maxAmountSats)) + : ( + Number(maxAmountSats) / + Math.floor(SATSPERBITCOIN / fiatStats?.value) + ).toFixed(2); + + setAmountValue(convertedMax); + } catch (err) { + navigate("/error", { + state: { errorMessage: t("errormessages.genericError") }, + }); + } finally { + setIsGettingMax(false); + } + }, + [ + sparkInformation, + inputDenomination, + currentWalletMnemoinc, + selectedContact, + ] + ); + + useEffect(() => { + if (!giftOption) { + setAmountValue(""); + return; + } + const totalSats = Math.round( + giftOption.selectedDenomination * giftOption.satsPerDollar + ); + const localfiatSatsPerDollar = fiatStats.value / SATSPERBITCOIN; + setAmountValue( + String( + isBTCdenominated + ? totalSats + : Math.round(localfiatSatsPerDollar * totalSats) + ) + ); + }, [giftOption]); + + const handleSearch = useCallback((term) => { + setAmountValue(term); + }, []); + + const handleSubmit = useCallback(async () => { + if (!isConnectedToTheInternet) { + navigate("/error", { + state: { errorMessage: t("errormessages.nointernet") }, + }); + return; + } + try { + if (!convertedSendAmount) return; + if (!canSendPayment) return; + + setIsLoading(true); + + const sendingAmountMsat = convertedSendAmount * 1000; + const contactMessage = descriptionValue; + const myProfileMessage = !!descriptionValue + ? descriptionValue + : t("contacts.sendAndRequestPage.profileMessage", { + name: selectedContact.name || selectedContact.uniqueName, + }); + const payingContactMessage = !!descriptionValue + ? descriptionValue + : { + usingTranslation: true, + type: "paid", + name: + globalContactsInformation.myProfile.name || + globalContactsInformation.myProfile.uniqueName, + }; + + const currentTime = getServerTime(); + const UUID = customUUID(); + let sendObject = {}; + + if (globalContactsInformation.myProfile.uniqueName) { + sendObject["senderProfileSnapshot"] = { + uniqueName: globalContactsInformation.myProfile.uniqueName, + }; + } + + if (giftOption) { + const retrivedContact = await getDataFromCollection( + "blitzWalletUsers", + selectedContact.uuid + ); + if (!retrivedContact) { + openOverlay({ + for: "error", + errorMessage: t("errormessages.fullDeeplinkError"), + }); + return; + } + if (!retrivedContact.enabledGiftCards) { + openOverlay({ + for: "error", + errorMessage: t( + "contacts.sendAndRequestPage.giftCardappVersionError" + ), + }); + return; + } + + const postData = { + type: "buyGiftCard", + productId: giftOption.id, + cardValue: giftOption.selectedDenomination, + quantity: Number(1), + }; + + const response = await fetchBackend( + "theBitcoinCompanyV3", + postData, + contactsPrivateKey, + publicKey + ); + + if (response.result) { + const { amount, invoice, orderId, uuid } = response.result; + const fiatRates = await (fiatStats.coin?.toLowerCase() === "usd" + ? Promise.resolve({ didWork: true, fiatRateResponse: fiatStats }) + : loadNewFiatData( + "usd", + contactsPrivateKey, + publicKey, + masterInfoObject + )); + const USDBTCValue = fiatRates.didWork + ? fiatRates.fiatRateResponse + : { coin: "USD", value: 100_000 }; + + const sendingAmountSat = amount; + const isOverDailyLimit = await giftCardPurchaseAmountTracker({ + sendingAmountSat: sendingAmountSat, + USDBTCValue: USDBTCValue, + testOnly: true, + }); + + if (isOverDailyLimit.shouldBlock) { + openOverlay({ + for: "error", + errorMessage: isOverDailyLimit.reason, + }); + return; + } + + sendObject["amountMsat"] = amount; + sendObject["description"] = giftOption.memo || ""; + sendObject["uuid"] = UUID; + sendObject["isRequest"] = false; + sendObject["isRedeemed"] = null; + sendObject["wasSeen"] = null; + sendObject["didSend"] = null; + sendObject["giftCardInfo"] = { + amount, + invoice, + orderId, + uuid, + logo: giftOption.logo, + name: giftOption.name, + }; + + navigate("/confirm-payment", { + state: { + btcAddress: invoice, + comingFromAccept: true, + enteredPaymentInfo: { + amount: amount, + description: + descriptionValue || + t("contacts.sendAndRequestPage.giftCardDescription", { + name: selectedContact.name || selectedContact.uniqueName, + giftCardName: giftOption.name, + }), + }, + contactInfo: { + imageData, + name: selectedContact.name || selectedContact.uniqueName, + uniqueName: selectedContact.uniqueName, + uuid: selectedContact.uuid, + }, + fromPage: "contacts", + publishMessageFunc: () => { + giftCardPurchaseAmountTracker({ + sendingAmountSat: sendingAmountSat, + USDBTCValue: USDBTCValue, + }); + publishMessage({ + toPubKey: selectedContact.uuid, + fromPubKey: globalContactsInformation.myProfile.uuid, + data: sendObject, + globalContactsInformation, + selectedContact, + isLNURLPayment: false, + privateKey: contactsPrivateKey, + retrivedContact, + currentTime, + masterInfoObject, + }); + }, + }, + }); + } else { + openOverlay({ + for: "error", + errorMessage: t("contacts.sendAndRequestPage.cardDetailsError"), + }); + } + return; + } + + const { + receiveAddress, + retrivedContact, + didWork, + error, + formattedPayingContactMessage, + } = await getReceiveAddressAndContactForContactsPayment({ + sendingAmountSat: convertedSendAmount, + selectedContact, + myProfileMessage, + payingContactMessage, + onlyGetContact: paymentType !== "send", + }); + + if (!didWork) { + openOverlay({ + for: "error", + errorMessage: error, + }); + return; + } + + if (paymentType === "send") { + sendObject["amountMsat"] = sendingAmountMsat; + sendObject["description"] = contactMessage; + sendObject["uuid"] = UUID; + sendObject["isRequest"] = false; + sendObject["isRedeemed"] = null; + sendObject["wasSeen"] = null; + sendObject["didSend"] = null; + + navigate("/send", { + state: { + btcAddress: receiveAddress, + comingFromAccept: true, + enteredPaymentInfo: { + amount: sendingAmountMsat / 1000, + description: myProfileMessage, + }, + contactInfo: { + imageData, + name: selectedContact.name || selectedContact.uniqueName, + isLNURLPayment: selectedContact?.isLNURL, + payingContactMessage: formattedPayingContactMessage, + uniqueName: retrivedContact?.contacts?.myProfile?.uniqueName, + uuid: selectedContact.uuid, + }, + fromPage: "contacts", + publishMessageFuncParams: { + toPubKey: selectedContact.uuid, + fromPubKey: globalContactsInformation.myProfile.uuid, + data: { + ...sendObject, + name: + globalContactsInformation.myProfile.name || + globalContactsInformation.myProfile.uniqueName, + }, + globalContactsInformation, + selectedContact, + isLNURLPayment: selectedContact?.isLNURL, + privateKey: contactsPrivateKey, + retrivedContact, + currentTime, + masterInfoObject, + }, + }, + }); + } else { + sendObject["amountMsat"] = sendingAmountMsat; + sendObject["description"] = descriptionValue; + sendObject["uuid"] = UUID; + sendObject["isRequest"] = true; + sendObject["isRedeemed"] = null; + sendObject["wasSeen"] = null; + sendObject["didSend"] = null; + + await publishMessage({ + toPubKey: selectedContact.uuid, + fromPubKey: globalContactsInformation.myProfile.uuid, + data: sendObject, + globalContactsInformation, + selectedContact, + isLNURLPayment: selectedContact?.isLNURL, + privateKey: contactsPrivateKey, + retrivedContact, + currentTime, + masterInfoObject, + }); + + navigate(-1); + } + } catch (err) { + console.log(err, "publishing message error"); + openOverlay({ + for: "error", + errorMessage: selectedContact.isLNURL + ? t("errormessages.contactInvoiceGenerationError") + : t("errormessages.invoiceRetrivalError"), + }); + } finally { + setIsLoading(false); + } + }, [ + isConnectedToTheInternet, + convertedSendAmount, + canSendPayment, + selectedContact, + navigate, + contactsPrivateKey, + descriptionValue, + paymentType, + globalContactsInformation, + getServerTime, + giftOption, + masterInfoObject, + fiatStats, + imageData, + ]); + + const memorizedContainerStyles = useMemo(() => { + return { + flex: 0, + borderRadius: 8, + height: "unset", + minWidth: "unset", + justifyContent: "center", + }; + }, []); + + const handleEmoji = (newDescription) => { + setDescriptionValue(newDescription); + }; + + return ( +
+
+ +
+ { + if (!isAmountFocused) return; + setInputDenomination((prev) => { + const newPrev = prev === "sats" ? "fiat" : "sats"; + return newPrev; + }); + setAmountValue( + convertTextInputValue(amountValue, fiatStats, inputDenomination) + ); + }} + /> + + + + {paymentType === "send" && !giftOption && !useAltLayout && ( +
+ {/* */} +
+ )} + + {giftOption && ( +
+ + {giftOption.memo && ( +
+ +
+ +
+
+ )} +
+ )} +
+ + {!giftOption && ( + <> +
+ {/* {paymentType === "send" && + !giftOption && + !selectedContact?.isLNURL && ( + + )} */} + { + setIsAmountFocused(false); + }} + onBlurFunction={() => { + setIsAmountFocused(true); + }} + textInputRef={descriptionRef} + placeholder={t( + "contacts.sendAndRequestPage.descriptionPlaceholder" + )} + customInputStyles={{ + borderRadius: useAltLayout ? 15 : 8, + height: useAltLayout ? 50 : "unset", + }} + editable={paymentType === "send" ? true : !!convertedSendAmount} + containerStyles={{ maxWidth: 350, marginTop: 8 }} + onchange={setDescriptionValue} + inputText={descriptionValue} + textInputMultiline={true} + textAlignVertical={"center"} + maxLength={149} + /> + + {useAltLayout && ( +
+
+ {/* */} +
+ + +
+ )} +
+ {isAmountFocused && ( + + )} + + )} + {((isAmountFocused && !useAltLayout) || giftOption) && ( + + )} +
+ {!isAmountFocused && ( + + )} +
+ ); +} diff --git a/src/pages/contacts/contacts.jsx b/src/pages/contacts/contacts.jsx index 4a10eb4..944e9c3 100644 --- a/src/pages/contacts/contacts.jsx +++ b/src/pages/contacts/contacts.jsx @@ -24,8 +24,10 @@ import { encryptMessage } from "../../functions/encodingAndDecoding"; import { ChevronRight, PlusIcon } from "lucide-react"; import { createFormattedDate, formatMessage } from "./utils/utilityFunctions"; import { formatDisplayName } from "./utils/formatListDisplayName"; +import { useOverlay } from "../../contexts/overlayContext"; -export default function Contacts({ openOverlay }) { +export default function Contacts() { + const { openOverlay } = useOverlay(); const { contactsPrivateKey, publicKey } = useKeysContext(); const { masterInfoObject } = useGlobalContextProvider(); const { cache } = useImageCache(); diff --git a/src/pages/contacts/internalComponents/contactTransactions/contactsTransactionItem.css b/src/pages/contacts/internalComponents/contactTransactions/contactsTransactionItem.css index e7653b9..5692bfb 100644 --- a/src/pages/contacts/internalComponents/contactTransactions/contactsTransactionItem.css +++ b/src/pages/contacts/internalComponents/contactTransactions/contactsTransactionItem.css @@ -43,8 +43,9 @@ .transaction-content > * { margin: 0; } -.transaction-content p:last-child { +.transaction-content .time-label { font-size: 0.8em; + font-weight: 300; opacity: 0.6; } diff --git a/src/pages/contacts/internalComponents/contactTransactions/contactsTransactions.jsx b/src/pages/contacts/internalComponents/contactTransactions/contactsTransactions.jsx index 2757ad9..0afda15 100644 --- a/src/pages/contacts/internalComponents/contactTransactions/contactsTransactions.jsx +++ b/src/pages/contacts/internalComponents/contactTransactions/contactsTransactions.jsx @@ -27,6 +27,11 @@ import { useNodeContext } from "../../../../contexts/nodeContext"; import "./contactsTransactionItem.css"; import { ArrowDown, ArrowUp, CircleX } from "lucide-react"; import { Colors } from "../../../../constants/theme"; +import { + handlePaymentUpdate, + sendPushNotification, +} from "../../../../functions/messaging/publishMessage"; +import { useOverlay } from "../../../../contexts/overlayContext"; function ConfirmedOrSentTransaction({ txParsed, @@ -100,7 +105,6 @@ function ConfirmedOrSentTransaction({ CustomEllipsizeMode={"tail"} CustomNumberOfLines={1} textStyles={{ - fontWeight: 400, color: didDeclinePayment ? theme && darkModeType ? textColor @@ -117,13 +121,13 @@ function ConfirmedOrSentTransaction({ /> - updatePaymentStatus(transaction, false, true, txid), + fromPage: "contacts-request", + publishMessageFuncParams: { + transaction, + didPay: true, + globalContactsInformation, + selectedContact, + currentTime, + }, }, }); return; @@ -398,6 +349,7 @@ export default function ContactsTransactionItem(props) { globalContactsInformation, imageData, selectedContact, + getServerTime, ] ); @@ -420,11 +372,7 @@ export default function ContactsTransactionItem(props) { (txParsed.isRequest && txParsed.isRedeemed != null); return ( - +
); } diff --git a/src/pages/contacts/screens/editMyProfilePage/editMyProfilePage.jsx b/src/pages/contacts/screens/editMyProfilePage/editMyProfilePage.jsx index 49aec3b..d6915a7 100644 --- a/src/pages/contacts/screens/editMyProfilePage/editMyProfilePage.jsx +++ b/src/pages/contacts/screens/editMyProfilePage/editMyProfilePage.jsx @@ -22,8 +22,10 @@ import { VALID_USERNAME_REGEX } from "../../../../constants"; import { useThemeContext } from "../../../../contexts/themeContext"; import useThemeColors from "../../../../hooks/useThemeColors"; import { ImagesIconDark, xSmallIconBlack } from "../../../../constants/icons"; +import { useOverlay } from "../../../../contexts/overlayContext"; -export default function EditMyProfilePage({ navProps, openOverlay }) { +export default function EditMyProfilePage({ navProps }) { + const { openOverlay } = useOverlay(); const navigate = useNavigate(); const { decodedAddedContacts, diff --git a/src/pages/contacts/screens/myProfilePage/myProfilePage.jsx b/src/pages/contacts/screens/myProfilePage/myProfilePage.jsx index ce17714..3ecc283 100644 --- a/src/pages/contacts/screens/myProfilePage/myProfilePage.jsx +++ b/src/pages/contacts/screens/myProfilePage/myProfilePage.jsx @@ -13,8 +13,10 @@ import { useThemeContext } from "../../../../contexts/themeContext"; import useThemeColors from "../../../../hooks/useThemeColors"; import ThemeImage from "../../../../components/ThemeImage/themeImage"; import { ImagesIconDark, settingsIcon } from "../../../../constants/icons"; +import { useOverlay } from "../../../../contexts/overlayContext"; -export default function MyProfilePage({ openOverlay }) { +export default function MyProfilePage() { + const { openOverlay } = useOverlay(); const { cache } = useImageCache(); const { theme, darkModeType } = useThemeContext(); const { backgroundOffset } = useThemeColors(); diff --git a/src/pages/createSeed/createSeed.jsx b/src/pages/createSeed/createSeed.jsx index 349f05d..ef98604 100644 --- a/src/pages/createSeed/createSeed.jsx +++ b/src/pages/createSeed/createSeed.jsx @@ -13,8 +13,10 @@ import { useTranslation } from "react-i18next"; import useThemeColors from "../../hooks/useThemeColors"; import ThemeText from "../../components/themeText/themeText"; import PageNavBar from "../../components/navBar/navBar"; +import { useOverlay } from "../../contexts/overlayContext"; -function CreateSeed({ openOverlay }) { +function CreateSeed() { + const { openOverlay } = useOverlay(); const { t } = useTranslation(); const { mnemoinc, setMnemoinc } = useAuth(); const seed = mnemoinc?.split(" "); diff --git a/src/pages/customHalfModal/Modal.css b/src/pages/customHalfModal/Modal.css index 2623c24..c143369 100644 --- a/src/pages/customHalfModal/Modal.css +++ b/src/pages/customHalfModal/Modal.css @@ -8,7 +8,7 @@ display: flex; justify-content: center; align-items: flex-end; /* modal slides up from bottom */ - z-index: 999; + z-index: 1002; } .modal { diff --git a/src/pages/customHalfModal/components/editLNURLOnReceive/index.jsx b/src/pages/customHalfModal/components/editLNURLOnReceive/index.jsx index 6a1fa68..91e3c05 100644 --- a/src/pages/customHalfModal/components/editLNURLOnReceive/index.jsx +++ b/src/pages/customHalfModal/components/editLNURLOnReceive/index.jsx @@ -13,13 +13,14 @@ import { useGlobalContacts } from "../../../../contexts/globalContacts"; import CustomButton from "../../../../components/customButton/customButton"; import { VALID_USERNAME_REGEX } from "../../../../constants"; import { isValidUniqueName } from "../../../../../db"; +import { useOverlay } from "../../../../contexts/overlayContext"; export default function EditLNURLContactOnReceivePage({ theme, darkModeType, - openOverlay, onClose, }) { + const { openOverlay } = useOverlay(); const { globalContactsInformation, toggleGlobalContactsInformation } = useGlobalContacts(); const { t } = useTranslation(); diff --git a/src/pages/disclaimer/disclaimer.jsx b/src/pages/disclaimer/disclaimer.jsx index 495d682..d04ec7b 100644 --- a/src/pages/disclaimer/disclaimer.jsx +++ b/src/pages/disclaimer/disclaimer.jsx @@ -8,7 +8,10 @@ import ThemeText from "../../components/themeText/themeText"; import Icon from "../../components/customIcon/customIcon"; import PageNavBar from "../../components/navBar/navBar"; import { disclaimerKeys } from "../../constants/icons"; -function DisclaimerPage({ openOverlay }) { +import { useOverlay } from "../../contexts/overlayContext"; + +function DisclaimerPage() { + const { openOverlay } = useOverlay(); const location = useLocation(); const params = location.state; const nextPageName = params?.nextPageName; diff --git a/src/pages/login/login.jsx b/src/pages/login/login.jsx index 3618892..ea4f870 100644 --- a/src/pages/login/login.jsx +++ b/src/pages/login/login.jsx @@ -9,8 +9,10 @@ import { Colors } from "../../constants/theme"; import { useThemeContext } from "../../contexts/themeContext"; import useThemeColors from "../../hooks/useThemeColors"; import ThemeText from "../../components/themeText/themeText"; +import { useOverlay } from "../../contexts/overlayContext"; -function Login({ openOverlay }) { +function Login() { + const { openOverlay } = useOverlay(); const { theme, darkModeType } = useThemeContext(); const { backgroundOffset, textInputBackground, textInputColor, textColor } = useThemeColors(); diff --git a/src/pages/receiveQRPage/components/buttonContainer.jsx b/src/pages/receiveQRPage/components/buttonContainer.jsx index 2a5345d..c2c209b 100644 --- a/src/pages/receiveQRPage/components/buttonContainer.jsx +++ b/src/pages/receiveQRPage/components/buttonContainer.jsx @@ -7,6 +7,7 @@ import CustomButton from "../../../components/customButton/customButton"; import { useThemeContext } from "../../../contexts/themeContext"; import { Colors } from "../../../constants/theme"; import useThemeColors from "../../../hooks/useThemeColors"; +import { useOverlay } from "../../../contexts/overlayContext"; export default function ReceiveButtonsContainer({ generatingInvoiceQRCode, @@ -14,8 +15,8 @@ export default function ReceiveButtonsContainer({ receiveOption, initialSendAmount, description, - openOverlay, }) { + const { openOverlay } = useOverlay(); const navigate = useNavigate(); const location = useLocation(); const { theme, darkModeType } = useThemeContext(); diff --git a/src/pages/receiveQRPage/receiveQRPage.jsx b/src/pages/receiveQRPage/receiveQRPage.jsx index fed87f1..af18dcc 100644 --- a/src/pages/receiveQRPage/receiveQRPage.jsx +++ b/src/pages/receiveQRPage/receiveQRPage.jsx @@ -20,8 +20,10 @@ import { useGlobalContextProvider } from "../../contexts/masterInfoObject"; import { useNodeContext } from "../../contexts/nodeContext"; import displayCorrectDenomination from "../../functions/displayCorrectDenomination"; import { Colors } from "../../constants/theme"; +import { useOverlay } from "../../contexts/overlayContext"; -export default function ReceiveQRPage({ openOverlay }) { +export default function ReceiveQRPage() { + const { openOverlay } = useOverlay(); const { masterInfoObject } = useGlobalContextProvider(); const { fiatStats } = useNodeContext(); const { globalContactsInformation } = useGlobalContacts(); diff --git a/src/pages/restoreWallet/restoreWallet.jsx b/src/pages/restoreWallet/restoreWallet.jsx index 473c3aa..c55b908 100644 --- a/src/pages/restoreWallet/restoreWallet.jsx +++ b/src/pages/restoreWallet/restoreWallet.jsx @@ -16,6 +16,7 @@ import { Colors } from "../../constants/theme"; import { validateMnemonic } from "@scure/bip39"; import { wordlist } from "@scure/bip39/wordlists/english"; import { handleRestoreFromText } from "../../functions/seed"; +import { useOverlay } from "../../contexts/overlayContext"; const NUMARRAY = Array.from({ length: 12 }, (_, i) => i + 1); const INITIAL_KEY_STATE = NUMARRAY.reduce((acc, num) => { @@ -23,7 +24,8 @@ const INITIAL_KEY_STATE = NUMARRAY.reduce((acc, num) => { return acc; }, {}); -export default function RestoreWallet({ openOverlay }) { +export default function RestoreWallet() { + const { openOverlay } = useOverlay(); const location = useLocation(); const params = location.state; const navigate = useNavigate(); diff --git a/src/pages/sendPage/sendPage.jsx b/src/pages/sendPage/sendPage.jsx index a1319b8..bc8de87 100644 --- a/src/pages/sendPage/sendPage.jsx +++ b/src/pages/sendPage/sendPage.jsx @@ -36,8 +36,16 @@ import SelectLRC20Token from "./components/selectLRC20Token"; import { formatTokensNumber } from "../../functions/lrc20/formatTokensBalance"; import CustomSettingsNavbar from "../../components/customSettingsNavbar"; import AcceptButtonSendPage from "./components/acceptButton"; +import { useOverlay } from "../../contexts/overlayContext"; +import NavBarWithBalance from "../../components/navBarWithBalance/navbarWithBalance"; +import { + handlePaymentUpdate, + publishMessage, +} from "../../functions/messaging/publishMessage"; +import { useKeysContext } from "../../contexts/keysContext"; -export default function SendPage({ openOverlay }) { +export default function SendPage() { + const { openOverlay } = useOverlay(); const location = useLocation(); const { sparkInformation } = useSpark(); const params = location.state || {}; @@ -50,9 +58,11 @@ export default function SendPage({ openOverlay }) { enteredPaymentInfo, errorMessage: globalError, } = params; + console.log(params, "oi"); const [paymentInfo, setPaymentInfo] = useState({}); const { masterInfoObject, toggleMasterInfoObject } = useGlobalContextProvider(); + const { contactsPrivateKey, publicKey } = useKeysContext(); const { currentWalletMnemoinc } = useActiveCustodyAccount(); const { liquidNodeInformation, fiatStats } = useNodeContext(); const { minMaxLiquidSwapAmounts } = useAppStatus(); @@ -228,6 +238,27 @@ export default function SendPage({ openOverlay }) { const paymentResponse = await sparkPaymenWrapper(paymentObject); if (paymentResponse.didWork) { + if (fromPage?.includes("contacts") && paymentResponse.response?.id) { + handlePaymentUpdate({ + transaction: params.publishMessageFuncParams.transaction, + didPay: params.publishMessageFuncParams.didPay, + txid: paymentResponse.response?.id, + globalContactsInformation: + params.publishMessageFuncParams.globalContactsInformation, + selectedContact: params.publishMessageFuncParams.selectedContact, + currentTime: params.publishMessageFuncParams.currentTime, + contactsPrivateKey, + publicKey, + masterInfoObject, + }); + if (fromPage === "contacts-request") { + } else { + const sendObject = params.publishMessageFuncParams; + sendObject.data.txid = paymentResponse.response?.id; + console.log(sendObject); + publishMessage(sendObject); + } + } navigate("/confirm-page", { state: { for: "paymentsucceed", @@ -416,40 +447,7 @@ export default function SendPage({ openOverlay }) { return (
-
- -
- - -
-
+
diff --git a/src/pages/settings/pages/currency/displayCurrency.jsx b/src/pages/settings/pages/currency/displayCurrency.jsx index d2a6348..41536ca 100644 --- a/src/pages/settings/pages/currency/displayCurrency.jsx +++ b/src/pages/settings/pages/currency/displayCurrency.jsx @@ -14,8 +14,10 @@ import ThemeText from "../../../../components/themeText/themeText"; import { fiatCurrencies } from "../../../../functions/currencyOptions"; import { useKeysContext } from "../../../../contexts/keysContext"; import loadNewFiatData from "../../../../functions/saveAndUpdateFiatData"; +import { useOverlay } from "../../../../contexts/overlayContext"; -export default function DisplayCurrency({ openOverlay }) { +export default function DisplayCurrency() { + const { openOverlay } = useOverlay(); const { masterInfoObject, toggleMasterInfoObject } = useGlobalContextProvider(); const { contactsPrivateKey, publicKey } = useKeysContext(); diff --git a/src/pages/settings/pages/fastPay/fastPay.jsx b/src/pages/settings/pages/fastPay/fastPay.jsx index 85d263c..5ddbd9c 100644 --- a/src/pages/settings/pages/fastPay/fastPay.jsx +++ b/src/pages/settings/pages/fastPay/fastPay.jsx @@ -6,8 +6,10 @@ import TextInputWithSliderSettingsItem from "../../components/textInputWithSlide import "./fastPay.css"; import { useThemeContext } from "../../../../contexts/themeContext"; import useThemeColors from "../../../../hooks/useThemeColors"; +import { useOverlay } from "../../../../contexts/overlayContext"; -export default function FastPay({ openOverlay }) { +export default function FastPay() { + const { openOverlay } = useOverlay(); const { masterInfoObject, toggleMasterInfoObject } = useGlobalContextProvider(); const location = useLocation(); diff --git a/src/pages/settings/pages/sparkInfo/sparkInfo.jsx b/src/pages/settings/pages/sparkInfo/sparkInfo.jsx index 3a8aa4e..aa30d8a 100644 --- a/src/pages/settings/pages/sparkInfo/sparkInfo.jsx +++ b/src/pages/settings/pages/sparkInfo/sparkInfo.jsx @@ -8,8 +8,10 @@ import { Colors } from "../../../../constants/theme"; import ThemeText from "../../../../components/themeText/themeText"; import ThemeImage from "../../../../components/ThemeImage/themeImage"; import { clipboardBlue } from "../../../../constants/icons"; +import { useOverlay } from "../../../../contexts/overlayContext"; -export default function SparkInformation({ openOverlay }) { +export default function SparkInformation() { + const { openOverlay } = useOverlay(); const { sparkInformation } = useSpark(); const navigate = useNavigate(); const location = useLocation(); diff --git a/src/pages/settings/pages/sparkSettingsPage/index.jsx b/src/pages/settings/pages/sparkSettingsPage/index.jsx index bda8d5b..fb371a4 100644 --- a/src/pages/settings/pages/sparkSettingsPage/index.jsx +++ b/src/pages/settings/pages/sparkSettingsPage/index.jsx @@ -6,8 +6,10 @@ import SettingsItemWithSlider from "../../components/settingsItemWithSlider/sett import displayCorrectDenomination from "../../../../functions/displayCorrectDenomination"; import { useNodeContext } from "../../../../contexts/nodeContext"; import { useSpark } from "../../../../contexts/sparkContext"; +import { useOverlay } from "../../../../contexts/overlayContext"; -export default function SparkSettingsPage({ openOverlay }) { +export default function SparkSettingsPage() { + const { openOverlay } = useOverlay(); const { sparkInformation } = useSpark(); const { masterInfoObject, toggleMasterInfoObject } = useGlobalContextProvider(); diff --git a/src/pages/settings/settings.jsx b/src/pages/settings/settings.jsx index f6cc820..683c2c1 100644 --- a/src/pages/settings/settings.jsx +++ b/src/pages/settings/settings.jsx @@ -22,6 +22,7 @@ import { receiptIcon, trashIcon, } from "../../constants/icons"; +import { useOverlay } from "../../contexts/overlayContext"; const GENERALOPTIONS = [ { @@ -117,7 +118,8 @@ const DOOMSDAYSETTINGS = [ ], ]; -export default function SettingsHome({ openOverlay }) { +export default function SettingsHome() { + const { openOverlay } = useOverlay(); const navigate = useNavigate(); const location = useLocation(); const queryParams = new URLSearchParams(location.search); diff --git a/src/pages/settings/settingsItem/settingsItem.jsx b/src/pages/settings/settingsItem/settingsItem.jsx index a13d4dc..3e6cfdc 100644 --- a/src/pages/settings/settingsItem/settingsItem.jsx +++ b/src/pages/settings/settingsItem/settingsItem.jsx @@ -12,8 +12,10 @@ import FastPay from "../pages/fastPay/fastPay"; import BlitzFeeInformation from "../pages/feeDetails/feeInformation"; import ExploreUsers from "../pages/exploreUsers/exploreUsers"; import CustomSettingsNavbar from "../../../components/customSettingsNavbar"; +import { useOverlay } from "../../../contexts/overlayContext"; -export default function SettingsContentIndex({ openOverlay }) { +export default function SettingsContentIndex() { + const { openOverlay } = useOverlay(); const location = useLocation(); const props = location.state; const selectedPage = props.for?.toLowerCase(); diff --git a/src/pages/switchReceiveOption/switchReceiveOption.jsx b/src/pages/switchReceiveOption/switchReceiveOption.jsx index dcac628..d72929b 100644 --- a/src/pages/switchReceiveOption/switchReceiveOption.jsx +++ b/src/pages/switchReceiveOption/switchReceiveOption.jsx @@ -20,6 +20,7 @@ import { useNavigate } from "react-router-dom"; import displayCorrectDenomination from "../../functions/displayCorrectDenomination"; import { useNodeContext } from "../../contexts/nodeContext"; import ThemeImage from "../../components/ThemeImage/themeImage"; +import { useOverlay } from "../../contexts/overlayContext"; const MAIN_PAYMENTS = [ ["Lightning", "Instant"], @@ -29,7 +30,9 @@ const MAIN_PAYMENTS = [ // ["Rootstock", "~ 1 minute"], ]; -export default function SwitchReceiveOption({ onClose, params, openOverlay }) { +export default function SwitchReceiveOption({ params }) { + const { openOverlay, closeOverlay } = useOverlay(); + const onClose = closeOverlay; const navigate = useNavigate(); const { fiatStats } = useNodeContext(); const { currentWalletMnemonic } = useActiveCustodyAccount(); diff --git a/src/pages/technicalDetails/technicalDetails.jsx b/src/pages/technicalDetails/technicalDetails.jsx index 1442163..8884cb4 100644 --- a/src/pages/technicalDetails/technicalDetails.jsx +++ b/src/pages/technicalDetails/technicalDetails.jsx @@ -3,7 +3,9 @@ import BackArrow from "../../components/backArrow/backArrow"; import ThemeText from "../../components/themeText/themeText"; import copyToClipboard from "../../functions/copyToClipboard"; import "./style.css"; -export default function TechnicalDetailsPage({ openOverlay }) { +import { useOverlay } from "../../contexts/overlayContext"; +export default function TechnicalDetailsPage() { + const { openOverlay } = useOverlay(); const location = useLocation(); const navigate = useNavigate(); const props = location.state; diff --git a/src/pages/viewkey/viewKey.jsx b/src/pages/viewkey/viewKey.jsx index a9e0975..9d975e9 100644 --- a/src/pages/viewkey/viewKey.jsx +++ b/src/pages/viewkey/viewKey.jsx @@ -12,8 +12,10 @@ import copyToClipboard from "../../functions/copyToClipboard"; import useThemeColors from "../../hooks/useThemeColors"; import ThemeText from "../../components/themeText/themeText"; import { useThemeContext } from "../../contexts/themeContext"; +import { useOverlay } from "../../contexts/overlayContext"; -export default function ViewMnemoinc({ openOverlay }) { +export default function ViewMnemoinc() { + const { openOverlay } = useOverlay(); const navigate = useNavigate(); const location = useLocation(); const props = location.state; diff --git a/src/pages/wallet/components/lrc20Assets/index.jsx b/src/pages/wallet/components/lrc20Assets/index.jsx index 9c9b26b..46ec8d7 100644 --- a/src/pages/wallet/components/lrc20Assets/index.jsx +++ b/src/pages/wallet/components/lrc20Assets/index.jsx @@ -18,8 +18,10 @@ import CustomInput from "../../../../components/customInput/customInput"; import formatBalanceAmount from "../../../../functions/formatNumber"; import { useSpark } from "../../../../contexts/sparkContext"; import { Colors } from "../../../../constants/theme"; +import { useOverlay } from "../../../../contexts/overlayContext"; -export default function LRC20Assets({ openOverlay }) { +export default function LRC20Assets() { + const { openOverlay } = useOverlay(); const { darkModeType, theme } = useThemeContext(); const { sparkInformation } = useSpark(); const { textColor } = useThemeColors(); diff --git a/src/pages/wallet/components/nav/nav.jsx b/src/pages/wallet/components/nav/nav.jsx index 75630b0..81718de 100644 --- a/src/pages/wallet/components/nav/nav.jsx +++ b/src/pages/wallet/components/nav/nav.jsx @@ -9,8 +9,10 @@ import useThemeColors from "../../../../hooks/useThemeColors"; import { useActiveCustodyAccount } from "../../../../contexts/activeAccount"; import { Moon, Sun, RefreshCw, Settings } from "lucide-react"; import NavBarProfileImage from "../../../../components/navBar/profileImage"; +import { useOverlay } from "../../../../contexts/overlayContext"; -export default function WalletNavBar({ openOverlay, didEnabledLrc20 }) { +export default function WalletNavBar({ didEnabledLrc20 }) { + const { openOverlay } = useOverlay(); const { theme, toggleTheme, darkModeType } = useThemeContext(); const { backgroundColor, backgroundOffset } = useThemeColors(); const [isRefreshing, setIsRefreshing] = useState(false); diff --git a/src/pages/wallet/components/sendAndRequestBTNS/sendAndRequstBtns.jsx b/src/pages/wallet/components/sendAndRequestBTNS/sendAndRequstBtns.jsx index a98655b..91d1ea9 100644 --- a/src/pages/wallet/components/sendAndRequestBTNS/sendAndRequstBtns.jsx +++ b/src/pages/wallet/components/sendAndRequestBTNS/sendAndRequstBtns.jsx @@ -7,8 +7,10 @@ import { Colors } from "../../../../constants/theme"; import { useThemeContext } from "../../../../contexts/themeContext"; import useThemeColors from "../../../../hooks/useThemeColors"; import { ArrowDown, ArrowUp, ScanLine } from "lucide-react"; +import { useOverlay } from "../../../../contexts/overlayContext"; -export default function SendAndRequestBtns({ openOverlay }) { +export default function SendAndRequestBtns() { + const { openOverlay } = useOverlay(); const { theme, darkModeType } = useThemeContext(); const naigate = useNavigate(); const { backgroundOffset, backgroundColor } = useThemeColors(); diff --git a/src/pages/wallet/components/sendOptions/manualEnter.jsx b/src/pages/wallet/components/sendOptions/manualEnter.jsx index c95affb..d5e2e1f 100644 --- a/src/pages/wallet/components/sendOptions/manualEnter.jsx +++ b/src/pages/wallet/components/sendOptions/manualEnter.jsx @@ -9,11 +9,13 @@ import { useState } from "react"; import "./manualEnter.css"; import { CONTENT_KEYBOARD_OFFSET } from "../../../../constants"; import openLinkToNewTab from "../../../../functions/openLinkToNewTab"; +import { useOverlay } from "../../../../contexts/overlayContext"; -export default function ManualEnterSendAddress(props) { +export default function ManualEnterSendAddress() { + const { openOverlay, closeOverlay } = useOverlay(); const navigate = useNavigate(); const { t } = useTranslation(); - const { onClose, openOverlay } = props; + const onClose = closeOverlay; const [inputValue, setInputValue] = useState(""); diff --git a/src/pages/wallet/wallet.jsx b/src/pages/wallet/wallet.jsx index c072df6..a21154f 100644 --- a/src/pages/wallet/wallet.jsx +++ b/src/pages/wallet/wallet.jsx @@ -9,8 +9,10 @@ import { useGlobalContextProvider } from "../../contexts/masterInfoObject"; import useThemeColors from "../../hooks/useThemeColors"; import SafeAreaComponent from "../../components/safeAreaContainer"; import LRC20Assets from "./components/lrc20Assets"; +import { useOverlay } from "../../contexts/overlayContext"; -export default function WalletHome({ openOverlay }) { +export default function WalletHome() { + const { openOverlay } = useOverlay(); const { masterInfoObject } = useGlobalContextProvider(); const { backgroundColor, backgroundOffset } = useThemeColors(); const { toggleDidGetToHomepage } = useAppStatus(); From 315cc4ef9633428cbf92ca57eead15efdb29feae Mon Sep 17 00:00:00 2001 From: Blake Kaufman Date: Thu, 18 Dec 2025 15:37:52 -0500 Subject: [PATCH 05/28] adding toast confimation for payments --- src/contexts/SDKNavigation.jsx | 118 ++++ src/contexts/sparkContext.jsx | 422 ++++++------- src/contexts/toastManager.css | 105 ++++ src/contexts/toastManager.jsx | 315 ++++++++++ src/functions/displayCorrectDenomination.js | 69 ++- src/main.jsx | 626 ++++++++++---------- src/pages/wallet/wallet.jsx | 2 +- 7 files changed, 1122 insertions(+), 535 deletions(-) create mode 100644 src/contexts/SDKNavigation.jsx create mode 100644 src/contexts/toastManager.css create mode 100644 src/contexts/toastManager.jsx diff --git a/src/contexts/SDKNavigation.jsx b/src/contexts/SDKNavigation.jsx new file mode 100644 index 0000000..3fae2ec --- /dev/null +++ b/src/contexts/SDKNavigation.jsx @@ -0,0 +1,118 @@ +import { useEffect, useRef } from "react"; + +import i18next from "i18next"; + +import { useAppStatus } from "./appStatus"; +import { useSpark } from "./sparkContext"; +import { useToast } from "./toastManager"; +import { useNodeContext } from "./nodeContext"; +import { useNavigate } from "react-router-dom"; + +// export function RootstockNavigationListener() { +// const navigation = useNavigate(); +// const { didGetToHomepage } = useAppStatus(); +// const { pendingNavigation, setPendingNavigation } = useRootstockProvider(); +// const isNavigating = useRef(false); // Use a ref for local state + +// useEffect(() => { +// if (!pendingNavigation) return; +// if (!didGetToHomepage) { +// setPendingNavigation(null); +// return; +// } +// if (isNavigating.current) return; +// crashlyticsLogReport(`Navigating to confirm tx page in roostock listener`); +// isNavigating.current = true; + +// setTimeout(() => { +// requestAnimationFrame(() => { +// navigation.navigate("ErrorScreen", { +// errorMessage: i18next.t("errormessages.receivedRootstock"), +// }); +// isNavigating.current = false; +// console.log("cleaning up navigation for rootstock"); +// }); +// }, 100); + +// setPendingNavigation(null); +// }, [pendingNavigation, didGetToHomepage]); + +// return null; +// } + +// export function LiquidNavigationListener() { +// const navigation = useNavigation(); +// const { didGetToHomepage } = useAppStatus(); +// const { pendingLiquidPayment, setPendingLiquidPayment } = useNodeContext(); +// const isNavigating = useRef(false); // Use a ref for local state + +// useEffect(() => { +// if (!pendingLiquidPayment) return; +// if (!didGetToHomepage) { +// setPendingLiquidPayment(null); +// return; +// } +// if (isNavigating.current) return; +// crashlyticsLogReport(`Navigating to confirm tx page in liquid listener `); +// isNavigating.current = true; + +// setTimeout(() => { +// requestAnimationFrame(() => { +// navigation.navigate("ErrorScreen", { +// errorMessage: i18next.t("errormessages.receivedLiquid"), +// }); +// isNavigating.current = false; +// console.log("cleaning up navigation for liquid"); +// }); +// }, 100); + +// setPendingLiquidPayment(null); +// }, [pendingLiquidPayment, didGetToHomepage]); + +// return null; +// } + +export function SparkNavigationListener() { + const navigate = useNavigate(); + const { didGetToHomepage } = useAppStatus(); + const { pendingNavigation, setPendingNavigation } = useSpark(); + const isNavigating = useRef(false); // Use a ref for local state + const { showToast } = useToast(); + + useEffect(() => { + console.log(pendingNavigation, didGetToHomepage, "in navigation"); + if (!pendingNavigation) return; + if (!didGetToHomepage) { + setPendingNavigation(null); + return; + } + if (isNavigating.current) return; + isNavigating.current = true; + + setTimeout(() => { + if (pendingNavigation.showFullAnimation) { + navigate("/confirm-page", { + state: { + for: "paymentsucceed", + transaction: pendingNavigation.tx, + }, + replace: true, + }); + } else { + showToast({ + amount: pendingNavigation.amount, + LRC20Token: pendingNavigation.LRC20Token, + isLRC20Payment: pendingNavigation.isLRC20Payment, + duration: 7000, + type: "confirmTx", + }); + } + console.log("cleaning up navigation for spark"); + isNavigating.current = false; + }, 100); + + setPendingNavigation(null); + }, [pendingNavigation, didGetToHomepage]); + + return null; +} diff --git a/src/contexts/sparkContext.jsx b/src/contexts/sparkContext.jsx index 4a95933..70bc2ee 100644 --- a/src/contexts/sparkContext.jsx +++ b/src/contexts/sparkContext.jsx @@ -58,6 +58,7 @@ import { createRestorePoller, } from "../functions/pollingManager"; import i18next from "i18next"; +import { useLocation } from "react-router-dom"; export const isSendingPayingEventEmiiter = new EventEmitter(); export const SENDING_PAYMENT_EVENT_NAME = "SENDING_PAYMENT_EVENT"; @@ -78,6 +79,7 @@ const SparkWalletManager = createContext(null); const sessionTime = new Date().getTime(); const SparkWalletProvider = ({ children, navigate }) => { + const location = useLocation(); const { masterInfoObject } = useGlobalContextProvider(); const { accountMnemoinc, contactsPrivateKey, publicKey } = useKeysContext(); const { currentWalletMnemoinc } = useActiveCustodyAccount(); @@ -365,255 +367,257 @@ const SparkWalletProvider = ({ children, navigate }) => { } }, []); - const handleUpdate = useCallback(async (...args) => { - try { - const [updateType = "transactions", fee = 0, passedBalance = 0] = args; - const mnemonic = currentMnemonicRef.current; - const { identityPubKey, balance: prevBalance } = sparkInfoRef.current; - - console.log( - "running update in spark context from db changes", - updateType - ); + const handleUpdate = useCallback( + async (...args) => { + try { + const [updateType = "transactions", fee = 0, passedBalance = 0] = args; + const mnemonic = currentMnemonicRef.current; + const { identityPubKey, balance: prevBalance } = sparkInfoRef.current; - if (!identityPubKey) { - console.warn( - "handleUpdate called but identityPubKey is not available yet" + console.log( + "running update in spark context from db changes", + updateType ); - return; - } - const txs = await getCachedSparkTransactions(null, identityPubKey); - - if ( - updateType === "lrc20Payments" || - updateType === "txStatusUpdate" || - updateType === "transactions" - ) { - setSparkInformation((prev) => ({ - ...prev, - transactions: txs || prev.transactions, - })); - } else if (updateType === "incomingPayment") { - handleBalanceCache({ - isCheck: false, - passedBalance: Number(passedBalance), - mnemonic, - }); - setSparkInformation((prev) => ({ - ...prev, - transactions: txs || prev.transactions, - balance: Number(passedBalance), - })); - } else if (updateType === "fullUpdate-waitBalance") { - if (balancePollingAbortControllerRef.current) { - balancePollingAbortControllerRef.current.abort(); + if (!identityPubKey) { + console.warn( + "handleUpdate called but identityPubKey is not available yet" + ); + return; } - balancePollingAbortControllerRef.current = new AbortController(); - currentPollingMnemonicRef.current = mnemonic; - - const pollingMnemonic = currentPollingMnemonicRef.current; - - setSparkInformation((prev) => ({ - ...prev, - transactions: txs || prev.transactions, - })); - - const poller = createBalancePoller( - mnemonic, - currentMnemonicRef, - balancePollingAbortControllerRef.current, - (newBalance) => { - setSparkInformation((prev) => { - if (pollingMnemonic !== currentMnemonicRef.current) { - return prev; - } - handleBalanceCache({ - isCheck: false, - passedBalance: newBalance, - mnemonic: pollingMnemonic, - }); - return { - ...prev, - balance: newBalance, - }; - }); - }, - prevBalance - ); - - balancePollingTimeoutRef.current = poller; - poller.start(); - } else { - const balanceResponse = await getSparkBalance(mnemonic); - - const newBalance = balanceResponse.didWork - ? Number(balanceResponse.balance) - : prevBalance; - - if (updateType === "paymentWrapperTx") { - const updatedBalance = Math.round(newBalance - fee); + const txs = await getCachedSparkTransactions(null, identityPubKey); + if ( + updateType === "lrc20Payments" || + updateType === "txStatusUpdate" || + updateType === "transactions" + ) { + setSparkInformation((prev) => ({ + ...prev, + transactions: txs || prev.transactions, + })); + } else if (updateType === "incomingPayment") { handleBalanceCache({ isCheck: false, - passedBalance: updatedBalance, + passedBalance: Number(passedBalance), mnemonic, }); - setSparkInformation((prev) => ({ ...prev, transactions: txs || prev.transactions, - balance: updatedBalance, - tokens: balanceResponse.didWork - ? balanceResponse.tokensObj - : prev.tokens, + balance: Number(passedBalance), })); - } else if (updateType === "fullUpdate-tokens") { + } else if (updateType === "fullUpdate-waitBalance") { + if (balancePollingAbortControllerRef.current) { + balancePollingAbortControllerRef.current.abort(); + } + + balancePollingAbortControllerRef.current = new AbortController(); + currentPollingMnemonicRef.current = mnemonic; + + const pollingMnemonic = currentPollingMnemonicRef.current; + setSparkInformation((prev) => ({ ...prev, transactions: txs || prev.transactions, - tokens: balanceResponse.didWork - ? balanceResponse.tokensObj - : prev.tokens, })); - } else if (updateType === "fullUpdate") { - handleBalanceCache({ - isCheck: false, - passedBalance: newBalance, + + const poller = createBalancePoller( mnemonic, - }); + currentMnemonicRef, + balancePollingAbortControllerRef.current, + (newBalance) => { + setSparkInformation((prev) => { + if (pollingMnemonic !== currentMnemonicRef.current) { + return prev; + } + handleBalanceCache({ + isCheck: false, + passedBalance: newBalance, + mnemonic: pollingMnemonic, + }); + return { + ...prev, + balance: newBalance, + }; + }); + }, + prevBalance + ); - setSparkInformation((prev) => ({ - ...prev, - balance: newBalance, - transactions: txs || prev.transactions, - tokens: balanceResponse.didWork - ? balanceResponse.tokensObj - : prev.tokens, - })); - } - } + balancePollingTimeoutRef.current = poller; + poller.start(); + } else { + const balanceResponse = await getSparkBalance(mnemonic); - if ( - updateType === "paymentWrapperTx" || - updateType === "transactions" || - updateType === "txStatusUpdate" || - updateType === "lrc20Payments" - ) { - console.log( - "Payment type is send payment, transaction, lrc20 first render, or txstatus update, skipping confirm tx page navigation" - ); - return; - } - const [lastAddedTx] = await getAllSparkTransactions({ - accountId: identityPubKey, - limit: 1, - }); + const newBalance = balanceResponse.didWork + ? Number(balanceResponse.balance) + : prevBalance; - console.log(lastAddedTx, "testing"); + if (updateType === "paymentWrapperTx") { + const updatedBalance = Math.round(newBalance - fee); - if (!lastAddedTx) { - console.log( - "No transaction found, skipping confirm tx page navigation" - ); + handleBalanceCache({ + isCheck: false, + passedBalance: updatedBalance, + mnemonic, + }); - return; - } + setSparkInformation((prev) => ({ + ...prev, + transactions: txs || prev.transactions, + balance: updatedBalance, + tokens: balanceResponse.didWork + ? balanceResponse.tokensObj + : prev.tokens, + })); + } else if (updateType === "fullUpdate-tokens") { + setSparkInformation((prev) => ({ + ...prev, + transactions: txs || prev.transactions, + tokens: balanceResponse.didWork + ? balanceResponse.tokensObj + : prev.tokens, + })); + } else if (updateType === "fullUpdate") { + handleBalanceCache({ + isCheck: false, + passedBalance: newBalance, + mnemonic, + }); - const parsedTx = { - ...lastAddedTx, - details: JSON.parse(lastAddedTx.details), - }; + setSparkInformation((prev) => ({ + ...prev, + balance: newBalance, + transactions: txs || prev.transactions, + tokens: balanceResponse.didWork + ? balanceResponse.tokensObj + : prev.tokens, + })); + } + } - if (handledNavigatedTxs.current.has(parsedTx.sparkID)) { - console.log( - "Already handled transaction, skipping confirm tx page navigation" + if ( + updateType === "paymentWrapperTx" || + updateType === "transactions" || + updateType === "txStatusUpdate" || + updateType === "lrc20Payments" + ) { + console.log( + "Payment type is send payment, transaction, lrc20 first render, or txstatus update, skipping confirm tx page navigation" + ); + return; + } + const [lastAddedTx] = await getCachedSparkTransactions( + 1, + identityPubKey ); - return; - } - handledNavigatedTxs.current.add(parsedTx.sparkID); - const details = parsedTx?.details; + console.log(lastAddedTx, "testing"); - if (new Date(details.time).getTime() < sessionTimeRef.current) { - console.log( - "created before session time was set, skipping confirm tx page navigation" - ); - return; - } + if (!lastAddedTx) { + console.log( + "No transaction found, skipping confirm tx page navigation" + ); - if (isSendingPaymentRef.current) { - console.log("Is sending payment, skipping confirm tx page navigation"); - return; - } + return; + } - if (details.direction === "OUTGOING") { - console.log( - "Only incoming payments navigate here, skipping confirm tx page navigation" - ); - return; - } + const parsedTx = { + ...lastAddedTx, + details: JSON.parse(lastAddedTx.details), + }; - const isOnReceivePage = - navigationRef - .getRootState() - .routes?.filter((item) => item.name === "ReceiveBTC").length === 1; + if (handledNavigatedTxs.current.has(parsedTx.sparkID)) { + console.log( + "Already handled transaction, skipping confirm tx page navigation" + ); + return; + } + handledNavigatedTxs.current.add(parsedTx.sparkID); - const isNewestPayment = - !!details?.createdTime || !!details?.time - ? new Date(details.createdTime || details?.time).getTime() > - newestPaymentTimeRef.current - : false; + const details = parsedTx?.details; - let shouldShowConfirm = false; + if (new Date(details.time).getTime() < sessionTimeRef.current) { + console.log( + "created before session time was set, skipping confirm tx page navigation" + ); + return; + } - if ( - (lastAddedTx.paymentType?.toLowerCase() === "lightning" && - !details.isLNURL && - !details?.shouldNavigate && - isOnReceivePage && - isNewestPayment) || - (lastAddedTx.paymentType?.toLowerCase() === "spark" && - !details.isLRC20Payment && - isOnReceivePage && - isNewestPayment) - ) { - if (lastAddedTx.paymentType?.toLowerCase() === "spark") { - const upaidLNInvoices = await getAllUnpaidSparkLightningInvoices(); - const lastMatch = upaidLNInvoices.findLast((invoice) => { - const savedInvoiceDetails = JSON.parse(invoice.details); - return ( - !savedInvoiceDetails.sendingUUID && - !savedInvoiceDetails.isLNURL && - invoice.amount === details.amount - ); - }); + if (isSendingPaymentRef.current) { + console.log( + "Is sending payment, skipping confirm tx page navigation" + ); + return; + } - if (lastMatch && !usedSavedTxIds.current.has(lastMatch.id)) { - usedSavedTxIds.current.add(lastMatch.id); - const lastInvoiceDetails = JSON.parse(lastMatch.details); - if (details.time - lastInvoiceDetails.createdTime < 60 * 1000) { - shouldShowConfirm = true; + if (details.direction === "OUTGOING") { + console.log( + "Only incoming payments navigate here, skipping confirm tx page navigation" + ); + return; + } + + const isOnReceivePage = location.pathname === "/receive"; + + const isNewestPayment = + !!details?.createdTime || !!details?.time + ? new Date(details.createdTime || details?.time).getTime() > + newestPaymentTimeRef.current + : false; + + let shouldShowConfirm = false; + + if ( + (lastAddedTx.paymentType?.toLowerCase() === "lightning" && + !details.isLNURL && + !details?.shouldNavigate && + isOnReceivePage && + isNewestPayment) || + (lastAddedTx.paymentType?.toLowerCase() === "spark" && + !details.isLRC20Payment && + isOnReceivePage && + isNewestPayment) + ) { + if (lastAddedTx.paymentType?.toLowerCase() === "spark") { + const upaidLNInvoices = await getAllUnpaidSparkLightningInvoices(); + const lastMatch = upaidLNInvoices.findLast((invoice) => { + const savedInvoiceDetails = JSON.parse(invoice.details); + return ( + !savedInvoiceDetails.sendingUUID && + !savedInvoiceDetails.isLNURL && + invoice.amount === details.amount + ); + }); + + if (lastMatch && !usedSavedTxIds.current.has(lastMatch.id)) { + usedSavedTxIds.current.add(lastMatch.id); + const lastInvoiceDetails = JSON.parse(lastMatch.details); + if (details.time - lastInvoiceDetails.createdTime < 60 * 1000) { + shouldShowConfirm = true; + } } + } else { + shouldShowConfirm = true; } - } else { - shouldShowConfirm = true; } - } - // Handle confirm animation here - setPendingNavigation({ - tx: parsedTx, - amount: details.amount, - LRC20Token: details.LRC20Token, - isLRC20Payment: !!details.LRC20Token, - showFullAnimation: shouldShowConfirm, - }); - } catch (err) { - console.log("error in spark handle db update function", err); - } - }, []); + // Handle confirm animation here + setPendingNavigation({ + tx: parsedTx, + amount: details.amount, + LRC20Token: details.LRC20Token, + isLRC20Payment: !!details.LRC20Token, + showFullAnimation: shouldShowConfirm, + }); + } catch (err) { + console.log("error in spark handle db update function", err); + } + }, + [location] + ); const transferHandler = useCallback((transferId, balance) => { if (handledTransfers.current.has(transferId)) return; diff --git a/src/contexts/toastManager.css b/src/contexts/toastManager.css new file mode 100644 index 0000000..baf6f74 --- /dev/null +++ b/src/contexts/toastManager.css @@ -0,0 +1,105 @@ +.toast-container { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + width: 95%; + max-width: 500px; + z-index: 1000; + pointer-events: none; +} + +.toast { + border-radius: 12px; + padding: 16px 20px; + margin-bottom: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.1); + opacity: 0; + transform: translateY(-20px); + transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55); + pointer-events: all; + cursor: grab; + user-select: none; +} + +.toast:active { + cursor: grabbing; +} + +.toast-visible { + opacity: 1; + transform: translateY(0); +} + +.toast-clipboard { + background: var(--dmt); +} +.toast-confirmTx { + background: var(--dmt); +} + +.toast-error { + background: linear-gradient(135deg, #f44336 0%, #d32f2f 100%); + border-color: #ef5350; +} + +.toast-warning { + background: linear-gradient(135deg, #ff9800 0%, #f57c00 100%); + border-color: #ffa726; +} + +.toast-info { + background: linear-gradient(135deg, #2196f3 0%, #1976d2 100%); + border-color: #42a5f5; +} + +.toast-success { + background: linear-gradient(135deg, #4caf50 0%, #388e3c 100%); + border-color: #66bb6a; +} + +.toast-content { + display: flex; + align-items: center; + gap: 12px; +} + +.toast-icon { + font-size: 24px; + flex-shrink: 0; +} + +.toast-text { + flex: 1; + min-width: 0; +} + +.toast-title { + color: white; + font-size: 15px; + font-weight: 500; + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.toast-message { + margin: 0; + font-size: 0.8em; +} + +@media (max-width: 640px) { + .demo-title { + font-size: 2rem; + } + + .button-grid { + grid-template-columns: 1fr; + } + + .toast-container { + width: 90%; + } +} diff --git a/src/contexts/toastManager.jsx b/src/contexts/toastManager.jsx new file mode 100644 index 0000000..9f7a0ba --- /dev/null +++ b/src/contexts/toastManager.jsx @@ -0,0 +1,315 @@ +import { Clipboard, HelpCircle } from "lucide-react"; +import React, { + createContext, + useContext, + useReducer, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { Colors } from "../constants/theme"; +import "./toastManager.css"; +import ThemeText from "../components/themeText/themeText"; +import { useTranslation } from "react-i18next"; +import displayCorrectDenomination from "../functions/displayCorrectDenomination"; +import { useSpark } from "./sparkContext"; +import { useNodeContext } from "./nodeContext"; +import { useGlobalContextProvider } from "./masterInfoObject"; + +// Toast Context +const ToastContext = createContext(); + +const initialState = { + toasts: [], +}; + +const toastReducer = (state, action) => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [...state.toasts, action.payload], + }; + case "REMOVE_TOAST": + return { + ...state, + toasts: state.toasts.filter((toast) => toast.id !== action.payload), + }; + case "CLEAR_TOASTS": + return { + ...state, + toasts: [], + }; + default: + return state; + } +}; + +// Toast Provider Component +export const ToastProvider = ({ children }) => { + const [state, dispatch] = useReducer(toastReducer, initialState); + + const showToast = useCallback((toast) => { + const id = Date.now() + Math.random(); + const toastWithId = { + id, + type: "success", + duration: 3000, + position: "top", + ...toast, + }; + + dispatch({ type: "ADD_TOAST", payload: toastWithId }); + + if (toastWithId.duration > 0) { + setTimeout(() => { + dispatch({ type: "REMOVE_TOAST", payload: id }); + }, toastWithId.duration); + } + + return id; + }, []); + + const hideToast = useCallback((id) => { + dispatch({ type: "REMOVE_TOAST", payload: id }); + }, []); + + const clearToasts = useCallback(() => { + dispatch({ type: "CLEAR_TOASTS" }); + }, []); + + return ( + + {children} + + ); +}; + +// Custom hook to use toast +export const useToast = () => { + const context = useContext(ToastContext); + if (!context) { + throw new Error("useToast must be used within a ToastProvider"); + } + return context; +}; + +// Individual Toast Component +const Toast = ({ + toast, + onHide, + fiatStats, + sparkInformation, + masterInfoObject, +}) => { + const [isVisible, setIsVisible] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [dragY, setDragY] = useState(0); + const toastRef = useRef(null); + const startY = useRef(0); + const { t } = useTranslation(); + + useEffect(() => { + // Animate in + setTimeout(() => setIsVisible(true), 10); + }, []); + + const handleAnimateOut = useCallback(() => { + setIsVisible(false); + setTimeout(() => { + onHide(); + }, 300); + }, [onHide]); + + const handleMouseDown = (e) => { + setIsDragging(true); + startY.current = e.clientY; + }; + + const handleTouchStart = (e) => { + setIsDragging(true); + startY.current = e.touches[0].clientY; + }; + + const handleMouseMove = (e) => { + if (!isDragging) return; + const currentY = e.clientY; + const diff = currentY - startY.current; + if (diff < 0) { + setDragY(diff); + } + }; + + const handleTouchMove = (e) => { + if (!isDragging) return; + const currentY = e.touches[0].clientY; + const diff = currentY - startY.current; + if (diff < 0) { + setDragY(diff); + } + }; + + const handleDragEnd = () => { + setIsDragging(false); + if (dragY < -20) { + handleAnimateOut(); + } else { + setDragY(0); + } + }; + + useEffect(() => { + if (isDragging) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleDragEnd); + document.addEventListener("touchmove", handleTouchMove); + document.addEventListener("touchend", handleDragEnd); + } + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleDragEnd); + document.removeEventListener("touchmove", handleTouchMove); + document.removeEventListener("touchend", handleDragEnd); + }; + }, [isDragging, dragY]); + + const getToastClass = () => { + const classes = ["toast"]; + switch (toast.type) { + case "clipboard": + classes.push("toast-clipboard"); + break; + case "confirmTx": + classes.push("toast-clipboard"); + break; + case "error": + classes.push("toast-error"); + break; + case "warning": + classes.push("toast-warning"); + break; + case "info": + classes.push("toast-info"); + break; + default: + classes.push("toast-default"); + } + if (isVisible) classes.push("toast-visible"); + return classes.join(" "); + }; + + const getIconForType = () => { + switch (toast.type) { + case "clipboard": + return "📋"; + case "confirmTx": + return "ℹ️"; + case "error": + return "✕"; + case "warning": + return "⚠️"; + case "info": + return "ℹ️"; + case "success": + return "✓"; + default: + return "•"; + } + }; + const token = toast.isLRC20Payment + ? sparkInformation.tokens?.[toast?.LRC20Token] + : ""; + + const formattedTokensBalance = + toast.type === "confirmTx" && !!token + ? formatTokensNumber(toast.amount, token?.tokenMetadata?.decimals) + : 0; + + const toastStyle = { + transform: `translateY(${dragY}px)`, + transition: isDragging ? "none" : "transform 0.3s ease", + }; + + return ( +
+
+ {toast.type === "clipboard" ? ( + + ) : toast.type === "confirmTx" ? ( + + ) : ( + + )} +
+ {toast.type === "confirmTx" ? ( + <> + + + + ) : ( + + )} +
+
+
+ ); +}; + +// Toast Container Component +export const ToastContainer = () => { + const { toasts, hideToast } = useToast(); + const { sparkInformation } = useSpark(); + const { fiatStats } = useNodeContext(); + const { masterInfoObject } = useGlobalContextProvider(); + + return ( +
+ {toasts.map((toast) => ( + hideToast(toast.id)} + sparkInformation={sparkInformation} + fiatStats={fiatStats} + masterInfoObject={masterInfoObject} + /> + ))} +
+ ); +}; diff --git a/src/functions/displayCorrectDenomination.js b/src/functions/displayCorrectDenomination.js index e6418eb..bee04c2 100644 --- a/src/functions/displayCorrectDenomination.js +++ b/src/functions/displayCorrectDenomination.js @@ -7,40 +7,63 @@ export default function displayCorrectDenomination({ amount, masterInfoObject, fiatStats, + useCustomLabel = false, + customLabel = "", + useMillionDenomination = false, }) { try { - const convertedAmount = numberConverter( - amount, - masterInfoObject.userBalanceDenomination, - masterInfoObject.userBalanceDenomination === "fiat" ? 2 : 0, - fiatStats - ); + const localBalanceDenomination = masterInfoObject.userBalanceDenomination; const currencyText = fiatStats?.coin || "USD"; + + if (useCustomLabel) { + const formattedBalance = formatBalanceAmount( + amount, + useMillionDenomination, + masterInfoObject + ); + const labelText = customLabel?.toUpperCase()?.slice(0, 10) || ""; + return `${formattedBalance} ${labelText}`; + } + + const formattedBalance = formatBalanceAmount( + numberConverter( + amount, + localBalanceDenomination, + localBalanceDenomination === "fiat" ? 2 : 0, + fiatStats + ), + useMillionDenomination, + masterInfoObject + ); + const showSymbol = masterInfoObject.satDisplay === "symbol"; const showSats = - masterInfoObject.userBalanceDenomination === "sats" || - masterInfoObject.userBalanceDenomination === "hidden"; + localBalanceDenomination === "sats" || + localBalanceDenomination === "hidden"; - const formattedCurrency = formatCurrency({ - amount: convertedAmount, + if (showSats) { + return showSymbol + ? `${BITCOIN_SATS_ICON}${formattedBalance}` + : `${formattedBalance} ${BITCOIN_SAT_TEXT}`; + } + + // Fiat display + const currencyOptions = formatCurrency({ + amount: formattedBalance, code: currencyText, }); - const isSymbolInFront = formattedCurrency[3]; - const currencySymbol = formattedCurrency[2]; - const formatedSat = `${formatBalanceAmount(convertedAmount)}`; + const isSymbolInFront = currencyOptions[3]; + const currencySymbol = currencyOptions[2]; - if (showSats) { - if (showSymbol) return BITCOIN_SATS_ICON + formatedSat; - else return formatedSat + ` ${BITCOIN_SAT_TEXT}`; - } else { - if (showSymbol && isSymbolInFront) - return currencySymbol + formattedCurrency[1]; - else if (showSymbol && !isSymbolInFront) - return formattedCurrency[1] + currencySymbol; - else return formattedCurrency[1] + ` ${currencyText}`; + if (showSymbol && isSymbolInFront) { + return `${currencySymbol}${currencyOptions[1]}`; + } + if (showSymbol && !isSymbolInFront) { + return `${currencyOptions[1]}${currencySymbol}`; } + return `${currencyOptions[1]} ${currencyText}`; } catch (err) { - console.log("display correct denomincation error", err); + console.log("display correct denomination error", err); return ""; } } diff --git a/src/main.jsx b/src/main.jsx index af582f9..6d6106a 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -122,6 +122,8 @@ import FullLoadingScreen from "./components/fullLoadingScreen/fullLoadingScreen. import { GlobalServerTimeProvider } from "./contexts/serverTime.jsx"; import { OverlayProvider } from "./contexts/overlayContext.jsx"; import OverlayHost from "./components/overlayHost.jsx"; +import { ToastContainer, ToastProvider } from "./contexts/toastManager.jsx"; +import { SparkNavigationListener } from "./contexts/SDKNavigation.jsx"; const ViewAllTxsPage = lazy(() => import("./pages/viewAllTx/viewAllTxPage.jsx") @@ -157,324 +159,344 @@ function Root() { - - - - -
- -
- - } - > - - {/* Public Routes */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - + + + + + + +
+ +
+ + } + > + + {/* Public Routes */} + - + - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - + } + /> + - + - - } - /> - + } + /> + - + - - } - /> - + } + /> + - + - - } - /> - } - /> - + } + /> + - + - - } - /> - + } + /> + + + + } + /> + - + - - } - /> - - - - } - /> - + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + } + /> + + + + + + } + /> + + + + + + } + /> + + + + } + /> + + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + - + - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - -
-
- {shouldShowBottomTabs && ( - - )} + } + /> +
+ +
+
+ {shouldShowBottomTabs && ( + + )} +
diff --git a/src/pages/wallet/wallet.jsx b/src/pages/wallet/wallet.jsx index a21154f..11340d5 100644 --- a/src/pages/wallet/wallet.jsx +++ b/src/pages/wallet/wallet.jsx @@ -22,7 +22,7 @@ export default function WalletHome() { useEffect(() => { toggleDidGetToHomepage(true); }, []); - console.log(masterInfoObject); + return ( Date: Thu, 18 Dec 2025 15:46:39 -0500 Subject: [PATCH 06/28] fixing eslint --- db/index.js | 3 +-- src/contexts/toastManager.jsx | 5 ++-- src/functions/initiateWalletConnection.js | 5 +--- src/functions/messaging/publishMessage.js | 6 ++--- src/functions/pollingManager.js | 4 +--- src/functions/spark/restore.js | 7 ++++-- .../sendAndRequestPage/sendAndRequsetPage.jsx | 4 ++-- .../sendPage/components/acceptButton.jsx | 3 +-- src/pages/sendPage/sendPage.jsx | 24 +++++++++---------- 9 files changed, 29 insertions(+), 32 deletions(-) diff --git a/db/index.js b/db/index.js index ecfe50e..df0b8c2 100644 --- a/db/index.js +++ b/db/index.js @@ -2,6 +2,7 @@ import { addDoc, and, collection, + deleteDoc, doc, getDoc, getDocs, @@ -416,7 +417,6 @@ function processWithRAF(allMessages, myPubKey, privateKey, onProgress) { export async function isValidNip5Name(wantedName) { try { - crashlyticsLogReport("Seeing if the unique name exists"); const usersRef = collection(db, "nip5Verification"); const q = query( usersRef, @@ -426,7 +426,6 @@ export async function isValidNip5Name(wantedName) { return querySnapshot.empty; } catch (error) { console.error("Error checking unique name:", error); - crashlyticsRecordErrorReport(error.message); return false; } } diff --git a/src/contexts/toastManager.jsx b/src/contexts/toastManager.jsx index 9f7a0ba..79895d6 100644 --- a/src/contexts/toastManager.jsx +++ b/src/contexts/toastManager.jsx @@ -16,6 +16,7 @@ import displayCorrectDenomination from "../functions/displayCorrectDenomination" import { useSpark } from "./sparkContext"; import { useNodeContext } from "./nodeContext"; import { useGlobalContextProvider } from "./masterInfoObject"; +import { formatTokensNumber } from "../functions/lrc20/formatTokensBalance"; // Toast Context const ToastContext = createContext(); @@ -252,7 +253,7 @@ const Toast = ({ ) : toast.type === "confirmTx" ? ( ) : ( - + )}
{toast.type === "confirmTx" ? ( @@ -268,7 +269,7 @@ const Toast = ({ className={"toast-message"} textContent={t("pushNotifications.paymentReceived.body", { totalAmount: displayCorrectDenomination({ - amount: !!token ? formattedTokensBalance : toast.amount, + amount: token ? formattedTokensBalance : toast.amount, masterInfoObject, fiatStats, useCustomLabel: !!token, diff --git a/src/functions/initiateWalletConnection.js b/src/functions/initiateWalletConnection.js index ddcd034..8326d91 100644 --- a/src/functions/initiateWalletConnection.js +++ b/src/functions/initiateWalletConnection.js @@ -112,10 +112,7 @@ async function initializeSparkSession({ if (runCount === 1) { currentBalance = Number(initialBalanceResponse.balance); } else { - const retryResponse = await getSparkBalance( - mnemonic, - sendWebViewRequest - ); + const retryResponse = await getSparkBalance(mnemonic); currentBalance = Number(retryResponse.balance); } diff --git a/src/functions/messaging/publishMessage.js b/src/functions/messaging/publishMessage.js index 38d18b5..5c1f3e1 100644 --- a/src/functions/messaging/publishMessage.js +++ b/src/functions/messaging/publishMessage.js @@ -233,15 +233,15 @@ export async function handlePaymentUpdate({ ? didPay ? "paid" : "declined" - : t( + : i18next.t( "contacts.internalComponents.contactsTransactions.pushNotificationUpdateMessage", { name: globalContactsInformation.myProfile.name || globalContactsInformation.myProfile.uniqueName, option: didPay - ? t("transactionLabelText.paidLower") - : t("transactionLabelText.declinedLower"), + ? i18next.t("transactionLabelText.paidLower") + : i18next.t("transactionLabelText.declinedLower"), } ), }, diff --git a/src/functions/pollingManager.js b/src/functions/pollingManager.js index 459774f..0c9c9db 100644 --- a/src/functions/pollingManager.js +++ b/src/functions/pollingManager.js @@ -176,8 +176,7 @@ export const createRestorePoller = ( currentMnemonicRef, abortController, onRestoreComplete, - sparkInfo, - sendWebViewRequest + sparkInfo ) => { return createPollingManager({ pollFn: async (delayIndex) => { @@ -187,7 +186,6 @@ export const createRestorePoller = ( isSendingPayment: isSendingPayment, mnemonic, identityPubKey: sparkInfo.identityPubKey, - sendWebViewRequest, isInitialRestore: false, }); return result; diff --git a/src/functions/spark/restore.js b/src/functions/spark/restore.js index 82ae3e1..40a2bab 100644 --- a/src/functions/spark/restore.js +++ b/src/functions/spark/restore.js @@ -1,6 +1,7 @@ import { findTransactionTxFromTxHistory, getCachedSparkTransactions, + getSingleTxDetails, getSparkBitcoinPaymentRequest, getSparkLightningPaymentStatus, getSparkLightningSendRequest, @@ -15,6 +16,7 @@ import { deleteSparkTransaction, deleteUnpaidSparkLightningTransaction, getAllPendingSparkPayments, + getAllSparkContactInvoices, getAllSparkTransactions, getAllUnpaidSparkLightningInvoices, } from "./transactions"; @@ -24,6 +26,7 @@ import { IS_SPARK_ID, IS_SPARK_REQUEST_ID, } from "../../constants"; +import sha256Hash from "../hash"; const RESTORE_STATE_KEY = "spark_tx_restore_state"; const MAX_BATCH_SIZE = 400; @@ -695,7 +698,7 @@ async function processLightningTransaction( mnemonic, }) : await getSparkLightningSendRequest(txStateUpdate.sparkID, mnemonic); - + const paymentStatus = getSparkPaymentStatus(sparkResponse.status); if ( details.direction === "OUTGOING" && getSparkPaymentStatus(sparkResponse.status) === "failed" @@ -793,7 +796,7 @@ async function getPaymentDetailsWithRetry( return null; } -async function processBitcoinTransactions(bitcoinTxs, mnemonic) { +async function processBitcoinTransactions(bitcoinTxs, mnemonic, accountId) { const lastRun = Storage.getItem("lastRunBitcoinTxUpdate"); const now = Date.now(); diff --git a/src/pages/contacts/components/sendAndRequestPage/sendAndRequsetPage.jsx b/src/pages/contacts/components/sendAndRequestPage/sendAndRequsetPage.jsx index 61f8cc2..be9d016 100644 --- a/src/pages/contacts/components/sendAndRequestPage/sendAndRequsetPage.jsx +++ b/src/pages/contacts/components/sendAndRequestPage/sendAndRequsetPage.jsx @@ -226,12 +226,12 @@ export default function SendAndRequestPage(props) { const sendingAmountMsat = convertedSendAmount * 1000; const contactMessage = descriptionValue; - const myProfileMessage = !!descriptionValue + const myProfileMessage = descriptionValue ? descriptionValue : t("contacts.sendAndRequestPage.profileMessage", { name: selectedContact.name || selectedContact.uniqueName, }); - const payingContactMessage = !!descriptionValue + const payingContactMessage = descriptionValue ? descriptionValue : { usingTranslation: true, diff --git a/src/pages/sendPage/components/acceptButton.jsx b/src/pages/sendPage/components/acceptButton.jsx index 952ded6..64925f1 100644 --- a/src/pages/sendPage/components/acceptButton.jsx +++ b/src/pages/sendPage/components/acceptButton.jsx @@ -28,7 +28,7 @@ export default function AcceptButtonSendPage({ sparkInformation, seletctedToken, isLRC20Payment, - sendWebViewRequest, + openOverlay, }) { const { t } = useTranslation(); @@ -215,7 +215,6 @@ export default function AcceptButtonSendPage({ seletctedToken, currentWalletMnemoinc, t, - sendWebViewRequest, }); } catch (error) { console.log("Accept button error:", error); diff --git a/src/pages/sendPage/sendPage.jsx b/src/pages/sendPage/sendPage.jsx index bc8de87..dc68005 100644 --- a/src/pages/sendPage/sendPage.jsx +++ b/src/pages/sendPage/sendPage.jsx @@ -239,19 +239,19 @@ export default function SendPage() { if (paymentResponse.didWork) { if (fromPage?.includes("contacts") && paymentResponse.response?.id) { - handlePaymentUpdate({ - transaction: params.publishMessageFuncParams.transaction, - didPay: params.publishMessageFuncParams.didPay, - txid: paymentResponse.response?.id, - globalContactsInformation: - params.publishMessageFuncParams.globalContactsInformation, - selectedContact: params.publishMessageFuncParams.selectedContact, - currentTime: params.publishMessageFuncParams.currentTime, - contactsPrivateKey, - publicKey, - masterInfoObject, - }); if (fromPage === "contacts-request") { + handlePaymentUpdate({ + transaction: params.publishMessageFuncParams.transaction, + didPay: params.publishMessageFuncParams.didPay, + txid: paymentResponse.response?.id, + globalContactsInformation: + params.publishMessageFuncParams.globalContactsInformation, + selectedContact: params.publishMessageFuncParams.selectedContact, + currentTime: params.publishMessageFuncParams.currentTime, + contactsPrivateKey, + publicKey, + masterInfoObject, + }); } else { const sendObject = params.publishMessageFuncParams; sendObject.data.txid = paymentResponse.response?.id; From 64fabccbaa7aabfeaff2f498815ba14be1575184 Mon Sep 17 00:00:00 2001 From: Blake Kaufman Date: Thu, 18 Dec 2025 16:19:38 -0500 Subject: [PATCH 07/28] fixing restore --- src/contexts/sparkContext.jsx | 5 +-- src/functions/spark/index.js | 2 +- src/functions/spark/restore.js | 2 +- src/functions/spark/transactions.js | 16 ++++++--- src/pages/wallet/components/nav/nav.jsx | 44 +++++++++++++++---------- 5 files changed, 44 insertions(+), 25 deletions(-) diff --git a/src/contexts/sparkContext.jsx b/src/contexts/sparkContext.jsx index 70bc2ee..5b8b0a9 100644 --- a/src/contexts/sparkContext.jsx +++ b/src/contexts/sparkContext.jsx @@ -855,7 +855,6 @@ const SparkWalletProvider = ({ children, navigate }) => { sparkInformation.didConnect, sparkInformation.identityPubKey, didGetToHomepage, - isSendingPayment, ]); useEffect(() => { @@ -1087,7 +1086,7 @@ const SparkWalletProvider = ({ children, navigate }) => { depositAddressIntervalRef.current = null; } }; - }, [didGetToHomepage, sparkInformation.didConnect, isSendingPayment]); + }, [didGetToHomepage, sparkInformation.didConnect]); // Run fullRestore when didConnect becomes true useEffect(() => { @@ -1232,6 +1231,7 @@ const SparkWalletProvider = ({ children, navigate }) => { setNumberOfCachedTxs, setStartConnectingToSpark, connectToSparkWallet, + isSendingPaymentRef, }), [ sparkInformation, @@ -1245,6 +1245,7 @@ const SparkWalletProvider = ({ children, navigate }) => { setNumberOfCachedTxs, setStartConnectingToSpark, connectToSparkWallet, + isSendingPaymentRef, ] ); diff --git a/src/functions/spark/index.js b/src/functions/spark/index.js index f36f14c..1b35c65 100644 --- a/src/functions/spark/index.js +++ b/src/functions/spark/index.js @@ -443,7 +443,7 @@ export const setPrivacyEnabled = async (mnemonic) => { export const getCachedSparkTransactions = async (limit, identifyPubKey) => { try { - const txResponse = await getAllSparkTransactions(limit, identifyPubKey); + const txResponse = await getAllSparkTransactions({ limit, identifyPubKey }); if (!txResponse) throw new Error("Unable to get cached spark transactins"); return txResponse; diff --git a/src/functions/spark/restore.js b/src/functions/spark/restore.js index 40a2bab..873025a 100644 --- a/src/functions/spark/restore.js +++ b/src/functions/spark/restore.js @@ -101,7 +101,7 @@ export const restoreSparkTxState = async ( getAllPendingSparkPayments(accountId), ]); - const savedIds = new Set(savedTxs?.map((tx) => tx.sparkID) || []); + const savedIds = new Set(savedTxs || []); const txsByType = { lightning: pendingTxs.filter((tx) => tx.paymentType === "lightning"), diff --git a/src/functions/spark/transactions.js b/src/functions/spark/transactions.js index 5244393..38ecd4e 100644 --- a/src/functions/spark/transactions.js +++ b/src/functions/spark/transactions.js @@ -50,10 +50,18 @@ const parseDetails = (details) => { else return details; }; -export const getAllSparkTransactions = async (limit = null, accountId) => { +export const getAllSparkTransactions = async (options = {}) => { try { const db = await dbPromise; const all = await db.getAll(SPARK_TRANSACTIONS_TABLE_NAME); + const { + limit = null, + offset = null, + accountId = null, + startRange = null, + endRange = null, + idsOnly = false, + } = options; // Filter by accountId if provided let filtered = all; @@ -70,7 +78,7 @@ export const getAllSparkTransactions = async (limit = null, accountId) => { })); // Sort by time (newest first). Use numeric fallback 0 when missing. - const sorted = normalized.sort((a, b) => { + filtered = normalized.sort((a, b) => { const aTime = JSON.parse(a.details).time; const bTime = JSON.parse(b.details).time; @@ -79,10 +87,10 @@ export const getAllSparkTransactions = async (limit = null, accountId) => { // Apply limit if provided if (limit) { - return sorted.slice(0, limit); + filtered = filtered.slice(0, limit); } - return sorted; + return idsOnly ? filtered.map((row) => row.sparkID) : filtered; } catch (err) { console.error("getAllSparkTransactions error:", err); return []; diff --git a/src/pages/wallet/components/nav/nav.jsx b/src/pages/wallet/components/nav/nav.jsx index 81718de..80da66b 100644 --- a/src/pages/wallet/components/nav/nav.jsx +++ b/src/pages/wallet/components/nav/nav.jsx @@ -10,31 +10,41 @@ import { useActiveCustodyAccount } from "../../../../contexts/activeAccount"; import { Moon, Sun, RefreshCw, Settings } from "lucide-react"; import NavBarProfileImage from "../../../../components/navBar/profileImage"; import { useOverlay } from "../../../../contexts/overlayContext"; +import { + SPARK_TX_UPDATE_ENVENT_NAME, + sparkTransactionsEventEmitter, +} from "../../../../functions/spark/transactions"; export default function WalletNavBar({ didEnabledLrc20 }) { - const { openOverlay } = useOverlay(); const { theme, toggleTheme, darkModeType } = useThemeContext(); const { backgroundColor, backgroundOffset } = useThemeColors(); const [isRefreshing, setIsRefreshing] = useState(false); - const { sparkInformation } = useSpark(); + const { sparkInformation, isSendingPaymentRef } = useSpark(); const { currentWalletMnemoinc } = useActiveCustodyAccount(); const handleRefresh = useCallback(async () => { - setIsRefreshing(true); + try { + setIsRefreshing(true); - await fullRestoreSparkState({ - sparkAddress: sparkInformation.sparkAddress, - batchSize: 5, - isSendingPayment: false, - mnemonic: currentWalletMnemoinc, - identityPubKey: sparkInformation.identityPubKey, - }); - - setIsRefreshing(false); - openOverlay({ - for: "error", - errorMessage: "Your wallet was successfully refreshed.", - }); - }, []); + const response = await fullRestoreSparkState({ + sparkAddress: sparkInformation.sparkAddress, + batchSize: 2, + isSendingPayment: isSendingPaymentRef.current, + mnemonic: currentWalletMnemoinc, + identityPubKey: sparkInformation.identityPubKey, + isInitialRestore: false, + }); + if (!response) { + sparkTransactionsEventEmitter.emit( + SPARK_TX_UPDATE_ENVENT_NAME, + "fullUpdate" + ); + } + } catch (err) { + console.log(err); + } finally { + setIsRefreshing(false); + } + }, [sparkInformation, currentWalletMnemoinc]); return (
toggleTheme(!theme)}> From 2a5bcc3bc9a3fc9f684655f420d08856967f0d7c Mon Sep 17 00:00:00 2001 From: Blake Kaufman Date: Fri, 19 Dec 2025 15:15:19 -0500 Subject: [PATCH 08/28] updating edit profile page + bug fixes --- db/index.js | 4 +- db/photoStorage.js | 32 +- src/components/customInput/customInput.jsx | 4 + src/components/customSettingsNavbar/index.jsx | 57 +- src/components/customSettingsNavbar/style.css | 5 - src/components/navBar/navbar.css | 7 +- src/contexts/globalContacts.jsx | 40 +- src/contexts/imageCacheContext.jsx | 2 +- .../contacts/navigateToExpandedContact.js | 2 +- src/functions/messaging/cachedMessages.js | 32 +- src/functions/spark/transactions.js | 4 +- .../EditProfileTextInput.jsx | 110 ++ .../editProfileTextInput.css | 194 ++++ .../expandedContactsPage.css | 2 + .../expandedContactsPage.jsx | 26 +- .../addContactsHalfModal.jsx | 16 +- .../components/addContactsHalfModal/style.css | 6 +- .../sendAndRequestPage/sendAndRequsetPage.jsx | 11 +- src/pages/contacts/contacts.css | 24 + src/pages/contacts/contacts.jsx | 36 +- .../contactsTransactions.jsx | 8 +- .../editMyProfilePage/editMyProfilePage.jsx | 1016 +++++++++-------- .../screens/editMyProfilePage/style.css | 4 +- src/pages/contacts/utils/imageComparison.js | 101 +- src/pages/contacts/utils/useExpandedNavbar.js | 13 +- src/pages/contacts/utils/useProfileImage.js | 191 ++-- src/pages/customHalfModal/index.jsx | 1 + src/pages/expandedTxPage/expandedTxPage.jsx | 545 +++++---- src/pages/expandedTxPage/style.css | 260 +++-- src/pages/receiveQRPage/receiveQRPage.jsx | 12 - .../settings/settingsItem/settingsItem.jsx | 15 +- src/pages/wallet/components/nav/nav.css | 1 - src/pages/wallet/wallet.css | 12 +- src/pages/wallet/wallet.jsx | 17 +- 34 files changed, 1798 insertions(+), 1012 deletions(-) create mode 100644 src/pages/contacts/components/EditProfileTextInput/EditProfileTextInput.jsx create mode 100644 src/pages/contacts/components/EditProfileTextInput/editProfileTextInput.css diff --git a/db/index.js b/db/index.js index df0b8c2..c613666 100644 --- a/db/index.js +++ b/db/index.js @@ -468,7 +468,7 @@ export async function addGiftToDatabase(dataObject) { await setDoc(docRef, dataObject, { merge: false }); - console.log("Document merged with ID: ", dataObject.uuid); + console.log("Gift added to database with ID: ", dataObject.uuid); return true; } catch (e) { console.error("Error adding gift to database: ", e); @@ -483,7 +483,7 @@ export async function updateGiftInDatabase(dataObject) { await setDoc(docRef, dataObject, { merge: true }); - console.log("Document merged with ID: ", dataObject.uuid); + console.log("Gift updated with ID: ", dataObject.uuid); return true; } catch (e) { console.error("Error adding gift to database: ", e); diff --git a/db/photoStorage.js b/db/photoStorage.js index b0760e3..3d4d1dc 100644 --- a/db/photoStorage.js +++ b/db/photoStorage.js @@ -1,30 +1,44 @@ -import { getStorage } from "firebase/storage"; +import { + deleteObject, + getDownloadURL, + getStorage, + ref, + uploadBytes, +} from "firebase/storage"; import { BLITZ_PROFILE_IMG_STORAGE_REF } from "../src/constants"; +import { storage } from "./initializeFirebase"; -export async function setDatabaseIMG(publicKey, imgURL) { +export async function setDatabaseIMG(publicKey, imgBlob) { try { - const reference = getStorage().ref( - `${BLITZ_PROFILE_IMG_STORAGE_REF}/${publicKey}.jpg` + if (!(imgBlob instanceof Blob)) { + throw new Error("Expected a Blob object"); + } + + const reference = ref( + storage, + `${BLITZ_PROFILE_IMG_STORAGE_REF}/${publicKey}.webp` ); - await reference.putFile(imgURL.uri); + await uploadBytes(reference, imgBlob, metadata); - const downloadURL = await reference.getDownloadURL(); + const downloadURL = await getDownloadURL(reference); return downloadURL; } catch (err) { console.log("set database image error", err); return false; } } + export async function deleteDatabaseImage(publicKey) { try { - const reference = getStorage().ref( + const reference = ref( + storage, `${BLITZ_PROFILE_IMG_STORAGE_REF}/${publicKey}.jpg` ); - await reference.delete(); + await deleteObject(reference); return true; } catch (err) { - console.log("delete profime imgage error", err); + console.log("delete profile image error", err); if (err.message.includes("No object exists at the desired reference")) { return true; } diff --git a/src/components/customInput/customInput.jsx b/src/components/customInput/customInput.jsx index 66e1c7d..19dc99f 100644 --- a/src/components/customInput/customInput.jsx +++ b/src/components/customInput/customInput.jsx @@ -11,6 +11,8 @@ export default function CustomInput({ onFocus, onBlur, multiline = false, + ref, + maxLength, }) { const commonProps = { value, @@ -23,10 +25,12 @@ export default function CustomInput({ ...customInputStyles, resize: "none", }, + maxLength, }; return (
diff --git a/src/components/customSettingsNavbar/index.jsx b/src/components/customSettingsNavbar/index.jsx index 6a1f2cf..90215a3 100644 --- a/src/components/customSettingsNavbar/index.jsx +++ b/src/components/customSettingsNavbar/index.jsx @@ -1,30 +1,53 @@ -import { useNavigate } from "react-router-dom"; -import BackArrow from "../backArrow/backArrow"; import ThemeText from "../themeText/themeText"; -import "./style.css"; -import ThemeImage from "../ThemeImage/themeImage"; -import { settingsIcon } from "../../constants/icons"; -export default function CustomSettingsNavbar({ +import BackArrow from "../backArrow/backArrow"; +import { useThemeContext } from "../../contexts/themeContext"; +import { Colors } from "../../constants/theme"; +import { useNavigate } from "react-router-dom"; +// Custom Settings Top Bar Component +export default function CustomSettingsNavBar({ + containerStyles = {}, + textStyles = {}, text = "", + showLeftImage = false, + leftImageFunction = () => {}, + LeftImageIcon = null, + leftImageStyles = {}, textClassName, - showWhite, - showSettings, - settingLocation, + customBackFunction = null, + showWhite = false, }) { const navigate = useNavigate(); + const handleBackClick = () => { + if (customBackFunction) { + customBackFunction(); + return; + } + navigate(-1); + }; + const { theme, darkModeType } = useThemeContext(); + return ( -
- +
+ + - {showSettings && ( - navigate(`./${settingLocation}`)} - className="settingsIcon" - icon={settingsIcon} - /> + {showLeftImage && ( + )}
); diff --git a/src/components/customSettingsNavbar/style.css b/src/components/customSettingsNavbar/style.css index aad79d7..2b28a83 100644 --- a/src/components/customSettingsNavbar/style.css +++ b/src/components/customSettingsNavbar/style.css @@ -27,8 +27,3 @@ right: 0; z-index: 1; } -@media screen and (max-width: 400px) { - .pageNavBar .pageHeaderText { - font-size: 20px; - } -} diff --git a/src/components/navBar/navbar.css b/src/components/navBar/navbar.css index 9368313..ee229f5 100644 --- a/src/components/navBar/navbar.css +++ b/src/components/navBar/navbar.css @@ -11,7 +11,7 @@ z-index: 1; } .pageNavBar .pageHeaderText { - font-size: 1.5rem; + font-size: 1.25rem; text-align: center; white-space: nowrap; /* Prevent line break */ overflow: hidden; /* Hide overflow */ @@ -21,8 +21,3 @@ padding: 0 35px; margin: 0; } -@media screen and (max-width: 400px) { - .pageNavBar .pageHeaderText { - font-size: 20px; - } -} diff --git a/src/contexts/globalContacts.jsx b/src/contexts/globalContacts.jsx index 4b5f1df..3559f0a 100644 --- a/src/contexts/globalContacts.jsx +++ b/src/contexts/globalContacts.jsx @@ -46,6 +46,7 @@ export const GlobalContactsList = ({ children }) => { ); const [contactsMessags, setContactsMessagses] = useState({}); const [decodedAddedContacts, setDecodedAddedContacts] = useState([]); + const [updateDB, setUpdateDB] = useState(null); const didTryToUpdate = useRef(false); const lookForNewMessages = useRef(true); @@ -57,26 +58,39 @@ export const GlobalContactsList = ({ children }) => { const addedContacts = globalContactsInformation.addedContacts; + useEffect(() => { + globalContactsInformationRef.current = globalContactsInformation; + }, [globalContactsInformation]); + const toggleGlobalContactsInformation = useCallback( (newData, writeToDB) => { - setGlobalContactsInformation((prev) => { - const newContacts = { ...prev, ...newData }; - if (writeToDB) { - addDataToCollection( - { contacts: newContacts }, - "blitzWalletUsers", - publicKey - ); - } - return newContacts; - }); + setUpdateDB({ newData, writeToDB }); }, [publicKey] ); useEffect(() => { - globalContactsInformationRef.current = globalContactsInformation; - }, [globalContactsInformation]); + if (!updateDB) return; + + async function handleUpdate() { + const { newData, writeToDB } = updateDB; + const newContacts = { + ...globalContactsInformationRef.current, + ...newData, + }; + setGlobalContactsInformation(newContacts); + if (writeToDB) { + addDataToCollection( + { contacts: newContacts }, + "blitzWalletUsers", + publicKey + ); + } + + setUpdateDB(null); + } + handleUpdate(); + }, [updateDB]); useEffect(() => { decodedAddedContactsRef.current = decodedAddedContacts; diff --git a/src/contexts/imageCacheContext.jsx b/src/contexts/imageCacheContext.jsx index b0916a0..99b1383 100644 --- a/src/contexts/imageCacheContext.jsx +++ b/src/contexts/imageCacheContext.jsx @@ -155,7 +155,7 @@ export function ImageCacheProvider({ children }) { updated: new Date().getTime(), }; - deleteCachedImage(key); + await deleteCachedImage(key); setCache((prev) => ({ ...prev, [uuid]: newCacheEntry })); return newCacheEntry; } catch (err) { diff --git a/src/functions/contacts/navigateToExpandedContact.js b/src/functions/contacts/navigateToExpandedContact.js index 7093107..50e6d2b 100644 --- a/src/functions/contacts/navigateToExpandedContact.js +++ b/src/functions/contacts/navigateToExpandedContact.js @@ -22,7 +22,7 @@ export default async function navigateToExpandedContact( toggleGlobalContactsInformation( { myProfile: { ...globalContactsInformation.myProfile }, - addedContacts: encryptMessage( + addedContacts: await encryptMessage( contactsPrivateKey, publicKey, JSON.stringify(newAddedContacts) diff --git a/src/functions/messaging/cachedMessages.js b/src/functions/messaging/cachedMessages.js index 7a35d25..16d8f03 100644 --- a/src/functions/messaging/cachedMessages.js +++ b/src/functions/messaging/cachedMessages.js @@ -2,6 +2,8 @@ import { deleteDB, openDB } from "idb"; import Storage from "../localStorage"; import { getTwoWeeksAgoDate } from "../rotateAddressDateChecker"; import EventEmitter from "events"; +import { addBulkUnpaidSparkContactTransactions } from "../spark/transactions"; +import i18next from "i18next"; export const CACHED_MESSAGES_KEY = "CASHED_CONTACTS_MESSAGES"; export const DB_NAME = `${CACHED_MESSAGES_KEY}`; @@ -102,7 +104,13 @@ const processQueue = async () => { while (messageQueue.length > 0) { const { newMessagesList, myPubKey } = messageQueue.shift(); try { - await setCashedMessages({ newMessagesList, myPubKey }); + await Promise.all([ + addUnpaidContactTransactions({ newMessagesList, myPubKey }), + setCashedMessages({ + newMessagesList, + myPubKey, + }), + ]); } catch (err) { console.error("Error processing batch in queue:", err); } @@ -111,6 +119,28 @@ const processQueue = async () => { isProcessing = false; }; +const addUnpaidContactTransactions = async ({ newMessagesList, myPubKey }) => { + let formatted = []; + for (const message of newMessagesList) { + const parsedMessage = message.message; + if (message.isReceived && parsedMessage?.txid) { + formatted.push({ + id: parsedMessage.txid, + description: + parsedMessage.description || + i18next.t("contacts.sendAndRequestPage.contactMessage", { + name: parsedMessage?.name || "", + }), + sendersPubkey: message.sendersPubkey, + details: "", + }); + } + } + if (formatted.length > 0) { + await addBulkUnpaidSparkContactTransactions(formatted); + } +}; + const setCashedMessages = async ({ newMessagesList, myPubKey }) => { const db = await getDB(); const tx = db.transaction(STORE_NAME_CONTACT_MESSAGES, "readwrite"); diff --git a/src/functions/spark/transactions.js b/src/functions/spark/transactions.js index 38ecd4e..a8f9601 100644 --- a/src/functions/spark/transactions.js +++ b/src/functions/spark/transactions.js @@ -10,7 +10,7 @@ export const SPARK_TX_UPDATE_ENVENT_NAME = "UPDATE_SPARK_STATE"; let bulkUpdateTransactionQueue = []; let isProcessingBulkUpdate = false; -let dbPromise = openDB(SPARK_TRANSACTIONS_DATABASE_NAME, 1, { +let dbPromise = openDB(SPARK_TRANSACTIONS_DATABASE_NAME, 2, { upgrade(db) { if (!db.objectStoreNames.contains(SPARK_TRANSACTIONS_TABLE_NAME)) { const txStore = db.createObjectStore(SPARK_TRANSACTIONS_TABLE_NAME, { @@ -130,7 +130,7 @@ export const getAllPendingSparkPayments = async (accountId) => { export const getAllSparkContactInvoices = async () => { try { const db = await dbPromise; - return await db.getAll(SPARK_TRANSACTIONS_TABLE_NAME); + return await db.getAll(SPARK_REQUEST_IDS_TABLE_NAME); } catch (error) { console.error("Error fetching contacts saved transactions:", error); } diff --git a/src/pages/contacts/components/EditProfileTextInput/EditProfileTextInput.jsx b/src/pages/contacts/components/EditProfileTextInput/EditProfileTextInput.jsx new file mode 100644 index 0000000..0b280e5 --- /dev/null +++ b/src/pages/contacts/components/EditProfileTextInput/EditProfileTextInput.jsx @@ -0,0 +1,110 @@ +import React, { useState, useRef } from "react"; +import { Info, Moon, Sun } from "lucide-react"; +import "./editProfileTextInput.css"; +import { useThemeContext } from "../../../../contexts/themeContext"; +import { Colors } from "../../../../constants/theme"; +import CustomInput from "../../../../components/customInput/customInput"; + +/** + * Reusable text input component for edit profile forms + */ +export function EditProfileTextInput({ + label, + placeholder, + value = "", + onChangeText, + onFocus, + onBlur, + inputRef, + maxLength = 30, + multiline = false, + minHeight, + maxHeight, + isDarkMode = false, + showInfoIcon = false, + onInfoPress, + containerStyle = {}, +}) { + const { theme, darkModeType } = useThemeContext(); + const isOverLimit = value.length >= maxLength; + + const handleChange = (e) => { + console.log(e); + onChangeText?.(e); + }; + + const handleContainerClick = () => { + inputRef?.current?.focus(); + }; + + return ( +
+ {showInfoIcon ? ( +
+ + +
+ ) : ( + + )} + + {/* {multiline ? ( +