From d9347014ecf12173e31f303be6e0959c9dbe6470 Mon Sep 17 00:00:00 2001 From: daniyal Date: Wed, 28 Aug 2024 22:03:43 +0500 Subject: [PATCH] feat: added the ability to report and block posts --- .env.example | 6 +- .gitea/workflows/release-production.yaml | 3 +- .gitea/workflows/release-staging.yaml | 1 + src/controllers/metadata.ts | 59 ++- src/controllers/relay.ts | 139 +++++- src/pages/innerMod.tsx | 609 +++++++++++++++++++---- src/pages/mods.tsx | 24 +- src/types/mod.ts | 2 +- src/utils/nostr.ts | 118 ++++- src/vite-env.d.ts | 1 + 10 files changed, 844 insertions(+), 118 deletions(-) diff --git a/.env.example b/.env.example index f726dee..6404610 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,8 @@ # This relay will be used to publish/retrieve events along with other relays (user's relays, admin relays) VITE_APP_RELAY=wss://relay.degmods.com + # A comma separated list of npubs, Relay list will be extracted for these npubs and this relay list will be used to publish event -VITE_ADMIN_NPUBS= \ No newline at end of file +VITE_ADMIN_NPUBS= + +# A dedicated npub used for reporting mods, blogs, profile and etc. +VITE_REPORTING_NPUB= \ No newline at end of file diff --git a/.gitea/workflows/release-production.yaml b/.gitea/workflows/release-production.yaml index c6abd6c..1415a50 100644 --- a/.gitea/workflows/release-production.yaml +++ b/.gitea/workflows/release-production.yaml @@ -24,6 +24,7 @@ jobs: run: | echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env + echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env cat .env - name: Create Build @@ -34,4 +35,4 @@ jobs: npm -g install cloudron-surfer surfer config --token ${{ secrets.PRODUCTION_CLOUDRON_SURFER_TOKEN }} --server degmods.com surfer put dist/* / --all -d - surfer put dist/.well-known / --all \ No newline at end of file + surfer put dist/.well-known / --all diff --git a/.gitea/workflows/release-staging.yaml b/.gitea/workflows/release-staging.yaml index 9fe8173..c08b4e8 100644 --- a/.gitea/workflows/release-staging.yaml +++ b/.gitea/workflows/release-staging.yaml @@ -24,6 +24,7 @@ jobs: run: | echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env + echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env cat .env - name: Create Build diff --git a/src/controllers/metadata.ts b/src/controllers/metadata.ts index b6b2e7c..55938b2 100644 --- a/src/controllers/metadata.ts +++ b/src/controllers/metadata.ts @@ -19,6 +19,7 @@ export class MetadataController { private usersMetadata = new Map() public adminNpubs: string[] public adminRelays = new Set() + public reportingNpub: string private constructor() { this.ndk = new NDK({ @@ -40,6 +41,7 @@ export class MetadataController { }) this.adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',') + this.reportingNpub = import.meta.env.VITE_REPORTING_NPUB } private setAdminRelays = async () => { @@ -129,13 +131,18 @@ export class MetadataController { return ndkRelayList[userRelaysType] } - public getAdminsMuteLists = async (): Promise => { - // Create a Set to collect all unique muted authors - + public getMuteLists = async (pubkey?: string): Promise => { + // Create sets to collect all unique muted authors and replaceable event Identifiers const mutedAuthors = new Set() + const mutedEvents = new Set() + + // construct an array of npubs with dedicated reporting npub and provided pubkey + const npubs = [this.reportingNpub] + + if (pubkey) npubs.push(pubkey) // Create an array of promises to fetch mute lists for each npub - const promises = this.adminNpubs.map(async (npub) => { + const promises = npubs.map(async (npub) => { const hexKey = npubToHex(npub) if (!hexKey) return @@ -148,9 +155,10 @@ export class MetadataController { const list = NDKList.from(muteListEvent) list.items.forEach((item) => { - // Add muted authors to the Set directly if (item[0] === 'p') { mutedAuthors.add(item[1]) + } else if (item[0] === 'a') { + mutedEvents.add(item[1]) } }) } @@ -160,7 +168,46 @@ export class MetadataController { return { authors: Array.from(mutedAuthors), - eventIds: [] + replaceableEvents: Array.from(mutedEvents) } } + + /** + * Retrieves a list of NSFW (Not Safe For Work) posts that were not specified as NSFW by post author but marked as NSFW by admin. + * + * @returns {Promise} - A promise that resolves to an array of NSFW post identifiers (e.g., URLs or IDs). + */ + public getNSFWList = async (): Promise => { + // Initialize an array to store the NSFW post identifiers + const nsfwPosts: string[] = [] + + // Convert the public key (npub) to a hexadecimal format + const hexKey = npubToHex(this.reportingNpub) + + // If the conversion is successful and we have a hexKey + if (hexKey) { + // Fetch the event that contains the NSFW list + const nsfwListEvent = await this.ndk.fetchEvent({ + kinds: [kinds.Curationsets], + authors: [hexKey], + '#d': ['nsfw'] + }) + + if (nsfwListEvent) { + // Convert the event data to an NDKList, which is a structured list format + const list = NDKList.from(nsfwListEvent) + + // Iterate through the items in the list + list.items.forEach((item) => { + if (item[0] === 'a') { + // Add the identifier of the NSFW post to the nsfwPosts array + nsfwPosts.push(item[1]) + } + }) + } + } + + // Return the array of NSFW post identifiers + return nsfwPosts + } } diff --git a/src/controllers/relay.ts b/src/controllers/relay.ts index 98ab1f1..f688878 100644 --- a/src/controllers/relay.ts +++ b/src/controllers/relay.ts @@ -1,4 +1,5 @@ import { Event, Filter, kinds, Relay } from 'nostr-tools' +import { ModDetails } from '../types' import { extractZapAmount, log, @@ -7,7 +8,6 @@ import { timeout } from '../utils' import { MetadataController, UserRelaysType } from './metadata' -import { ModDetails } from '../types' /** * Singleton class to manage relay operations. @@ -155,6 +155,103 @@ export class RelayController { return publishedOnRelays } + /** + * Publishes an encrypted DM to receiver's read relays. + * + * This method connects to the application relay and a set of receiver's read 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. + * + * @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. + */ + publishDM = async (event: Event, receiver: string): Promise => { + // Connect to the application relay specified by 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 + + 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( + 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) + }) + + // Connect to all write relays obtained from MetadataController + const relayPromises = readRelayUrls.map((relayUrl) => + this.connectRelay(relayUrl) + ) + + // Wait for all relay connections 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 (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 + + // Create a promise for publishing 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 cases where publishing takes too long + ]) + .then((res) => { + log( + this.debug, + LogType.Info, + `⬆️ nostr (${relay.url}): Publish result:`, + res + ) + publishedOnRelays.push(relay.url) // Add the relay URL to the list of successfully published relays + }) + .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) + + // Return the list of relay URLs where the event was 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. @@ -247,6 +344,46 @@ export class RelayController { return events[0] || null } + /** + * 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 + ) => { + // 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( + 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.fetchEvent(filter, relayUrls) + } + getTotalZapAmount = async ( modDetails: ModDetails, currentLoggedInUser?: string diff --git a/src/pages/innerMod.tsx b/src/pages/innerMod.tsx index 2e8c4de..8182939 100644 --- a/src/pages/innerMod.tsx +++ b/src/pages/innerMod.tsx @@ -2,7 +2,7 @@ import Link from '@tiptap/extension-link' import { EditorContent, useEditor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import { formatDate } from 'date-fns' -import { Filter, nip19 } from 'nostr-tools' +import { Filter, kinds, nip19, UnsignedEvent } from 'nostr-tools' import { Dispatch, SetStateAction, useCallback, useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { toast } from 'react-toastify' @@ -13,6 +13,7 @@ import { ZapButtons, ZapPresets, ZapQR } from '../components/Zap' import { MetadataController, RelayController, + UserRelaysType, ZapController } from '../controllers' import { useAppSelector, useDidMount } from '../hooks' @@ -37,7 +38,11 @@ import { getFilenameFromUrl, log, LogType, - unformatNumber + now, + npubToHex, + sendDMUsingRandomKey, + unformatNumber, + signAndPublish } from '../utils' export const InnerModPage = () => { @@ -114,6 +119,7 @@ export const InnerModPage = () => { naddr={naddr!} game={modData.game} author={modData.author} + aTag={modData.aTag} /> { +const Game = ({ naddr, game, author, aTag }: GameProps) => { const navigate = useNavigate() const userState = useAppSelector((state) => state.user) + const [isLoading, setIsLoading] = useState(false) + const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') + const [showReportPopUp, setShowReportPopUp] = useState(false) + + const handleBlock = async () => { + let hexPubkey: string + + setIsLoading(true) + setLoadingSpinnerDesc('Getting user pubkey') + + 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 for updating mute list') + setIsLoading(false) + return + } + + setLoadingSpinnerDesc(`Finding user's mute list`) + + // Define the event filter to search for the user's mute list events. + // We look for events of a specific kind (Mutelist) authored by the given hexPubkey. + const filter: Filter = { + kinds: [kinds.Mutelist], + authors: [hexPubkey] + } + + // Fetch the mute list event from the relays. This returns the event containing the user's mute list. + const muteListEvent = + await RelayController.getInstance().fetchEventFromUserRelays( + filter, + hexPubkey, + UserRelaysType.Write + ) + + let unsignedEvent: UnsignedEvent + + if (muteListEvent) { + // get a list of tags + const tags = muteListEvent.tags + const alreadyExists = + tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1 + + if (alreadyExists) { + setIsLoading(false) + return toast.warn(`Mod reference is already in user's mute list`) + } + + tags.push(['a', aTag]) + + unsignedEvent = { + pubkey: muteListEvent.pubkey, + kind: muteListEvent.kind, + content: muteListEvent.content, + created_at: now(), + tags: [...tags] + } + } else { + unsignedEvent = { + pubkey: hexPubkey, + kind: kinds.Mutelist, + content: '', + created_at: now(), + tags: [['a', aTag]] + } + } + + setLoadingSpinnerDesc('Updating mute list event') + + await signAndPublish(unsignedEvent) + setIsLoading(false) + } + + const handleBlockNSFW = async () => { + const pubkey = userState.user?.pubkey as string | undefined + + if (!pubkey) return + + const filter: Filter = { + kinds: [kinds.Curationsets], + authors: [pubkey], + '#d': ['nsfw'] + } + + setIsLoading(true) + setLoadingSpinnerDesc('Finding NSFW list') + + const nsfwListEvent = + await RelayController.getInstance().fetchEventFromUserRelays( + filter, + pubkey, + UserRelaysType.Write + ) + + let unsignedEvent: UnsignedEvent + + if (nsfwListEvent) { + // get a list of tags + const tags = nsfwListEvent.tags + const alreadyExists = + tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1 + + if (alreadyExists) { + setIsLoading(false) + return toast.warn(`Mod reference is already in user's nsfw list`) + } + + tags.push(['a', aTag]) + + unsignedEvent = { + pubkey: nsfwListEvent.pubkey, + kind: nsfwListEvent.kind, + content: nsfwListEvent.content, + created_at: now(), + tags: [...tags] + } + } else { + unsignedEvent = { + pubkey: pubkey, + kind: kinds.Curationsets, + content: '', + created_at: now(), + tags: [ + ['a', aTag], + ['d', 'nsfw'] + ] + } + } + + setLoadingSpinnerDesc('Updating nsfw list event') + + await signAndPublish(unsignedEvent) + setIsLoading(false) + } + + const isAdmin = + userState.user?.npub && + userState.user.npub === import.meta.env.VITE_REPORTING_NPUB return ( -
-

- Mod for:  - - {game} - -

-
- -
- {userState.auth && userState.user?.pubkey === author && ( + + + + +
+ {userState.auth && userState.user?.pubkey === author && ( + navigate(getModsEditPageRoute(naddr))} + > + + + + Edit + + )} + navigate(getModsEditPageRoute(naddr))} + onClick={() => { + copyTextToClipboard(window.location.href).then((isCopied) => { + if (isCopied) toast.success('Url copied to clipboard!') + }) + }} > { fill='currentColor' className='IBMSMSMSSS_Author_Top_Icon' > - + - Edit + Copy URL - )} - - { - copyTextToClipboard(window.location.href).then((isCopied) => { - if (isCopied) toast.success('Url copied to clipboard!') - }) - }} - > - + + + + Share + + setShowReportPopUp(true)} > - - - Copy URL - - - + + + Report + + - - - Share - - - - - - Report - - - - - - Block Post - + + + + Block Post + + {isAdmin && ( + + + + + Block Post NSFW + + )} +
-
+ {showReportPopUp && ( + setShowReportPopUp(false)} + /> + )} + + ) +} + +type ReportPopupProps = { + aTag: string + handleClose: () => void +} + +const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => { + const userState = useAppSelector((state) => state.user) + const [selectedOptions, setSelectedOptions] = useState({ + actuallyCP: false, + spam: false, + scam: false, + notAGameMod: false, + stolenGameMod: false + }) + const [isLoading, setIsLoading] = useState(false) + const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') + + const handleCheckboxChange = (option: keyof typeof selectedOptions) => { + setSelectedOptions((prevState) => ({ + ...prevState, + [option]: !prevState[option] + })) + } + + const handleSubmit = async () => { + const selectedOptionsCount = Object.values(selectedOptions).filter( + (isSelected) => isSelected + ).length + + if (selectedOptionsCount === 0) { + toast.error('At least one option should be checked!') + return + } + let hexPubkey: string + + setIsLoading(true) + setLoadingSpinnerDesc('Getting user pubkey') + + 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 for reporting mod!') + setIsLoading(false) + return + } + + const metadataController = await MetadataController.getInstance() + const reportingPubkey = npubToHex(metadataController.reportingNpub) + + if (reportingPubkey === hexPubkey) { + setLoadingSpinnerDesc(`Finding user's mute list`) + // Define the event filter to search for the user's mute list events. + // We look for events of a specific kind (Mutelist) authored by the given hexPubkey. + const filter: Filter = { + kinds: [kinds.Mutelist], + authors: [hexPubkey] + } + + // Fetch the mute list event from the relays. This returns the event containing the user's mute list. + const muteListEvent = + await RelayController.getInstance().fetchEventFromUserRelays( + filter, + hexPubkey, + UserRelaysType.Write + ) + + let unsignedEvent: UnsignedEvent + + if (muteListEvent) { + // get a list of tags + const tags = muteListEvent.tags + const alreadyExists = + tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1 + + if (alreadyExists) { + setIsLoading(false) + return toast.warn(`Mod reference is already in user's mute list`) + } + + tags.push(['a', aTag]) + + unsignedEvent = { + pubkey: muteListEvent.pubkey, + kind: muteListEvent.kind, + content: muteListEvent.content, + created_at: now(), + tags: [...tags] + } + } else { + unsignedEvent = { + pubkey: hexPubkey, + kind: kinds.Mutelist, + content: '', + created_at: now(), + tags: [['a', aTag]] + } + } + + setLoadingSpinnerDesc('Updating mute list event') + const isUpdated = await signAndPublish(unsignedEvent) + if (isUpdated) handleClose() + } else { + const href = window.location.href + let message = `I'd like to report ${href} due to following reasons:\n` + + Object.entries(selectedOptions).forEach(([key, value]) => { + if (value) { + message += `* ${key}\n` + } + }) + + setLoadingSpinnerDesc('Sending report') + const isSent = await sendDMUsingRandomKey(message, reportingPubkey!) + if (isSent) handleClose() + } + setIsLoading(false) + } + + return ( + <> + {isLoading && } +
+
+
+
+
+
+

Report Post

+
+
+ + + +
+
+
+
+
+ +
+ + handleCheckboxChange('actuallyCP')} + /> +
+
+ + handleCheckboxChange('spam')} + /> +
+
+ + handleCheckboxChange('scam')} + /> +
+
+ + handleCheckboxChange('notAGameMod')} + /> +
+
+ + handleCheckboxChange('stolenGameMod')} + /> +
+
+ +
+
+
+
+
+
+ ) } diff --git a/src/pages/mods.tsx b/src/pages/mods.tsx index 83d08be..7fbe7c0 100644 --- a/src/pages/mods.tsx +++ b/src/pages/mods.tsx @@ -11,7 +11,7 @@ import { useNavigate } from 'react-router-dom' import { LoadingSpinner } from '../components/LoadingSpinner' import { ModCard } from '../components/ModCard' import { MetadataController } from '../controllers' -import { useDidMount } from '../hooks' +import { useAppSelector, useDidMount } from '../hooks' import { getModsInnerPageRoute } from '../routes' import '../styles/filters.css' import '../styles/pagination.css' @@ -58,16 +58,25 @@ export const ModsPage = () => { }) const [muteLists, setMuteLists] = useState({ authors: [], - eventIds: [] + replaceableEvents: [] }) + const [nsfwList, setNSFWList] = useState([]) const [page, setPage] = useState(1) + const userState = useAppSelector((state) => state.user) + useDidMount(async () => { + const pubkey = userState.user?.pubkey as string | undefined + const metadataController = await MetadataController.getInstance() - metadataController.getAdminsMuteLists().then((lists) => { + metadataController.getMuteLists(pubkey).then((lists) => { setMuteLists(lists) }) + + metadataController.getNSFWList().then((list) => { + setNSFWList(list) + }) }) useEffect(() => { @@ -118,13 +127,13 @@ export const ModsPage = () => { switch (filterOptions.nsfw) { case NSFWFilter.Hide_NSFW: // If 'Hide_NSFW' is selected, filter out NSFW mods - return mods.filter((mod) => !mod.nsfw) + return mods.filter((mod) => !mod.nsfw && !nsfwList.includes(mod.aTag)) case NSFWFilter.Show_NSFW: // If 'Show_NSFW' is selected, return all mods (no filtering) return mods case NSFWFilter.Only_NSFW: // If 'Only_NSFW' is selected, filter to show only NSFW mods - return mods.filter((mod) => mod.nsfw) + return mods.filter((mod) => mod.nsfw || nsfwList.includes(mod.aTag)) } } @@ -134,7 +143,7 @@ export const ModsPage = () => { filtered = filtered.filter( (mod) => !muteLists.authors.includes(mod.author) && - !muteLists.eventIds.includes(mod.id) + !muteLists.replaceableEvents.includes(mod.aTag) ) } @@ -150,7 +159,8 @@ export const ModsPage = () => { filterOptions.moderated, filterOptions.nsfw, mods, - muteLists + muteLists, + nsfwList ]) return ( diff --git a/src/types/mod.ts b/src/types/mod.ts index 06650af..b161187 100644 --- a/src/types/mod.ts +++ b/src/types/mod.ts @@ -32,5 +32,5 @@ export interface ModDetails extends Omit { export interface MuteLists { authors: string[] - eventIds: string[] + replaceableEvents: string[] } diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index 9d1c7c8..95f78dd 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -1,4 +1,16 @@ -import { nip19, Event } from 'nostr-tools' +import { + Event, + finalizeEvent, + generateSecretKey, + getPublicKey, + kinds, + nip04, + nip19, + UnsignedEvent +} from 'nostr-tools' +import { toast } from 'react-toastify' +import { RelayController } from '../controllers' +import { log, LogType } from './utils' /** * Get the current time in seconds since the Unix epoch (January 1, 1970). @@ -121,3 +133,107 @@ export const extractZapAmount = (event: Event): number => { // Return 0 if the zap amount cannot be determined return 0 } + +/** + * Signs and publishes an event to user's relays. + * + * @param unsignedEvent - The event object which needs to be signed before publishing. + * @returns - A promise that resolves to an array of relay URLs where the event was successfully published, or null if the operation failed. + */ +export const signAndPublish = async (unsignedEvent: UnsignedEvent) => { + // Sign the event. This returns a signed event or null if signing fails. + const signedEvent = await window.nostr + ?.signEvent(unsignedEvent) + .then((event) => event as Event) + .catch((err) => { + // If signing the event fails, display an error toast and log the error. + toast.error('Failed to sign the event!') + log(true, LogType.Error, 'Failed to sign the event!', err) + return null + }) + + // If the event couldn't be signed, exit the function and return null. + if (!signedEvent) return false + + // Publish the signed event to the relays using the RelayController. + // This returns an array of relay URLs where the event was successfully published. + const publishedOnRelays = await RelayController.getInstance().publish( + signedEvent as Event + ) + + // Handle cases where publishing to the relays failed + if (publishedOnRelays.length === 0) { + // Display an error toast if the event could not be published to any relay. + toast.error('Failed to publish event on any relay') + return false + } + + // Display a success toast with the list of relays where the event was successfully published. + toast.success( + `Event published successfully to the following relays\n\n${publishedOnRelays.join( + '\n' + )}` + ) + + return true +} + +/** + * Sends an encrypted direct message (DM) to a receiver using a randomly generated secret key. + * + * @param message - The plaintext message content to be sent. + * @param receiver - The public key of the receiver to whom the message is being sent. + * @returns - A promise that resolves to true if the message was successfully sent, or false if an error occurred. + */ +export const sendDMUsingRandomKey = async ( + message: string, + receiver: string +) => { + // Generate a random secret key for encrypting the message + const secretKey = generateSecretKey() + + // Encrypt the message using the generated secret key and the receiver's public key + const encryptedMessage = await nip04 + .encrypt(secretKey, receiver, message) + .catch((err) => { + // If encryption fails, display an error toast + toast.error( + `An error occurred in encrypting message content: ${err.message || err}` + ) + return null + }) + + // If encryption failed, exit the function and return false + if (!encryptedMessage) return false + + // Construct the unsigned event containing the encrypted message and relevant metadata + const unsignedEvent: UnsignedEvent = { + pubkey: getPublicKey(secretKey), + kind: kinds.EncryptedDirectMessage, + created_at: now(), + tags: [['p', receiver]], + content: encryptedMessage + } + + // Finalize and sign the event using the generated secret key + const signedEvent = finalizeEvent(unsignedEvent, secretKey) + + // Publish the signed event (the encrypted DM) to the relays + const publishedOnRelays = await RelayController.getInstance().publishDM( + signedEvent, + receiver + ) + + // Handle cases where publishing to the relays failed + if (publishedOnRelays.length === 0) { + // Display an error toast if the event could not be published to any relay + toast.error('Failed to publish encrypted direct message on any relay') + return false + } + + // Display a success toast if the event was successfully published to one or more relays + toast.success(`Report successfully submitted!`) + + // Return true indicating that the DM was successfully sent + return true +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index a399374..141f56d 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -3,6 +3,7 @@ interface ImportMetaEnv { readonly VITE_APP_RELAY: string readonly VITE_ADMIN_NPUBS: string + readonly VITE_REPORTING_NPUB: string // more env variables... }