diff --git a/src/components/ModCard.tsx b/src/components/ModCard.tsx index 86b7070..781d65c 100644 --- a/src/components/ModCard.tsx +++ b/src/components/ModCard.tsx @@ -3,6 +3,7 @@ import { handleModImageError } from '../utils' type ModCardProps = { title: string + gameName: string summary: string imageUrl: string link: string @@ -11,6 +12,7 @@ type ModCardProps = { export const ModCard = ({ title, + gameName, summary, imageUrl, link, @@ -36,6 +38,9 @@ export const ModCard = ({

{title}

{summary}

+
+

{gameName}

+
diff --git a/src/components/ProfileSection.tsx b/src/components/ProfileSection.tsx index 8dbc1c9..3051b1f 100644 --- a/src/components/ProfileSection.tsx +++ b/src/components/ProfileSection.tsx @@ -1,30 +1,22 @@ -import { Filter, kinds, nip19, UnsignedEvent, Event } from 'nostr-tools' +import { Event, Filter, kinds, nip19, UnsignedEvent } from 'nostr-tools' import { QRCodeSVG } from 'qrcode.react' -import { useCallback, useState } from 'react' +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' import { toast } from 'react-toastify' import { MetadataController, RelayController, - UserRelaysType, - ZapController + UserRelaysType } from '../controllers' import { useAppSelector, useDidMount } from '../hooks' +import { getProfilePageRoute } from '../routes' import '../styles/author.css' import '../styles/innerPage.css' import '../styles/socialPosts.css' -import { PaymentRequest, UserProfile } from '../types' -import { - copyTextToClipboard, - formatNumber, - log, - LogType, - unformatNumber, - now -} from '../utils' +import { UserProfile } from '../types' +import { copyTextToClipboard, log, LogType, now } from '../utils' import { LoadingSpinner } from './LoadingSpinner' -import { ZapButtons, ZapPresets, ZapQR } from './Zap' -import { getProfilePageRoute } from '../routes' -import { useNavigate } from 'react-router-dom' +import { ZapPopUp } from './Zap' type Props = { pubkey: string @@ -259,7 +251,7 @@ const QRButtonWithPopUp = ({ pubkey }: QRButtonWithPopUpProps) => {
{isOpen && ( -
+
@@ -307,122 +299,6 @@ type ZapButtonWithPopUpProps = { const ZapButtonWithPopUp = ({ pubkey }: ZapButtonWithPopUpProps) => { const [isOpen, setIsOpen] = useState(false) - const [amount, setAmount] = useState(0) - const [message, setMessage] = useState('') - - const [isLoading, setIsLoading] = useState(false) - const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') - - const [paymentRequest, setPaymentRequest] = useState() - - const userState = useAppSelector((state) => state.user) - - const handleClose = useCallback(() => { - setPaymentRequest(undefined) - setIsLoading(false) - setIsOpen(false) - }, []) - - const handleQRExpiry = useCallback(() => { - setPaymentRequest(undefined) - }, []) - - const handleAmountChange = (event: React.ChangeEvent) => { - const unformattedValue = unformatNumber(event.target.value) - setAmount(unformattedValue) - } - - 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 authorMetadata = await metadataController.findMetadata(pubkey) - - if (!authorMetadata?.lud16) { - setIsLoading(false) - toast.error('Lighting address (lud16) is missing in admin metadata!') - return null - } - - if (!authorMetadata?.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( - authorMetadata.lud16, - amount, - authorMetadata.pubkey as string, - userHexKey, - message - ) - .catch((err) => { - toast.error(err.message || err) - return null - }) - .finally(() => { - setIsLoading(false) - }) - }, [amount, message, userState, pubkey]) - - 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 ( <> @@ -442,77 +318,12 @@ const ZapButtonWithPopUp = ({ pubkey }: ZapButtonWithPopUpProps) => {
{isOpen && ( -
-
-
-
-
-
-

Tip/Zap

-
-
- - - -
-
-
-
-
-
- - -
-
- -
-
-
- - setMessage(e.target.value)} - /> -
- - {paymentRequest && ( - - )} -
-
-
-
-
-
+ setIsOpen(false)} + /> )} - {isLoading && } ) } diff --git a/src/components/Zap.tsx b/src/components/Zap.tsx index 2f441c5..8ef64ad 100644 --- a/src/components/Zap.tsx +++ b/src/components/Zap.tsx @@ -1,12 +1,20 @@ import { QRCodeSVG } from 'qrcode.react' -import React, { Dispatch, SetStateAction, useMemo } from 'react' +import React, { + Dispatch, + ReactNode, + SetStateAction, + useCallback, + useMemo, + useState +} from 'react' import Countdown, { CountdownRenderProps } from 'react-countdown' import { toast } from 'react-toastify' -import { ZapController } from '../controllers' -import { useDidMount } from '../hooks' +import { MetadataController, ZapController } from '../controllers' +import { useAppSelector, useDidMount } from '../hooks' import '../styles/popup.css' import { PaymentRequest } from '../types' -import { copyTextToClipboard } from '../utils' +import { copyTextToClipboard, formatNumber, unformatNumber } from '../utils' +import { LoadingSpinner } from './LoadingSpinner' type PresetAmountProps = { label: string @@ -194,3 +202,223 @@ const Timer = React.memo(({ onTimerExpired }: TimerProps) => {
) }) + +type ZapPopUpProps = { + title: string + labelDescriptionMain?: ReactNode + receiver: string + eventId?: string + aTag?: string + notCloseAfterZap?: boolean + lastNode?: ReactNode + handleClose: () => void +} + +export const ZapPopUp = ({ + title, + labelDescriptionMain, + receiver, + eventId, + aTag, + lastNode, + notCloseAfterZap, + handleClose +}: ZapPopUpProps) => { + const [isLoading, setIsLoading] = useState(false) + const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') + const [amount, setAmount] = useState(0) + const [message, setMessage] = useState('') + const [paymentRequest, setPaymentRequest] = useState() + + const userState = useAppSelector((state) => state.user) + + const handleAmountChange = (event: React.ChangeEvent) => { + const unformattedValue = unformatNumber(event.target.value) + setAmount(unformattedValue) + } + + const 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('finding receiver metadata') + const metadataController = await MetadataController.getInstance() + + const receiverMetadata = await metadataController.findMetadata(receiver) + + if (!receiverMetadata?.lud16) { + setIsLoading(false) + toast.error('Lighting address (lud16) is missing in receiver metadata!') + return null + } + + if (!receiverMetadata?.pubkey) { + setIsLoading(false) + toast.error('pubkey is missing in receiver metadata!') + return null + } + + const zapController = ZapController.getInstance() + + setLoadingSpinnerDesc('Creating zap request') + return await zapController + .getLightningPaymentRequest( + receiverMetadata.lud16, + amount, + receiverMetadata.pubkey as string, + userHexKey, + message, + eventId, + aTag + ) + .catch((err) => { + toast.error(err.message || err) + return null + }) + .finally(() => { + setIsLoading(false) + }) + }, [amount, message, userState, receiver, eventId, aTag]) + + const handleGenerateQRCode = async () => { + const pr = await generatePaymentRequest() + + if (!pr) return + + setPaymentRequest(pr) + } + + 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!`) + if (!notCloseAfterZap) { + 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, notCloseAfterZap, handleClose, generatePaymentRequest]) + + const handleQRExpiry = useCallback(() => { + setPaymentRequest(undefined) + }, []) + + const handleQRClose = useCallback(() => { + setPaymentRequest(undefined) + setIsLoading(false) + if (!notCloseAfterZap) { + handleClose() + } + }, [notCloseAfterZap, handleClose]) + + return ( + <> + {isLoading && } +
+
+
+
+
+
+

{title}

+
+
+ + + +
+
+
+
+
+
+ {labelDescriptionMain} + + +
+
+ +
+
+
+ + setMessage(e.target.value)} + /> +
+ + {paymentRequest && ( + + )} + {lastNode} +
+
+
+
+
+
+ + ) +} diff --git a/src/constants.ts b/src/constants.ts index f0e7bff..16a3eef 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -36,3 +36,21 @@ export const LANDING_PAGE_DATA = { } ] } +// we use this object to check if 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: [':red_heart:', ':blue_heart:', ':sparkling_heart:', ':green_heart:', ':star:', ':rocket:', ':people_hugging:', ':party_popper:', + ':tada:', ':partying_face:', ':confetti_ball:', ':thumbs_up:', ':+1:', ':thumbsup:', ':thumbup:', ':flexed_biceps:', ':muscle:'] + }, + negative: { + emojis: ['-', '💩', '💔', '👎', '😠', '😞', '🤬', '🤢', '🤮', '🖕', '😡', '💢', '😠', '💀'], + shortCodes: [':poop:', ':shit:', ':poo:', ':hankey:', ':pile_of_poo:', ':broken_heart:', ':thumbsdown:', ':thumbdown:', ':nauseated_face:', ':sick:', + ':face_vomiting:', ':vomiting_face:', ':face_with_open_mouth_vomiting:', ':middle_finger:', ':rage:', ':anger:', ':anger_symbol:', ':angry_face:', ':angry:', + ':smiling_face_with_sunglasses:', ':sunglasses:', ':skull:', ':skeleton:'] + } +} 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/layout/header.tsx b/src/layout/header.tsx index 8b1599b..27a157f 100644 --- a/src/layout/header.tsx +++ b/src/layout/header.tsx @@ -2,21 +2,18 @@ import { init as initNostrLogin, launch as launchNostrLoginDialog } from 'nostr-login' -import React, { useCallback, useEffect, useState } from 'react' +import React, { useEffect, useState } from 'react' import { Link } from 'react-router-dom' -import { toast } from 'react-toastify' import { Banner } from '../components/Banner' -import { LoadingSpinner } from '../components/LoadingSpinner' -import { ZapButtons, ZapPresets, ZapQR } from '../components/Zap' -import { MetadataController, ZapController } from '../controllers' -import { useAppDispatch, useAppSelector } from '../hooks' +import { ZapPopUp } from '../components/Zap' +import { MetadataController } from '../controllers' +import { useAppDispatch, useAppSelector, useDidMount } from '../hooks' import { appRoutes } from '../routes' import { setAuth, setUser } from '../store/reducers/user' import mainStyles from '../styles//main.module.scss' import navStyles from '../styles/nav.module.scss' import '../styles/popup.css' -import { PaymentRequest } from '../types' -import { formatNumber, npubToHex, unformatNumber } from '../utils' +import { npubToHex } from '../utils' export const Header = () => { const dispatch = useAppDispatch() @@ -173,7 +170,9 @@ export const Header = () => {
-
+
{ Blog
- @@ -235,124 +251,13 @@ export const Header = () => { } const TipButtonWithDialog = React.memo(() => { + const [adminNpub, setAdminNpub] = useState(null) const [isOpen, setIsOpen] = useState(false) - const [isLoading, setIsLoading] = useState(false) - const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') - - const [amount, setAmount] = useState(0) - const [message, setMessage] = useState('') - - const [paymentRequest, setPaymentRequest] = useState() - - const userState = useAppSelector((state) => state.user) - - const handleClose = useCallback(() => { - setPaymentRequest(undefined) - setIsLoading(false) - setIsOpen(false) - }, []) - - const handleQRExpiry = useCallback(() => { - setPaymentRequest(undefined) - }, []) - - const handleAmountChange = (event: React.ChangeEvent) => { - const unformattedValue = unformatNumber(event.target.value) - setAmount(unformattedValue) - } - - 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, - message - ) - .catch((err) => { - toast.error(err.message || err) - return null - }) - .finally(() => { - setIsLoading(false) - }) - }, [amount, message, 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) - } + useDidMount(async () => { + const metadataController = await MetadataController.getInstance() + setAdminNpub(metadataController.adminNpubs[0]) + }) return ( <> @@ -371,91 +276,38 @@ const TipButtonWithDialog = React.memo(() => { Tip - {isOpen && ( -
-
-
-
-
-
-

Tip/Zap DEG Mods

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

- If you don't want the development and maintenance of DEG - Mods to stop, then a tip helps! -

- - -
-
- -
-
-
- - setMessage(e.target.value)} - /> -
- - {paymentRequest && ( - - )} -
-

- DEG Mod's Silent Payment Bitcoin Address (Be careful. Learn more):
- sp1qq205tj23sq3z6qjxt5ts5ps8gdwcrkwypej3h2z2hdclmaptl25xxqjfqhc2de4gaxprgm0yqwfr737swpvvmrph9ctkeyk60knz6xpjhqumafrd -

-
-
-
-
+ {isOpen && adminNpub && ( + setIsOpen(false)} + labelDescriptionMain={ +

+ If you don't want the development and maintenance of DEG Mods to + stop, then a tip helps! +

+ } + lastNode={ +
+

+ DEG Mod's Silent Payment Bitcoin Address (Be careful.{' '} + + Learn more + + ): +
+ + sp1qq205tj23sq3z6qjxt5ts5ps8gdwcrkwypej3h2z2hdclmaptl25xxqjfqhc2de4gaxprgm0yqwfr737swpvvmrph9ctkeyk60knz6xpjhqumafrd + +

-
-
+ } + /> )} - {isLoading && } ) }) diff --git a/src/pages/home.tsx b/src/pages/home.tsx index dc642cd..243f58f 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -178,6 +178,10 @@ const SlideContent = ({ naddr }: SlideContentProps) => { {mod.summary}

+

+ {mod.game} +
+

-
-
- - - -
-

4.2k

-
-
-
-
-
-
- - - -
-

69

-
-
-
-
+
) @@ -1638,221 +1591,17 @@ const Zap = ({ modDetails }: ZapProps) => {
- {isOpen && } - - ) -} - -type ZapModalProps = { - modDetails: ModDetails - handleClose: Dispatch> -} - -const ZapModal = ({ modDetails, handleClose }: ZapModalProps) => { - return ( -
-
-
-
-
-
-

Tip/Zap

-
-
handleClose(false)} - > - - - -
-
-
-
- - -
-
-
-
-
-
- ) -} - -type ZapModProps = { - modDetails: ModDetails -} - -const ZapMod = ({ modDetails }: ZapModProps) => { - const [isLoading, setIsLoading] = useState(false) - const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') - - const [amount, setAmount] = useState(0) - const [message, setMessage] = useState('') - - const [paymentRequest, setPaymentRequest] = useState() - - const userState = useAppSelector((state) => state.user) - - const handleAmountChange = (event: React.ChangeEvent) => { - const unformattedValue = unformatNumber(event.target.value) - setAmount(unformattedValue) - } - - const handleClose = useCallback(() => { - setPaymentRequest(undefined) - setIsLoading(false) - }, []) - - const handleQRExpiry = useCallback(() => { - setPaymentRequest(undefined) - }, []) - - const generatePaymentRequest = - useCallback(async (): Promise => { - let userHexKey: string - - setIsLoading(true) - setLoadingSpinnerDesc('Getting user pubkey') - - if (userState.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 authorMetadata = await metadataController.findMetadata( - modDetails.author - ) - - if (!authorMetadata?.lud16) { - setIsLoading(false) - toast.error('Lighting address (lud16) is missing in author metadata!') - return null - } - - if (!authorMetadata?.pubkey) { - setIsLoading(false) - toast.error('pubkey is missing in author metadata!') - return null - } - - const zapController = ZapController.getInstance() - - setLoadingSpinnerDesc('Creating zap request') - return await zapController - .getLightningPaymentRequest( - authorMetadata.lud16, - amount, - authorMetadata.pubkey as string, - userHexKey, - message, - modDetails.id, - modDetails.aTag - ) - .catch((err) => { - toast.error(err.message || err) - return null - }) - .finally(() => { - setIsLoading(false) - }) - }, [amount, message, userState, modDetails]) - - const handleSend = useCallback(async () => { - const pr = await generatePaymentRequest() - - if (!pr) return - - setIsLoading(true) - setLoadingSpinnerDesc('Sending payment!') - - const zapController = ZapController.getInstance() - - if (await zapController.isWeblnProviderExists()) { - await zapController - .sendPayment(pr.pr) - .then(() => { - toast.success(`Successfully sent ${amount} sats!`) - handleClose() - }) - .catch((err) => { - toast.error(err.message || err) - }) - } else { - toast.warn('Webln is not present. Use QR code to send zap.') - setPaymentRequest(pr) - } - - setIsLoading(false) - }, [amount, handleClose, generatePaymentRequest]) - - const handleGenerateQRCode = async () => { - const pr = await generatePaymentRequest() - - if (!pr) return - - setPaymentRequest(pr) - } - - return ( - <> -
-
- - -
-
- -
-
-
- - setMessage(e.target.value)} - /> -
- - {paymentRequest && ( - setIsOpen(false)} + lastNode={} + notCloseAfterZap /> )} - {isLoading && } ) } @@ -2030,3 +1779,213 @@ 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 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/mods.tsx b/src/pages/mods.tsx index f64b1c3..ef8b62b 100644 --- a/src/pages/mods.tsx +++ b/src/pages/mods.tsx @@ -220,6 +220,7 @@ export const ModsPage = () => { { const location = useLocation() @@ -47,8 +49,21 @@ export const SettingsPage = () => { const SettingTabs = () => { const location = useLocation() + const [isAdmin, setIsAdmin] = useState(false) const userState = useAppSelector((state) => state.user) + useEffect(() => { + MetadataController.getInstance().then((controller) => { + if (userState.auth && userState.user?.npub) { + setIsAdmin( + controller.adminNpubs.includes(userState.user.npub as string) + ) + } else { + setIsAdmin(false) + } + }) + }, [userState]) + const handleSignOut = () => { logout() } @@ -120,26 +135,28 @@ const SettingTabs = () => { Preference - - - - - Admin - + + + + Admin + + )}
{userState.auth && diff --git a/src/styles/SimpleSlider.css b/src/styles/SimpleSlider.css index 60bf918..38b2ac0 100644 --- a/src/styles/SimpleSlider.css +++ b/src/styles/SimpleSlider.css @@ -274,6 +274,13 @@ } } +.IBMSMSCWSInfoText.IBMSMSCWSInfoText2 { + -webkit-line-clamp: 1; + border-top: solid 1px rgba(255,255,255,0.1); + padding: 10px 0 0 5px; + flex-grow: 0; +} + .swiper-pagination { display: none; bottom: -10px !important; diff --git a/src/styles/cardMod.css b/src/styles/cardMod.css index 4bd8600..49702e1 100644 --- a/src/styles/cardMod.css +++ b/src/styles/cardMod.css @@ -112,6 +112,20 @@ line-height: 1.5; } +.cMMBodyGame { + border-radius: 5px; + padding: 5px 10px; + flex-direction: row; + justify-content: start; + align-items: center; + font-size: 14px; + background: rgba(255,255,255,0.05); + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; + -webkit-line-clamp: 1; +} + .cMMFootReactions { display: flex; flex-direction: row;