diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index 3374a08..40da254 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -39,7 +39,9 @@ import { updateUsersAppData, uploadToFileStorage, DEFAULT_TOOLBOX, - settleAllFullfilfedPromises + settleAllFullfilfedPromises, + sendPrivateDirectMessage, + parseNostrEvent } from '../../utils' import { Container } from '../../components/Container' import fileListStyles from '../../components/FileList/style.module.scss' @@ -62,6 +64,7 @@ import { } from '@fortawesome/free-solid-svg-icons' import { getSigitFile, SigitFile } from '../../utils/file.ts' import _ from 'lodash' +import { SendDMError } from '../../types/errors/SendDMError.ts' export const CreatePage = () => { const navigate = useNavigate() @@ -713,6 +716,31 @@ export const CreatePage = () => { toast.error('Failed to publish notifications') }) + // Send DM to the next signer + setLoadingSpinnerDesc('Sending DMs') + if (signers.length > 0 && signers[0].pubkey !== usersPubkey) { + // No need to send notification to self so remove it from the list + const nextSigner = signers[0].pubkey + + if (nextSigner) { + const createSignatureEvent = await parseNostrEvent( + meta.createSignature + ) + const { id } = createSignatureEvent + try { + await sendPrivateDirectMessage( + `Sigit created, visit ${window.location.origin}/#/sign/${id}`, + npubToHex(nextSigner)! + ) + } catch (error) { + if (error instanceof SendDMError) { + toast.error(error.message) + } + console.error(error) + } + } + } + navigate(appPrivateRoutes.sign, { state: { meta } }) } else { const zip = new JSZip() diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index dbbcd98..ed925e1 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -33,7 +33,9 @@ import { signEventForMetaFile, updateUsersAppData, findOtherUserMarks, - timeout + timeout, + sendPrivateDirectMessage, + parseNostrEvent } from '../../utils' import { Container } from '../../components/Container' import { DisplayMeta } from './internal/displayMeta' @@ -53,6 +55,7 @@ import { SigitFile } from '../../utils/file.ts' import { ARRAY_BUFFER, DEFLATE } from '../../utils/const.ts' +import { SendDMError } from '../../types/errors/SendDMError.ts' enum SignedStatus { Fully_Signed, @@ -720,6 +723,65 @@ export const SignPage = () => { toast.error('Failed to publish notifications') }) + // Send DMs + setLoadingSpinnerDesc('Sending DMs') + const createSignatureEvent = await parseNostrEvent(meta.createSignature) + const { id } = createSignatureEvent + + if (isLastSigner) { + // Final sign sends to everyone (creator, signers, viewers - /verify) + const areSent: boolean[] = Array(users.length).fill(false) + for (let i = 0; i < users.length; i++) { + try { + areSent[i] = await sendPrivateDirectMessage( + `Sigit completed, visit ${window.location.origin}/#/verify/${id}`, + npubToHex(users[i])! + ) + } catch (error) { + if (error instanceof SendDMError) { + toast.error(error.message) + } + console.error(error) + } + } + + if (areSent.some((r) => r)) { + toast.success( + `DMs sent ${areSent.filter((r) => r).length}/${users.length}` + ) + } + } else { + // Notify the creator and the next signer (/sign). + try { + await sendPrivateDirectMessage( + `Sigit signed by ${usersNpub}, visit ${window.location.origin}/#/sign/${id}`, + npubToHex(submittedBy!)! + ) + } catch (error) { + if (error instanceof SendDMError) { + toast.error(error.message) + } + console.error(error) + } + + // No need to notify creator twice, skipping + const currentSignerIndex = signers.indexOf(usersNpub) + const nextSigner = signers[currentSignerIndex + 1] + if (nextSigner !== submittedBy) { + try { + await sendPrivateDirectMessage( + `You're the next signer, visit ${window.location.origin}/#/sign/${id}`, + npubToHex(nextSigner)! + ) + } catch (error) { + if (error instanceof SendDMError) { + toast.error(error.message) + } + console.error(error) + } + } + } + setIsLoading(false) } diff --git a/src/types/errors/SendDMError.ts b/src/types/errors/SendDMError.ts new file mode 100644 index 0000000..223823b --- /dev/null +++ b/src/types/errors/SendDMError.ts @@ -0,0 +1,24 @@ +import { Jsonable } from '.' + +export enum SendDMErrorType { + 'METADATA_FETCH_FAILED' = 'Sending DM failed. An error occured while fetching user metadata.', + 'RELAY_READ_EMPTY' = `Sending DM failed. The user's relay read set is empty.`, + 'ENCRYPTION_FAILED' = 'Sending DM failed. An error occurred in encrypting dm message.', + 'RELAY_PUBLISH_FAILED' = 'Sending DM failed. Publishing events failed.' +} + +export class SendDMError extends Error { + public readonly context?: Jsonable + + constructor( + message: string, + options: { cause?: Error; context?: Jsonable } = {} + ) { + const { cause, context } = options + + super(message, { cause }) + this.name = this.constructor.name + + this.context = context + } +} diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index ec8c97e..2046b38 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -6,6 +6,7 @@ import { EventTemplate, Filter, UnsignedEvent, + VerifiedEvent, finalizeEvent, generateSecretKey, getEventHash, @@ -21,7 +22,8 @@ import { NIP05_REGEX } from '../constants' import { MetadataController, NostrController, - relayController + relayController, + RelayController } from '../controllers' import { updateProcessedGiftWraps, @@ -35,6 +37,7 @@ import { parseJson, removeLeadingSlash } from './string' import { timeout } from './utils' import { getHash } from './hash' import { SIGIT_BLOSSOM } from './const.ts' +import { SendDMError, SendDMErrorType } from '../types/errors/SendDMError.ts' /** * Generates a `d` tag for userAppData @@ -248,6 +251,12 @@ export const toUnixTimestamp = (date: number | Date) => { export const fromUnixTimestamp = (unix: number) => { return unix * 1000 } +export const randomTimeUpTo2DaysInThePast = (): number => { + const now = Date.now() + const twoDaysInMilliseconds = 2 * 24 * 60 * 60 * 1000 + const randomPastTime = now - Math.floor(Math.random() * twoDaysInMilliseconds) + return toUnixTimestamp(randomPastTime) +} /** * Generate nip44 conversation key @@ -297,19 +306,21 @@ export const countLeadingZeroes = (hex: string) => { /** * Function to create a wrapped event with PoW - * @param event Original event to be wrapped + * @param event Original event to be wrapped (can be unsigned or verified) * @param receiver Public key of the receiver - * @param difficulty PoW difficulty level (default is 20) * @returns */ // -export const createWrap = (unsignedEvent: UnsignedEvent, receiver: string) => { +export const createWrap = ( + event: UnsignedEvent | VerifiedEvent, + receiver: string +) => { // Generate a random secret key and its corresponding public key const randomKey = generateSecretKey() const pubkey = getPublicKey(randomKey) // Encrypt the event content using nip44 encryption - const content = nip44Encrypt(unsignedEvent, randomKey, receiver) + const content = nip44Encrypt(event, randomKey, receiver) // Initialize nonce and leadingZeroes for PoW calculation let nonce = 0 @@ -320,11 +331,12 @@ export const createWrap = (unsignedEvent: UnsignedEvent, receiver: string) => { // eslint-disable-next-line no-constant-condition while (true) { // Create an unsigned event with the necessary fields + // TODO: kinds.GiftWrap (wrong kind number in nostr-tools 10/11/2024 at v2.7.2) const event: UnsignedEvent = { kind: 1059, // Event kind content, // Encrypted content pubkey, // Public key of the creator - created_at: unixNow(), // Current timestamp + created_at: randomTimeUpTo2DaysInThePast(), tags: [ // Tags including receiver and nonce ['p', receiver], @@ -989,3 +1001,145 @@ export const getProfileUsername = ( truncate(profile?.display_name || profile?.name || hexToNpub(npub), { length: 16 }) + +/** + * Modified {@link UnsignedEvent Unsigned Event} that includes an id + * + * Fields id and created_at are required. + * @see {@link UnsignedEvent} + * @see {@link https://github.com/nostr-protocol/nips/blob/master/17.md#direct-message-kind} + */ +type UnsignedEventWithId = UnsignedEvent & { + id?: string +} +export const sendPrivateDirectMessage = async ( + message: string, + receiver: string, + subject?: string +) => { + // Instantiate the MetadataController to retrieve relay list metadata to look for preferred DM relays + const metadataController = MetadataController.getInstance() + const relaySet = await metadataController + .findRelayListMetadata(receiver) + .catch((err) => { + // Log an error if retrieving relay list metadata fails + console.log( + `An error occurred while finding relay list metadata for ${hexToNpub(receiver)}`, + err + ) + return null + }) + + // Throw if metadata retrieval failed + if (!relaySet) { + throw new SendDMError(SendDMErrorType.METADATA_FETCH_FAILED, { + context: { + receiver + } + }) + } + + // Ensure relay list is not empty + if (relaySet.read.length === 0) { + throw new SendDMError(SendDMErrorType.RELAY_READ_EMPTY, { + context: { + receiver, + relaySet: JSON.stringify(relaySet) + } + }) + } + // Get the direct message preferred relays list + // TODO: kinds.DirectMessageRelaysList (unavailabe in nostr-tools 10/10/2024 at v2.7.0) + // https://github.com/nostr-protocol/nips/blob/master/17.md#publishing + const eventFilter: Filter = { + kinds: [10050], + authors: [receiver] + } + const preferredRelaysListEvents = + await RelayController.getInstance().fetchEvents(eventFilter, relaySet.read) + + const isRelayTag = (tag: string[]): boolean => tag[0] === 'relay' + const preferredRelaysList = preferredRelaysListEvents.reduce( + (previous: string[], current: Event) => { + const relaysList = current.tags + .filter((t) => isRelayTag(t) && !previous.includes(t[1])) + .map((t) => t[1]) + + return [...previous, ...relaysList] + }, + [] + ) + // Empty preferred relays list + const finalRelaysList: string[] = + preferredRelaysList?.length > 0 ? preferredRelaysList : [...relaySet.write] + + // Generate "sender" + const senderSecret = generateSecretKey() + const senderPubkey = getPublicKey(senderSecret) + + // Prepare tags for the message + const tags: string[][] = [['p', receiver]] + + // Conversation title + if (subject) tags.push(['subject', subject]) + + // Create private DM event containing the message and relevant metadata + // TODO: kinds.PrivateDirectMessage (unavailabe in nostr-tools 10/10/2024 at v2.7.0) + const dm: UnsignedEventWithId = { + pubkey: senderPubkey, + created_at: unixNow(), + kind: 14, + tags, + content: message + } + + // Calculate the hash based on the UnverifiedEvent + dm.id = getEventHash(dm) + + // Encrypt the private dm using the sender secret and the receiver's public key + const encryptedDm = nip44Encrypt(dm, senderSecret, receiver) + if (!encryptedDm) { + throw new SendDMError(SendDMErrorType.ENCRYPTION_FAILED, { + context: { + receiver, + message, + kind: dm.kind + } + }) + } + + // Seal the message + // TODO: kinds.Seal (unavailabe in nostr-tools 10/10/2024 at v2.7.0) + const sealedMessage: UnsignedEvent = { + kind: 13, // seal + pubkey: senderPubkey, + content: encryptedDm, + created_at: randomTimeUpTo2DaysInThePast(), + tags: [] // no tags + } + + // Finalize and sign the sealed event + const finalizedSeal = finalizeEvent(sealedMessage, senderSecret) + + // Encrypt the seal and gift wrap + const finalizedGiftWrap = createWrap(finalizedSeal, receiver) + + // Publish the finalized gift wrap event (the encrypted DM) to the relays + const publishedOnRelays = await relayController.publish( + finalizedGiftWrap, + finalRelaysList + ) + + // Handle cases where publishing to the relays failed + if (publishedOnRelays.length === 0) { + throw new SendDMError(SendDMErrorType.ENCRYPTION_FAILED, { + context: { + receiver, + count: publishedOnRelays.length + } + }) + } + + // Return true indicating that the DM was successfully sent + return true +} diff --git a/src/utils/relays.ts b/src/utils/relays.ts index bfef7aa..c7be280 100644 --- a/src/utils/relays.ts +++ b/src/utils/relays.ts @@ -97,20 +97,23 @@ const isOlderThanOneDay = (cachedAt: number) => { } const isRelayTag = (tag: string[]): boolean => tag[0] === 'r' - +const addRelay = (list: string[], relay: string) => { + // Only add if the list doesn't already include the relay + if (!list.includes(relay)) list.push(relay) +} const toRelaySet = (obj: RelaySet, tag: string[]): RelaySet => { if (tag.length >= 3) { const marker = tag[2] if (marker === READ_MARKER) { - obj.read.push(tag[1]) + addRelay(obj.read, tag[1]) } else if (marker === WRITE_MARKER) { - obj.write.push(tag[1]) + addRelay(obj.write, tag[1]) } } if (tag.length === 2) { - obj.read.push(tag[1]) - obj.write.push(tag[1]) + addRelay(obj.read, tag[1]) + addRelay(obj.write, tag[1]) } return obj