feat: add private dm sending
This commit is contained in:
parent
70e525357c
commit
e85e9519d2
@ -39,7 +39,9 @@ import {
|
|||||||
updateUsersAppData,
|
updateUsersAppData,
|
||||||
uploadToFileStorage,
|
uploadToFileStorage,
|
||||||
DEFAULT_TOOLBOX,
|
DEFAULT_TOOLBOX,
|
||||||
settleAllFullfilfedPromises
|
settleAllFullfilfedPromises,
|
||||||
|
sendPrivateDirectMessage,
|
||||||
|
parseNostrEvent
|
||||||
} from '../../utils'
|
} from '../../utils'
|
||||||
import { Container } from '../../components/Container'
|
import { Container } from '../../components/Container'
|
||||||
import fileListStyles from '../../components/FileList/style.module.scss'
|
import fileListStyles from '../../components/FileList/style.module.scss'
|
||||||
@ -62,6 +64,7 @@ import {
|
|||||||
} from '@fortawesome/free-solid-svg-icons'
|
} from '@fortawesome/free-solid-svg-icons'
|
||||||
import { getSigitFile, SigitFile } from '../../utils/file.ts'
|
import { getSigitFile, SigitFile } from '../../utils/file.ts'
|
||||||
import _ from 'lodash'
|
import _ from 'lodash'
|
||||||
|
import { SendDMError } from '../../types/errors/SendDMError.ts'
|
||||||
|
|
||||||
export const CreatePage = () => {
|
export const CreatePage = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@ -713,6 +716,34 @@ export const CreatePage = () => {
|
|||||||
toast.error('Failed to publish notifications')
|
toast.error('Failed to publish notifications')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setLoadingSpinnerDesc('Sending DMs')
|
||||||
|
// Send DM to signers and viewers
|
||||||
|
// No need to send notification to self so remove it from the list
|
||||||
|
const receivers = (
|
||||||
|
signers.length > 0
|
||||||
|
? [signers[0].pubkey]
|
||||||
|
: viewers.map((viewer) => viewer.pubkey)
|
||||||
|
).filter((receiver) => receiver !== usersPubkey)
|
||||||
|
|
||||||
|
for (let i = 0; i < receivers.length; i++) {
|
||||||
|
const createSignatureEvent = await parseNostrEvent(
|
||||||
|
meta.createSignature
|
||||||
|
)
|
||||||
|
const { id } = createSignatureEvent
|
||||||
|
const r = receivers[i]
|
||||||
|
try {
|
||||||
|
await sendPrivateDirectMessage(
|
||||||
|
`Sigit created, visit ${window.location.origin}/#/${signers.length > 0 ? 'sign' : 'verify'}/${id}`,
|
||||||
|
npubToHex(r!)!
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SendDMError) {
|
||||||
|
toast.error(error.message)
|
||||||
|
}
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
navigate(appPrivateRoutes.sign, { state: { meta } })
|
navigate(appPrivateRoutes.sign, { state: { meta } })
|
||||||
} else {
|
} else {
|
||||||
const zip = new JSZip()
|
const zip = new JSZip()
|
||||||
|
@ -33,7 +33,9 @@ import {
|
|||||||
signEventForMetaFile,
|
signEventForMetaFile,
|
||||||
updateUsersAppData,
|
updateUsersAppData,
|
||||||
findOtherUserMarks,
|
findOtherUserMarks,
|
||||||
timeout
|
timeout,
|
||||||
|
sendPrivateDirectMessage,
|
||||||
|
parseNostrEvent
|
||||||
} from '../../utils'
|
} from '../../utils'
|
||||||
import { Container } from '../../components/Container'
|
import { Container } from '../../components/Container'
|
||||||
import { DisplayMeta } from './internal/displayMeta'
|
import { DisplayMeta } from './internal/displayMeta'
|
||||||
@ -53,6 +55,7 @@ import {
|
|||||||
SigitFile
|
SigitFile
|
||||||
} from '../../utils/file.ts'
|
} from '../../utils/file.ts'
|
||||||
import { ARRAY_BUFFER, DEFLATE } from '../../utils/const.ts'
|
import { ARRAY_BUFFER, DEFLATE } from '../../utils/const.ts'
|
||||||
|
import { SendDMError } from '../../types/errors/SendDMError.ts'
|
||||||
|
|
||||||
enum SignedStatus {
|
enum SignedStatus {
|
||||||
Fully_Signed,
|
Fully_Signed,
|
||||||
@ -720,6 +723,56 @@ export const SignPage = () => {
|
|||||||
toast.error('Failed to publish notifications')
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentSignerIndex = signers.indexOf(usersNpub)
|
||||||
|
const nextSigner = signers[currentSignerIndex + 1]
|
||||||
|
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)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
24
src/types/errors/SendDMError.ts
Normal file
24
src/types/errors/SendDMError.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
@ -6,6 +6,7 @@ import {
|
|||||||
EventTemplate,
|
EventTemplate,
|
||||||
Filter,
|
Filter,
|
||||||
UnsignedEvent,
|
UnsignedEvent,
|
||||||
|
VerifiedEvent,
|
||||||
finalizeEvent,
|
finalizeEvent,
|
||||||
generateSecretKey,
|
generateSecretKey,
|
||||||
getEventHash,
|
getEventHash,
|
||||||
@ -21,7 +22,8 @@ import { NIP05_REGEX } from '../constants'
|
|||||||
import {
|
import {
|
||||||
MetadataController,
|
MetadataController,
|
||||||
NostrController,
|
NostrController,
|
||||||
relayController
|
relayController,
|
||||||
|
RelayController
|
||||||
} from '../controllers'
|
} from '../controllers'
|
||||||
import {
|
import {
|
||||||
updateProcessedGiftWraps,
|
updateProcessedGiftWraps,
|
||||||
@ -35,6 +37,7 @@ import { parseJson, removeLeadingSlash } from './string'
|
|||||||
import { timeout } from './utils'
|
import { timeout } from './utils'
|
||||||
import { getHash } from './hash'
|
import { getHash } from './hash'
|
||||||
import { SIGIT_BLOSSOM } from './const.ts'
|
import { SIGIT_BLOSSOM } from './const.ts'
|
||||||
|
import { SendDMError, SendDMErrorType } from '../types/errors/SendDMError.ts'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a `d` tag for userAppData
|
* Generates a `d` tag for userAppData
|
||||||
@ -248,6 +251,12 @@ export const toUnixTimestamp = (date: number | Date) => {
|
|||||||
export const fromUnixTimestamp = (unix: number) => {
|
export const fromUnixTimestamp = (unix: number) => {
|
||||||
return unix * 1000
|
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
|
* Generate nip44 conversation key
|
||||||
@ -297,19 +306,21 @@ export const countLeadingZeroes = (hex: string) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Function to create a wrapped event with PoW
|
* 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 receiver Public key of the receiver
|
||||||
* @param difficulty PoW difficulty level (default is 20)
|
|
||||||
* @returns
|
* @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
|
// Generate a random secret key and its corresponding public key
|
||||||
const randomKey = generateSecretKey()
|
const randomKey = generateSecretKey()
|
||||||
const pubkey = getPublicKey(randomKey)
|
const pubkey = getPublicKey(randomKey)
|
||||||
|
|
||||||
// Encrypt the event content using nip44 encryption
|
// 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
|
// Initialize nonce and leadingZeroes for PoW calculation
|
||||||
let nonce = 0
|
let nonce = 0
|
||||||
@ -320,11 +331,12 @@ export const createWrap = (unsignedEvent: UnsignedEvent, receiver: string) => {
|
|||||||
// eslint-disable-next-line no-constant-condition
|
// eslint-disable-next-line no-constant-condition
|
||||||
while (true) {
|
while (true) {
|
||||||
// Create an unsigned event with the necessary fields
|
// 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 = {
|
const event: UnsignedEvent = {
|
||||||
kind: 1059, // Event kind
|
kind: 1059, // Event kind
|
||||||
content, // Encrypted content
|
content, // Encrypted content
|
||||||
pubkey, // Public key of the creator
|
pubkey, // Public key of the creator
|
||||||
created_at: unixNow(), // Current timestamp
|
created_at: randomTimeUpTo2DaysInThePast(),
|
||||||
tags: [
|
tags: [
|
||||||
// Tags including receiver and nonce
|
// Tags including receiver and nonce
|
||||||
['p', receiver],
|
['p', receiver],
|
||||||
@ -989,3 +1001,145 @@ export const getProfileUsername = (
|
|||||||
truncate(profile?.display_name || profile?.name || hexToNpub(npub), {
|
truncate(profile?.display_name || profile?.name || hexToNpub(npub), {
|
||||||
length: 16
|
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
|
||||||
|
}
|
||||||
|
@ -97,20 +97,23 @@ const isOlderThanOneDay = (cachedAt: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isRelayTag = (tag: string[]): boolean => tag[0] === 'r'
|
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 => {
|
const toRelaySet = (obj: RelaySet, tag: string[]): RelaySet => {
|
||||||
if (tag.length >= 3) {
|
if (tag.length >= 3) {
|
||||||
const marker = tag[2]
|
const marker = tag[2]
|
||||||
|
|
||||||
if (marker === READ_MARKER) {
|
if (marker === READ_MARKER) {
|
||||||
obj.read.push(tag[1])
|
addRelay(obj.read, tag[1])
|
||||||
} else if (marker === WRITE_MARKER) {
|
} else if (marker === WRITE_MARKER) {
|
||||||
obj.write.push(tag[1])
|
addRelay(obj.write, tag[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (tag.length === 2) {
|
if (tag.length === 2) {
|
||||||
obj.read.push(tag[1])
|
addRelay(obj.read, tag[1])
|
||||||
obj.write.push(tag[1])
|
addRelay(obj.write, tag[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
return obj
|
return obj
|
||||||
|
Loading…
Reference in New Issue
Block a user