feat: implemented the logic for handling reactions on mods

This commit is contained in:
daniyal 2024-09-05 12:39:37 +05:00
parent a85314f0a7
commit 2dd2992810
3 changed files with 322 additions and 90 deletions

View File

@ -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:']
}
}

View File

@ -62,62 +62,77 @@ export class RelayController {
/** /**
* Publishes an event to multiple relays. * Publishes an event to multiple relays.
* *
* This method connects to the application relay and a set of write relays * This method establishes a connection to the application relay specified by
* obtained from the `MetadataController`. It then publishes the event to * an environment variable and a set of relays obtained from the
* all connected relays and returns a list of relays where the event was successfully published. * `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. * @param event - The event to be published.
* @returns A promise that resolves to an array of URLs of relays where the event was published, * @param userHexKey - The user's hexadecimal public key, used to retrieve their relays.
* or an empty array if no relays were connected or the event could not be published. * 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<string[]> => { publish = async (
// Connect to the application relay specified by environment variable event: Event,
userHexKey?: string,
userRelaysType?: UserRelaysType
): Promise<string[]> => {
// Connect to the application relay specified by an environment variable
const appRelayPromise = this.connectRelay(import.meta.env.VITE_APP_RELAY) 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() const metadataController = await MetadataController.getInstance()
// Retrieve the list of write relays for the event's public key // Retrieve the list of relays for the specified user's public key
// Use a timeout to handle cases where retrieving write relays takes too long // A timeout is used to prevent long waits if the relay retrieval is delayed
const writeRelaysPromise = metadataController.findUserRelays( const relaysPromise = metadataController.findUserRelays(
event.pubkey, userHexKey || event.pubkey,
UserRelaysType.Write userRelaysType || UserRelaysType.Write
) )
log(this.debug, LogType.Info, ` Finding user's write relays`) log(this.debug, LogType.Info, ` Finding user's write relays`)
// Use Promise.race to either get the write relay URLs or timeout // Use Promise.race to either get the relay URLs or handle the timeout
const writeRelayUrls = await Promise.race([ const relayUrls = await Promise.race([
writeRelaysPromise, relaysPromise,
timeout() // This is a custom timeout function that rejects the promise after a specified time timeout() // Custom timeout function that rejects after a specified time
]).catch((err) => { ]).catch((err) => {
log(this.debug, LogType.Error, err) log(this.debug, LogType.Error, err)
return [] as string[] // Return an empty array if an error occurs 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) => { metadataController.adminRelays.forEach((url) => {
writeRelayUrls.push(url) relayUrls.push(url)
}) })
// Connect to all write relays obtained from MetadataController // Attempt to connect to all write relays obtained from MetadataController
const relayPromises = writeRelayUrls.map((relayUrl) => const relayPromises = relayUrls.map((relayUrl) =>
this.connectRelay(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]) 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) { if (this.connectedRelays.length === 0) {
log(this.debug, LogType.Error, 'No relay is connected!') log(this.debug, LogType.Error, 'No relay is connected!')
return [] 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) => { const publishPromises = this.connectedRelays.map((relay) => {
log( log(
this.debug, this.debug,
@ -128,7 +143,7 @@ export class RelayController {
return Promise.race([ return Promise.race([
relay.publish(event), // Publish the event to the relay 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) => { .then((res) => {
log( log(
@ -137,7 +152,7 @@ export class RelayController {
`⬆️ nostr (${relay.url}): Publish result:`, `⬆️ nostr (${relay.url}): Publish result:`,
res 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) => { .catch((err) => {
log( log(
@ -153,16 +168,15 @@ export class RelayController {
await Promise.allSettled(publishPromises) await Promise.allSettled(publishPromises)
if (publishedOnRelays.length > 0) { if (publishedOnRelays.length > 0) {
// if the event was successfully published to relays then check if it contains the `aTag` // If the event was successfully published to any relays, check if it contains an `aTag`
// if so, then cache the event // If the `aTag` is present, cache the event locally
const aTag = event.tags.find((item) => item[0] === 'a') const aTag = event.tags.find((item) => item[0] === 'a')
if (aTag && aTag[1]) { if (aTag && aTag[1]) {
this.events.set(aTag[1], event) 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 return publishedOnRelays
} }
@ -378,28 +392,19 @@ export class RelayController {
} }
/** /**
* Fetches an event from the user's relays based on a specified 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 event using the provided 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 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 hexKey - The hexadecimal representation of the user's public key.
* @param userRelaysType - The type of relays to search (e.g., write, read). * @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, filter: Filter,
hexKey: string, hexKey: string,
userRelaysType: UserRelaysType userRelaysType: UserRelaysType
) => { ): Promise<Event[]> => {
// 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
}
// Get an instance of the MetadataController, which manages user metadata and relays // Get an instance of the MetadataController, which manages user metadata and relays
const metadataController = await MetadataController.getInstance() 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 // 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<Event | null> => {
// 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 ( getTotalZapAmount = async (

View File

@ -3,8 +3,8 @@ import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
import { formatDate } from 'date-fns' import { formatDate } from 'date-fns'
import FsLightbox from 'fslightbox-react' import FsLightbox from 'fslightbox-react'
import { Filter, kinds, nip19, UnsignedEvent } from 'nostr-tools' import { Filter, kinds, nip19, UnsignedEvent, Event } from 'nostr-tools'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { BlogCard } from '../components/BlogCard' import { BlogCard } from '../components/BlogCard'
@ -45,6 +45,7 @@ import {
signAndPublish, signAndPublish,
unformatNumber unformatNumber
} from '../utils' } from '../utils'
import { REACTIONS } from '../constants'
export const ModPage = () => { export const ModPage = () => {
const { naddr } = useParams() const { naddr } = useParams()
@ -1042,48 +1043,7 @@ const Interactions = ({ modDetails }: InteractionsProps) => {
</div> </div>
</a> </a>
<Zap modDetails={modDetails} /> <Zap modDetails={modDetails} />
<div <Reactions modDetails={modDetails} />
id='reactUp'
className='IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactUp IBMSMSMBSS_D_CRUActive'
>
<div className='IBMSMSMBSS_Details_CardVisual'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSS_Details_CardVisualIcon'
>
<path d='M0 190.9V185.1C0 115.2 50.52 55.58 119.4 44.1C164.1 36.51 211.4 51.37 244 84.02L256 96L267.1 84.02C300.6 51.37 347 36.51 392.6 44.1C461.5 55.58 512 115.2 512 185.1V190.9C512 232.4 494.8 272.1 464.4 300.4L283.7 469.1C276.2 476.1 266.3 480 256 480C245.7 480 235.8 476.1 228.3 469.1L47.59 300.4C17.23 272.1 .0003 232.4 .0003 190.9L0 190.9z'></path>
</svg>
</div>
<p className='IBMSMSMBSS_Details_CardText'>4.2k</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
<div
id='reactDown'
className='IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactDown'
>
<div className='IBMSMSMBSS_Details_CardVisual'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSS_Details_CardVisualIcon'
>
<path d='M512 440.1C512 479.9 479.7 512 439.1 512H71.92C32.17 512 0 479.8 0 440c0-35.88 26.19-65.35 60.56-70.85C43.31 356 32 335.4 32 312C32 272.2 64.25 240 104 240h13.99C104.5 228.2 96 211.2 96 192c0-35.38 28.56-64 63.94-64h16C220.1 128 256 92.12 256 48c0-17.38-5.784-33.35-15.16-46.47C245.8 .7754 250.9 0 256 0c53 0 96 43 96 96c0 11.25-2.288 22-5.913 32h5.879C387.3 128 416 156.6 416 192c0 19.25-8.59 36.25-22.09 48H408C447.8 240 480 272.2 480 312c0 23.38-11.38 44.01-28.63 57.14C485.7 374.6 512 404.3 512 440.1z'></path>
</svg>
</div>
<p className='IBMSMSMBSS_Details_CardText'>69</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
</div> </div>
</div> </div>
) )
@ -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<Event[]>([])
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 (
<>
<div
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactUp ${
hasReactedPositively ? 'IBMSMSMBSS_D_CRUActive' : ''
}`}
onClick={() => handleReaction(true)}
>
<div className='IBMSMSMBSS_Details_CardVisual'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSS_Details_CardVisualIcon'
>
<path d='M0 190.9V185.1C0 115.2 50.52 55.58 119.4 44.1C164.1 36.51 211.4 51.37 244 84.02L256 96L267.1 84.02C300.6 51.37 347 36.51 392.6 44.1C461.5 55.58 512 115.2 512 185.1V190.9C512 232.4 494.8 272.1 464.4 300.4L283.7 469.1C276.2 476.1 266.3 480 256 480C245.7 480 235.8 476.1 228.3 469.1L47.59 300.4C17.23 272.1 .0003 232.4 .0003 190.9L0 190.9z'></path>
</svg>
</div>
<p className='IBMSMSMBSS_Details_CardText'>{likesCount}</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
<div
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactDown ${
hasReactedNegatively ? 'IBMSMSMBSS_D_CRDActive' : ''
}`}
onClick={() => handleReaction()}
>
<div className='IBMSMSMBSS_Details_CardVisual'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSS_Details_CardVisualIcon'
>
<path d='M512 440.1C512 479.9 479.7 512 439.1 512H71.92C32.17 512 0 479.8 0 440c0-35.88 26.19-65.35 60.56-70.85C43.31 356 32 335.4 32 312C32 272.2 64.25 240 104 240h13.99C104.5 228.2 96 211.2 96 192c0-35.38 28.56-64 63.94-64h16C220.1 128 256 92.12 256 48c0-17.38-5.784-33.35-15.16-46.47C245.8 .7754 250.9 0 256 0c53 0 96 43 96 96c0 11.25-2.288 22-5.913 32h5.879C387.3 128 416 156.6 416 192c0 19.25-8.59 36.25-22.09 48H408C447.8 240 480 272.2 480 312c0 23.38-11.38 44.01-28.63 57.14C485.7 374.6 512 404.3 512 440.1z'></path>
</svg>
</div>
<p className='IBMSMSMBSS_Details_CardText'>{disLikesCount}</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
</>
)
}