From 548ba9b29c6c2d8a27ac47b46a6d3293bc9dd153 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Sat, 8 Mar 2025 03:24:58 +0200 Subject: [PATCH] added payment notification --- src/App.tsx | 9 ++ src/atoms/global.ts | 5 + src/background.ts | 142 ++++++++++++++++++++- src/components/GeneralNotifications.tsx | 132 +++++++++++++++++++ src/hooks/useHandlePaymentNotification.tsx | 116 +++++++++++++++++ 5 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 src/components/GeneralNotifications.tsx create mode 100644 src/hooks/useHandlePaymentNotification.tsx diff --git a/src/App.tsx b/src/App.tsx index 3520489..777c25e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -120,6 +120,7 @@ import { hasSettingsChangedAtom, isDisabledEditorEnterAtom, isUsingImportExportSettingsAtom, + lastPaymentSeenTimestampAtom, mailsAtom, oldPinnedAppsAtom, qMailLastEnteredTimestampAtom, @@ -154,6 +155,7 @@ import { UserLookup } from "./components/UserLookup.tsx/UserLookup"; import { RegisterName } from "./components/RegisterName"; import { BuyQortInformation } from "./components/BuyQortInformation"; import { QortPayment } from "./components/QortPayment"; +import { GeneralNotifications } from "./components/GeneralNotifications"; type extStates = | "not-authenticated" @@ -508,6 +510,7 @@ function App() { const resetAtomQMailLastEnteredTimestampAtom = useResetRecoilState(qMailLastEnteredTimestampAtom) const resetAtomMailsAtom = useResetRecoilState(mailsAtom) const resetGroupPropertiesAtom = useResetRecoilState(groupsPropertiesAtom) + const resetLastPaymentSeenTimestampAtom = useResetRecoilState(lastPaymentSeenTimestampAtom) const resetAllRecoil = () => { resetAtomSortablePinnedAppsAtom(); resetAtomCanSaveSettingToQdnAtom(); @@ -518,6 +521,7 @@ function App() { resetAtomQMailLastEnteredTimestampAtom() resetAtomMailsAtom() resetGroupPropertiesAtom() + resetLastPaymentSeenTimestampAtom() }; useEffect(() => { @@ -1802,6 +1806,11 @@ function App() { + + {extState === 'authenticated' && ( + + )} + ({ + key: 'lastPaymentSeenTimestampAtom', + default: null, +}); + export const mailsAtom = atom({ key: 'mailsAtom', default: [], diff --git a/src/background.ts b/src/background.ts index 96febcd..c43dacf 100644 --- a/src/background.ts +++ b/src/background.ts @@ -262,11 +262,12 @@ export const getForeignKey = async (foreignBlockchain)=> { export const pauseAllQueues = () => controlAllQueues("pause"); export const resumeAllQueues = () => controlAllQueues("resume"); -const checkDifference = (createdTimestamp) => { +export const checkDifference = (createdTimestamp, diff = timeDifferenceForNotificationChatsBackground) => { return ( - Date.now() - createdTimestamp < timeDifferenceForNotificationChatsBackground + Date.now() - createdTimestamp < diff ); }; + export const getApiKeyFromStorage = async (): Promise => { return getData("apiKey").catch(() => null); }; @@ -518,6 +519,7 @@ const handleNotificationDirect = async (directs) => { `_from=${newestLatestTimestamp.address}`); const notification = new window.Notification(title, { body, + icon: window.location.origin + "/qortal192.png", data: { id: notificationId }, }); @@ -559,6 +561,7 @@ const handleNotificationDirect = async (directs) => { const notification = new window.Notification(title, { body, + icon: window.location.origin + "/qortal192.png", data: { id: notificationId }, }); @@ -746,6 +749,7 @@ const handleNotification = async (groups) => { const notification = new window.Notification(title, { body, + icon: window.location.origin + "/qortal192.png", data: { id: notificationId }, }); @@ -788,6 +792,7 @@ const handleNotification = async (groups) => { // Create and show the notification immediately const notification = new window.Notification(title, { body, + icon: window.location.origin + "/qortal192.png", data: { id: notificationId }, }); @@ -2739,6 +2744,31 @@ export async function addTimestampGroupAnnouncement({ }); } +export async function getTimestampLatestPayment() { + const wallet = await getSaveWallet(); + const address = wallet.address0; + const key = `latest-payment-${address}`; + const res = await getData(key).catch(() => null); + if (res) { + const parsedData = res; + return parsedData; + } else return 0 +} + +export async function addTimestampLatestPayment(timestamp) { + const wallet = await getSaveWallet(); + const address = wallet.address0; + + return await new Promise((resolve, reject) => { + storeData(`latest-payment-${address}`, timestamp) + .then(() => resolve(true)) + .catch((error) => { + reject(new Error(error.message || "Error saving data")); + }); + }); +} + + export async function addEnteredQmailTimestamp() { const wallet = await getSaveWallet(); const address = wallet.address0; @@ -3273,6 +3303,7 @@ export const checkNewMessages = async () => { // Create and show the notification const notification = new window.Notification(title, { body, + icon: window.location.origin + "/qortal192.png", data: { id: notificationId }, }); @@ -3302,6 +3333,95 @@ export const checkNewMessages = async () => { } }; +export const checkPaymentsForNotifications = async (address) => { + try { + const isDisableNotifications = + (await getUserSettings({ key: "disable-push-notifications" })) || false; + if(isDisableNotifications) return + let latestPayment = null + const savedtimestamp = await getTimestampLatestPayment(); + + const url = await createEndpoint( + `/transactions/search?txType=PAYMENT&address=${address}&confirmationStatus=CONFIRMED&limit=5&reverse=true` + ); + + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + const responseData = await response.json(); + + const latestTx = responseData.filter( + (tx) => tx?.creatorAddress !== address && tx?.recipient === address + )[0]; + if (!latestTx) { + return; // continue to the next group + } + if ( + checkDifference(latestTx.timestamp) && + (!savedtimestamp || + latestTx.timestamp > + savedtimestamp) + ) { + if(latestTx.timestamp){ + latestPayment = latestTx + await addTimestampLatestPayment(latestTx.timestamp); + } + + // save new timestamp + } + + + + if ( + latestPayment + ) { + // Create a unique notification ID with type and group announcement details + const notificationId = + encodeURIComponent("payment_notification_" + + Date.now() + + "_type=payment-announcement"); + + const title = "New payment!"; + const body = `You have received a new payment of ${latestPayment?.amount} QORT`; + + // Create and show the notification + const notification = new window.Notification(title, { + body, + icon: window.location.origin + "/qortal192.png", + data: { id: notificationId }, + }); + + // Handle notification click with specific actions based on `notificationId` + notification.onclick = () => { + handleNotificationClick(notificationId); + notification.close(); // Clean up the notification on click + }; + + // Automatically close the notification after 5 seconds if it’s not clicked + setTimeout(() => { + notification.close(); + }, 10000); // Close after 5 seconds + + const targetOrigin = window.location.origin; + + window.postMessage( + { + action: "SET_PAYMENT_ANNOUNCEMENT", + payload: latestPayment, + }, + targetOrigin + ); + } + + } catch (error) { + console.error(error) + } +}; + const checkActiveChatsForNotifications = async () => { try { checkGroupList(); @@ -3437,6 +3557,7 @@ export const checkThreads = async (bringBack) => { // Create and show the notification const notification = new window.Notification(title, { body, + icon: window.location.origin + "/qortal192.png", data: { id: notificationId }, }); @@ -3494,6 +3615,7 @@ export const checkThreads = async (bringBack) => { // }); let notificationCheckInterval +let paymentsCheckInterval const createNotificationCheck = () => { // Check if an interval already exists before creating it @@ -3513,6 +3635,22 @@ const createNotificationCheck = () => { } }, 10 * 60 * 1000); // 10 minutes } + + if (!paymentsCheckInterval) { + paymentsCheckInterval = setInterval(async () => { + try { + // This would replace the Chrome alarm callback + const wallet = await getSaveWallet(); + const address = wallet?.address0; + if (!address) return; + + checkPaymentsForNotifications(address); + + } catch (error) { + console.error('Error checking payments:', error); + } + }, 3 * 60 * 1000); // 3 minutes + } }; // Call this function when initializing your app diff --git a/src/components/GeneralNotifications.tsx b/src/components/GeneralNotifications.tsx new file mode 100644 index 0000000..532c0e6 --- /dev/null +++ b/src/components/GeneralNotifications.tsx @@ -0,0 +1,132 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { + Box, + ButtonBase, + Card, + MenuItem, + Popover, + Tooltip, + Typography, +} from "@mui/material"; +import NotificationsIcon from "@mui/icons-material/Notifications"; +import AccountBalanceWalletIcon from "@mui/icons-material/AccountBalanceWallet"; +import { formatDate } from "../utils/time"; +import { useHandlePaymentNotification } from "../hooks/useHandlePaymentNotification"; + +export const GeneralNotifications = ({ address }) => { + const [anchorEl, setAnchorEl] = useState(null); + const {latestTx, + getNameOrAddressOfSenderMiddle, + hasNewPayment, setLastEnteredTimestampPayment, nameAddressOfSender} = useHandlePaymentNotification(address) + + const handlePopupClick = (event) => { + event.stopPropagation(); // Prevent parent onClick from firing + setAnchorEl(event.currentTarget); + }; + + return ( + <> + { + handlePopupClick(e); + + + }} + style={{}} + > + + + + { + if(hasNewPayment){ + setLastEnteredTimestampPayment(Date.now()) + } + setAnchorEl(null) + + }} // Close popover on click outside + > + + {!hasNewPayment && No new notifications} + {hasNewPayment && ( + { + // executeEvent("addTab", { data: { service: 'APP', name: 'q-mail' } }); + // executeEvent("open-apps-mode", { }); + }} + > + + + {" "} + {formatDate(latestTx?.timestamp)} + + + + {latestTx?.amount} + + {nameAddressOfSender.current[latestTx?.creatorAddress] || getNameOrAddressOfSenderMiddle(latestTx?.creatorAddress)} + + + + )} + + + + ); +}; diff --git a/src/hooks/useHandlePaymentNotification.tsx b/src/hooks/useHandlePaymentNotification.tsx new file mode 100644 index 0000000..cfed663 --- /dev/null +++ b/src/hooks/useHandlePaymentNotification.tsx @@ -0,0 +1,116 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { getBaseApiReact } from '../App'; +import { getData, storeData } from '../utils/chromeStorage'; +import { checkDifference, getNameInfoForOthers } from '../background'; +import { useRecoilState } from 'recoil'; +import { lastPaymentSeenTimestampAtom } from '../atoms/global'; + +export const useHandlePaymentNotification = (address) => { + const [latestTx, setLatestTx] = useState(null); + + const nameAddressOfSender = useRef({}) + const isFetchingName = useRef({}) + + + const [lastEnteredTimestampPayment, setLastEnteredTimestampPayment] = + useRecoilState(lastPaymentSeenTimestampAtom); + + useEffect(() => { + if (lastEnteredTimestampPayment && address) { + storeData(`last-seen-payment-${address}`, Date.now()).catch((error) => { + console.error(error); + }); + } + }, [lastEnteredTimestampPayment, address]); + + const getNameOrAddressOfSender = useCallback(async(senderAddress)=> { + if(isFetchingName.current[senderAddress]) return senderAddress + try { + isFetchingName.current[senderAddress] = true + const res = await getNameInfoForOthers(senderAddress) + nameAddressOfSender.current[senderAddress] = res || senderAddress + } catch (error) { + console.error(error) + } finally { + isFetchingName.current[senderAddress] = false + } + + }, []) + + const getNameOrAddressOfSenderMiddle = useCallback(async(senderAddress)=> { + getNameOrAddressOfSender(senderAddress) + return senderAddress + + }, [getNameOrAddressOfSender]) + + const hasNewPayment = useMemo(() => { + if (!latestTx) return false; + if (!checkDifference(latestTx?.timestamp)) return false; + if ( + !lastEnteredTimestampPayment || + lastEnteredTimestampPayment < latestTx?.timestamp + ) + return true; + + return false; + }, [lastEnteredTimestampPayment, latestTx]); + + const getLastSeenData = useCallback(async () => { + try { + if (!address) return; + const key = `last-seen-payment-${address}`; + + const res = await getData(key).catch(() => null); + if (res) { + setLastEnteredTimestampPayment(res); + } + + const response = await fetch( + `${getBaseApiReact()}/transactions/search?txType=PAYMENT&address=${address}&confirmationStatus=CONFIRMED&limit=5&reverse=true` + ); + + const responseData = await response.json(); + + const latestTx = responseData.filter( + (tx) => tx?.creatorAddress !== address && tx?.recipient === address + )[0]; + if (!latestTx) { + return; // continue to the next group + } + + setLatestTx(latestTx); + } catch (error) { + console.error(error); + } + }, [address, setLastEnteredTimestampPayment]); + + + useEffect(() => { + getLastSeenData(); + // Handler function for incoming messages + const messageHandler = (event) => { + if (event.origin !== window.location.origin) { + return; + } + const message = event.data; + if (message?.action === "SET_PAYMENT_ANNOUNCEMENT" && message?.payload) { + setLatestTx(message.payload); + } + }; + + // Attach the event listener + window.addEventListener("message", messageHandler); + + // Clean up the event listener on component unmount + return () => { + window.removeEventListener("message", messageHandler); + }; + }, [getLastSeenData]); + return { + latestTx, + getNameOrAddressOfSenderMiddle, + hasNewPayment, + setLastEnteredTimestampPayment, + nameAddressOfSender + } +}