From 7c2f43653dd7f508149c540aaf2c3b5ac8ea078f Mon Sep 17 00:00:00 2001 From: manimejia Date: Fri, 9 May 2025 12:05:08 -0500 Subject: [PATCH 1/2] initial commit adds directmessage.ts and chatroom.ts --- ndk-core/src/events/chatroom.ts | 437 +++++++++++++++++++++ ndk-core/src/events/kinds/directmessage.ts | 247 ++++++++++++ ndk-core/src/index.ts | 3 + 3 files changed, 687 insertions(+) create mode 100644 ndk-core/src/events/chatroom.ts create mode 100644 ndk-core/src/events/kinds/directmessage.ts diff --git a/ndk-core/src/events/chatroom.ts b/ndk-core/src/events/chatroom.ts new file mode 100644 index 00000000..60fc1861 --- /dev/null +++ b/ndk-core/src/events/chatroom.ts @@ -0,0 +1,437 @@ +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 { NDKUser } from "../user"; +import { NDKKind } from "./kinds"; +import { DirectMessageRelays, NDKDirectMessage, DirectMessageKind } from "./kinds/directmessage"; + + + + +// callback function passed to controller.chatrooms.subscribe() +// called whenever chatrooms are updated by NDKChatroomController +export type NDKChatroomSubscriber = (chatrooms : NDKChatroom[]) => void +export type NDKChatroomUnsubscriber = () => void +export type NDKChatroomSubscription = { + subscribe : (subscriber : NDKChatroomSubscriber) => NDKChatroomUnsubscriber +} + +// 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 + +// NDKChatroomController retrieves NDKDirectMessages and manages NDKChatroom instances +// 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. +// NDKChatroomController notifies subscribers +// when threads or messages in chatrooms have been updated +// NDKChatroomController static methods +// provide json serialization and deserialization of chatrooms for ofline storage +export class NDKChatroomController{ + + // parse a JSON string into an array of NDKChatroom instances + static fromJson(json : string, ndk : NDK) : NDKChatroomController | undefined { + const controller = new NDKChatroomController(ndk) + try{ + let parsed = JSON.parse(json) + if(parsed instanceof Array){ + (parsed as Array).forEach((data) => { + if( + ((data.threads instanceof Array && typeof data.threads[0] == 'string') || + (typeof data.threads == 'undefined') ) && + (typeof data.subject == 'string' || typeof data.subject == 'undefined') && + (typeof data.readuntil == 'number' || typeof data.readuntil == 'undefined' ) + ){ + controller._chatrooms.add( new NDKChatroom( controller, + data.threads as string[] | undefined, + data.subject as string | undefined, + data.readuntil as number | undefined + )) + } + }) + } + if(typeof parsed.readuntil == 'number'){ + controller._readuntil = parsed.readuntil + } + }catch(e){ + console.log("skipped error in NDKChatroomController.fromJson()") + } + return controller + } + + // serialize an array of NDKChatroom instances into a JSON string + static toJson(controller : NDKChatroomController) : string { + let readuntil = controller._readuntil + let chatrooms : string[] = [] + controller._chatrooms.forEach(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({ + threadids : [...chatroom.threadids], + subject : chatroom.subject, + readuntil : chatroom.readuntil + })) + } + }) + return JSON.stringify(chatrooms) + } + + // the NDKSubscription object controlled by .start() and .stop() + // this is what pulls new messages from relays + private _ndksubscription : NDKSubscription | undefined + // 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 chatrooms, + // chatrooms may be imported from json, or generated one for every new thread + // chatrooms may be merged to include multiple threads, and exported to json + private _chatrooms : Set = new Set() + // a reference to all subscribers being notified of chatroom uopdates, via .subscribe() + private _subscribers : Set = new Set() + private _readuntil : number = 0 + + // Instantiate a ChatroomManager with NDK + // with optional callbacks to configure how chatrooms are built + constructor( + private ndk : NDK, + ){ + if(!ndk.signer) throw new Error('missing signer required by NDKChatroomController') + } + + // instantiates an NDKSubscription for this ChatroomManager + async subscribe( filter? : 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 NDKChatroomController') + // assure that the signer and recipient are the same + const user : NDKUser = await this.ndk.signer.user(); + // TODO should preffered DM relays be passed as relaySet or rlayUrls? + options.relaySet = await DirectMessageRelays.for(user) + // this._messages = new Set() + // this._chatrooms = new Map() + filter = { + ...filter, + kinds:[ NDKKind.EncryptedDirectMessage, NDKKind.GiftWrap ], + "#p" : [user.pubkey] + } + this._ndksubscription = this.ndk.subscribe(filter,options) + this._ndksubscription.on("event", async (event) => { + // instntiate 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._chatrooms.size, message.rawEvent()) + message.publishStatus = undefined + // add the message to the chatroom and call the callback + this.addMessage(message) + } + }) + return this.chatrooms + } + + // a chatroom subscription + chatrooms : NDKChatroomSubscription = { + // manage subscribers, to notify on update of chatrooms + subscribe : (subscriber: NDKChatroomSubscriber) : NDKChatroomUnsubscriber => { + // notify the subscriber of the current chatrooms + subscriber([...this._chatrooms]) + // add the subscriber to the list of subscribers + this._subscribers.add(subscriber) + // return the unsubscribe function + return () => this.unsubscribe(subscriber) + + }, + } + + // stop subscriptions + unsubscribe(subscriber? : NDKChatroomSubscriber){ + if(subscriber) { + this._subscribers.delete(subscriber) + }else{ + this._subscribers.clear() + } + if(this._ndksubscription && !this._subscribers.size){ + this._ndksubscription.stop() + } + } + + // notify subscribers + notify() : void { + this._subscribers.forEach(subscriber => { + subscriber([...this._chatrooms]) + }) + } + + + + // instantiates and publishes a new DirectMessage with body and subject to recipients + async sendMessage(recipients:NDKUser[] | string[], body:string, subject? : string, kind?:DirectMessageKind){ + const message = new NDKDirectMessage(this.ndk, kind) + message.content = body + message.subject = subject + message.recipients = recipients + await message.publish().then(relays =>{ + console.log('message "'+body+'" 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.getChatroom(message) + if(chatroom) + return chatroom.readuntil >= message.created_at ? + "read" : "unread" + } + return undefined + } + + // add a DirectMessage to a chatroom + private addMessage(message : NDKDirectMessage) : NDKChatroom { + const thread = this.addMessageToThread(message) + let chatroom = this.getChatroom(thread) + if(chatroom){ + chatroom.update(undefined, undefined, false) + }else{ + chatroom = new NDKChatroom(this, [thread.id], message.subject) + this._chatrooms.add(chatroom) + } + this.notify() + return chatroom + } + + // new threads can only be set via addMessage(message) method + private addMessageToThread(message:NDKDirectMessage) : NDKChatroomThread { + let {id, pubkeys} = this.getThreadId(message) + let thread = this.getThread(id) + if(!thread){ + thread = new NDKChatroomThread(id, pubkeys) + this._threads.set(id, thread) + } + thread.set(message.id, message) + return thread + } + // get the thread id and pubkeys from a message + private getThreadId(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, pubkeys} + } + + + private getThread(id : string) : NDKChatroomThread + private getThread(message : NDKDirectMessage) : NDKChatroomThread + private getThread(idormessage : string | NDKDirectMessage) { + let id : string = typeof idormessage == 'string' ? idormessage : this.getThreadId(idormessage).id + return this._threads.get(id) + } + + getThreads(ids : string[]) : NDKChatroomThread[] { + let threads : NDKChatroomThread[] = [] + this._threads.forEach((thread, id) => { + if(thread && ids.includes(id)) threads.push(thread) + }) + return threads + } + + moveThread(thread : NDKChatroomThread, newchatroom : NDKChatroom) : NDKChatroom | undefined { + let oldchatroom = this.getChatroom(thread) + newchatroom.addThread(oldchatroom?.removeThread(thread.id)) + if(oldchatroom && oldchatroom.numthreads < 1 ){ + this._chatrooms.delete(oldchatroom) + } + this.notify() + return newchatroom + } + + + // get chatroom by index or thread or message + getChatroom(index: number ) : NDKChatroom | undefined + getChatroom(threadid: string ) : NDKChatroom | undefined + getChatroom(thread: NDKChatroomThread ) : NDKChatroom | undefined + getChatroom(message: NDKDirectMessage ) : NDKChatroom | undefined + getChatroom(identifier: number | string | NDKChatroomThread | NDKDirectMessage) : NDKChatroom | undefined { + const chatroom = + typeof identifier == 'number' ? [...this._chatrooms.values()][identifier] : + typeof identifier == 'string' ? + this._chatrooms.values().find(chatroom => chatroom.threadids.includes(identifier)) : + identifier instanceof NDKChatroomThread ? + this._chatrooms.values().find(chatroom => chatroom.threads.includes(identifier)) : + this._chatrooms.values().find(chatroom => chatroom.messages.includes(identifier)) + return chatroom + } + + reorderChatrooms(chatrooms : NDKChatroom[]) : boolean { + if(chatrooms.length != this._chatrooms.size) { + console.log("skipped reordering chatrooms : arrays do not match") + return false + } + for(let i in chatrooms){ + if(!this._chatrooms.has(chatrooms[i])) { + console.log("skipped reordering chatrooms : chatroom not found ", i) + return false + } + } + this._chatrooms = new Set(chatrooms) + this.notify() + return true + } + + mergeChatrooms(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._chatrooms.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 => { + merged.update(chatroom.threadids, chatroom.subject, false) + this._chatrooms.delete(chatroom) + }) + this.notify() + return merged + } + + getChatroomIndex(chatroom : NDKChatroom) : number | undefined { + return [...this._chatrooms].findIndex(ichatroom => ichatroom == chatroom) + } +} + + +// NDKChatroomThread represents a thread of DirectMessages +export class NDKChatroomThread extends Map{ + constructor( + readonly id : string, + readonly pubkeys : string[] = [], + ){ + super() + } +} + + +// NDKChatroom represents a chatroom +// It is a collection of DirectMessages threadsa and thier members +export class NDKChatroom { + + private _messages : NDKDirectMessage[] = [] + private _threadids : Set = new Set() + private _readuntil : number + + constructor( + private _controller : NDKChatroomController, + threadids? : string[], + private _subject? : string, + readuntil : number = 0, + notify = false + ){ + this._readuntil = readuntil + this.update(threadids, _subject, notify) + } + + update(threadids? : string[], subject? : string, notify = false) : NDKChatroom { + if(threadids) threadids.forEach(id => this._threadids.add(id)) + 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._controller.notify() + return this + } + + get threads(){ + return this._controller.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._controller.notify() + } + + 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.read - this.count + } + + get pubkeys() : string[] { + return this.threads.flatMap((thread) => thread.pubkeys) + } + +} + + diff --git a/ndk-core/src/events/kinds/directmessage.ts b/ndk-core/src/events/kinds/directmessage.ts new file mode 100644 index 00000000..3fd9c7f6 --- /dev/null +++ b/ndk-core/src/events/kinds/directmessage.ts @@ -0,0 +1,247 @@ +import { NDKKind } from "." +import { NDKEvent, 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 + + // 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 && Object.hasOwn(event, 'ndk')) + // ndk = (event as NDKEvent).ndk + if(!ndk.signer) throw('missing signer required by DirectMessage') + + if(!Object.hasOwn(event, 'ndk')){ + event = new NDKEvent(ndk, event) + } + let dmevent = event as NDKEvent + // catch any errors decrypting or unwrapping the event + try{ + // decrypt kind 4 content + if(dmevent.kind == NDKKind.EncryptedDirectMessage){ + await dmevent.decrypt() + } + // unwrap kind 14 event + if(dmevent.kind == NDKKind.GiftWrap){ + dmevent = await giftUnwrap(dmevent,undefined, ndk.signer) + } + }catch(e){ + console.log('error decrypting or unwrapping DirectMessage : ', e) + // return undefined if unable to decrypt or unwrap + return undefined + } + // return DirectMessage 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 | NDKEvent | NostrEvent = NDKDirectMessage.defaultkind, + ){ + + let event : NDKEvent | NostrEvent + 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; + // decrypt content (if encrypted kind 4) + if(this.kind == NDKKind.EncryptedDirectMessage && this.content.search("\\?iv=")){ + this.decrypt() + } + } + + readonly kind : DirectMessageKind; + + // recipients are message members NOT including the author + get recipients() : NDKUser[] { + let recipients : NDKUser[] = []; + this.getMatchingTags("p").forEach(ptag => + recipients.push(new NDKUser({pubkey:ptag[1], relayUrls:[ptag[2]]})) + ) + 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) => { + 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[] { + return [this.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 + } + + // override event .publish to handle encrypted or gift wrapped events + // publish once as a kind 4 event + // or publish for each recipient as gift wrapped kind 14 event + async publish( + relaySet?: NDKRelaySet, + timeoutMs?: number, + requiredRelayCount?: number + ): Promise> { + // 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 = (await this.ndk?.signer?.user())?.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 thread tag to published event + // this.replaceTag(["thread",this.thread.toString()]) + // add required event properties + await this.toNostrEvent() + if(this.kind == NDKKind.EncryptedDirectMessage){ + // publish as a kind 4 event (with encrypted content) + await this.encrypt() + publishedto = await super.publish(relaySet,timeoutMs,requiredRelayCount) + }else if(this.kind == NDKKind.PrivateDirectMessage){ + // publish as multiple kind 1059 gift wrapped kind 14 events + // one for each member recipient + for(let member of this.members){ + if(!member.pubkey) throw new Error('missing member pubkey') + console.log("publishing for recipient : ", member.pubkey) + // get relayset for each message member + let memberRelaySet = await DirectMessageRelays.for(member, this.ndk) + memberRelaySet ??= relaySet + console.log('publishing to '+memberRelaySet?.size+' relays : ',memberRelaySet?.relayUrls) + // gift wrap and publish to each member + const wrapped = await giftWrap(this, member) + 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) : Promise + static async for(user : NDKUser, ndk? : NDK) : Promise + static async for(userorpubkey : NDKUser | string, possiblendk? : NDK) : 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) + 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 => { + for(let tag in event?.tags){ + if(tag[0] == 'relay') { + // console.log('adding user relay : ',tag[1]) + let relay = new NDKRelay(tag[1], undefined, ndk) + 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"; From 98aade5d09149468ea6113c8335198a9c2daa8c0 Mon Sep 17 00:00:00 2001 From: manimejia Date: Wed, 21 May 2025 19:17:31 -0500 Subject: [PATCH 2/2] UPDATE NDKDirectMessage and NDKChatroom refactoring, bug fixes, and new features Various updates and bug fixes, still not quite ready for prime time... FIXME kind 4 messages sent by client user (from this or other clients) are not being decrypted properly when recieved back from relays by this client ... but other messages are fine ... and other clients render these messages fine also. FIXME fetching messages from relays is slow and some messages are not fetched at all ... --- ndk-core/src/events/chatroom.ts | 640 ++++++++++++++------- ndk-core/src/events/kinds/directmessage.ts | 186 ++++-- 2 files changed, 581 insertions(+), 245 deletions(-) diff --git a/ndk-core/src/events/chatroom.ts b/ndk-core/src/events/chatroom.ts index 60fc1861..75f9f775 100644 --- a/ndk-core/src/events/chatroom.ts +++ b/ndk-core/src/events/chatroom.ts @@ -3,163 +3,133 @@ 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 { NDKUser } from "../user"; +import { Hexpubkey, NDKUser } from "../user"; import { NDKKind } from "./kinds"; -import { DirectMessageRelays, NDKDirectMessage, DirectMessageKind } from "./kinds/directmessage"; +import { DirectMessageRelays, NDKDirectMessage } from "./kinds/directmessage"; +import { NDKUserProfile } from 'src'; - -// callback function passed to controller.chatrooms.subscribe() -// called whenever chatrooms are updated by NDKChatroomController -export type NDKChatroomSubscriber = (chatrooms : NDKChatroom[]) => void -export type NDKChatroomUnsubscriber = () => void -export type NDKChatroomSubscription = { - subscribe : (subscriber : NDKChatroomSubscriber) => NDKChatroomUnsubscriber -} - // 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 -// NDKChatroomController retrieves NDKDirectMessages and manages NDKChatroom instances -// 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. -// NDKChatroomController notifies subscribers -// when threads or messages in chatrooms have been updated -// NDKChatroomController static methods -// provide json serialization and deserialization of chatrooms for ofline storage -export class NDKChatroomController{ - // parse a JSON string into an array of NDKChatroom instances - static fromJson(json : string, ndk : NDK) : NDKChatroomController | undefined { - const controller = new NDKChatroomController(ndk) - try{ - let parsed = JSON.parse(json) - if(parsed instanceof Array){ - (parsed as Array).forEach((data) => { - if( - ((data.threads instanceof Array && typeof data.threads[0] == 'string') || - (typeof data.threads == 'undefined') ) && - (typeof data.subject == 'string' || typeof data.subject == 'undefined') && - (typeof data.readuntil == 'number' || typeof data.readuntil == 'undefined' ) - ){ - controller._chatrooms.add( new NDKChatroom( controller, - data.threads as string[] | undefined, - data.subject as string | undefined, - data.readuntil as number | undefined - )) - } - }) - } - if(typeof parsed.readuntil == 'number'){ - controller._readuntil = parsed.readuntil - } - }catch(e){ - console.log("skipped error in NDKChatroomController.fromJson()") - } - return controller - } +// 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 +// } - // serialize an array of NDKChatroom instances into a JSON string - static toJson(controller : NDKChatroomController) : string { - let readuntil = controller._readuntil - let chatrooms : string[] = [] - controller._chatrooms.forEach(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({ - threadids : [...chatroom.threadids], - subject : chatroom.subject, - readuntil : chatroom.readuntil - })) - } - }) - return JSON.stringify(chatrooms) - } +// 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 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 chatrooms, - // chatrooms may be imported from json, or generated one for every new thread - // chatrooms may be merged to include multiple threads, and exported to json - private _chatrooms : Set = new Set() // a reference to all subscribers being notified of chatroom uopdates, via .subscribe() - private _subscribers : Set = new Set() - private _readuntil : number = 0 + private _subscribers : Set = new Set() + private _manager : NDKChatroomManager - // Instantiate a ChatroomManager with NDK - // with optional callbacks to configure how chatrooms are built + // Instantiate a NDKChatroomSubscriber with NDK constructor( - private ndk : NDK, + 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 NDKChatroomController') + 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( filter? : NDKFilter, options : NDKSubscriptionOptions = {}) : Promise { + 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 NDKChatroomController') + 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._messages = new Set() - // this._chatrooms = new Map() - filter = { - ...filter, - kinds:[ NDKKind.EncryptedDirectMessage, NDKKind.GiftWrap ], - "#p" : [user.pubkey] - } - this._ndksubscription = this.ndk.subscribe(filter,options) + 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) => { - // instntiate a DirectMessage from each new 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._chatrooms.size, message.rawEvent()) + console.log("Chatroom message recieved ", this._manager.size, message.rawEvent()) message.publishStatus = undefined - // add the message to the chatroom and call the callback - this.addMessage(message) + // 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 : NDKChatroomSubscription = { + chatrooms : NDKChatroomSubscribable = { // manage subscribers, to notify on update of chatrooms - subscribe : (subscriber: NDKChatroomSubscriber) : NDKChatroomUnsubscriber => { + subscribe : (subscription: NDKChatroomSubscription) : ()=>void => { // notify the subscriber of the current chatrooms - subscriber([...this._chatrooms]) + subscription(this._manager) // add the subscriber to the list of subscribers - this._subscribers.add(subscriber) + this._subscribers.add(subscription) // return the unsubscribe function - return () => this.unsubscribe(subscriber) - + return () => this.unsubscribe(subscription) }, } // stop subscriptions - unsubscribe(subscriber? : NDKChatroomSubscriber){ - if(subscriber) { - this._subscribers.delete(subscriber) + unsubscribe(subscription? : NDKChatroomSubscription){ + if(subscription) { + this._subscribers.delete(subscription) }else{ this._subscribers.clear() } @@ -168,50 +138,162 @@ export class NDKChatroomController{ } } + restart() : void { + this._ndksubscription?.start() + } + // notify subscribers notify() : void { this._subscribers.forEach(subscriber => { - subscriber([...this._chatrooms]) + 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 + } - // instantiates and publishes a new DirectMessage with body and subject to recipients - async sendMessage(recipients:NDKUser[] | string[], body:string, subject? : string, kind?:DirectMessageKind){ - const message = new NDKDirectMessage(this.ndk, kind) - message.content = body - message.subject = subject - message.recipients = recipients - await message.publish().then(relays =>{ - console.log('message "'+body+'" published to '+relays.size+' relays ',[...relays].map(r => r.url)) - this.addMessage(message) + // 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) } - 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.getChatroom(message) - if(chatroom) - return chatroom.readuntil >= message.created_at ? - "read" : "unread" + // 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 undefined + return recipients } - // add a DirectMessage to a chatroom - private addMessage(message : NDKDirectMessage) : NDKChatroom { + + // add a DirectMessage to an existing or new chatroom + addMessage(message : NDKDirectMessage) : NDKChatroom { const thread = this.addMessageToThread(message) - let chatroom = this.getChatroom(thread) + let chatroom = this.get(thread) if(chatroom){ + // update() returns undefined if thread kind does not match chatroom kind chatroom.update(undefined, undefined, false) }else{ - chatroom = new NDKChatroom(this, [thread.id], message.subject) - this._chatrooms.add(chatroom) + let config : NDKChatroomConfig = { + threadids : [thread.id], + subject : message.subject + } + chatroom = new NDKChatroom(this, config) + this.set(chatroom) } this.notify() return chatroom @@ -219,31 +301,45 @@ export class NDKChatroomController{ // new threads can only be set via addMessage(message) method private addMessageToThread(message:NDKDirectMessage) : NDKChatroomThread { - let {id, pubkeys} = this.getThreadId(message) - let thread = this.getThread(id) + let thread = this.getThread(message)?.set(message.id, message) if(!thread){ - thread = new NDKChatroomThread(id, pubkeys) + let id = NDKChatroomThread.getId(message) + thread = new NDKChatroomThread(id, message) this._threads.set(id, thread) } - thread.set(message.id, message) return thread } - // get the thread id and pubkeys from a message - private getThreadId(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, pubkeys} + + // 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 + } - private getThread(id : string) : NDKChatroomThread - private getThread(message : NDKDirectMessage) : NDKChatroomThread - private getThread(idormessage : string | NDKDirectMessage) { - let id : string = typeof idormessage == 'string' ? idormessage : this.getThreadId(idormessage).id - return this._threads.get(id) + 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[] { @@ -254,56 +350,77 @@ export class NDKChatroomController{ 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.getChatroom(thread) + let oldchatroom = this.get(thread) newchatroom.addThread(oldchatroom?.removeThread(thread.id)) if(oldchatroom && oldchatroom.numthreads < 1 ){ - this._chatrooms.delete(oldchatroom) + super.delete(oldchatroom) } this.notify() return newchatroom } - // get chatroom by index or thread or message - getChatroom(index: number ) : NDKChatroom | undefined - getChatroom(threadid: string ) : NDKChatroom | undefined - getChatroom(thread: NDKChatroomThread ) : NDKChatroom | undefined - getChatroom(message: NDKDirectMessage ) : NDKChatroom | undefined - getChatroom(identifier: number | string | NDKChatroomThread | NDKDirectMessage) : NDKChatroom | undefined { + 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._chatrooms.values()][identifier] : + typeof identifier == 'number' ? [...this.values()][identifier] : typeof identifier == 'string' ? - this._chatrooms.values().find(chatroom => chatroom.threadids.includes(identifier)) : + this.values().find(chatroom => chatroom.id == identifier) || + this.values().find(chatroom => chatroom.threadids.includes(identifier)) : identifier instanceof NDKChatroomThread ? - this._chatrooms.values().find(chatroom => chatroom.threads.includes(identifier)) : - this._chatrooms.values().find(chatroom => chatroom.messages.includes(identifier)) + this.values().find(chatroom => chatroom.threads.includes(identifier)) : + this.values().find(chatroom => chatroom.messages.includes(identifier)) return chatroom } - reorderChatrooms(chatrooms : NDKChatroom[]) : boolean { - if(chatrooms.length != this._chatrooms.size) { + 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._chatrooms.has(chatrooms[i])) { + if(!this.has(chatrooms[i])) { console.log("skipped reordering chatrooms : chatroom not found ", i) return false } } - this._chatrooms = new Set(chatrooms) + this.forEach(chatroom => super.delete(chatroom)) + chatrooms.forEach(chatroom => super.add(chatroom)) this.notify() return true } - mergeChatrooms(chatrooms : NDKChatroom[]) : NDKChatroom | undefined{ + 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._chatrooms.has(chatrooms[i])){ + if(!this.has(chatrooms[i])){ console.log("skipped merging chatrooms : chatroom not found ", i) return undefined } @@ -312,59 +429,164 @@ export class NDKChatroomController{ 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) - this._chatrooms.delete(chatroom) + super.delete(chatroom) }) this.notify() return merged } - getChatroomIndex(chatroom : NDKChatroom) : number | undefined { - return [...this._chatrooms].findIndex(ichatroom => ichatroom == chatroom) + 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, - readonly pubkeys : string[] = [], + message : NDKDirectMessage, ){ - super() + 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 _controller : NDKChatroomController, - threadids? : string[], - private _subject? : string, - readuntil : number = 0, - notify = false + private _manager : NDKChatroomManager, + private _config? : NDKChatroomConfig ){ - this._readuntil = readuntil - this.update(threadids, _subject, notify) - } - - update(threadids? : string[], subject? : string, notify = false) : NDKChatroom { - if(threadids) threadids.forEach(id => this._threadids.add(id)) + // 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._controller.notify() + if(notify) this._manager.notify() return this } + get id() : string { + return this._id + } + get threads(){ - return this._controller.getThreads([...this._threadids]) + return this._manager.getThreads([...this._threadids]) } get threadids() : string[] { @@ -385,9 +607,25 @@ export class NDKChatroom { // if(this._readuntil && this._readuntil > timestamp){ this._readuntil = timestamp // } - this._controller.notify() + 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() @@ -406,8 +644,9 @@ export class NDKChatroom { } get subject() : string | undefined { if(this._subject) return this._subject - return this.messages.toReversed().find(message => message.subject)?.subject + return this.messages.toReversed().find(message => message.subject)?.subject } + hasOwnSubject() : boolean { return this._subject !== undefined } @@ -425,13 +664,30 @@ export class NDKChatroom { } get unread() : number { - return this.read - this.count + return this.count - this.read } - get pubkeys() : string[] { - return this.threads.flatMap((thread) => thread.pubkeys) + 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 index 3fd9c7f6..a6b1a7b0 100644 --- a/ndk-core/src/events/kinds/directmessage.ts +++ b/ndk-core/src/events/kinds/directmessage.ts @@ -1,12 +1,11 @@ import { NDKKind } from "." -import { NDKEvent, NostrEvent } 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 @@ -23,33 +22,36 @@ export class NDKDirectMessage extends NDKEvent{ // 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 && Object.hasOwn(event, 'ndk')) - // ndk = (event as NDKEvent).ndk - if(!ndk.signer) throw('missing signer required by DirectMessage') - - if(!Object.hasOwn(event, 'ndk')){ - event = new NDKEvent(ndk, event) - } - let dmevent = event as NDKEvent + 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{ - // decrypt kind 4 content - if(dmevent.kind == NDKKind.EncryptedDirectMessage){ - await dmevent.decrypt() - } // 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){ - console.log('error decrypting or unwrapping DirectMessage : ', 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 undefined } - // return DirectMessage if kind is 4 or 14 + // return NDKDirectMessage if kind is 4 or 14 if(dmevent.kind == NDKKind.EncryptedDirectMessage || dmevent.kind == NDKKind.PrivateDirectMessage){ return new NDKDirectMessage(ndk, dmevent) } @@ -57,10 +59,10 @@ export class NDKDirectMessage extends NDKEvent{ constructor( ndk: NDK , - eventorkind : DirectMessageKind | NDKEvent | NostrEvent = NDKDirectMessage.defaultkind, + eventorkind : DirectMessageKind | Partial | NDKEvent = NDKDirectMessage.defaultkind, ){ - let event : NDKEvent | NostrEvent + let event : NDKEvent | Partial if (typeof eventorkind == 'number'){ event = new NDKEvent() event.kind = eventorkind @@ -68,44 +70,57 @@ export class NDKDirectMessage extends NDKEvent{ event = eventorkind } - if(event.kind && (event.kind != NDKKind.EncryptedDirectMessage && event.kind != NDKKind.PrivateDirectMessage)) + 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; - // decrypt content (if encrypted kind 4) + // backwards compatible decrypt content if instantiated from encrypted kind 4 event if(this.kind == NDKKind.EncryptedDirectMessage && this.content.search("\\?iv=")){ this.decrypt() } } - readonly kind : DirectMessageKind; - // recipients are message members NOT including the author get recipients() : NDKUser[] { let recipients : NDKUser[] = []; - this.getMatchingTags("p").forEach(ptag => - recipients.push(new NDKUser({pubkey:ptag[1], relayUrls:[ptag[2]]})) - ) + 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[]){ + 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]) + this.tags.push(["p", pubkey]) }) } // message members are author and recipients get members() : NDKUser[] { - return [this.author, ...this.recipients]; + 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[] { @@ -135,40 +150,104 @@ export class NDKDirectMessage extends NDKEvent{ return !pubkey || pubkey == this.pubkey } - // override event .publish to handle encrypted or gift wrapped events - // publish once as a kind 4 event - // or publish for each recipient as gift wrapped kind 14 event + // 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 + 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 = (await this.ndk?.signer?.user())?.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 thread tag to published event - // this.replaceTag(["thread",this.thread.toString()]) + 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){ - // publish as a kind 4 event (with encrypted content) - await this.encrypt() - publishedto = await super.publish(relaySet,timeoutMs,requiredRelayCount) - }else if(this.kind == NDKKind.PrivateDirectMessage){ + // 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){ - if(!member.pubkey) throw new Error('missing member pubkey') - console.log("publishing for recipient : ", member.pubkey) + console.log("publishing for recipient : ", member.pubkey) // get relayset for each message member - let memberRelaySet = await DirectMessageRelays.for(member, this.ndk) - memberRelaySet ??= relaySet + 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) @@ -190,9 +269,9 @@ export class DirectMessageRelays { private constructor(){} - static async for(pubkey : string, ndk : NDK) : Promise - static async for(user : NDKUser, ndk? : NDK) : Promise - static async for(userorpubkey : NDKUser | string, possiblendk? : NDK) : Promise{ + 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 @@ -220,7 +299,7 @@ export class DirectMessageRelays { let url = this.defaulturls[u] console.log('instantiating new relay from default : ', url) let relay = new NDKRelay(url, undefined, ndk) - await relay.connect().then(()=> + if(connect) await relay.connect().then(()=> console.log('connected to default relay : ', relay.url) ) relayset.add(relay) @@ -230,11 +309,12 @@ export class DirectMessageRelays { kinds:[NDKKind.DirectMessageReceiveRelayList] } await ndk.fetchEvent(dmrelaysfilter).then(async event => { - for(let tag in event?.tags){ + 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) - await relay.connect().then(()=> console.log('connected to user relay : ', relay.url)) + if(connect) await relay.connect().then(()=> console.log('connected to user relay : ', relay.url)) relayset.add(relay) } }