diff --git a/src/components/Zap.tsx b/src/components/Zap.tsx index 8ef64ad..4da9d26 100644 --- a/src/components/Zap.tsx +++ b/src/components/Zap.tsx @@ -13,7 +13,12 @@ import { MetadataController, ZapController } from '../controllers' import { useAppSelector, useDidMount } from '../hooks' import '../styles/popup.css' import { PaymentRequest } from '../types' -import { copyTextToClipboard, formatNumber, unformatNumber } from '../utils' +import { + copyTextToClipboard, + formatNumber, + getZapAmount, + unformatNumber +} from '../utils' import { LoadingSpinner } from './LoadingSpinner' type PresetAmountProps = { @@ -114,15 +119,25 @@ type ZapQRProps = { paymentRequest: PaymentRequest handleClose: () => void handleQRExpiry: () => void + setTotalZapAmount?: Dispatch> } export const ZapQR = React.memo( - ({ paymentRequest, handleClose, handleQRExpiry }: ZapQRProps) => { + ({ + paymentRequest, + handleClose, + handleQRExpiry, + setTotalZapAmount + }: ZapQRProps) => { useDidMount(() => { ZapController.getInstance() .pollZapReceipt(paymentRequest) - .then(() => { + .then((zapReceipt) => { toast.success(`Successfully sent sats!`) + if (setTotalZapAmount) { + const amount = getZapAmount(zapReceipt) + setTotalZapAmount((prev) => prev + amount) + } }) .catch((err) => { toast.error(err.message || err) @@ -211,6 +226,7 @@ type ZapPopUpProps = { aTag?: string notCloseAfterZap?: boolean lastNode?: ReactNode + setTotalZapAmount?: Dispatch> handleClose: () => void } @@ -222,6 +238,7 @@ export const ZapPopUp = ({ aTag, lastNode, notCloseAfterZap, + setTotalZapAmount, handleClose }: ZapPopUpProps) => { const [isLoading, setIsLoading] = useState(false) @@ -318,6 +335,10 @@ export const ZapPopUp = ({ .sendPayment(pr.pr) .then(() => { toast.success(`Successfully sent ${amount} sats!`) + if (setTotalZapAmount) { + setTotalZapAmount((prev) => prev + amount) + } + if (!notCloseAfterZap) { handleClose() } @@ -331,7 +352,13 @@ export const ZapPopUp = ({ } setIsLoading(false) - }, [amount, notCloseAfterZap, handleClose, generatePaymentRequest]) + }, [ + amount, + notCloseAfterZap, + handleClose, + generatePaymentRequest, + setTotalZapAmount + ]) const handleQRExpiry = useCallback(() => { setPaymentRequest(undefined) @@ -410,6 +437,7 @@ export const ZapPopUp = ({ paymentRequest={paymentRequest} handleClose={handleQRClose} handleQRExpiry={handleQRExpiry} + setTotalZapAmount={setTotalZapAmount} /> )} {lastNode} diff --git a/src/controllers/metadata.ts b/src/controllers/metadata.ts index 30730bc..cd2592c 100644 --- a/src/controllers/metadata.ts +++ b/src/controllers/metadata.ts @@ -2,7 +2,7 @@ import NDK, { getRelayListForUser, NDKList, NDKUser } from '@nostr-dev-kit/ndk' import { kinds } from 'nostr-tools' import { MuteLists } from '../types' import { UserProfile } from '../types/user' -import { hexToNpub, log, LogType, npubToHex } from '../utils' +import { hexToNpub, log, LogType, npubToHex, timeout } from '../utils' export enum UserRelaysType { Read = 'readRelayUrls', @@ -122,13 +122,22 @@ export class MetadataController { hexKey: string, userRelaysType: UserRelaysType = UserRelaysType.Both ) => { - const ndkRelayList = await getRelayListForUser(hexKey, this.ndk) + log(true, LogType.Info, `ℹ Finding user's relays`, hexKey, userRelaysType) - if (!ndkRelayList) { - throw new Error(`Couldn't found user's relay list`) - } + const ndkRelayListPromise = await getRelayListForUser(hexKey, this.ndk) - return ndkRelayList[userRelaysType] + // Use Promise.race to either get the NDKRelayList instance or handle the timeout + return await Promise.race([ + ndkRelayListPromise, + timeout() // Custom timeout function that rejects after a specified time + ]) + .then((ndkRelayList) => { + return ndkRelayList[userRelaysType] + }) + .catch((err) => { + log(true, LogType.Error, err) + return [] as string[] // Return an empty array if an error occurs + }) } public getMuteLists = async ( diff --git a/src/controllers/relay.ts b/src/controllers/relay.ts index 75528c2..deae1b8 100644 --- a/src/controllers/relay.ts +++ b/src/controllers/relay.ts @@ -1,5 +1,4 @@ import { Event, Filter, kinds, Relay } from 'nostr-tools' -import { ModDetails } from '../types' import { extractZapAmount, log, @@ -94,23 +93,11 @@ export class RelayController { const metadataController = await MetadataController.getInstance() // Retrieve the list of relays for the specified user's public key - // A timeout is used to prevent long waits if the relay retrieval is delayed - const relaysPromise = metadataController.findUserRelays( + const relayUrls = await metadataController.findUserRelays( userHexKey || event.pubkey, userRelaysType || UserRelaysType.Write ) - log(this.debug, LogType.Info, `ℹ Finding user's write relays`) - - // Use Promise.race to either get the relay URLs or handle the timeout - const relayUrls = await Promise.race([ - relaysPromise, - timeout() // Custom timeout function that rejects after a specified time - ]).catch((err) => { - log(this.debug, LogType.Error, err) - return [] as string[] // Return an empty array if an error occurs - }) - // Add admin relay URLs from the metadata controller to the list of relay URLs metadataController.adminRelays.forEach((url) => { relayUrls.push(url) @@ -200,23 +187,11 @@ export class RelayController { const metadataController = await MetadataController.getInstance() // Retrieve the list of read relays for the receiver - // Use a timeout to handle cases where retrieving read relays takes too long - const readRelaysPromise = metadataController.findUserRelays( + const readRelayUrls = await metadataController.findUserRelays( receiver, UserRelaysType.Read ) - log(this.debug, LogType.Info, `ℹ Finding receiver's read relays`) - - // Use Promise.race to either get the read relay URLs or timeout - const readRelayUrls = await Promise.race([ - readRelaysPromise, - 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 - }) - // push admin relay urls obtained from metadata controller to readRelayUrls list metadataController.adminRelays.forEach((url) => { readRelayUrls.push(url) @@ -277,6 +252,112 @@ export class RelayController { return publishedOnRelays } + /** + * Publishes an event to multiple relays. + * + * This method establishes a connection to the application relay specified by + * an environment variable and a set of relays provided as argument. + * It attempts to publish the event to all connected relays + * and returns a list of URLs of relays where the event was successfully published. + * + * If the process of publishing the event takes too long, + * it handles the timeout to prevent blocking the operation. + * + * @param event - The event to be published. + * @param relayUrls - The array of relayUrl where event should be published + * @returns A promise that resolves to an array of URLs of relays where the event + * was published, or an empty array if no relays were connected or the + * event could not be published. + */ + publishOnRelays = async ( + event: Event, + relayUrls: string[] + ): Promise => { + const appRelay = import.meta.env.VITE_APP_RELAY + + if (!relayUrls.includes(appRelay)) { + /** + * NOTE: To avoid side-effects on external relayUrls array passed as argument + * re-assigned relayUrls with added sigit relay instead of just appending to same array + */ + relayUrls = [...relayUrls, appRelay] // Add app relay to relays array if not exists already + } + + // connect to all specified relays + const relayPromises = relayUrls.map((relayUrl) => + this.connectRelay(relayUrl) + ) + + // Use Promise.allSettled to wait for all promises to settle + const results = await Promise.allSettled(relayPromises) + + // Extract non-null values from fulfilled promises in a single pass + const relays = results.reduce((acc, result) => { + if (result.status === 'fulfilled') { + const value = result.value + if (value) { + acc.push(value) + } + } + return acc + }, []) + + // Check if any relays are connected + if (relays.length === 0) { + log(this.debug, LogType.Error, 'No relay is connected!') + return [] + } + + const publishedOnRelays: string[] = [] // Track relays where the event was successfully published + + // Create promises to publish the event to each connected relay + const publishPromises = this.connectedRelays.map((relay) => { + log( + this.debug, + LogType.Info, + `⬆️ nostr (${relay.url}): Sending event:`, + event + ) + + return Promise.race([ + relay.publish(event), // Publish the event to the relay + timeout(30000) // Set a timeout to handle slow publishing operations + ]) + .then((res) => { + log( + this.debug, + LogType.Info, + `⬆️ nostr (${relay.url}): Publish result:`, + res + ) + publishedOnRelays.push(relay.url) // Add successful relay URL to the list + }) + .catch((err) => { + log( + this.debug, + LogType.Error, + `❌ nostr (${relay.url}): Publish error!`, + err + ) + }) + }) + + // Wait for all publish operations to complete (either fulfilled or rejected) + await Promise.allSettled(publishPromises) + + if (publishedOnRelays.length > 0) { + // If the event was successfully published to any relays, check if it contains an `aTag` + // If the `aTag` is present, cache the event locally + const aTag = event.tags.find((item) => item[0] === 'a') + if (aTag && aTag[1]) { + this.events.set(aTag[1], event) + } + } + + // Return the list of relay URLs where the event was successfully published + return publishedOnRelays + } + /** * Asynchronously retrieves multiple event from a set of relays based on a provided filter. * If no relays are specified, it defaults to using connected relays. @@ -408,25 +489,12 @@ export class RelayController { // Get an instance of the MetadataController, which manages user metadata and relays const metadataController = await MetadataController.getInstance() - // Find the user's relays using the MetadataController. This is an asynchronous operation. - const usersRelaysPromise = metadataController.findUserRelays( + // Find the user's relays using the MetadataController. + const relayUrls = await metadataController.findUserRelays( hexKey, userRelaysType ) - log(true, LogType.Info, `ℹ Finding user's relays`) - - // Use Promise.race to attempt to resolve the user's relays, or timeout if it takes too long - const relayUrls = await Promise.race([ - usersRelaysPromise, - timeout() // This is a custom timeout function that rejects the promise after a specified time - ]).catch((err) => { - // Log an error if the relay fetching operation fails - log(true, LogType.Error, err) - // Return an empty array to indicate failure in retrieving relay URLs - return [] as string[] - }) - // Fetch the event from the user's relays using the provided filter and relay URLs return this.fetchEvents(filter, relayUrls) } @@ -479,28 +547,97 @@ export class RelayController { return null } + /** + * Subscribes to events from multiple relays. + * + * This method connects to the specified relay URLs and subscribes to events + * using the provided filter. It handles incoming events through the given + * `eventHandler` callback and manages the subscription lifecycle. + * + * @param filter - The filter criteria to apply when subscribing to events. + * @param relayUrls - An optional array of relay URLs to connect to. The default relay URL (`APP_RELAY`) is added automatically. + * @param eventHandler - A callback function to handle incoming events. It receives an `Event` object. + * + */ + subscribeForEvents = async ( + filter: Filter, + relayUrls: string[] = [], + eventHandler: (event: Event) => void + ) => { + const appRelay = import.meta.env.VITE_APP_RELAY + if (!relayUrls.includes(appRelay)) { + /** + * NOTE: To avoid side-effects on external relayUrls array passed as argument + * re-assigned relayUrls with added sigit relay instead of just appending to same array + */ + relayUrls = [...relayUrls, appRelay] // Add app relay to relays array if not exists already + } + + // connect to all specified relays + const relayPromises = relayUrls.map((relayUrl) => + this.connectRelay(relayUrl) + ) + + // Use Promise.allSettled to wait for all promises to settle + const results = await Promise.allSettled(relayPromises) + + // Extract non-null values from fulfilled promises in a single pass + const relays = results.reduce((acc, result) => { + if (result.status === 'fulfilled') { + const value = result.value + if (value) { + acc.push(value) + } + } + return acc + }, []) + + // Check if any relays are connected + if (relays.length === 0) { + throw new Error('No relay is connected to fetch events!') + } + + const processedEvents: string[] = [] // To keep track of processed events + + // Create a promise for each relay subscription + const subPromises = relays.map((relay) => { + return new Promise((resolve) => { + // Subscribe to the relay with the specified filter + const sub = relay.subscribe([filter], { + // Handle incoming events + onevent: (e) => { + // Process event only if it hasn't been processed before + if (!processedEvents.includes(e.id)) { + processedEvents.push(e.id) + eventHandler(e) // Call the event handler with the event + } + }, + // 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) + } + getTotalZapAmount = async ( - modDetails: ModDetails, + user: string, + eTag: string, + aTag?: string, currentLoggedInUser?: string ) => { const metadataController = await MetadataController.getInstance() - const authorReadRelaysPromise = metadataController.findUserRelays( - modDetails.author, + const relayUrls = await metadataController.findUserRelays( + user, 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) @@ -520,42 +657,43 @@ export class RelayController { const eventIds = new Set() // To keep track of event IDs and avoid duplicates + const filter: Filter = { + kinds: [kinds.Zap] + } + + if (aTag) { + filter['#a'] = [aTag] + } else { + filter['#e'] = [eTag] + } + // 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 + const sub = relay.subscribe([filter], { + // Handle incoming events + onevent: (e) => { + // Add the event to the array if it's not a duplicate + if (!eventIds.has(e.id)) { + 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 - } + 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 } + }, + // Handle the End-Of-Stream (EOSE) message + oneose: () => { + sub.close() // Close the subscription + resolve() // Resolve the promise when EOSE is received } - ) + }) }) }) diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 7d4ee84..178d686 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,2 +1,3 @@ export * from './redux' export * from './useDidMount' +export * from './useReactions' diff --git a/src/hooks/useReactions.ts b/src/hooks/useReactions.ts new file mode 100644 index 0000000..5ce1716 --- /dev/null +++ b/src/hooks/useReactions.ts @@ -0,0 +1,174 @@ +import { useState, useMemo } from 'react' +import { toast } from 'react-toastify' +import { REACTIONS } from 'constants.ts' +import { RelayController, UserRelaysType } from 'controllers' +import { useAppSelector, useDidMount } from 'hooks' +import { Event, Filter, UnsignedEvent, kinds } from 'nostr-tools' +import { abbreviateNumber, log, LogType, now } from 'utils' + +type UseReactionsParams = { + pubkey: string + eTag: string + aTag?: string +} + +export const useReactions = (params: UseReactionsParams) => { + const [isReactionInProgress, setIsReactionInProgress] = useState(false) + const [isDataLoaded, setIsDataLoaded] = useState(false) + const [reactionEvents, setReactionEvents] = useState([]) + + const userState = useAppSelector((state) => state.user) + + useDidMount(() => { + const filter: Filter = { + kinds: [kinds.Reaction] + } + + if (params.aTag) { + filter['#a'] = [params.aTag] + } else { + filter['#e'] = [params.eTag] + } + + RelayController.getInstance() + .fetchEventsFromUserRelays(filter, params.pubkey, UserRelaysType.Read) + .then((events) => { + setReactionEvents(events) + }) + .finally(() => { + setIsDataLoaded(true) + }) + }) + + const hasReactedPositively = useMemo(() => { + return ( + !!userState.auth && + reactionEvents.some( + (event) => + event.pubkey === userState.user?.pubkey && + (REACTIONS.positive.emojis.includes(event.content) || + REACTIONS.positive.shortCodes.includes(event.content)) + ) + ) + }, [reactionEvents, userState]) + + const hasReactedNegatively = useMemo(() => { + return ( + !!userState.auth && + reactionEvents.some( + (event) => + event.pubkey === userState.user?.pubkey && + (REACTIONS.negative.emojis.includes(event.content) || + REACTIONS.negative.shortCodes.includes(event.content)) + ) + ) + }, [reactionEvents, userState]) + + const getPubkey = async () => { + let hexPubkey: string + + if (userState.auth && userState.user?.pubkey) { + hexPubkey = userState.user.pubkey as string + } else { + hexPubkey = (await window.nostr?.getPublicKey()) as string + } + + if (!hexPubkey) { + toast.error('Could not get pubkey') + return null + } + + return hexPubkey + } + + const handleReaction = async (isPositive?: boolean) => { + if (!isDataLoaded || hasReactedPositively || hasReactedNegatively) return + + if (isReactionInProgress) return + + setIsReactionInProgress(true) + + try { + const pubkey = await getPubkey() + if (!pubkey) return + + const unsignedEvent: UnsignedEvent = { + kind: kinds.Reaction, + created_at: now(), + content: isPositive ? '+' : '-', + pubkey, + tags: [ + ['e', params.eTag], + ['p', params.pubkey] + ] + } + + if (params.aTag) { + unsignedEvent.tags.push(['a', params.aTag]) + } + + const signedEvent = await window.nostr + ?.signEvent(unsignedEvent) + .then((event) => event as Event) + .catch((err) => { + toast.error('Failed to sign the reaction event!') + log(true, LogType.Error, 'Failed to sign the event!', err) + return null + }) + + if (!signedEvent) return + + setReactionEvents((prev) => [...prev, signedEvent]) + + const publishedOnRelays = await RelayController.getInstance().publish( + signedEvent as Event, + params.pubkey, + UserRelaysType.Read + ) + + if (publishedOnRelays.length === 0) { + log( + true, + LogType.Error, + 'Failed to publish reaction event on any relay' + ) + return + } + } finally { + setIsReactionInProgress(false) + } + } + + const { likesCount, disLikesCount } = useMemo(() => { + let positiveCount = 0 + let negativeCount = 0 + + reactionEvents.forEach((event) => { + if ( + REACTIONS.positive.emojis.includes(event.content) || + REACTIONS.positive.shortCodes.includes(event.content) + ) { + positiveCount++ + } else if ( + REACTIONS.negative.emojis.includes(event.content) || + REACTIONS.negative.shortCodes.includes(event.content) + ) { + negativeCount++ + } + }) + + return { + likesCount: abbreviateNumber(positiveCount), + disLikesCount: abbreviateNumber(negativeCount) + } + }, [reactionEvents]) + + return { + isDataLoaded, + likesCount, + disLikesCount, + hasReactedPositively, + hasReactedNegatively, + handleReaction + } +} diff --git a/src/pages/mod.tsx b/src/pages/mod/index.tsx similarity index 69% rename from src/pages/mod.tsx rename to src/pages/mod/index.tsx index cd27bf3..b0763d6 100644 --- a/src/pages/mod.tsx +++ b/src/pages/mod/index.tsx @@ -3,54 +3,53 @@ import { EditorContent, useEditor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import { formatDate } from 'date-fns' import FsLightbox from 'fslightbox-react' -import { Filter, kinds, nip19, UnsignedEvent, Event } from 'nostr-tools' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Filter, kinds, nip19, UnsignedEvent } from 'nostr-tools' +import { useEffect, 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 { ZapButtons, ZapPopUp, ZapPresets, ZapQR } from '../components/Zap' +import { BlogCard } from '../../components/BlogCard' +import { LoadingSpinner } from '../../components/LoadingSpinner' +import { ProfileSection } from '../../components/ProfileSection' import { MetadataController, RelayController, - UserRelaysType, - ZapController -} from '../controllers' -import { useAppSelector, useDidMount } from '../hooks' -import { getModsEditPageRoute } from '../routes' -import '../styles/comments.css' -import '../styles/downloads.css' -import '../styles/innerPage.css' -import '../styles/popup.css' -import '../styles/post.css' -import '../styles/reactions.css' -import '../styles/styles.css' -import '../styles/tabs.css' -import '../styles/tags.css' -import '../styles/write.css' -import { DownloadUrl, ModDetails, PaymentRequest } from '../types' + UserRelaysType +} from '../../controllers' +import { useAppSelector, useDidMount } from '../../hooks' +import { getModsEditPageRoute } from '../../routes' +import '../../styles/comments.css' +import '../../styles/downloads.css' +import '../../styles/innerPage.css' +import '../../styles/popup.css' +import '../../styles/post.css' +import '../../styles/reactions.css' +import '../../styles/styles.css' +import '../../styles/tabs.css' +import '../../styles/tags.css' +import '../../styles/write.css' +import { DownloadUrl, ModDetails } from '../../types' import { abbreviateNumber, copyTextToClipboard, downloadFile, extractModData, - formatNumber, getFilenameFromUrl, log, LogType, now, npubToHex, sendDMUsingRandomKey, - signAndPublish, - unformatNumber -} from '../utils' -import { REACTIONS } from '../constants' + signAndPublish +} from '../../utils' +import { Reactions } from './internal/reactions' +import { Zap } from './internal/zap' +import { Comments } from './internal/comment' export const ModPage = () => { const { naddr } = useParams() const [modData, setModData] = useState() const [isFetching, setIsFetching] = useState(true) + const [commentCount, setCommentCount] = useState(0) useDidMount(async () => { if (naddr) { @@ -131,7 +130,10 @@ export const ModPage = () => { tags={modData.tags} nsfw={modData.nsfw} /> - + {
- +
@@ -1016,16 +1021,14 @@ const Body = ({ type InteractionsProps = { modDetails: ModDetails + commentCount: number } -const Interactions = ({ modDetails }: InteractionsProps) => { +const Interactions = ({ modDetails, commentCount }: InteractionsProps) => { return (
- +
{
-

420

+

+ {abbreviateNumber(commentCount)} +

@@ -1382,610 +1387,3 @@ const Download = ({
) } - -const Comments = () => { - return ( -
-

Comments (WIP)

-
-
-
- -
- -
-
- - -
-
-
- -
-

Example user comment

-
-
-
-
- - - -

52

-
-
-
-
-
- - - -

4

-
-
-
-
-
- - - -

6

-
-
-
-
-
- - - -

500K

-
-
-
-
-
- - - -

12

-

Replies

-
-
-

Reply

-
-
-
-
-
-
-
- ) -} - -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 && ( - setIsOpen(false)} - lastNode={} - notCloseAfterZap - /> - )} - - ) -} - -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.auth && 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 && } - - ) -} - -type ReactionsProps = { - modDetails: ModDetails -} - -const Reactions = ({ modDetails }: ReactionsProps) => { - const [isReactionInProgress, setIsReactionInProgress] = useState(false) - const [isDataLoaded, setIsDataLoaded] = useState(false) - const [reactionEvents, setReactionEvents] = useState([]) - - const userState = useAppSelector((state) => state.user) - - useDidMount(() => { - const filter: Filter = { - kinds: [kinds.Reaction], - '#a': [modDetails.aTag] - } - - RelayController.getInstance() - .fetchEventsFromUserRelays(filter, modDetails.author, UserRelaysType.Read) - .then((events) => { - setReactionEvents(events) - }) - .finally(() => { - setIsDataLoaded(true) - }) - }) - - const checkHasPositiveReaction = () => { - return ( - !!userState.auth && - reactionEvents.some( - (event) => - event.pubkey === userState.user?.pubkey && - (REACTIONS.positive.emojis.includes(event.content) || - REACTIONS.positive.shortCodes.includes(event.content)) - ) - ) - } - - const checkHasNegativeReaction = () => { - return ( - !!userState.auth && - reactionEvents.some( - (event) => - event.pubkey === userState.user?.pubkey && - (REACTIONS.negative.emojis.includes(event.content) || - REACTIONS.negative.shortCodes.includes(event.content)) - ) - ) - } - - const getPubkey = async () => { - let hexPubkey: string - - if (userState.auth && userState.user?.pubkey) { - hexPubkey = userState.user.pubkey as string - } else { - hexPubkey = (await window.nostr?.getPublicKey()) as string - } - - if (!hexPubkey) { - toast.error('Could not get pubkey') - return null - } - - return hexPubkey - } - - const handleReaction = async (isPositive?: boolean) => { - if ( - !isDataLoaded || - checkHasPositiveReaction() || - checkHasNegativeReaction() - ) - return - - // Check if the voting process is already in progress - if (isReactionInProgress) return - - // Set the flag to indicate that the voting process has started - setIsReactionInProgress(true) - - try { - const pubkey = await getPubkey() - if (!pubkey) return - - const unsignedEvent: UnsignedEvent = { - kind: kinds.Reaction, - created_at: now(), - content: isPositive ? '+' : '-', - pubkey, - tags: [ - ['e', modDetails.id], - ['p', modDetails.author], - ['a', modDetails.aTag] - ] - } - - const signedEvent = await window.nostr - ?.signEvent(unsignedEvent) - .then((event) => event as Event) - .catch((err) => { - toast.error('Failed to sign the reaction event!') - log(true, LogType.Error, 'Failed to sign the event!', err) - return null - }) - - if (!signedEvent) return - - setReactionEvents((prev) => [...prev, signedEvent]) - - const publishedOnRelays = await RelayController.getInstance().publish( - signedEvent as Event, - modDetails.author, - UserRelaysType.Read - ) - - if (publishedOnRelays.length === 0) { - log( - true, - LogType.Error, - 'Failed to publish reaction event on any relay' - ) - return - } - } finally { - setIsReactionInProgress(false) - } - } - - const { likesCount, disLikesCount } = useMemo(() => { - let positiveCount = 0 - let negativeCount = 0 - reactionEvents.forEach((event) => { - if ( - REACTIONS.positive.emojis.includes(event.content) || - REACTIONS.positive.shortCodes.includes(event.content) - ) { - positiveCount++ - } else if ( - REACTIONS.negative.emojis.includes(event.content) || - REACTIONS.negative.shortCodes.includes(event.content) - ) { - negativeCount++ - } - }) - - return { - likesCount: abbreviateNumber(positiveCount), - disLikesCount: abbreviateNumber(negativeCount) - } - }, [reactionEvents]) - - const hasReactedPositively = checkHasPositiveReaction() - const hasReactedNegatively = checkHasNegativeReaction() - - if (!isDataLoaded) return null - - return ( - <> -
handleReaction(true)} - > -
- - - -
-

{likesCount}

-
-
-
-
-
handleReaction()} - > -
- - - -
-

{disLikesCount}

-
-
-
-
- - ) -} diff --git a/src/pages/mod/internal/comment/index.tsx b/src/pages/mod/internal/comment/index.tsx new file mode 100644 index 0000000..1cc47f7 --- /dev/null +++ b/src/pages/mod/internal/comment/index.tsx @@ -0,0 +1,528 @@ +import { ZapPopUp } from 'components/Zap' +import { + MetadataController, + RelayController, + UserRelaysType +} from 'controllers' +import { formatDate } from 'date-fns' +import { useAppSelector, useDidMount, useReactions } from 'hooks' +import { + Event, + kinds, + nip19, + Filter as NostrEventFilter, + UnsignedEvent +} from 'nostr-tools' +import React, { useMemo } from 'react' +import { Dispatch, SetStateAction, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { toast } from 'react-toastify' +import { getProfilePageRoute } from 'routes' +import { ModDetails, UserProfile } from 'types' +import { abbreviateNumber, hexToNpub, log, LogType, now } from 'utils' + +enum SortByEnum { + Latest = 'Latest', + Oldest = 'Oldest' +} + +enum AuthorFilterEnum { + All_Comments = 'All Comments', + Creator_Comments = 'Creator Comments' +} + +type FilterOptions = { + sort: SortByEnum + author: AuthorFilterEnum +} + +type Props = { + modDetails: ModDetails + setCommentCount: Dispatch> +} + +export const Comments = ({ modDetails, setCommentCount }: Props) => { + const [commentEvents, setCommentEvents] = useState([]) + const [filterOptions, setFilterOptions] = useState({ + sort: SortByEnum.Latest, + author: AuthorFilterEnum.All_Comments + }) + + const userState = useAppSelector((state) => state.user) + + useDidMount(async () => { + const metadataController = await MetadataController.getInstance() + + const authorReadRelays = await metadataController.findUserRelays( + modDetails.author, + UserRelaysType.Read + ) + + const filter: NostrEventFilter = { + kinds: [kinds.ShortTextNote], + '#a': [modDetails.aTag] + } + + RelayController.getInstance().subscribeForEvents( + filter, + authorReadRelays, + (event) => { + setCommentEvents((prev) => { + if (prev.find((e) => e.id === event.id)) { + return [...prev] + } + + return [event, ...prev] + }) + } + ) + }) + + const handleSubmit = async (content: string): Promise => { + if (content === '') return false + + let pubkey: string + + if (userState.auth && userState.user?.pubkey) { + pubkey = userState.user.pubkey as string + } else { + pubkey = (await window.nostr?.getPublicKey()) as string + } + + if (!pubkey) { + toast.error('Could not get user pubkey') + return false + } + + const unsignedEvent: UnsignedEvent = { + content: content, + pubkey: pubkey, + kind: kinds.ShortTextNote, + created_at: now(), + tags: [ + ['e', modDetails.id], + ['a', modDetails.aTag] + ] + } + + const signedEvent = await window.nostr + ?.signEvent(unsignedEvent) + .then((event) => event as Event) + .catch((err) => { + toast.error('Failed to sign the event!') + log(true, LogType.Error, 'Failed to sign the event!', err) + return null + }) + + if (!signedEvent) return false + + setCommentEvents((prev) => [signedEvent, ...prev]) + + const publish = async () => { + const metadataController = await MetadataController.getInstance() + const modAuthorReadRelays = await metadataController.findUserRelays( + modDetails.author, + UserRelaysType.Read + ) + const commentatorWriteRelays = await metadataController.findUserRelays( + pubkey, + UserRelaysType.Write + ) + + const combinedRelays = [ + ...new Set(...modAuthorReadRelays, ...commentatorWriteRelays) + ] + + RelayController.getInstance().publishOnRelays(signedEvent, combinedRelays) + } + + publish() + + return true + } + + setCommentCount(commentEvents.length) + + const comments = useMemo(() => { + let filteredComments = commentEvents + if (filterOptions.author === AuthorFilterEnum.Creator_Comments) { + filteredComments = filteredComments.filter( + (comment) => comment.pubkey === modDetails.author + ) + } + + if (filterOptions.sort === SortByEnum.Latest) { + filteredComments.sort((a, b) => b.created_at - a.created_at) + } else if (filterOptions.sort === SortByEnum.Oldest) { + filteredComments.sort((a, b) => a.created_at - b.created_at) + } + + return filteredComments + }, [commentEvents, filterOptions, modDetails.author]) + + return ( +
+

Comments

+
+ + +
+ {comments.map((event) => ( + + ))} +
+
+
+ ) +} + +type CommentFormProps = { + handleSubmit: (content: string) => Promise +} + +const CommentForm = ({ handleSubmit }: CommentFormProps) => { + const [isSubmitting, setIsSubmitting] = useState(false) + const [commentText, setCommentText] = useState('') + + const handleComment = async () => { + setIsSubmitting(true) + const submitted = await handleSubmit(commentText) + if (submitted) setCommentText('') + setIsSubmitting(false) + } + + return ( +
+
+