From 2dd2992810a3f951bdd986f0344f55cdb01572c6 Mon Sep 17 00:00:00 2001 From: daniyal Date: Thu, 5 Sep 2024 12:39:37 +0500 Subject: [PATCH] feat: implemented the logic for handling reactions on mods --- src/constants.ts | 15 +++ src/controllers/relay.ts | 145 +++++++++++++++------- src/pages/mod.tsx | 252 ++++++++++++++++++++++++++++++++------- 3 files changed, 322 insertions(+), 90 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index f0e7bff..a611b6c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -36,3 +36,18 @@ export const LANDING_PAGE_DATA = { } ] } +// we use this object to check is a user has reacted positively or negatively to a post +// reactions are kind 7 events and their content is either emoji icon or emoji shortcode +// Extend the following object as per need to include more emojis and shortcodes +// NOTE: In following object emojis and shortcode array are not interlinked. +// Both of these arrays can have separate items +export const REACTIONS = { + positive: { + emojis: ['+', '❤️'], + shortCodes: [':star_struck:', ':red_heart:'] + }, + negative: { + emojis: ['-', '🥵'], + shortCodes: [':hot_face:', ':woozy_face:', ':face_with_thermometer:'] + } +} diff --git a/src/controllers/relay.ts b/src/controllers/relay.ts index 2e362ca..75528c2 100644 --- a/src/controllers/relay.ts +++ b/src/controllers/relay.ts @@ -62,62 +62,77 @@ export class RelayController { /** * Publishes an event to multiple relays. * - * This method connects to the application relay and a set of write relays - * obtained from the `MetadataController`. It then publishes the event to - * all connected relays and returns a list of relays where the event was successfully published. + * This method establishes a connection to the application relay specified by + * an environment variable and a set of relays obtained from the + * `MetadataController`. 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 finding relays or publishing the event takes too long, + * it handles the timeout to prevent blocking the operation. * * @param event - The event to 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. + * @param userHexKey - The user's hexadecimal public key, used to retrieve their relays. + * If not provided, the event's public key will be used. + * @param userRelaysType - The type of relays to be retrieved (e.g., write relays). + * Defaults to `UserRelaysType.Write`. + * @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. */ - publish = async (event: Event): Promise => { - // Connect to the application relay specified by environment variable + publish = async ( + event: Event, + userHexKey?: string, + userRelaysType?: UserRelaysType + ): Promise => { + // Connect to the application relay specified by an environment variable const appRelayPromise = this.connectRelay(import.meta.env.VITE_APP_RELAY) - // todo: window.nostr.getRelays() is not implemented yet in nostr-login, implement the logic once its done + // TODO: Implement logic to retrieve relays using `window.nostr.getRelays()` once it becomes available in nostr-login. + // Retrieve an instance of MetadataController to find user relays const metadataController = await MetadataController.getInstance() - // 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.findUserRelays( - event.pubkey, - UserRelaysType.Write + // 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( + userHexKey || event.pubkey, + userRelaysType || UserRelaysType.Write ) log(this.debug, LogType.Info, `ℹ Finding user's write relays`) - // Use Promise.race to either get the write relay URLs or timeout - const writeRelayUrls = await Promise.race([ - writeRelaysPromise, - timeout() // This is a custom timeout function that rejects the promise after a specified time + // 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 }) - // push admin relay urls obtained from metadata controller to writeRelayUrls list + // Add admin relay URLs from the metadata controller to the list of relay URLs metadataController.adminRelays.forEach((url) => { - writeRelayUrls.push(url) + relayUrls.push(url) }) - // Connect to all write relays obtained from MetadataController - const relayPromises = writeRelayUrls.map((relayUrl) => + // Attempt to connect to all write relays obtained from MetadataController + const relayPromises = relayUrls.map((relayUrl) => this.connectRelay(relayUrl) ) - // Wait for all relay connections to settle (either fulfilled or rejected) + // Wait for all relay connection attempts to settle (either fulfilled or rejected) await Promise.allSettled([appRelayPromise, ...relayPromises]) - // Check if any relays are connected; if not, log an error and return null + // If no relays are connected, log an error and return an empty array if (this.connectedRelays.length === 0) { log(this.debug, LogType.Error, 'No relay is connected!') return [] } - const publishedOnRelays: string[] = [] // List to track which relays successfully published the event + const publishedOnRelays: string[] = [] // Track relays where the event was successfully published - // Create a promise for publishing the event to each connected relay + // Create promises to publish the event to each connected relay const publishPromises = this.connectedRelays.map((relay) => { log( this.debug, @@ -128,7 +143,7 @@ export class RelayController { return Promise.race([ relay.publish(event), // Publish the event to the relay - timeout(30000) // Set a timeout to handle cases where publishing takes too long + timeout(30000) // Set a timeout to handle slow publishing operations ]) .then((res) => { log( @@ -137,7 +152,7 @@ export class RelayController { `⬆️ nostr (${relay.url}): Publish result:`, res ) - publishedOnRelays.push(relay.url) // Add the relay URL to the list of successfully published relays + publishedOnRelays.push(relay.url) // Add successful relay URL to the list }) .catch((err) => { log( @@ -153,16 +168,15 @@ export class RelayController { await Promise.allSettled(publishPromises) if (publishedOnRelays.length > 0) { - // if the event was successfully published to relays then check if it contains the `aTag` - // if so, then cache the event - + // 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 published + // Return the list of relay URLs where the event was successfully published return publishedOnRelays } @@ -378,28 +392,19 @@ export class RelayController { } /** - * Fetches an event from the user's relays based on a specified filter. - * The function first retrieves the user's relays, and then fetches the event using the provided filter. + * Asynchronously retrieves multiple events from the user's relays based on a specified filter. + * The function first retrieves the user's relays, and then fetches the events using the provided filter. * * @param filter - The event filter to use when fetching the event (e.g., kinds, authors). * @param hexKey - The hexadecimal representation of the user's public key. * @param userRelaysType - The type of relays to search (e.g., write, read). - * @returns A promise that resolves to the fetched event or null if the operation fails. + * @returns A promise that resolves with an array of events. */ - fetchEventFromUserRelays = async ( + fetchEventsFromUserRelays = async ( filter: Filter, hexKey: string, userRelaysType: UserRelaysType - ) => { - // first check if event is present in cached map then return that - // otherwise query relays - if (filter['#a']) { - const aTag = filter['#a'][0] - const cachedEvent = this.events.get(aTag) - - if (cachedEvent) return cachedEvent - } - + ): Promise => { // Get an instance of the MetadataController, which manages user metadata and relays const metadataController = await MetadataController.getInstance() @@ -423,7 +428,55 @@ export class RelayController { }) // Fetch the event from the user's relays using the provided filter and relay URLs - return this.fetchEvent(filter, relayUrls) + return this.fetchEvents(filter, relayUrls) + } + + /** + * Fetches an event from the user's relays based on a specified filter. + * The function first retrieves the user's relays, and then fetches the event using the provided filter. + * + * @param filter - The event filter to use when fetching the event (e.g., kinds, authors). + * @param hexKey - The hexadecimal representation of the user's public key. + * @param userRelaysType - The type of relays to search (e.g., write, read). + * @returns A promise that resolves to the fetched event or null if the operation fails. + */ + fetchEventFromUserRelays = async ( + filter: Filter, + hexKey: string, + userRelaysType: UserRelaysType + ): Promise => { + // first check if event is present in cached map then return that + // otherwise query relays + if (filter['#a']) { + const aTag = filter['#a'][0] + const cachedEvent = this.events.get(aTag) + + if (cachedEvent) return cachedEvent + } + + const events = await this.fetchEventsFromUserRelays( + filter, + hexKey, + userRelaysType + ) + // Sort events by creation date in descending order + events.sort((a, b) => b.created_at - a.created_at) + + if (events.length > 0) { + const event = events[0] + + // if the aTag was specified in filter then cache the fetched event before returning + if (filter['#a']) { + const aTag = filter['#a'][0] + this.events.set(aTag, event) + } + + // return the event + return event + } + + // return null if event array is empty + return null } getTotalZapAmount = async ( diff --git a/src/pages/mod.tsx b/src/pages/mod.tsx index 9efc2de..1bdf6f9 100644 --- a/src/pages/mod.tsx +++ b/src/pages/mod.tsx @@ -3,8 +3,8 @@ 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 } from 'nostr-tools' -import { useCallback, useEffect, useRef, useState } from 'react' +import { Filter, kinds, nip19, UnsignedEvent, Event } from 'nostr-tools' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { toast } from 'react-toastify' import { BlogCard } from '../components/BlogCard' @@ -45,6 +45,7 @@ import { signAndPublish, unformatNumber } from '../utils' +import { REACTIONS } from '../constants' export const ModPage = () => { const { naddr } = useParams() @@ -1042,48 +1043,7 @@ const Interactions = ({ modDetails }: InteractionsProps) => { -
-
- - - -
-

4.2k

-
-
-
-
-
-
- - - -
-

69

-
-
-
-
+ ) @@ -1819,3 +1779,207 @@ const ZapSite = () => { ) } + +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 event!') + log(true, LogType.Error, 'Failed to sign the event!', err) + return null + }) + + if (!signedEvent) return + + const publishedOnRelays = await RelayController.getInstance().publish( + signedEvent as Event, + modDetails.author, + UserRelaysType.Read + ) + + if (publishedOnRelays.length === 0) { + toast.error('Failed to publish reaction event on any relay') + return + } + + setReactionEvents((prev) => [...prev, signedEvent]) + } 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() + + return ( + <> +
handleReaction(true)} + > +
+ + + +
+

{likesCount}

+
+
+
+
+
handleReaction()} + > +
+ + + +
+

{disLikesCount}

+
+
+
+
+ + ) +}