feat: implemented the logic for zapping mod
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s

This commit is contained in:
daniyal 2024-08-13 15:51:05 +05:00
parent 42c40c2d8e
commit a8a2d3dbf3
7 changed files with 651 additions and 41 deletions

View File

@ -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<MuteLists> => {

View File

@ -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<string>() // 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<void>((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
}
}
}

View File

@ -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<ZapRequest> {
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
}

View File

@ -334,11 +334,7 @@ const TipButtonWithDialog = React.memo(() => {
Tip
</a>
{isOpen && (
<div
id='PopUpMainZap'
className='popUpMain'
style={{ display: 'flex' }}
>
<div id='PopUpMainZap' className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard popUpMainCardQR'>

View File

@ -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}
/>
<Interactions />
<Interactions modDetails={modData} />
<PublishDetails
published_at={modData.published_at}
edited_at={modData.edited_at}
@ -396,7 +405,11 @@ const Body = ({
)
}
const Interactions = () => {
type InteractionsProps = {
modDetails: ModDetails
}
const Interactions = ({ modDetails }: InteractionsProps) => {
return (
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSS_Details'>
@ -420,27 +433,7 @@ const Interactions = () => {
<p className='IBMSMSMBSS_Details_CardText'>420</p>
</div>
</a>
<div
id='reactBolt'
className='IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CBolt'
>
<div className='IBMSMSMBSS_Details_CardVisual'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-64 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSS_Details_CardVisualIcon'
>
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
</svg>
</div>
<p className='IBMSMSMBSS_Details_CardText'>69k</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
<Zap modDetails={modDetails} />
<div
id='reactUp'
className='IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactUp IBMSMSMBSS_D_CRUActive'
@ -924,3 +917,446 @@ const Comments = () => {
</div>
)
}
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 (
<>
<div
id='reactBolt'
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CBolt ${
hasZapped ? 'IBMSMSMBSS_D_CBActive' : ''
}`}
onClick={() => setIsOpen(true)}
>
<div className='IBMSMSMBSS_Details_CardVisual'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-64 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSS_Details_CardVisualIcon'
>
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
</svg>
</div>
<p className='IBMSMSMBSS_Details_CardText'>{totalZappedAmount}</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
{isOpen && <ZapModal modDetails={modDetails} handleClose={setIsOpen} />}
</>
)
}
type ZapModalProps = {
modDetails: ModDetails
handleClose: Dispatch<SetStateAction<boolean>>
}
const ZapModal = ({ modDetails, handleClose }: ZapModalProps) => {
return (
<div id='PopUpMainZapSplitAlt' className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard popUpMainCardQR'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Tip/Zap</h3>
</div>
<div
className='popUpMainCardTopClose'
onClick={() => handleClose(false)}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
style={{ zIndex: 1 }}
>
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
</svg>
</div>
</div>
<div className='pUMCB_Zaps'>
<div className='pUMCB_ZapsInside'>
<ZapMod modDetails={modDetails} />
<ZapSite />
</div>
</div>
</div>
</div>
</div>
</div>
)
}
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<PaymentRequest>()
const userState = useAppSelector((state) => state.user)
const handleAmountChange = (event: React.ChangeEvent<HTMLInputElement>) => {
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<PaymentRequest | null> => {
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 (
<>
<div className='pUMCB_ZapsInsideAmount'>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>Amount (Satoshis)</label>
<input
type='text'
className='inputMain'
inputMode='numeric'
placeholder='69 or 420? or 69,420?'
value={amount ? formatNumber(amount) : ''}
onChange={handleAmountChange}
/>
</div>
<div className='pUMCB_ZapsInsideAmountOptions'>
<ZapPresets setAmount={setAmount} />
</div>
</div>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>Message (optional)</label>
<input
type='text'
className='inputMain'
placeholder='This is awesome!'
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
</div>
<ZapButtons
disabled={!amount}
handleGenerateQRCode={handleGenerateQRCode}
handleSend={handleSend}
/>
{paymentRequest && (
<ZapQR
paymentRequest={paymentRequest}
handleClose={handleClose}
handleQRExpiry={handleQRExpiry}
/>
)}
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
</>
)
}
const ZapSite = () => {
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [amount, setAmount] = useState(0)
const [paymentRequest, setPaymentRequest] = useState<PaymentRequest>()
const userState = useAppSelector((state) => state.user)
const handleAmountChange = (event: React.ChangeEvent<HTMLInputElement>) => {
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<PaymentRequest | null> => {
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 (
<>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>
Tip DEG Mods too (Optional)
</label>
<div className='ZapSplitUserBox'>
<div className='ZapSplitUserBoxUser'>
<div
className='ZapSplitUserBoxUserPic'
style={{
background: `url('/assets/img/Logo%20with%20circle.png')
center / cover no-repeat`
}}
></div>
<div className='ZapSplitUserBoxUserDetails'>
<p className='ZapSplitUserBoxUserDetailsName'>DEG Mods</p>
<p className='ZapSplitUserBoxUserDetailsHandle'>
degmods@degmods.com
</p>
</div>
</div>
<p className='ZapSplitUserBoxText'>
Help with the development, maintenance, management, and growth of
DEG Mods.
</p>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>Amount (Satoshis)</label>
<input
type='text'
className='inputMain'
inputMode='numeric'
placeholder='69 or 420? or 69,420?'
value={amount ? formatNumber(amount) : ''}
onChange={handleAmountChange}
/>
</div>
<div className='pUMCB_ZapsInsideAmountOptions'>
<ZapPresets setAmount={setAmount} />
</div>
<ZapButtons
disabled={!amount}
handleGenerateQRCode={handleGenerateQRCode}
handleSend={handleSend}
/>
{paymentRequest && (
<ZapQR
paymentRequest={paymentRequest}
handleClose={handleClose}
handleQRExpiry={handleQRExpiry}
/>
)}
</div>
</div>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
</>
)
}

View File

@ -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
}

View File

@ -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()
}
}