diff --git a/ndk-core/src/events/chatroom.ts b/ndk-core/src/events/chatroom.ts new file mode 100644 index 00000000..75f9f775 --- /dev/null +++ b/ndk-core/src/events/chatroom.ts @@ -0,0 +1,693 @@ +import { sha256 } from '@noble/hashes/sha256' +import { bytesToHex } from '@noble/hashes/utils' +import { utf8Encoder } from "nostr-tools/lib/types/utils"; +import { NDKFilter, NDKSubscription, NDKSubscriptionOptions } from "../subscription"; +import { NDK } from "../ndk"; +import { Hexpubkey, NDKUser } from "../user"; +import { NDKKind } from "./kinds"; +import { DirectMessageRelays, NDKDirectMessage } from "./kinds/directmessage"; +import { NDKUserProfile } from 'src'; + + + +// published messages get status strings from publisShatus +// published messages recieved back from relay, get "delivered" status +// recieved messages (from other users) get "read" OR "unread" +// status depending on chatroom.readuntil +export type NDKDirectMessageStatus = "pending" | "success" | "error" | "delivered" | "read" | "unread" | undefined + + +// callback function passed to subscriber.chatrooms.subscribe() +// called whenever chatrooms are updated by NDKChatroomManager +export type NDKChatroomSubscription = (chatrooms : NDKChatroomManager) => void +export type NDKChatroomSubscribable = { + subscribe : (subscription : NDKChatroomSubscription) => () => void +} +// define a callback method for use by NDKChatroomManager +// to determine if a thread should be marked `trusted` +export type NDKChatroomIsTrusted = (this : NDKChatroom) => Promise + +// // NDKChatroomSubscriberOptions are options for configuring the NDKChatroomManager +// export type NDKChatroomSubscriberOptions = { +// // use cache adapter for storing profiles and messages ? +// useCacheAdapter?: boolean +// fromJson? : string +// } + + +// NDKChatroomSubscriber retrieves NDKDirectMessages from local cache or relays, +// and updates a single NDKChatroomManager +export class NDKChatroomSubscriber { + // the NDKSubscription object controlled by .start() and .stop() + // this is what pulls new messages from relays + private _ndksubscription : NDKSubscription | undefined + // a reference to all subscribers being notified of chatroom uopdates, via .subscribe() + private _subscribers : Set = new Set() + private _manager : NDKChatroomManager + + // Instantiate a NDKChatroomSubscriber with NDK + constructor( + readonly ndk : NDK, + // use cache adapter for storing profiles and messages ? + readonly useCacheAdapter = true, + // load chatrooms from a json localstorage + fromjson ? : string + ){ + if(!ndk.signer) throw new Error('missing signer required by NDKChatroomManager') + this._manager = NDKChatroomManager.fromJson(fromjson, this) || new NDKChatroomManager(this) + } + + // instantiates an NDKSubscription for this ChatroomManager + async subscribe( addfilter? : NDKFilter, options : NDKSubscriptionOptions = {}) : Promise { + // only one subscription per chatroom manager + if(this._ndksubscription) { + this._ndksubscription.start() + return this.chatrooms + } + // a signer is required for decrypting DirectMessages + if(!this.ndk.signer) throw('missing signer required by NDKChatroomManager') + // assure that the signer and recipient are the same + const user : NDKUser = await this.ndk.signer.user(); + if(!user.pubkey) throw('NDK signer missing user pubkey') + // TODO should preffered DM relays be passed as relaySet or rlayUrls? + options.relaySet = await DirectMessageRelays.for(user, this.ndk) + // allows the dexie event cache to be always querried first + if(this.useCacheAdapter) options.addSinceFromCache = true + + + let filters : NDKFilter[] = [ + { + kinds:[ NDKKind.EncryptedDirectMessage], + "authors" : [user.pubkey], + }, + { + kinds:[ NDKKind.EncryptedDirectMessage, NDKKind.GiftWrap ], + "#p" : [user.pubkey] + }, + ] + if(addfilter) filters.push({ + ...addfilter, + kinds:[ NDKKind.EncryptedDirectMessage, NDKKind.GiftWrap ], + }) + + this._ndksubscription = this.ndk.subscribe(filters,options) + this._ndksubscription.on("event", async (event) => { + // instantiate a DirectMessage from each new event + // using this.preferredrelays as the DirectMessageRelayManager, + let message = await NDKDirectMessage.from(event, this.ndk); + if(message){ + console.log("Chatroom message recieved ", this._manager.size, message.rawEvent()) + message.publishStatus = undefined + // update all cached profiles with each message recieved + await this._manager.updateProfiles(message) + // add the message to an existing or new thread/chatroom + this._manager.addMessage(message) + // TODO is it nessecary to add member DM relays? + // add message member relays to subscription + // for(let member of message.members){ + // (await DirectMessageRelays.for(member, this.ndk))?.relays.forEach(relay => + // this._ndksubscription?.relaySet?.addRelay(relay) + // )} + } + }) + return this.chatrooms + } + + // a chatroom subscription + chatrooms : NDKChatroomSubscribable = { + // manage subscribers, to notify on update of chatrooms + subscribe : (subscription: NDKChatroomSubscription) : ()=>void => { + // notify the subscriber of the current chatrooms + subscription(this._manager) + // add the subscriber to the list of subscribers + this._subscribers.add(subscription) + // return the unsubscribe function + return () => this.unsubscribe(subscription) + }, + } + + // stop subscriptions + unsubscribe(subscription? : NDKChatroomSubscription){ + if(subscription) { + this._subscribers.delete(subscription) + }else{ + this._subscribers.clear() + } + if(this._ndksubscription && !this._subscribers.size){ + this._ndksubscription.stop() + } + } + + restart() : void { + this._ndksubscription?.start() + } + + // notify subscribers + notify() : void { + this._subscribers.forEach(subscriber => { + subscriber(this._manager) + }) + } + + +} + + + +// It starts an NDK subscription for kind 4 and 1059 events and a given signer and filter. +// It organizes recieved messages into threads, based on the pubkeys in message.thread +// It creates a new NDKChatroom instance for every new thread, +// NDKChatroom instances may be merged or reordered, and threads may be moved between chatrooms. +// NDKChatroomManager notifies subscribers +// when threads or messages in chatrooms have been updated +// NDKChatroomManager static methods +// provide json serialization and deserialization of chatrooms for ofline storage +export class NDKChatroomManager extends Set { + + // parse a JSON string into an array of NDKChatroom instances + static fromJson(json : string | undefined, subscriber : NDKChatroomSubscriber) : NDKChatroomManager | undefined { + if(!json) return undefined + const manager = new NDKChatroomManager(subscriber) + try{ + let parsed = JSON.parse(json) + if(parsed instanceof Array){ + (parsed as Array).forEach((data) => { + if( + typeof data.id == 'string' && + ((data.kinds instanceof Array && typeof data.kinds[0] == 'number' ) || + typeof data.kinds == 'undefined') && + ((data.threads instanceof Array && typeof data.threads[0] == 'string') || + (typeof data.threads == 'undefined') ) && + (typeof data.subject == 'string' || typeof data.subject == 'undefined') && + (typeof data.trusted == 'boolean' || typeof data.trusted == 'undefined' ) && + (typeof data.readuntil == 'number' || typeof data.readuntil == 'undefined' ) + ){ + manager.set(new NDKChatroom( manager, { + id : data.id as string, + kinds : data.kinds as number[] | undefined, + threadids : data.threads as string[] | undefined, + subject : data.subject as string | undefined, + trusted : data.trusted as boolean | undefined, + readuntil : data.readuntil as number | undefined, + })) + } + }) + } + }catch(e){ + console.log("skipped error in NDKChatroomManager.fromJson()") + } + return manager + } + + // serialize an array of NDKChatroom instances into a JSON string + static toJson(mnanager : NDKChatroomManager) : string { + let chatrooms : string[] = [] + mnanager.forEach(chatroom => { + // FIXME determining exportability should be a method of the chatroom? + // only export chatrooms that have been modified by the user + // those with more than one thread (added by user) + // or those whose subject has been set by the user + // or those with messages that have been read + if(chatroom.hasOwnSubject() || chatroom.threadids.length > 1 || chatroom.readuntil){ + chatrooms.push(JSON.stringify({ + kinds : chatroom.kinds, + id : chatroom.id, + threadids : [...chatroom.threadids], + subject : chatroom.subject, + // FIXME won't this always return true? + trusted : chatroom.isTrusted(), + readuntil : chatroom.readuntil + })) + } + }) + return JSON.stringify(chatrooms) + } + + // a reference to all chatroom threads, indexed by message member pubkeys + // every message recieved gets added to an existing or new thread, accoding to it's member pubkeys + private _threads : Map = new Map() + // a reference to all chatroom members with profiles already loaded + readonly recipients : Map = new Map() + + // Instantiate a NDKChatroomManager with NDKChatroomSubscriber + constructor( + private _subscriber : NDKChatroomSubscriber, + ){ + if(!_subscriber.ndk.signer) throw new Error('missing signer required by NDKChatroomManager') + super() + } + + restart() : void { + this._subscriber.restart() + } + + notify() : void { + this._subscriber.notify() + } + + get ndk() : NDK { + return this._subscriber.ndk + } + + // get the active user's pubkey + get pubkey() : string { + if(!this.ndk.signer) throw new Error('missing signer required by NDKChatroomManager') + return this.ndk.signer.pubkey + } + + async getAuthor() : Promise { + if(!this._subscriber.ndk.signer) throw new Error('missing signer required by NDKChatroomManager') + return await this._subscriber.ndk.signer.user() + } + + async updateProfiles(from? : NDKDirectMessage | NDKChatroom) : Promise { + // cache user profiles for message members + let recipients = from?.recipients || [...this.recipients.values()] + let useCache = this._subscriber.useCacheAdapter && this.ndk.cacheAdapter ? true : false + let user : NDKUser | undefined + let profile : NDKUserProfile | undefined + for(let i in recipients) { + user = recipients[i] + user.ndk ??= this.ndk + if(!user?.profile) profile = await user?.fetchProfile({},useCache) || undefined + if(user && profile) { + this.recipients.set(user.pubkey,user) + } + // FIXME precaching DM relays results in ... threads being merged? + // await DirectMessageRelays.for(user) + } + return recipients + } + + + // add a DirectMessage to an existing or new chatroom + addMessage(message : NDKDirectMessage) : NDKChatroom { + const thread = this.addMessageToThread(message) + let chatroom = this.get(thread) + if(chatroom){ + // update() returns undefined if thread kind does not match chatroom kind + chatroom.update(undefined, undefined, false) + }else{ + let config : NDKChatroomConfig = { + threadids : [thread.id], + subject : message.subject + } + chatroom = new NDKChatroom(this, config) + this.set(chatroom) + } + this.notify() + return chatroom + } + + // new threads can only be set via addMessage(message) method + private addMessageToThread(message:NDKDirectMessage) : NDKChatroomThread { + let thread = this.getThread(message)?.set(message.id, message) + if(!thread){ + let id = NDKChatroomThread.getId(message) + thread = new NDKChatroomThread(id, message) + this._threads.set(id, thread) + } + return thread + } + + // instantiates and publishes a new DirectMessage with body and subject to recipients + async sendMessage(message : NDKDirectMessage, publishBestKind? : boolean, publishKind4Group? : boolean){ + await message.publish(undefined,undefined,undefined,publishBestKind,publishKind4Group).then(relays =>{ + console.log('message published to '+relays.size+' relays ',[...relays].map(r => r.url)) + this.addMessage(message) + }) + } + + getMessageStatus(message: NDKDirectMessage) : NDKDirectMessageStatus { + if(message.isauthored){ + // undefined publishStatus means + // authored message has been recieved from relay + return message.publishStatus || "delivered" + }else{ + let chatroom = this.get(message) + if(chatroom) + return chatroom.readuntil >= message.created_at ? + "read" : "unread" + } + return undefined + } + + getThread(id : string) : NDKChatroomThread | undefined + getThread(message : NDKDirectMessage) : NDKChatroomThread | undefined + getThread(idormessage : string | NDKDirectMessage) { + if(typeof idormessage == 'string') + return this._threads.get(idormessage) + return this._threads.values().find(thread => thread?.pubkeys.every(pubkey => + idormessage.thread.includes(pubkey) + )) + } + + getThreads(ids : string[]) : NDKChatroomThread[] { + let threads : NDKChatroomThread[] = [] + this._threads.forEach((thread, id) => { + if(thread && ids.includes(id)) threads.push(thread) + }) + return threads + } + + getThreadsByPubkey(pubkey : string) : NDKChatroomThread[] { + let threads : NDKChatroomThread[] = [] + this._threads.forEach((thread) => { + if(thread && thread.pubkeys.includes(pubkey)) threads.push(thread) + }) + return threads + } + + moveThread(thread : NDKChatroomThread, newchatroom : NDKChatroom) : NDKChatroom | undefined { + let oldchatroom = this.get(thread) + newchatroom.addThread(oldchatroom?.removeThread(thread.id)) + if(oldchatroom && oldchatroom.numthreads < 1 ){ + super.delete(oldchatroom) + } + this.notify() + return newchatroom + } + + // get chatroom by index or thread or message + get(index: number ) : NDKChatroom | undefined + get(id: string ) : NDKChatroom | undefined + get(thread: NDKChatroomThread ) : NDKChatroom | undefined + get(message: NDKDirectMessage ) : NDKChatroom | undefined + get(identifier: number | string | NDKChatroomThread | NDKDirectMessage) : NDKChatroom | undefined { + const chatroom = + typeof identifier == 'number' ? [...this.values()][identifier] : + typeof identifier == 'string' ? + this.values().find(chatroom => chatroom.id == identifier) || + this.values().find(chatroom => chatroom.threadids.includes(identifier)) : + identifier instanceof NDKChatroomThread ? + this.values().find(chatroom => chatroom.threads.includes(identifier)) : + this.values().find(chatroom => chatroom.messages.includes(identifier)) + return chatroom + } + + private set(chatroom : NDKChatroom) { + let existing = this.get(chatroom.id) + // FIXME update() returns undefined if thread kind does not match chatroom kind + if(existing) { + existing.update(chatroom.threadids, chatroom.subject, false) + }else{ + super.add(chatroom) + } + this.notify() + return this + } + + reorder(chatrooms : NDKChatroom[]) : boolean { + if(chatrooms.length != this.size) { + console.log("skipped reordering chatrooms : arrays do not match") + return false + } + for(let i in chatrooms){ + if(!this.has(chatrooms[i])) { + console.log("skipped reordering chatrooms : chatroom not found ", i) + return false + } + } + this.forEach(chatroom => super.delete(chatroom)) + chatrooms.forEach(chatroom => super.add(chatroom)) + this.notify() + return true + } + + merge(chatrooms : NDKChatroom[]) : NDKChatroom | undefined{ + if(chatrooms.length < 2){ + console.log("skipped merging chatrooms : less than two chatrooms") + return undefined + } + for(let i in chatrooms){ + if(!this.has(chatrooms[i])){ + console.log("skipped merging chatrooms : chatroom not found ", i) + return undefined + } + } + // merge into the first chatroom + let merged = chatrooms.shift() + if(!merged) return undefined + chatrooms.forEach(chatroom => { + // FIXME update() returns undefined if thread kind does not match chatroom kind + merged.update(chatroom.threadids, chatroom.subject, false) + super.delete(chatroom) + }) + this.notify() + return merged + } + + getIndex(chatroom : NDKChatroom) : number | undefined { + return [...this.values()].findIndex(entry => entry.id == chatroom.id) + } + + // dissable add() for chatrooms + add(){ + throw new Error("set() is not a public method") + return this + } + // dissable delete() for chatrooms + delete() { + throw new Error("delete() is not a public method") + return false + } + +} + + +// NDKChatroomThread represents a thread of DirectMessages +export class NDKChatroomThread extends Map{ + + // get the thread id and pubkeys from a message + static getId(message : NDKDirectMessage, usehash = true){ + let pubkeys = [...message.thread] + let id = pubkeys.sort().join(':') + // hash is used to hide pubkeys when saving chatroom (threads) to local storage + // FIXME : utf8Encoder is not being imported properly from nostr-tools + // if(usehash) id = bytesToHex(sha256(utf8Encoder.encode(id))) + return id + } + + private _pubkeys : Set + private _kinds : Set + constructor( + readonly id : string, + message : NDKDirectMessage, + ){ + if(!message.kind) throw new Error('missing event kind in NDKChatroomThread') + super() + this._kinds = new Set([message.kind]) + this._pubkeys = new Set(message.thread) + this.set(message.id, message) + } + + set(id : string, message : NDKDirectMessage) : this { + super.set(id, message) + this._kinds.add(message.kind) + message.thread.forEach(pubkey => this._pubkeys.add(pubkey)) + return this + } + + get pubkeys() : string[] { + return [...this._pubkeys] + } + get kinds() : number[] { + return [...this._kinds] + } + +} + + + +export type NDKChatroomConfig = { + id? : string, + kinds? : number[], + threadids? : string[], + subject? : string, + trusted? : boolean, + readuntil? : number, + publishBestKind? : boolean, + publishKind4Group? : boolean, +} +// NDKChatroom represents a chatroom +// It is a collection of DirectMessages threadsa and thier members +export class NDKChatroom { + + // default method for determining if a chatroom is `trusted` (not spam) + private static _isTrusted(this : NDKChatroom, trusted? : Set) : boolean { + let istrusted : boolean = false + // if trust is explicitly set, use this + if(this._trusted !== undefined ) istrusted = this._trusted + // trust if user has replied in this chatroom + else if(this.messages.some(message => message.pubkey == this._manager.pubkey)) istrusted = true + // trust if all pubkeys are also recipients in other trusted chatrooms + else if(this.pubkeys.every( + pubkey => this._manager.values().some(chatroom => + chatroom?.pubkeys.includes(pubkey) && chatroom._trusted) + )) istrusted = true + // otherwise, trust if every pubkey is `trusted` by the user + else if(trusted) { + if(this.pubkeys.every(pubkey => trusted.has(pubkey))) istrusted = true + } + return istrusted + } + + private _messages : NDKDirectMessage[] = [] + private _threadids : Set = new Set() + private _pubkeys : Set = new Set() + private _kinds : Set = new Set() + private _subject : string | undefined + private _readuntil : number + private _id : string + private _trusted? : boolean + + constructor( + private _manager : NDKChatroomManager, + private _config? : NDKChatroomConfig + ){ + // this._id = _config?.id || bytesToHex(sha256(utf8Encoder.encode(this._manager.pubkey + Date.now().toString()))) + this._id = _config?.id || this._manager.pubkey+':'+Date.now().toString() + + this._kinds = new Set(_config?.kinds) + this._trusted = _config?.trusted + this._readuntil = _config?.readuntil || 0 + this.update(_config?.threadids, _config?.subject, false) + } + + // TODO send message to a subset of pubkeys, and add (new) thread to this chatroom + sendMessage(message : NDKDirectMessage){ + if(!message.recipients?.length) message.recipients = this.recipients + return this._manager.sendMessage(message, this._config?.publishBestKind, this._config?.publishKind4Group) + } + + update(threadids? : string[], subject? : string, notify = false) : NDKChatroom | undefined { + if(threadids) { + // TODO skip updating if new thread kinds dont match chatroom kind + // if(!this._manager.getThreads(threadids).every(thread => { + // // TODO check if members can recieve gift wrapped DMS? + // thread.kind == this.kind + // })) return undefined + // add new threads + threadids.forEach(id => this._threadids.add(id)) + // add pubkeys and kinds from new threads + this.threads.forEach((thread) => { + thread.pubkeys.forEach( pubkey => this._pubkeys.add(pubkey)) + thread.kinds.forEach( kind => this._kinds.add(kind)) + }) + } + if(subject) this._subject = subject + this._messages = this.threads.flatMap((thread) => [...thread.values()]).sort((a, b) => (a.created_at || 0) - ( b.created_at || 0)) + if(notify) this._manager.notify() + return this + } + + get id() : string { + return this._id + } + + get threads(){ + return this._manager.getThreads([...this._threadids]) + } + + get threadids() : string[] { + return [...this._threadids] + } + + get numthreads() : number { + return this._threadids.size + } + + get readuntil() : number { + return this._readuntil + } + + set readuntil(messageortimestamp : NDKDirectMessage | number) { + const timestamp = typeof messageortimestamp == 'number' ? + messageortimestamp : messageortimestamp.created_at + // if(this._readuntil && this._readuntil > timestamp){ + this._readuntil = timestamp + // } + this._manager.notify() + } + + // getter/setter for trusted + // set _trusted by passing a boolean + // get _trusted by optionally passing a set of `trusted` pubkeys + isTrusted(trusted? : boolean | Set ) : boolean{ + if(typeof trusted == 'boolean'){ + this._trusted = trusted + this._manager.notify() + } + else if(this._trusted === undefined){ + this._trusted = this._isTrusted(trusted) + this._manager.notify() + } + return this._trusted + } + private _isTrusted = NDKChatroom._isTrusted + + addThread(threadid? : string) : void { + if(threadid) this._threadids.add(threadid) + this.update() + } + + // only remove thread if chatroom has more than one thread + removeThread(id : string) : string | undefined { + let deleted = this._threadids.delete(id) ? id : undefined + this.update() + return deleted + } + + // chatroom subject may be set by the user or derived from the latest message containing a subject + set subject(subject : string | undefined) { + this.update(undefined, subject, true) + } + get subject() : string | undefined { + if(this._subject) return this._subject + return this.messages.toReversed().find(message => message.subject)?.subject + } + + hasOwnSubject() : boolean { + return this._subject !== undefined + } + + get messages() : NDKDirectMessage[] { + return this._messages + } + + get count() : number { + return this.messages.length + } + get read() : number { + let isreadindex = this.messages.findIndex((message) => message.created_at >= this.readuntil) + return this.messages.slice(0,isreadindex).length + } + + get unread() : number { + return this.count - this.read + } + + author(message : NDKDirectMessage) : NDKUser { + return this._manager.recipients.get(message?.pubkey || this._manager.pubkey) || message.author + } + + get members() : NDKUser[] { + return this.pubkeys.map(pubkey => this._manager.recipients.get(pubkey) || new NDKUser({pubkey})) + } + + get recipients() : NDKUser[] { + let pubkeys = this.pubkeys.filter(pubkey => pubkey !== this._manager.pubkey) + if(!pubkeys.length) pubkeys = this.pubkeys + return pubkeys.map(pubkey => this._manager.recipients.get(pubkey) || new NDKUser({pubkey})) + } + + // return pubkeys as a sorted list for consistency in display + get pubkeys() : string[] { + return [...this._pubkeys].sort() + } + + get kinds() : number[] { + return [...this._kinds] + } + +} diff --git a/ndk-core/src/events/kinds/directmessage.ts b/ndk-core/src/events/kinds/directmessage.ts new file mode 100644 index 00000000..a6b1a7b0 --- /dev/null +++ b/ndk-core/src/events/kinds/directmessage.ts @@ -0,0 +1,327 @@ +import { NDKKind } from "." +import { NDKEvent, NDKRawEvent, NostrEvent } from ".." +import { NDK } from "../../ndk" +import { NDKRelay } from "../../relay" +import { NDKRelaySet } from "../../relay/sets" +import { NDKUser } from "../../user" +import { giftUnwrap, giftWrap } from "../gift-wrapping" + +export type DirectMessageKind = NDKKind.EncryptedDirectMessage | NDKKind.PrivateDirectMessage +export type DirectMessagePublishedKind = NDKKind.EncryptedDirectMessage | NDKKind.GiftWrap + +/** + * Handles an unecrypted kind 4 or unwrapped kind 14 event + * Encrypted events (kind 4 and 1058 gift wrapped kind 14) + * may be decrypted or unwrapped by calling DirectMessage.from() + */ +export class NDKDirectMessage extends NDKEvent{ + + // configure default kind for new DirectMessages + static defaultkind : DirectMessageKind = NDKKind.PrivateDirectMessage + + // configure default relays for publishing gift wrapped DirectMessages + static defaultrelayurls : string[] | undefined + + kind : DirectMessageKind; + + // instantiate a DirectMessage NDKEvent (kind 4 or kind 14) + // from an encrypted (kind 4) or gift wrapped (kind 1058) event + static async from(event : NDKEvent | NostrEvent, ndk:NDK ) : Promise { + if(!ndk.signer || !(await ndk.signer.user()).pubkey) + throw('missing or invalid signer required by NDKDirectMessage.from()') + // if event is not an NDKEvent, create a new NDKEvent + let dmevent = new NDKEvent(ndk, event) + // let dmevent : NDKEvent = !Object.hasOwn(event, 'ndk') ? new NDKEvent(ndk, event) : event as NDKEvent + // catch any errors decrypting or unwrapping the event + try{ + // unwrap kind 14 event + if(dmevent.kind == NDKKind.GiftWrap){ + dmevent = await giftUnwrap(dmevent,undefined, ndk.signer) + } + // decrypt kind 4 content + if(dmevent.kind == NDKKind.EncryptedDirectMessage){ + let user = await ndk.signer.user() + let sender = dmevent.pubkey == user?.pubkey ? user : undefined + await dmevent.decrypt(sender, ndk.signer, 'nip04') + } + }catch(e){ + let user = await ndk.signer.user() + console.log('error decrypting or unwrapping DirectMessage : ', e, dmevent.rawEvent()) + console.log('signing user pubkey : ', user?.pubkey) + // return undefined if unable to decrypt or unwrap + // return undefined + } + // return NDKDirectMessage if kind is 4 or 14 + if(dmevent.kind == NDKKind.EncryptedDirectMessage || dmevent.kind == NDKKind.PrivateDirectMessage){ + return new NDKDirectMessage(ndk, dmevent) + } + } + + constructor( + ndk: NDK , + eventorkind : DirectMessageKind | Partial | NDKEvent = NDKDirectMessage.defaultkind, + ){ + + let event : NDKEvent | Partial + if (typeof eventorkind == 'number'){ + event = new NDKEvent() + event.kind = eventorkind + }else{ + event = eventorkind + } + + if(event.kind && event.kind != NDKKind.EncryptedDirectMessage && event.kind != NDKKind.PrivateDirectMessage) + throw new Error('DirectMessage must be of kind 4 or 14'); + + super(ndk, event); + this.kind ??= NDKDirectMessage.defaultkind; + // backwards compatible decrypt content if instantiated from encrypted kind 4 event + if(this.kind == NDKKind.EncryptedDirectMessage && this.content.search("\\?iv=")){ + this.decrypt() + } + } + + // recipients are message members NOT including the author + get recipients() : NDKUser[] { + let recipients : NDKUser[] = []; + this.getMatchingTags("p").forEach(ptag =>{ + if(!this.ndk) throw('Missing NDK') + // skip if no pubkey + if(!ptag[1]) return + // skip if author is tagged as recipient + if(ptag[1] == this.pubkey) return + let user = this.ndk.getUser({ + pubkey:ptag[1], + relayUrls:[ptag[2]] + }) + recipients.push(user) + }) + return recipients; + } + // REPLACE existing recipients + // accepts array of NDKUser or pubkey strings + set recipients(recipients : NDKUser[] | string[]) { + if(!this.isauthored) return + // remove existing recipients + this.tags = this.tags.filter((tag) => tag[0] != 'p') + // add new recipients + recipients.forEach((recipient) => { + // skip if no pubkey + if(!(recipient as any)?.pubkey) return + // skip if author is tagged as recipient + if((recipient as any)?.pubkey == this.pubkey) return + let pubkey : string = typeof recipient == 'string' ? recipient : recipient.pubkey + // TODO add peferrredrelay for DMs to p tag + // this.tag(recipient) + this.tags.push(["p", pubkey]) + }) + } + // message members are author and recipients + get members() : NDKUser[] { + if(!this.ndk) throw('Missing NDK') + let author = this.ndk.getUser({pubkey : this.pubkey}) + return [author, ...this.recipients]; + } + // message thread is pubkeys for all message members + get thread() : string[] { + return [ + this.pubkey, + ...this.getMatchingTags("p").map(ptag => ptag[1]) + ] + } + + // get the subject from tag value + get subject() : string | undefined{ + return this.tagValue("subject"); + } + // set or remove the subject tag + set subject(subject : string | undefined){ + if(!this.isauthored) return + if(subject){ + let index = this.tags.findIndex((tag) => tag[0] == 'subject') + if(index < 0) index = this.tags.length + this.tags[index] = ["subject", subject] + } + } + + // is the message authored by the current user + get isauthored() : boolean { + let pubkey = this.ndk?.activeUser?.pubkey + return !pubkey || pubkey == this.pubkey + } + + // get best kind for publishing + async getBestKind() : Promise { + let kind : DirectMessageKind = NDKKind.EncryptedDirectMessage + // get DM relays for publishing kind 14 + let dmRelays = await this.getDmRelays() + // if relays are found for every recipient, allow publishing as kind 14 + if(this.members.every(member => dmRelays.has(member.pubkey))) + kind = NDKKind.PrivateDirectMessage + console.log('messages best kind : ', kind) + return kind + } + + private _dmRelays : Map = new Map() + async getDmRelays() : Promise> { + if(this._dmRelays.size > 0) return this._dmRelays + for(let member of this.members){ + if(!member?.pubkey) + throw('Cannot publish message to all recipients. Missing pubkey') + // get DM relays for each recipient + let memberRelaySet = (await DirectMessageRelays.for(member, this.ndk)) + if(memberRelaySet?.size) this._dmRelays.set(member.pubkey, memberRelaySet) + } + console.log('Generated DMRelays for all members : ', this._dmRelays) + return this._dmRelays + } + + // override event.publish + // handles kind 4 "encrypted content" OR kind 14 "gift wrapped and encrypted" + // publish as kind 4 event, with individual messages if multiple recipients + // or publish as kind 14 event, with group messages if multiple recipients + async publish( + relaySet?: NDKRelaySet, + timeoutMs?: number, + requiredRelayCount?: number, + publishBestKind: boolean = true, + publishKind4Group: boolean = false, + ): Promise> { + console.log('publishing DirectMessage : ', this) + // either encrypt kind 4 or gift wrap kind 14 to publish + let publishedto : Set = new Set() + // get author pubkey from ndk if not set + const ndkpubkey = this.ndk?.activeUser?.pubkey + if(!ndkpubkey) + throw new Error('Cannot publish message : missing signer pubkey.') + if(this.pubkey && this.pubkey !== ndkpubkey) + throw new Error('Cannot publish message : wrong author pubkey.') + // add required event properties + await this.toNostrEvent() + // but unset the ID... this will be provided during sign() + this.id = '' + + console.log("Author pubkey:", this.pubkey); + console.log("Recipient pubkeys:", this.recipients.map(r => r.pubkey)); + + // validate DM relays for kind 14 AND get best kind for publishing + if(this.kind == NDKKind.PrivateDirectMessage || publishBestKind){ + let bestkind = await this.getBestKind() + // update kind to bestkind + if(publishBestKind) this.kind = bestkind + // otherwise throw error if publishing kind 14 and no DM relays found + else if(bestkind !== this.kind && this.kind == NDKKind.PrivateDirectMessage) + throw("Cannot publish as a private group message. Some recipients don't have relays setup. Please choose another publishing option.") + } + + console.log('publishing DirectMessage Event : ', this.rawEvent()) + + // publish a kind 4 event (with encrypted content only) + if(this.kind == NDKKind.EncryptedDirectMessage){ + // check if allow publishing to multiple recipients + if(this.recipients.length > 1 && !publishKind4Group) + throw ('Cannot publish legacy kind 4 messages to a group. Please choose another publishing option.') + // publishing separate events for each recipient, each with a single 'p' tag + let recipients = [...this.recipients] + // clear event p tags + this.recipients = [] + for(let recipient of recipients){ + // add a single p tag + this.recipients = [recipient] + await this.encrypt(undefined,undefined, 'nip04') + console.log('publishing kind 4 encrypted message :', this.rawEvent()) + publishedto = await super.publish(relaySet,timeoutMs,requiredRelayCount) + } + } + + // publish as kind 14 event (with encrypted metadata) + if(this.kind == NDKKind.PrivateDirectMessage){ + // get DM relays for publishing kind 14 + let dmRelays = await this.getDmRelays() + // publish as multiple kind 1059 gift wrapped kind 14 events + // one for each member recipient + for(let member of this.members){ + console.log("publishing for recipient : ", member.pubkey) + // get relayset for each message member + let memberRelaySet = dmRelays.get(member.pubkey) + console.log('publishing to '+memberRelaySet?.size+' relays : ',memberRelaySet?.relayUrls) + // gift wrap and publish to each member + const wrapped = await giftWrap(this, member) + console.log('publishing kind 1056 gift wrapped encrypted message :', wrapped.rawEvent()) + await memberRelaySet?.publish(wrapped,timeoutMs,requiredRelayCount).then((relays) => { + relays.forEach((relay) => { + console.log('published to relay : ',relay.url) + publishedto.add(relay) + }) + }) + } + } + return publishedto + } +} + +// retrieve and cache the preferred DM relay list (kind 10050) for any pubkey +// used to identify relays for publishing gift wrapped DirectMessages +export class DirectMessageRelays { + + private static _preferredrelays = new Map() + static defaulturls : string[] = [] + + private constructor(){} + + static async for(pubkey : string, ndk : NDK, connect?: boolean) : Promise + static async for(user : NDKUser, ndk? : NDK, connect?: boolean) : Promise + static async for(userorpubkey : NDKUser | string, possiblendk? : NDK, connect = true) : Promise{ + // return relayset if already fetched + let user : NDKUser + let ndk : NDK + let pubkey : string = typeof userorpubkey == 'string' ? userorpubkey : userorpubkey.pubkey + if(this._preferredrelays.has(pubkey)){ + return this._preferredrelays.get(pubkey) + } + + if(typeof userorpubkey == 'string'){ + if(!possiblendk) throw new Error('missing ndk') + ndk = possiblendk + user = ndk.getUser({pubkey}) + }else{ + user = userorpubkey + if(possiblendk) ndk = possiblendk + else if(user.ndk) ndk = user.ndk + else throw new Error('missing ndk') + } + + // let user : NDKUser | undefined + let preferredrelays : NDKRelaySet | undefined + + const relayset : Set = new Set() + for(let u in this.defaulturls){ + let url = this.defaulturls[u] + console.log('instantiating new relay from default : ', url) + let relay = new NDKRelay(url, undefined, ndk) + if(connect) await relay.connect().then(()=> + console.log('connected to default relay : ', relay.url) + ) + relayset.add(relay) + } + const dmrelaysfilter = { + authors:[user.pubkey], + kinds:[NDKKind.DirectMessageReceiveRelayList] + } + await ndk.fetchEvent(dmrelaysfilter).then(async event => { + if(event?.tags) + for(let tag of event.tags){ + if(tag[0] == 'relay') { + // console.log('adding user relay : ',tag[1]) + let relay = new NDKRelay(tag[1], undefined, ndk) + if(connect) await relay.connect().then(()=> console.log('connected to user relay : ', relay.url)) + relayset.add(relay) + } + } + }) + preferredrelays = new NDKRelaySet(relayset, ndk) + console.log('DirectMessageRelays.for('+pubkey+') : ', preferredrelays.relayUrls) + this._preferredrelays.set(user.pubkey, preferredrelays) + return preferredrelays + } +} \ No newline at end of file diff --git a/ndk-core/src/index.ts b/ndk-core/src/index.ts index 841ddc16..4cbe5293 100644 --- a/ndk-core/src/index.ts +++ b/ndk-core/src/index.ts @@ -39,6 +39,9 @@ export * from "./events/kinds/subscriptions/tier.js"; export * from "./events/kinds/video.js"; export * from "./events/kinds/wiki.js"; +export * from "./events/kinds/directmessage.js"; +export * from "./events/chatroom.js"; + export * from "./events/wrap.js"; export * from "./events/gift-wrapping.js";