diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index b2b2796b..f9450e85 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -22,7 +22,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 16.x + node-version: 20.x # ESLint and Prettier must be in `package.json` - name: Install Node.js dependencies diff --git a/.replit b/.replit index e10f2ee2..71d745fb 100644 --- a/.replit +++ b/.replit @@ -1,4 +1,5 @@ run = "npm run dev" +modules = ["nodejs-20:v8-20230920-bd784b9"] [nix] channel = "stable-22_11" diff --git a/components/display-products.tsx b/components/display-products.tsx index 06022e67..f5df4eab 100644 --- a/components/display-products.tsx +++ b/components/display-products.tsx @@ -33,6 +33,8 @@ const DisplayEvents = ({ const router = useRouter(); + const { userPubkey } = getLocalStorageData(); + useEffect(() => { setIsLoading(productEventContext.isLoading); }, [productEventContext.isLoading]); @@ -79,6 +81,17 @@ const DisplayEvents = ({ handleSendMessage: (pubkeyToOpenChatWith: string) => void, ) => { if (focusedPubkey && productData.pubkey !== focusedPubkey) return; + + if ( + (productData.pubkey === + "95a5e73109d4c419456372ce99bbf5823dfb6f77aed58d03f77ea052f150ee4a" || + productData.pubkey === + "773ed8aba7ee59f6f24612533e891450b6197b5ca24e7680209adb944e330e2f") && + userPubkey !== productData.pubkey + ) { + return; // temp fix, add adult categories or separate from global later + } + return ( { + const handleLightningPayment = async () => { + let newPrice = totalCost; const wallet = new CashuWallet(new CashuMint(mints[0])); - if (currency === "USD") { + if (currency.toUpperCase() === "USD") { try { const res = await axios.get( "https://api.coinbase.com/v2/prices/BTC-USD/spot", @@ -157,7 +161,7 @@ export default function InvoiceCard({ if (encoded) { sendTokens(encoded); - captureInvoicePaidmetric(metricsInvoiceId, productData.id); + captureInvoicePaidmetric(metricsInvoiceId, productData); setPaymentConfirmed(true); setQrCodeUrl(null); if (setInvoiceIsPaid) { @@ -225,8 +229,9 @@ export default function InvoiceCard({ const formattedTotalCost = formatWithCommas(totalCost, currency); - const handleCashuPayment = async (price: number, currency: string) => { + const handleCashuPayment = async () => { try { + let price = totalCost; const mint = new CashuMint(mints[0]); const wallet = new CashuWallet(mint); if (currency === "USD") { @@ -255,9 +260,11 @@ export default function InvoiceCard({ }, ], }); - sendTokens(encodedSendToken); - // captureInvoicePaidmetric(metricsInvoiceId, productData.id); - // another metric to capture native Cashu payments is needed + sendTokens(encodedSendToken) + .then(() => { + captureCashuPaidMetric(productData); + }) + .catch(console.log); const changeProofs = tokenToSend?.returnChange; const remainingProofs = tokens.filter( (p: Proof) => !mintKeySetIds?.includes(p.id), @@ -313,7 +320,7 @@ export default function InvoiceCard({ return; } if (randomNsec !== "") { - handleLightningPayment(totalCost, currency); + handleLightningPayment(); } setShowInvoiceCard(true); }} @@ -333,7 +340,7 @@ export default function InvoiceCard({ return; } if (randomNsec !== "") { - handleCashuPayment(totalCost, currency); + handleCashuPayment(); } }} startContent={ diff --git a/components/messages/chat-message.tsx b/components/messages/chat-message.tsx index 534918a7..550409a1 100644 --- a/components/messages/chat-message.tsx +++ b/components/messages/chat-message.tsx @@ -1,5 +1,5 @@ import { getLocalStorageData } from "../utility/nostr-helper-functions"; -import RedeemButton from "../utility-components/redeem-button"; +import ClaimButton from "../utility-components/claim-button"; import { NostrMessageEvent } from "../../utils/types/types"; import { timeSinceMessageDisplayText } from "../../utils/messages/utils"; @@ -56,7 +56,7 @@ export const ChatMessage = ({ tokenAfterCashuA ? ( <> {messageEvent.content} - + ) : ( <>{messageEvent.content} diff --git a/components/messages/chat-panel.tsx b/components/messages/chat-panel.tsx index 3bdaf90f..ede8ec6c 100644 --- a/components/messages/chat-panel.tsx +++ b/components/messages/chat-panel.tsx @@ -17,12 +17,14 @@ export const ChatPanel = ({ currentChatPubkey, chatsMap, isSendingDMLoading, + isPayment, }: { handleGoBack: () => void; handleSendMessage: (message: string) => void; currentChatPubkey: string; chatsMap: Map; isSendingDMLoading: boolean; + isPayment: boolean; }) => { const [messageInput, setMessageInput] = useState(""); const [messages, setMessages] = useState([]); // [chatPubkey, chat] @@ -61,8 +63,8 @@ export const ChatPanel = ({ }; return ( -
-

+
+

-
- { - setMessageInput(e.target.value); - }} - onKeyDown={(e) => { - if ( - e.key === "Enter" && - !(messageInput === "" || isSendingDMLoading) - ) - sendMessage(); - }} - /> - -
+ {!isPayment && ( +
+ { + setMessageInput(e.target.value); + }} + onKeyDown={(e) => { + if ( + e.key === "Enter" && + !(messageInput === "" || isSendingDMLoading) + ) + sendMessage(); + }} + /> + +
+ )}

); }; diff --git a/components/messages/inquiries.tsx b/components/messages/inquiries.tsx new file mode 100644 index 00000000..3601c474 --- /dev/null +++ b/components/messages/inquiries.tsx @@ -0,0 +1,345 @@ +import { useState, useEffect, useContext } from "react"; +import { nip04 } from "nostr-tools"; +import { useRouter } from "next/router"; +import { + constructEncryptedMessageEvent, + decryptNpub, + getLocalStorageData, + getPrivKeyWithPassphrase, + sendEncryptedMessage, + validPassphrase, +} from "../utility/nostr-helper-functions"; +import { ChatsContext } from "../../utils/context/context"; +import RequestPassphraseModal from "../utility-components/request-passphrase-modal"; +import ShopstrSpinner from "../utility-components/shopstr-spinner"; +import axios from "axios"; +import { ChatPanel } from "./chat-panel"; +import { ChatButton } from "./chat-button"; +import { NostrMessageEvent, ChatObject } from "../../utils/types/types"; +import { + addChatMessagesToCache, + fetchChatMessagesFromCache, +} from "../../pages/api/nostr/cache-service"; +import { useKeyPress } from "../utility/functions"; + +const Inquiries = () => { + const router = useRouter(); + const chatsContext = useContext(ChatsContext); + const arrowUpPressed = useKeyPress("ArrowUp"); + const arrowDownPressed = useKeyPress("ArrowDown"); + const escapePressed = useKeyPress("Escape"); + + const [chatsMap, setChatsMap] = useState>(new Map()); // Map + const [sortedChatsByLastMessage, setSortedChatsByLastMessage] = useState< + [string, ChatObject][] + >([]); // [chatPubkey, chat] + const [currentChatPubkey, setCurrentChatPubkey] = useState(""); + + const [enterPassphrase, setEnterPassphrase] = useState(false); + const [passphrase, setPassphrase] = useState(""); + + const [isChatsLoading, setIsChatsLoading] = useState(true); + const [isSendingDMLoading, setIsSendingDMLoading] = useState(false); + const { signInMethod, userPubkey } = getLocalStorageData(); + + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + setIsClient(true); + }, []); + + useEffect(() => { + async function loadChats() { + if (!chatsContext) { + setIsChatsLoading(false); + return; + } + if (signInMethod === "nsec" && !validPassphrase(passphrase)) { + setEnterPassphrase(true); // prompt for passphrase when chatsContext is loaded + } else if (!chatsContext.isLoading && chatsContext.chatsMap) { + // comes here only if signInMethod is extension or its nsec and passphrase is valid + let decryptedChats = await getDecryptedChatsFromContext(); + const passedNPubkey = router.query.pk ? router.query.pk : null; + if (passedNPubkey) { + let pubkey = decryptNpub(passedNPubkey as string) as string; + if (!decryptedChats.has(pubkey)) { + decryptedChats.set(pubkey as string, { + unreadCount: 0, + decryptedChat: [], + }); + } + enterChat(pubkey); + } + setChatsMap(decryptedChats); + if (currentChatPubkey) { + // if the current chat is already open, mark all messages as read + markAllMessagesAsReadInChatRoom(currentChatPubkey); + } + setIsChatsLoading(chatsContext.isLoading); + return; + } + } + loadChats(); + }, [chatsContext, passphrase]); + + useEffect(() => { + let sortedChatsByLastMessage = Array.from(chatsMap.entries()).sort( + (a: [string, ChatObject], b: [string, ChatObject]) => { + if (a[1].decryptedChat.length === 0) return -1; + let aLastMessage = + a[1].decryptedChat.length > 0 + ? a[1].decryptedChat[a[1].decryptedChat.length - 1].created_at + : 0; + let bLastMessage = + b[1].decryptedChat.length > 0 + ? b[1].decryptedChat[b[1].decryptedChat.length - 1].created_at + : 0; + return bLastMessage - aLastMessage; + }, + ); + setSortedChatsByLastMessage(sortedChatsByLastMessage); + }, [chatsMap]); + + // useEffect used to traverse chats via arrow keys + useEffect(() => { + if (chatsMap.size === 0 || isChatsLoading) return; + if (arrowUpPressed) { + if (currentChatPubkey === "") { + setCurrentChatPubkey(sortedChatsByLastMessage[0][0]); + } else { + let index = sortedChatsByLastMessage.findIndex( + ([pubkey, chatObject]) => pubkey === currentChatPubkey, + ); + if (index > 0) enterChat(sortedChatsByLastMessage[index - 1][0]); + } + } + if (arrowDownPressed) { + if (currentChatPubkey === "") { + setCurrentChatPubkey(sortedChatsByLastMessage[0][0]); + } else { + let index = sortedChatsByLastMessage.findIndex( + ([pubkey, chatObject]) => pubkey === currentChatPubkey, + ); + if (index < sortedChatsByLastMessage.length - 1) + enterChat(sortedChatsByLastMessage[index + 1][0]); + } + } + if (escapePressed) { + goBackFromChatRoom(); + } + }, [arrowUpPressed, arrowDownPressed, escapePressed]); + + const decryptEncryptedMessageContent = async ( + messageEvent: NostrMessageEvent, + chatPubkey: string, + ) => { + try { + let plaintext = ""; + if (signInMethod === "extension") { + plaintext = await window.nostr.nip04.decrypt( + chatPubkey, + messageEvent.content, + ); + } else { + let sk2 = getPrivKeyWithPassphrase(passphrase) as Uint8Array; + plaintext = await nip04.decrypt(sk2, chatPubkey, messageEvent.content); + } + return plaintext; + } catch (e) { + console.error(e, "Error decrypting message.", messageEvent); + } + }; + + const getDecryptedChatsFromContext: () => Promise< + Map + > = async () => { + let decryptedChats: Map = new Map(); // entry: [chatPubkey, chat] + let chatMessagesFromCache: Map = + await fetchChatMessagesFromCache(); + for (let entry of chatsContext.chatsMap) { + let chatPubkey = entry[0] as string; + let chat = entry[1] as NostrMessageEvent[]; + let decryptedChat: NostrMessageEvent[] = []; + let unreadCount = 0; + + for (let messageEvent of chat) { + let plainText = await decryptEncryptedMessageContent( + messageEvent, + chatPubkey, + ); + if (!plainText?.includes("cashuA")) { + plainText && + decryptedChat.push({ ...messageEvent, content: plainText }); + if (chatMessagesFromCache.get(messageEvent.id)?.read === false) { + unreadCount++; + } + } + } + if (decryptedChat.length > 0) { + decryptedChats.set(chatPubkey, { unreadCount, decryptedChat }); + } + } + return decryptedChats; + }; + + const markAllMessagesAsReadInChatRoom = (pubkeyOfChat: string) => { + setChatsMap((prevChatMap) => { + let updatedChat = prevChatMap.get(pubkeyOfChat) as ChatObject; + if (updatedChat) { + updatedChat.unreadCount = 0; + let encryptedChat = chatsContext.chatsMap.get( + pubkeyOfChat, + ) as NostrMessageEvent[]; + if (!encryptedChat) return prevChatMap; + encryptedChat.forEach((message) => { + message.read = true; + }); + let newChatMap = new Map(prevChatMap); + newChatMap.set(pubkeyOfChat, updatedChat); + addChatMessagesToCache(encryptedChat); + return newChatMap; + } + return prevChatMap; + }); + }; + + const enterChat = (pubkeyOfChat: string) => { + setCurrentChatPubkey(pubkeyOfChat as string); + // mark all messages in chat as read + + markAllMessagesAsReadInChatRoom(pubkeyOfChat as string); + }; + + const goBackFromChatRoom = () => { + // used when in chatroom on smaller devices + setCurrentChatPubkey(""); + }; + + const handleSendMessage = async (message: string) => { + setIsSendingDMLoading(true); + try { + let encryptedMessageEvent = await constructEncryptedMessageEvent( + userPubkey, + message, + currentChatPubkey, + passphrase, + ); + await sendEncryptedMessage(encryptedMessageEvent, passphrase); + // update chats locally to reflect new message + setChatsMap((prevChatMap) => { + let updatedChat = prevChatMap.get(currentChatPubkey) as ChatObject; + let unEncryptedMessageEvent: NostrMessageEvent = { + ...encryptedMessageEvent, + content: message, + read: true, + id: "mock-id", + sig: "mock-sig", + }; + let updatedDecryptedChat = updatedChat.decryptedChat + ? [...updatedChat.decryptedChat, unEncryptedMessageEvent] + : []; + let newChatMap = new Map(prevChatMap); + newChatMap.set(currentChatPubkey, { + decryptedChat: updatedDecryptedChat, + unreadCount: 0, + }); + return newChatMap; + }); + if ( + chatsMap.get(currentChatPubkey) != undefined && + chatsMap.get(currentChatPubkey)?.decryptedChat.length === 0 + ) { + // only logs if this is the first msg, aka an iniquiry + axios({ + method: "POST", + url: "/api/metrics/post-inquiry", + headers: { + "Content-Type": "application/json", + }, + data: { + customer_id: userPubkey, + merchant_id: currentChatPubkey, + // listing_id: "TODO" + // relays: relays, + }, + }); + } + setIsSendingDMLoading(false); + } catch (e) { + console.log("handleSendMessage errored", e); + alert("Error sending message."); + setIsSendingDMLoading(false); + } + router.replace(`/messages`); + }; + + return ( +
+
+ {chatsMap.size === 0 ? ( +
+ {isChatsLoading ? ( +
+ +
+ ) : ( +

+ {isClient && userPubkey ? ( + <> + No messages . . . yet! +

+

+ Just logged in? +

+ Try reloading the page! + + ) : ( + <>You must be signed in to see your chats! + )} +

+ )} +
+ ) : ( +
+
+ {sortedChatsByLastMessage.map( + ([pubkeyOfChat, chatObject]: [string, ChatObject]) => { + const hasCashuA = chatObject.decryptedChat.some((message) => + message.content.includes("cashuA"), + ); + return ( + !hasCashuA && ( + + ) + ); + }, + )} +
+ +
+ )} +
+ +
+ ); +}; + +export default Inquiries; diff --git a/components/messages/message-feed.tsx b/components/messages/message-feed.tsx new file mode 100644 index 00000000..9f2ff9a9 --- /dev/null +++ b/components/messages/message-feed.tsx @@ -0,0 +1,44 @@ +"use client"; + +import React, { useState } from "react"; + +import { useTabs } from "@/components/hooks/use-tabs"; +import { Framer } from "@/components/framer"; + +import Inquiries from "./inquiries"; +import Payments from "./payments"; + +const MessageFeed = () => { + const [hookProps] = useState({ + tabs: [ + { + label: "Inquiries", + children: , + id: "inquiries", + }, + { + label: "Payments", + children: , + id: "payments", + }, + ], + initialTabId: "inquiries", + }); + const framer = useTabs(hookProps); + + return ( +
+
+
+ +
+
+ +
+ {framer.selectedTab.children} +
+
+ ); +}; + +export default MessageFeed; diff --git a/components/messages/payments.tsx b/components/messages/payments.tsx new file mode 100644 index 00000000..d9cdbe37 --- /dev/null +++ b/components/messages/payments.tsx @@ -0,0 +1,345 @@ +import { useState, useEffect, useContext } from "react"; +import { nip04 } from "nostr-tools"; +import { useRouter } from "next/router"; +import { + constructEncryptedMessageEvent, + decryptNpub, + getLocalStorageData, + getPrivKeyWithPassphrase, + sendEncryptedMessage, + validPassphrase, +} from "../utility/nostr-helper-functions"; +import { ChatsContext } from "../../utils/context/context"; +import RequestPassphraseModal from "../utility-components/request-passphrase-modal"; +import ShopstrSpinner from "../utility-components/shopstr-spinner"; +import axios from "axios"; +import { ChatPanel } from "./chat-panel"; +import { ChatButton } from "./chat-button"; +import { NostrMessageEvent, ChatObject } from "../../utils/types/types"; +import { + addChatMessagesToCache, + fetchChatMessagesFromCache, +} from "../../pages/api/nostr/cache-service"; +import { useKeyPress } from "../utility/functions"; + +const Payments = () => { + const router = useRouter(); + const chatsContext = useContext(ChatsContext); + const arrowUpPressed = useKeyPress("ArrowUp"); + const arrowDownPressed = useKeyPress("ArrowDown"); + const escapePressed = useKeyPress("Escape"); + + const [chatsMap, setChatsMap] = useState>(new Map()); // Map + const [sortedChatsByLastMessage, setSortedChatsByLastMessage] = useState< + [string, ChatObject][] + >([]); // [chatPubkey, chat] + const [currentChatPubkey, setCurrentChatPubkey] = useState(""); + + const [enterPassphrase, setEnterPassphrase] = useState(false); + const [passphrase, setPassphrase] = useState(""); + + const [isChatsLoading, setIsChatsLoading] = useState(true); + const [isSendingDMLoading, setIsSendingDMLoading] = useState(false); + const { signInMethod, userPubkey } = getLocalStorageData(); + + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + setIsClient(true); + }, []); + + useEffect(() => { + async function loadChats() { + if (!chatsContext) { + setIsChatsLoading(false); + return; + } + if (signInMethod === "nsec" && !validPassphrase(passphrase)) { + setEnterPassphrase(true); // prompt for passphrase when chatsContext is loaded + } else if (!chatsContext.isLoading && chatsContext.chatsMap) { + // comes here only if signInMethod is extension or its nsec and passphrase is valid + let decryptedChats = await getDecryptedChatsFromContext(); + const passedNPubkey = router.query.pk ? router.query.pk : null; + if (passedNPubkey) { + let pubkey = decryptNpub(passedNPubkey as string) as string; + if (!decryptedChats.has(pubkey)) { + decryptedChats.set(pubkey as string, { + unreadCount: 0, + decryptedChat: [], + }); + } + enterChat(pubkey); + } + setChatsMap(decryptedChats); + if (currentChatPubkey) { + // if the current chat is already open, mark all messages as read + markAllMessagesAsReadInChatRoom(currentChatPubkey); + } + setIsChatsLoading(chatsContext.isLoading); + return; + } + } + loadChats(); + }, [chatsContext, passphrase]); + + useEffect(() => { + let sortedChatsByLastMessage = Array.from(chatsMap.entries()).sort( + (a: [string, ChatObject], b: [string, ChatObject]) => { + if (a[1].decryptedChat.length === 0) return -1; + let aLastMessage = + a[1].decryptedChat.length > 0 + ? a[1].decryptedChat[a[1].decryptedChat.length - 1].created_at + : 0; + let bLastMessage = + b[1].decryptedChat.length > 0 + ? b[1].decryptedChat[b[1].decryptedChat.length - 1].created_at + : 0; + return bLastMessage - aLastMessage; + }, + ); + setSortedChatsByLastMessage(sortedChatsByLastMessage); + }, [chatsMap]); + + // useEffect used to traverse chats via arrow keys + useEffect(() => { + if (chatsMap.size === 0 || isChatsLoading) return; + if (arrowUpPressed) { + if (currentChatPubkey === "") { + setCurrentChatPubkey(sortedChatsByLastMessage[0][0]); + } else { + let index = sortedChatsByLastMessage.findIndex( + ([pubkey, chatObject]) => pubkey === currentChatPubkey, + ); + if (index > 0) enterChat(sortedChatsByLastMessage[index - 1][0]); + } + } + if (arrowDownPressed) { + if (currentChatPubkey === "") { + setCurrentChatPubkey(sortedChatsByLastMessage[0][0]); + } else { + let index = sortedChatsByLastMessage.findIndex( + ([pubkey, chatObject]) => pubkey === currentChatPubkey, + ); + if (index < sortedChatsByLastMessage.length - 1) + enterChat(sortedChatsByLastMessage[index + 1][0]); + } + } + if (escapePressed) { + goBackFromChatRoom(); + } + }, [arrowUpPressed, arrowDownPressed, escapePressed]); + + const decryptEncryptedMessageContent = async ( + messageEvent: NostrMessageEvent, + chatPubkey: string, + ) => { + try { + let plaintext = ""; + if (signInMethod === "extension") { + plaintext = await window.nostr.nip04.decrypt( + chatPubkey, + messageEvent.content, + ); + } else { + let sk2 = getPrivKeyWithPassphrase(passphrase) as Uint8Array; + plaintext = await nip04.decrypt(sk2, chatPubkey, messageEvent.content); + } + return plaintext; + } catch (e) { + console.error(e, "Error decrypting message.", messageEvent); + } + }; + + const getDecryptedChatsFromContext: () => Promise< + Map + > = async () => { + let decryptedChats: Map = new Map(); // entry: [chatPubkey, chat] + let chatMessagesFromCache: Map = + await fetchChatMessagesFromCache(); + for (let entry of chatsContext.chatsMap) { + let chatPubkey = entry[0] as string; + let chat = entry[1] as NostrMessageEvent[]; + let decryptedChat: NostrMessageEvent[] = []; + let unreadCount = 0; + + for (let messageEvent of chat) { + let plainText = await decryptEncryptedMessageContent( + messageEvent, + chatPubkey, + ); + if (plainText?.includes("cashuA")) { + plainText && + decryptedChat.push({ ...messageEvent, content: plainText }); + if (chatMessagesFromCache.get(messageEvent.id)?.read === false) { + unreadCount++; + } + } + } + if (decryptedChat.length > 0) { + decryptedChats.set(chatPubkey, { unreadCount, decryptedChat }); + } + } + return decryptedChats; + }; + + const markAllMessagesAsReadInChatRoom = (pubkeyOfChat: string) => { + setChatsMap((prevChatMap) => { + let updatedChat = prevChatMap.get(pubkeyOfChat) as ChatObject; + if (updatedChat) { + updatedChat.unreadCount = 0; + let encryptedChat = chatsContext.chatsMap.get( + pubkeyOfChat, + ) as NostrMessageEvent[]; + if (!encryptedChat) return prevChatMap; + encryptedChat.forEach((message) => { + message.read = true; + }); + let newChatMap = new Map(prevChatMap); + newChatMap.set(pubkeyOfChat, updatedChat); + addChatMessagesToCache(encryptedChat); + return newChatMap; + } + return prevChatMap; + }); + }; + + const enterChat = (pubkeyOfChat: string) => { + setCurrentChatPubkey(pubkeyOfChat as string); + // mark all messages in chat as read + + markAllMessagesAsReadInChatRoom(pubkeyOfChat as string); + }; + + const goBackFromChatRoom = () => { + // used when in chatroom on smaller devices + setCurrentChatPubkey(""); + }; + + const handleSendMessage = async (message: string) => { + setIsSendingDMLoading(true); + try { + let encryptedMessageEvent = await constructEncryptedMessageEvent( + userPubkey, + message, + currentChatPubkey, + passphrase, + ); + await sendEncryptedMessage(encryptedMessageEvent, passphrase); + // update chats locally to reflect new message + setChatsMap((prevChatMap) => { + let updatedChat = prevChatMap.get(currentChatPubkey) as ChatObject; + let unEncryptedMessageEvent: NostrMessageEvent = { + ...encryptedMessageEvent, + content: message, + read: true, + id: "mock-id", + sig: "mock-sig", + }; + let updatedDecryptedChat = updatedChat.decryptedChat + ? [...updatedChat.decryptedChat, unEncryptedMessageEvent] + : []; + let newChatMap = new Map(prevChatMap); + newChatMap.set(currentChatPubkey, { + decryptedChat: updatedDecryptedChat, + unreadCount: 0, + }); + return newChatMap; + }); + if ( + chatsMap.get(currentChatPubkey) != undefined && + chatsMap.get(currentChatPubkey)?.decryptedChat.length === 0 + ) { + // only logs if this is the first msg, aka an iniquiry + axios({ + method: "POST", + url: "/api/metrics/post-inquiry", + headers: { + "Content-Type": "application/json", + }, + data: { + customer_id: userPubkey, + merchant_id: currentChatPubkey, + // listing_id: "TODO" + // relays: relays, + }, + }); + } + setIsSendingDMLoading(false); + } catch (e) { + console.log("handleSendMessage errored", e); + alert("Error sending message."); + setIsSendingDMLoading(false); + } + router.replace(`/messages`); + }; + + return ( +
+
+ {chatsMap.size === 0 ? ( +
+ {isChatsLoading ? ( +
+ +
+ ) : ( +

+ {isClient && userPubkey ? ( + <> + No messages . . . yet! +

+

+ Just logged in? +

+ Try reloading the page! + + ) : ( + <>You must be signed in to see your chats! + )} +

+ )} +
+ ) : ( +
+
+ {sortedChatsByLastMessage.map( + ([pubkeyOfChat, chatObject]: [string, ChatObject]) => { + const hasCashuA = chatObject.decryptedChat.some((message) => + message.content.includes("cashuA"), + ); + return ( + hasCashuA && ( + + ) + ); + }, + )} +
+ +
+ )} +
+ +
+ ); +}; + +export default Payments; diff --git a/components/product-form.tsx b/components/product-form.tsx index 5600e510..4026ab69 100644 --- a/components/product-form.tsx +++ b/components/product-form.tsx @@ -407,8 +407,8 @@ export default function NewForm({ rules={{ required: "A description is required.", maxLength: { - value: 300, - message: "This input exceed maxLength of 300.", + value: 500, + message: "This input exceed maxLength of 500.", }, }} render={({ diff --git a/components/utility-components/claim-button.tsx b/components/utility-components/claim-button.tsx new file mode 100644 index 00000000..b811525e --- /dev/null +++ b/components/utility-components/claim-button.tsx @@ -0,0 +1,474 @@ +import { useState, useEffect, useContext, useMemo } from "react"; +import { + Modal, + ModalContent, + ModalBody, + ModalHeader, + Button, + Spinner, +} from "@nextui-org/react"; +import { + ArrowDownTrayIcon, + BoltIcon, + CheckCircleIcon, + XCircleIcon, +} from "@heroicons/react/24/outline"; +import { useTheme } from "next-themes"; +import { ProfileMapContext } from "../../utils/context/context"; +import { getLocalStorageData } from "../utility/nostr-helper-functions"; +import { SHOPSTRBUTTONCLASSNAMES } from "../utility/STATIC-VARIABLES"; +import { LightningAddress } from "@getalby/lightning-tools"; +import { CashuMint, CashuWallet, Proof } from "@cashu/cashu-ts"; +import RedemptionModal from "./redemption-modal"; +import { formatWithCommas } from "./display-monetary-info"; + +function decodeBase64ToJson(base64: string): any { + // Step 1: Decode the base64 string to a regular string + const decodedString = atob(base64); + // Step 2: Parse the decoded string as JSON + try { + const json = JSON.parse(decodedString); + return json; + } catch (error) { + console.error("Error parsing JSON from base64", error); + throw new Error("Invalid JSON format in base64 string."); + } +} + +export default function ClaimButton({ token }: { token: string }) { + const [lnurl, setLnurl] = useState(""); + const profileContext = useContext(ProfileMapContext); + const { userNPub, userPubkey } = getLocalStorageData(); + + const [openClaimTypeModal, setOpenClaimTypeModal] = useState(false); + const [openRedemptionModal, setOpenRedemptionModal] = useState(false); + const [isPaid, setIsPaid] = useState(false); + const [isRedeemed, setIsRedeemed] = useState(false); + const [isRedeeming, setIsRedeeming] = useState(false); + const [wallet, setWallet] = useState(); + const [proofs, setProofs] = useState([]); + const [tokenMint, setTokenMint] = useState(""); + const [tokenAmount, setTokenAmount] = useState(0); + const [formattedTokenAmount, setFormattedTokenAmount] = useState(""); + const [claimChangeAmount, setClaimChangeAmount] = useState(0); + const [claimChangeProofs, setClaimChangeProofs] = useState([]); + + const [isInvalidSuccess, setIsInvalidSuccess] = useState(false); + const [isReceived, setIsReceived] = useState(false); + const [isSpent, setIsSpent] = useState(false); + const [isInvalidToken, setIsInvalidToken] = useState(false); + const [isDuplicateToken, setIsDuplicateToken] = useState(false); + + const { mints, tokens, history } = getLocalStorageData(); + + const [name, setName] = useState(""); + + const { theme, setTheme } = useTheme(); + + useEffect(() => { + const decodedToken = decodeBase64ToJson(token); + const mint = decodedToken.token[0].mint; + setTokenMint(mint); + const proofs = decodedToken.token[0].proofs; + setProofs(proofs); + const newWallet = new CashuWallet(new CashuMint(mint)); + setWallet(newWallet); + const totalAmount = + Array.isArray(proofs) && proofs.length > 0 + ? proofs.reduce((acc, current: Proof) => acc + current.amount, 0) + : 0; + + setTokenAmount(totalAmount); + setFormattedTokenAmount(formatWithCommas(totalAmount, "sats")); + }, [token]); + + useEffect(() => { + setIsRedeemed(false); + const checkProofsSpent = async () => { + try { + if (proofs.length > 0) { + const spentProofs = await wallet?.checkProofsSpent(proofs); + if (spentProofs && spentProofs.length > 0) setIsRedeemed(true); + } + } catch (error) { + console.error(error); + } + }; + checkProofsSpent(); + }, [proofs, wallet]); + + useEffect(() => { + const sellerProfileMap = profileContext.profileData; + const sellerProfile = sellerProfileMap.has(userPubkey) + ? sellerProfileMap.get(userPubkey) + : undefined; + setLnurl( + sellerProfile && + sellerProfile.content.lud16 && + tokenMint !== + "https://legend.lnbits.com/cashu/api/v1/AptDNABNBXv8gpuywhx6NV" + ? sellerProfile.content.lud16 + : "invalid", + ); + setName( + sellerProfile && sellerProfile.content.name + ? sellerProfile.content.name + : userNPub, + ); + }, [profileContext, tokenMint]); + + const handleClaimType = (type: string) => { + if (type === "receive") { + receive(false); + } else if (type === "redeem") { + if (lnurl === "invalid") { + receive(true); + } else { + redeem(); + } + } + }; + + const receive = async (isInvalid: boolean) => { + setOpenClaimTypeModal(false); + setIsDuplicateToken(false); + setIsInvalidSuccess(false); + setIsReceived(false); + setIsSpent(false); + setIsInvalidToken(false); + setIsRedeeming(true); + try { + const wallet = new CashuWallet(new CashuMint(tokenMint)); + const spentProofs = await wallet?.checkProofsSpent(proofs); + if (spentProofs.length === 0) { + const uniqueProofs = proofs.filter( + (proof: Proof) => !tokens.some((token: Proof) => token.C === proof.C), + ); + if (JSON.stringify(uniqueProofs) != JSON.stringify(proofs)) { + setIsDuplicateToken(true); + setIsRedeeming(false); + return; + } + const tokenArray = [...tokens, ...uniqueProofs]; + localStorage.setItem("tokens", JSON.stringify(tokenArray)); + if (!mints.includes(tokenMint)) { + const updatedMints = [...mints, tokenMint]; + localStorage.setItem("mints", JSON.stringify(updatedMints)); + } + if (isInvalid) { + setIsInvalidSuccess(true); + } else { + setIsReceived(true); + } + setIsRedeeming(false); + localStorage.setItem( + "history", + JSON.stringify([ + { + type: 1, + amount: tokenAmount, + date: Math.floor(Date.now() / 1000), + }, + ...history, + ]), + ); + } else { + setIsSpent(true); + setIsRedeeming(false); + } + } catch (error) { + console.log(error); + setIsInvalidToken(true); + setIsRedeeming(false); + } + }; + + const redeem = async () => { + setOpenClaimTypeModal(false); + setOpenRedemptionModal(false); + setIsRedeeming(true); + const newAmount = Math.floor(tokenAmount * 0.98 - 2); + const ln = new LightningAddress(lnurl); + try { + await ln.fetch(); + const invoice = await ln.requestInvoice({ satoshi: newAmount }); + const invoicePaymentRequest = invoice.paymentRequest; + const response = await wallet?.payLnInvoice( + invoicePaymentRequest, + proofs, + ); + const changeProofs = response?.change; + const changeAmount = + Array.isArray(changeProofs) && changeProofs.length > 0 + ? changeProofs.reduce( + (acc, current: Proof) => acc + current.amount, + 0, + ) + : 0; + if (changeAmount >= 1 && changeProofs) { + setClaimChangeAmount(changeAmount); + setClaimChangeProofs(changeProofs); + } + setIsPaid(true); + setOpenRedemptionModal(true); + setIsRedeeming(false); + } catch (error) { + console.log(error); + setIsPaid(false); + setOpenRedemptionModal(true); + setIsRedeeming(false); + } + }; + + const buttonClassName = useMemo(() => { + const disabledStyle = + "min-w-fit from-gray-300 to-gray-400 cursor-not-allowed"; + const enabledStyle = SHOPSTRBUTTONCLASSNAMES; + const className = isRedeemed ? disabledStyle : enabledStyle; + return className; + }, [isRedeemed]); + + return ( +
+ + setOpenClaimTypeModal(false)} + // className="bg-light-fg dark:bg-dark-fg text-black dark:text-white" + classNames={{ + body: "py-6 ", + backdrop: "bg-[#292f46]/50 backdrop-opacity-60", + header: "border-b-[1px] border-[#292f46]", + footer: "border-t-[1px] border-[#292f46]", + closeButton: "hover:bg-black/5 active:bg-white/10", + }} + isDismissable={true} + scrollBehavior={"normal"} + placement={"center"} + size="2xl" + > + + +
+ Would you like to claim the token directly to your Shopstr wallet, + or to your Lightning address? +
+
+ + +
+
+
+
+ {isInvalidSuccess ? ( + <> + setIsInvalidSuccess(false)} + // className="bg-light-fg dark:bg-dark-fg text-black dark:text-white" + classNames={{ + body: "py-6 ", + backdrop: "bg-[#292f46]/50 backdrop-opacity-60", + header: "border-b-[1px] border-[#292f46]", + footer: "border-t-[1px] border-[#292f46]", + closeButton: "hover:bg-black/5 active:bg-white/10", + }} + isDismissable={true} + scrollBehavior={"normal"} + placement={"center"} + size="2xl" + > + + + +
No valid Lightning address found!
+
+ +
+ Check your Shopstr wallet for your sats. +
+
+
+
+ + ) : null} + {isReceived ? ( + <> + setIsReceived(false)} + // className="bg-light-fg dark:bg-dark-fg text-black dark:text-white" + classNames={{ + body: "py-6 ", + backdrop: "bg-[#292f46]/50 backdrop-opacity-60", + header: "border-b-[1px] border-[#292f46]", + footer: "border-t-[1px] border-[#292f46]", + closeButton: "hover:bg-black/5 active:bg-white/10", + }} + isDismissable={true} + scrollBehavior={"normal"} + placement={"center"} + size="2xl" + > + + + +
Token successfully claimed!
+
+ +
+ Check your Shopstr wallet for your sats. +
+
+
+
+ + ) : null} + {isDuplicateToken ? ( + <> + setIsDuplicateToken(false)} + // className="bg-light-fg dark:bg-dark-fg text-black dark:text-white" + classNames={{ + body: "py-6 ", + backdrop: "bg-[#292f46]/50 backdrop-opacity-60", + header: "border-b-[1px] border-[#292f46]", + footer: "border-t-[1px] border-[#292f46]", + closeButton: "hover:bg-black/5 active:bg-white/10", + }} + isDismissable={true} + scrollBehavior={"normal"} + placement={"center"} + size="2xl" + > + + + +
Duplicate token!
+
+ +
+ The token you are trying to claim is already in your Shopstr + wallet. +
+
+
+
+ + ) : null} + {isInvalidToken ? ( + <> + setIsInvalidToken(false)} + // className="bg-light-fg dark:bg-dark-fg text-black dark:text-white" + classNames={{ + body: "py-6 ", + backdrop: "bg-[#292f46]/50 backdrop-opacity-60", + header: "border-b-[1px] border-[#292f46]", + footer: "border-t-[1px] border-[#292f46]", + closeButton: "hover:bg-black/5 active:bg-white/10", + }} + isDismissable={true} + scrollBehavior={"normal"} + placement={"center"} + size="2xl" + > + + + +
Invalid token!
+
+ +
+ The token you are trying to claim is not a valid Cashu string. +
+
+
+
+ + ) : null} + {isSpent ? ( + <> + setIsSpent(false)} + // className="bg-light-fg dark:bg-dark-fg text-black dark:text-white" + classNames={{ + body: "py-6 ", + backdrop: "bg-[#292f46]/50 backdrop-opacity-60", + header: "border-b-[1px] border-[#292f46]", + footer: "border-t-[1px] border-[#292f46]", + closeButton: "hover:bg-black/5 active:bg-white/10", + }} + isDismissable={true} + scrollBehavior={"normal"} + placement={"center"} + size="2xl" + > + + + +
Spent token!
+
+ +
+ The token you are trying to claim has already been redeemed. +
+
+
+
+ + ) : null} + +
+ ); +} diff --git a/components/utility-components/redeem-button.tsx b/components/utility-components/redeem-button.tsx deleted file mode 100644 index e0a452fe..00000000 --- a/components/utility-components/redeem-button.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { useState, useEffect, useContext, useMemo } from "react"; -import Link from "next/link"; -import { Button, Spinner, Tooltip } from "@nextui-org/react"; -import { useTheme } from "next-themes"; -import { ProfileMapContext } from "../../utils/context/context"; -import { getLocalStorageData } from "../utility/nostr-helper-functions"; -import { SHOPSTRBUTTONCLASSNAMES } from "../utility/STATIC-VARIABLES"; -import { LightningAddress } from "@getalby/lightning-tools"; -import { CashuMint, CashuWallet, Proof } from "@cashu/cashu-ts"; -import RedemptionModal from "./redemption-modal"; -import { formatWithCommas } from "./display-monetary-info"; - -function decodeBase64ToJson(base64: string): any { - // Step 1: Decode the base64 string to a regular string - const decodedString = atob(base64); - // Step 2: Parse the decoded string as JSON - try { - const json = JSON.parse(decodedString); - return json; - } catch (error) { - console.error("Error parsing JSON from base64", error); - throw new Error("Invalid JSON format in base64 string."); - } -} - -export default function RedeemButton({ token }: { token: string }) { - const [lnurl, setLnurl] = useState(""); - const profileContext = useContext(ProfileMapContext); - const { userNPub, userPubkey, relays } = getLocalStorageData(); - - const [openRedemptionModal, setOpenRedemptionModal] = useState(false); - const [isPaid, setIsPaid] = useState(false); - const [isCashu, setIsCashu] = useState(false); - const [isSpent, setIsSpent] = useState(false); - const [isRedeeming, setIsRedeeming] = useState(false); - const [wallet, setWallet] = useState(); - const [proofs, setProofs] = useState([]); - const [tokenMint, setTokenMint] = useState(""); - const [tokenAmount, setTokenAmount] = useState(0); - const [formattedTokenAmount, setFormattedTokenAmount] = useState(""); - const [redemptionChangeAmount, setRedemptionChangeAmount] = useState(0); - const [redemptionChangeProofs, setRedemptionChangeProofs] = useState( - [], - ); - - const [name, setName] = useState(""); - - const { theme, setTheme } = useTheme(); - - useEffect(() => { - const decodedToken = decodeBase64ToJson(token); - const mint = decodedToken.token[0].mint; - setTokenMint(mint); - const proofs = decodedToken.token[0].proofs; - setProofs(proofs); - const newWallet = new CashuWallet(new CashuMint(mint)); - setWallet(newWallet); - const totalAmount = - Array.isArray(proofs) && proofs.length > 0 - ? proofs.reduce((acc, current: Proof) => acc + current.amount, 0) - : 0; - - setTokenAmount(totalAmount); - setFormattedTokenAmount(formatWithCommas(totalAmount, "sats")); - }, [token]); - - useEffect(() => { - setIsSpent(false); - const checkProofsSpent = async () => { - if (proofs.length > 0) { - const spentProofs = await wallet?.checkProofsSpent(proofs); - if (spentProofs && spentProofs.length > 0) setIsSpent(true); - } - }; - checkProofsSpent(); - }, [proofs, wallet]); - - useEffect(() => { - const sellerProfileMap = profileContext.profileData; - const sellerProfile = sellerProfileMap.has(userPubkey) - ? sellerProfileMap.get(userPubkey) - : undefined; - setLnurl( - sellerProfile && - sellerProfile.content.lud16 && - tokenMint !== - "https://legend.lnbits.com/cashu/api/v1/AptDNABNBXv8gpuywhx6NV" - ? sellerProfile.content.lud16 - : userNPub + "@npub.cash", - ); - setName( - sellerProfile && sellerProfile.content.name - ? sellerProfile.content.name - : userNPub, - ); - }, [profileContext, tokenMint]); - - const redeem = async () => { - setOpenRedemptionModal(false); - setIsRedeeming(true); - const newAmount = Math.floor(tokenAmount * 0.98 - 2); - const ln = new LightningAddress(lnurl); - if (lnurl.includes("@npub.cash")) { - setIsCashu(true); - } - try { - await ln.fetch(); - const invoice = await ln.requestInvoice({ satoshi: newAmount }); - const invoicePaymentRequest = invoice.paymentRequest; - const response = await wallet?.payLnInvoice( - invoicePaymentRequest, - proofs, - ); - const changeProofs = response?.change; - const changeAmount = - Array.isArray(changeProofs) && changeProofs.length > 0 - ? changeProofs.reduce( - (acc, current: Proof) => acc + current.amount, - 0, - ) - : 0; - if (changeAmount >= 1 && changeProofs) { - setRedemptionChangeAmount(changeAmount); - setRedemptionChangeProofs(changeProofs); - } - setIsPaid(true); - setOpenRedemptionModal(true); - setIsRedeeming(false); - } catch (error) { - console.log(error); - setIsPaid(false); - setIsCashu(false); - setOpenRedemptionModal(true); - setIsRedeeming(false); - } - }; - - const buttonClassName = useMemo(() => { - const disabledStyle = - "min-w-fit from-gray-300 to-gray-400 cursor-not-allowed"; - const enabledStyle = SHOPSTRBUTTONCLASSNAMES; - const className = isSpent ? disabledStyle : enabledStyle; - return className; - }, [isSpent]); - - return ( -
- -
- You can either redeem your tokens here, or by pasting the token - string (cashuA...) and mint URL (found in settings) into a Cashu - wallet of your choice (like{" "} - - - Nutstash - - - ). -
-
- } - > - - - - - ); -} diff --git a/components/utility-components/redemption-modal.tsx b/components/utility-components/redemption-modal.tsx index e06e49d1..31cdb89e 100644 --- a/components/utility-components/redemption-modal.tsx +++ b/components/utility-components/redemption-modal.tsx @@ -1,8 +1,12 @@ import React, { useEffect, useState } from "react"; -import Link from "next/link"; import axios from "axios"; -import { Modal, ModalContent, ModalBody, Button } from "@nextui-org/react"; -import { useRouter } from "next/router"; +import { + Modal, + ModalContent, + ModalBody, + ModalHeader, + Button, +} from "@nextui-org/react"; import { CheckCircleIcon, ArrowUpTrayIcon, @@ -18,7 +22,6 @@ import { formatWithCommas } from "./display-monetary-info"; export default function RedemptionModal({ opened, isPaid, - isCashu, changeAmount, changeProofs, lnurl, @@ -26,22 +29,19 @@ export default function RedemptionModal({ }: { opened: boolean; isPaid: boolean; - isCashu: boolean; changeAmount: number; changeProofs: any[]; lnurl: string; changeMint: string; }) { const [showModal, setShowModal] = useState(false); - const { userNPub, userPubkey, mints, relays } = getLocalStorageData(); + const { userPubkey, relays } = getLocalStorageData(); const [formattedChangeAmount, setFormattedChangeAmount] = useState(""); const [randomNpub, setRandomNpub] = useState(""); const [randomNsec, setRandomNsec] = useState(""); - const router = useRouter(); - useEffect(() => { axios({ method: "GET", @@ -117,34 +117,16 @@ export default function RedemptionModal({ size="2xl" > + + +
Token successfully redeemed!
+
- -
Redeemed!
+ Check your Lightning address ({lnurl}) for your sats! Would you + like to donate your overpaid Lightning fees ( + {formattedChangeAmount}) to support the development of Shopstr?
- {isCashu ? ( -
- Sign in to{" "} - - - npub.cash - - {" "} - with your Nostr keys to claim your sats! Would you like to - donate your overpaid Lightning fees ({formattedChangeAmount}) to - support the development of Shopstr? -
- ) : ( -
- Check your Lightning address ({lnurl}) for your sats! Would you - like to donate your overpaid Lightning fees ( - {formattedChangeAmount}) to support the development of Shopstr? -
- )}