From a8a2d3dbf3090e3b37a39b8b074d79e97b6440e4 Mon Sep 17 00:00:00 2001 From: daniyal Date: Tue, 13 Aug 2024 15:51:05 +0500 Subject: [PATCH] feat: implemented the logic for zapping mod --- src/controllers/metadata.ts | 13 +- src/controllers/relay.ts | 107 +++++++- src/controllers/zap.ts | 13 +- src/layout/header.tsx | 6 +- src/pages/innerMod.tsx | 490 ++++++++++++++++++++++++++++++++++-- src/utils/nostr.ts | 37 +++ src/utils/utils.ts | 26 ++ 7 files changed, 651 insertions(+), 41 deletions(-) diff --git a/src/controllers/metadata.ts b/src/controllers/metadata.ts index c8339cc..b6b2e7c 100644 --- a/src/controllers/metadata.ts +++ b/src/controllers/metadata.ts @@ -4,6 +4,12 @@ import { MuteLists } from '../types' import { UserProfile } from '../types/user' import { hexToNpub, log, LogType, npubToHex } from '../utils' +export enum UserRelaysType { + Read = 'readRelayUrls', + Write = 'writeRelayUrls', + Both = 'bothRelayUrls' +} + /** * Singleton class to manage metadata operations using NDK. */ @@ -110,14 +116,17 @@ export class MetadataController { return this.findMetadata(this.adminNpubs[0]) } - public findWriteRelays = async (hexKey: string) => { + public findUserRelays = async ( + hexKey: string, + userRelaysType: UserRelaysType = UserRelaysType.Both + ) => { const ndkRelayList = await getRelayListForUser(hexKey, this.ndk) if (!ndkRelayList) { throw new Error(`Couldn't found user's relay list`) } - return ndkRelayList.writeRelayUrls + return ndkRelayList[userRelaysType] } public getAdminsMuteLists = async (): Promise => { diff --git a/src/controllers/relay.ts b/src/controllers/relay.ts index fed0cbf..98ab1f1 100644 --- a/src/controllers/relay.ts +++ b/src/controllers/relay.ts @@ -1,6 +1,13 @@ -import { Event, Filter, Relay } from 'nostr-tools' -import { log, LogType, normalizeWebSocketURL, timeout } from '../utils' -import { MetadataController } from './metadata' +import { Event, Filter, kinds, Relay } from 'nostr-tools' +import { + extractZapAmount, + log, + LogType, + normalizeWebSocketURL, + timeout +} from '../utils' +import { MetadataController, UserRelaysType } from './metadata' +import { ModDetails } from '../types' /** * Singleton class to manage relay operations. @@ -72,7 +79,10 @@ export class RelayController { // Retrieve the list of write relays for the event's public key // Use a timeout to handle cases where retrieving write relays takes too long - const writeRelaysPromise = metadataController.findWriteRelays(event.pubkey) + const writeRelaysPromise = metadataController.findUserRelays( + event.pubkey, + UserRelaysType.Write + ) log(this.debug, LogType.Info, `ℹ Finding user's write relays`) @@ -236,4 +246,93 @@ export class RelayController { // Return the most recent event, or null if no events were received return events[0] || null } + + getTotalZapAmount = async ( + modDetails: ModDetails, + currentLoggedInUser?: string + ) => { + const metadataController = await MetadataController.getInstance() + + const authorReadRelaysPromise = metadataController.findUserRelays( + modDetails.author, + UserRelaysType.Read + ) + + log(this.debug, LogType.Info, `ℹ Finding user's read relays`) + + // Use Promise.race to either get the write relay URLs or timeout + const relayUrls = await Promise.race([ + authorReadRelaysPromise, + timeout() // This is a custom timeout function that rejects the promise after a specified time + ]).catch((err) => { + log(this.debug, LogType.Error, err) + return [] as string[] // Return an empty array if an error occurs + }) + + // add app relay to relays array + relayUrls.push(import.meta.env.VITE_APP_RELAY) + + // add admin relays to relays array + metadataController.adminRelays.forEach((url) => { + relayUrls.push(url) + }) + + // Connect to all specified relays + const relayPromises = relayUrls.map((relayUrl) => + this.connectRelay(relayUrl) + ) + await Promise.allSettled(relayPromises) + + let accumulatedZapAmount = 0 + let hasZapped = false + + const eventIds = new Set() // To keep track of event IDs and avoid duplicates + + // Create a promise for each relay subscription + const subPromises = this.connectedRelays.map((relay) => { + return new Promise((resolve) => { + // Subscribe to the relay with the specified filter + const sub = relay.subscribe( + [ + { + kinds: [kinds.Zap], + '#a': [modDetails.aTag] + } + ], + { + // Handle incoming events + onevent: (e) => { + // Add the event to the array if it's not a duplicate + if (!eventIds.has(e.id)) { + console.log('e :>> ', e) + eventIds.add(e.id) // Record the event ID + const amount = extractZapAmount(e) + accumulatedZapAmount += amount + + if (!hasZapped) { + hasZapped = + e.tags.findIndex( + (tag) => tag[0] === 'P' && tag[1] === currentLoggedInUser + ) > -1 + } + } + }, + // Handle the End-Of-Stream (EOSE) message + oneose: () => { + sub.close() // Close the subscription + resolve() // Resolve the promise when EOSE is received + } + } + ) + }) + }) + + // Wait for all subscriptions to complete + await Promise.allSettled(subPromises) + + return { + accumulatedZapAmount, + hasZapped + } + } } diff --git a/src/controllers/zap.ts b/src/controllers/zap.ts index 3efc698..a82bb38 100644 --- a/src/controllers/zap.ts +++ b/src/controllers/zap.ts @@ -47,6 +47,7 @@ export class ZapController { * @param senderPubkey - pubKey of of the sender. * @param content - optional content (comment). * @param eventId - event id, if zapping an event. + * @param aTag - value of `a` tag. * @returns - promise that resolves into object containing zap request and payment * request string */ @@ -56,7 +57,8 @@ export class ZapController { recipientPubKey: string, senderPubkey: string, content?: string, - eventId?: string + eventId?: string, + aTag?: string ) { // Check if amount is greater than 0 if (amount <= 0) throw 'Amount should be > 0.' @@ -90,7 +92,8 @@ export class ZapController { lnurlBech32, recipientPubKey, senderPubkey, - eventId + eventId, + aTag ) if (!window.nostr?.signEvent) { @@ -272,6 +275,7 @@ export class ZapController { * @param recipientPubKey - pubKey of the recipient. * @param senderPubkey - pubKey of of the sender. * @param eventId - event id, if zapping an event. + * @param aTag - value of `a` tag. * @returns zap request */ private async createZapRequest( @@ -280,7 +284,8 @@ export class ZapController { lnurl: string, recipientPubKey: string, senderPubkey: string, - eventId?: string + eventId?: string, + aTag?: string ): Promise { const recipientHexKey = npubToHex(recipientPubKey) @@ -302,6 +307,8 @@ export class ZapController { // add event id to the tags, if zapping an event. if (eventId) zapRequest.tags.push(['e', eventId]) + if (aTag) zapRequest.tags.push(['a', aTag]) + return zapRequest } diff --git a/src/layout/header.tsx b/src/layout/header.tsx index 7febe43..77f8a40 100644 --- a/src/layout/header.tsx +++ b/src/layout/header.tsx @@ -334,11 +334,7 @@ const TipButtonWithDialog = React.memo(() => { Tip {isOpen && ( -
+
diff --git a/src/pages/innerMod.tsx b/src/pages/innerMod.tsx index 27f4a26..2fb4206 100644 --- a/src/pages/innerMod.tsx +++ b/src/pages/innerMod.tsx @@ -1,13 +1,17 @@ import { formatDate } from 'date-fns' import DOMPurify from 'dompurify' import { Filter, nip19 } from 'nostr-tools' -import { useRef, useState } from 'react' +import { Dispatch, SetStateAction, useCallback, useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { toast } from 'react-toastify' import { BlogCard } from '../components/BlogCard' import { LoadingSpinner } from '../components/LoadingSpinner' import { ProfileSection } from '../components/ProfileSection' -import { RelayController } from '../controllers' +import { + MetadataController, + RelayController, + ZapController +} from '../controllers' import { useAppSelector, useDidMount } from '../hooks' import '../styles/comments.css' import '../styles/downloads.css' @@ -18,16 +22,21 @@ import '../styles/styles.css' import '../styles/tabs.css' import '../styles/tags.css' import '../styles/write.css' -import { ModDetails } from '../types' +import '../styles/popup.css' +import { ModDetails, PaymentRequest } from '../types' import { + abbreviateNumber, copyTextToClipboard, extractModData, + formatNumber, getFilenameFromUrl, log, - LogType + LogType, + unformatNumber } from '../utils' import saveAs from 'file-saver' +import { ZapButtons, ZapPresets, ZapQR } from '../components/Zap' export const InnerModPage = () => { const { nevent } = useParams() @@ -113,7 +122,7 @@ export const InnerModPage = () => { tags={modData.tags} nsfw={modData.nsfw} /> - + { +type InteractionsProps = { + modDetails: ModDetails +} + +const Interactions = ({ modDetails }: InteractionsProps) => { return (
@@ -420,27 +433,7 @@ const Interactions = () => {

420

-
-
- - - -
-

69k

-
-
-
-
+
{
) } + +type ZapProps = { + modDetails: ModDetails +} + +const Zap = ({ modDetails }: ZapProps) => { + const [isOpen, setIsOpen] = useState(false) + const [hasZapped, setHasZapped] = useState(false) + + const userState = useAppSelector((state) => state.user) + + const [totalZappedAmount, setTotalZappedAmount] = useState('0') + + useDidMount(() => { + RelayController.getInstance() + .getTotalZapAmount(modDetails, userState.user?.pubkey as string) + .then((res) => { + setTotalZappedAmount(abbreviateNumber(res.accumulatedZapAmount)) + setHasZapped(res.hasZapped) + }) + .catch((err) => { + toast.error(err.message || err) + }) + }) + + return ( + <> +
setIsOpen(true)} + > +
+ + + +
+

{totalZappedAmount}

+
+
+
+
+ {isOpen && } + + ) +} + +type ZapModalProps = { + modDetails: ModDetails + handleClose: Dispatch> +} + +const ZapModal = ({ modDetails, handleClose }: ZapModalProps) => { + return ( +
+
+
+
+
+
+

Tip/Zap

+
+
handleClose(false)} + > + + + +
+
+
+
+ + +
+
+
+
+
+
+ ) +} + +type ZapModProps = { + modDetails: ModDetails +} + +const ZapMod = ({ modDetails }: ZapModProps) => { + const [isLoading, setIsLoading] = useState(false) + const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') + + const [amount, setAmount] = useState(0) + const [message, setMessage] = useState('') + + const [paymentRequest, setPaymentRequest] = useState() + + const userState = useAppSelector((state) => state.user) + + const handleAmountChange = (event: React.ChangeEvent) => { + const unformattedValue = unformatNumber(event.target.value) + setAmount(unformattedValue) + } + + const handleClose = useCallback(() => { + setPaymentRequest(undefined) + setIsLoading(false) + }, []) + + const handleQRExpiry = useCallback(() => { + setPaymentRequest(undefined) + }, []) + + const generatePaymentRequest = + useCallback(async (): Promise => { + let userHexKey: string + + setIsLoading(true) + setLoadingSpinnerDesc('Getting user pubkey') + + if (userState.isAuth && userState.user?.pubkey) { + userHexKey = userState.user.pubkey as string + } else { + userHexKey = (await window.nostr?.getPublicKey()) as string + } + + if (!userHexKey) { + setIsLoading(false) + toast.error('Could not get pubkey') + return null + } + + setLoadingSpinnerDesc('Getting admin metadata') + const metadataController = await MetadataController.getInstance() + + const authorMetadata = await metadataController.findMetadata( + modDetails.author + ) + + if (!authorMetadata?.lud16) { + setIsLoading(false) + toast.error('Lighting address (lud16) is missing in author metadata!') + return null + } + + if (!authorMetadata?.pubkey) { + setIsLoading(false) + toast.error('pubkey is missing in author metadata!') + return null + } + + const zapController = ZapController.getInstance() + + setLoadingSpinnerDesc('Creating zap request') + return await zapController + .getLightningPaymentRequest( + authorMetadata.lud16, + amount, + authorMetadata.pubkey as string, + userHexKey, + message, + modDetails.id, + modDetails.aTag + ) + .catch((err) => { + toast.error(err.message || err) + return null + }) + .finally(() => { + setIsLoading(false) + }) + }, [amount, message, userState, modDetails]) + + const handleSend = useCallback(async () => { + const pr = await generatePaymentRequest() + + if (!pr) return + + setIsLoading(true) + setLoadingSpinnerDesc('Sending payment!') + + const zapController = ZapController.getInstance() + + if (await zapController.isWeblnProviderExists()) { + await zapController + .sendPayment(pr.pr) + .then(() => { + toast.success(`Successfully sent ${amount} sats!`) + handleClose() + }) + .catch((err) => { + toast.error(err.message || err) + }) + } else { + toast.warn('Webln is not present. Use QR code to send zap.') + setPaymentRequest(pr) + } + + setIsLoading(false) + }, [amount, handleClose, generatePaymentRequest]) + + const handleGenerateQRCode = async () => { + const pr = await generatePaymentRequest() + + if (!pr) return + + setPaymentRequest(pr) + } + + return ( + <> +
+
+ + +
+
+ +
+
+
+ + setMessage(e.target.value)} + /> +
+ + {paymentRequest && ( + + )} + {isLoading && } + + ) +} + +const ZapSite = () => { + const [isLoading, setIsLoading] = useState(false) + const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') + + const [amount, setAmount] = useState(0) + + const [paymentRequest, setPaymentRequest] = useState() + + const userState = useAppSelector((state) => state.user) + + const handleAmountChange = (event: React.ChangeEvent) => { + const unformattedValue = unformatNumber(event.target.value) + setAmount(unformattedValue) + } + + const handleClose = useCallback(() => { + setPaymentRequest(undefined) + setIsLoading(false) + }, []) + + const handleQRExpiry = useCallback(() => { + setPaymentRequest(undefined) + }, []) + + const generatePaymentRequest = + useCallback(async (): Promise => { + let userHexKey: string + + setIsLoading(true) + setLoadingSpinnerDesc('Getting user pubkey') + + if (userState.isAuth && userState.user?.pubkey) { + userHexKey = userState.user.pubkey as string + } else { + userHexKey = (await window.nostr?.getPublicKey()) as string + } + + if (!userHexKey) { + setIsLoading(false) + toast.error('Could not get pubkey') + return null + } + + setLoadingSpinnerDesc('Getting admin metadata') + const metadataController = await MetadataController.getInstance() + + const adminMetadata = await metadataController.findAdminMetadata() + + if (!adminMetadata?.lud16) { + setIsLoading(false) + toast.error('Lighting address (lud16) is missing in admin metadata!') + return null + } + + if (!adminMetadata?.pubkey) { + setIsLoading(false) + toast.error('pubkey is missing in admin metadata!') + return null + } + + const zapController = ZapController.getInstance() + + setLoadingSpinnerDesc('Creating zap request') + return await zapController + .getLightningPaymentRequest( + adminMetadata.lud16, + amount, + adminMetadata.pubkey as string, + userHexKey + ) + .catch((err) => { + toast.error(err.message || err) + return null + }) + .finally(() => { + setIsLoading(false) + }) + }, [amount, userState]) + + const handleSend = useCallback(async () => { + const pr = await generatePaymentRequest() + + if (!pr) return + + setIsLoading(true) + setLoadingSpinnerDesc('Sending payment!') + + const zapController = ZapController.getInstance() + + if (await zapController.isWeblnProviderExists()) { + await zapController + .sendPayment(pr.pr) + .then(() => { + toast.success(`Successfully sent ${amount} sats!`) + handleClose() + }) + .catch((err) => { + toast.error(err.message || err) + }) + } else { + toast.warn('Webln is not present. Use QR code to send zap.') + setPaymentRequest(pr) + } + + setIsLoading(false) + }, [amount, handleClose, generatePaymentRequest]) + + const handleGenerateQRCode = async () => { + const pr = await generatePaymentRequest() + + if (!pr) return + + setPaymentRequest(pr) + } + + return ( + <> +
+ +
+
+
+
+

DEG Mods

+

+ degmods@degmods.com +

+
+
+

+ Help with the development, maintenance, management, and growth of + DEG Mods. +

+
+ + +
+
+ +
+ + {paymentRequest && ( + + )} +
+
+ {isLoading && } + + ) +} diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index 0aa99d1..9d1c7c8 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -84,3 +84,40 @@ export const npubToHex = (pubKey: string): string | null => { // Not a valid hex key return null } + +/** + * Extracts the zap amount from an event object. + * + * @param event - The event object from which the zap amount needs to be extracted. + * @returns The zap amount in the form of a number, converted from the extracted data, or 0 if the amount cannot be determined. + */ +export const extractZapAmount = (event: Event): number => { + // Find the 'description' tag within the event's tags + const description = event.tags.find( + (tag) => tag[0] === 'description' && typeof tag[1] === 'string' + ) + + // If the 'description' tag is found and it has a valid value + if (description && description[1]) { + try { + // Parse the description as JSON to get additional details + const parsedDescription: Event = JSON.parse(description[1]) + + // Find the 'amount' tag within the parsed description's tags + const amountTag = parsedDescription.tags.find( + (tag) => tag[0] === 'amount' && typeof tag[1] === 'string' + ) + + // If the 'amount' tag is found and it has a valid value, convert it to an integer and return + if (amountTag && amountTag[1]) return parseInt(amountTag[1]) / 1000 + } catch (error) { + // Log an error message if JSON parsing fails + console.log( + `An error occurred while parsing description of zap event: ${error}` + ) + } + } + + // Return 0 if the zap amount cannot be determined + return 0 +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts index d2ecbeb..85dabeb 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -97,3 +97,29 @@ export const unformatNumber = (value: string): number => { // If `parseFloat` fails to parse the string, `|| 0` ensures that the function returns 0. return parseFloat(value.replace(/,/g, '')) || 0 } + +/** + * Formats a number into a more readable string with suffixes. + * + * @param value - The number to be formatted. + * @returns A string representing the formatted number with suffixes. + * - "K" for thousands + * - "M" for millions + * - "B" for billions + * - The number as-is if it's less than a thousand + */ +export const abbreviateNumber = (value: number): string => { + if (value >= 1000000000) { + // Format as billions + return `${(value / 1000000000).toFixed(1)}B` + } else if (value >= 1000000) { + // Format as millions + return `${(value / 1000000).toFixed(1)}M` + } else if (value >= 1000) { + // Format as thousands + return `${(value / 1000).toFixed(1)}K` + } else { + // Format as regular number + return value.toString() + } +}