@nostr-dev-kit/messages
Version:
High-level messaging library for NDK supporting NIP-17 and NIP-EE
863 lines (857 loc) • 26.2 kB
JavaScript
// src/cache-module.ts
var messagesCacheModule = {
namespace: "messages",
version: 1,
collections: {
messages: {
primaryKey: "id",
indexes: ["conversationId", "timestamp", "sender", "recipient"],
compoundIndexes: [
["conversationId", "timestamp"],
// For fetching conversation messages in order
["recipient", "read"]
// For fetching unread messages for a user
],
schema: {
id: "string",
content: "string",
sender: "string",
// pubkey
recipient: "string?",
// pubkey, optional for group messages
timestamp: "number",
protocol: "string",
read: "boolean",
rumor: "object?",
conversationId: "string"
}
},
conversations: {
primaryKey: "id",
indexes: ["lastMessageAt"],
compoundIndexes: [
["participants"]
// For finding conversations by participant
],
schema: {
id: "string",
participants: "string[]",
// array of pubkeys
name: "string?",
avatar: "string?",
lastMessageAt: "number?",
unreadCount: "number",
protocol: "string",
metadata: "object?"
}
},
// For future MLS support
mlsGroups: {
primaryKey: "id",
indexes: ["createdAt"],
schema: {
id: "string",
groupId: "string",
epoch: "number",
members: "string[]",
createdAt: "number",
updatedAt: "number",
treeHash: "string",
confirmedTranscriptHash: "string",
interimTranscriptHash: "string",
groupContext: "string"
// serialized binary
}
},
// Track which relays are used for DMs
dmRelays: {
primaryKey: "pubkey",
indexes: ["updatedAt"],
schema: {
pubkey: "string",
relays: "string[]",
updatedAt: "number"
}
}
},
migrations: {
1: async (context) => {
await context.createCollection("messages", messagesCacheModule.collections.messages);
await context.createCollection("conversations", messagesCacheModule.collections.conversations);
await context.createCollection("mlsGroups", messagesCacheModule.collections.mlsGroups);
await context.createCollection("dmRelays", messagesCacheModule.collections.dmRelays);
}
// Future migrations would go here
// 2: async (context) => {
// // Add new field or index
// await context.addIndex('messages', 'newField');
// }
}
};
// src/conversation.ts
import { NDKUser } from "@nostr-dev-kit/ndk";
import { EventEmitter } from "eventemitter3";
var NDKConversation = class extends EventEmitter {
id;
participants;
protocol;
messages = [];
storage;
nip17;
myPubkey;
constructor(id, participants, protocol, storage, myPubkey, nip17) {
super();
this.id = id;
this.participants = participants;
this.protocol = protocol;
this.storage = storage;
this.myPubkey = myPubkey;
this.nip17 = nip17;
}
/**
* Send a message in this conversation
*/
async sendMessage(content) {
if (this.protocol === "nip17" && this.nip17) {
const recipient = this.participants.find((p) => p.pubkey !== this.myPubkey);
if (!recipient) {
throw new Error("No recipient found in conversation");
}
try {
const wrappedEvent = await this.nip17.sendMessage(recipient, content);
const message = {
id: wrappedEvent.id || "",
content,
sender: new NDKUser({ pubkey: this.myPubkey }),
recipient,
timestamp: Math.floor(Date.now() / 1e3),
protocol: "nip17",
read: true,
// Our own messages are always "read"
conversationId: this.id
};
await this.storage.saveMessage(message);
this.messages.push(message);
this.emit("message", message);
return message;
} catch (error) {
const errorEvent = {
type: "send-failed",
message: `Failed to send message: ${error}`,
error
};
this.emit("error", errorEvent);
throw error;
}
} else {
throw new Error(`Protocol ${this.protocol} not supported yet`);
}
}
/**
* Get messages in this conversation
*/
async getMessages(limit) {
if (this.messages.length === 0) {
this.messages = await this.storage.getMessages(this.id, limit);
}
if (limit && this.messages.length > limit) {
return this.messages.slice(-limit);
}
return [...this.messages];
}
/**
* Mark all messages as read
*/
async markAsRead() {
const unreadMessages = this.messages.filter((m) => !m.read);
if (unreadMessages.length > 0) {
const messageIds = unreadMessages.map((m) => m.id);
await this.storage.markAsRead(messageIds);
unreadMessages.forEach((m) => m.read = true);
}
}
/**
* Get unread count
*/
getUnreadCount() {
return this.messages.filter((m) => !m.read && m.sender.pubkey !== this.myPubkey).length;
}
/**
* Get the other participant in a two-person conversation
*/
getOtherParticipant() {
return this.participants.find((p) => p.pubkey !== this.myPubkey);
}
/**
* Get the last message in the conversation
*/
getLastMessage() {
return this.messages[this.messages.length - 1];
}
/**
* Handle an incoming message (called by NDKMessenger)
*/
async _handleIncomingMessage(message) {
const exists = this.messages.find((m) => m.id === message.id);
if (!exists) {
this.messages.push(message);
this.messages.sort((a, b) => a.timestamp - b.timestamp);
await this.storage.saveMessage(message);
this.emit("message", message);
}
}
/**
* Handle a state change (for future MLS support)
*/
_handleStateChange(event) {
this.emit("state-change", event);
}
/**
* Handle an error
*/
_handleError(error) {
this.emit("error", error);
}
/**
* Clean up resources
*/
destroy() {
this.removeAllListeners();
this.messages = [];
}
};
// src/messenger.ts
import { NDKKind as NDKKind2, NDKRelaySet as NDKRelaySet2, NDKUser as NDKUser4 } from "@nostr-dev-kit/ndk";
import { EventEmitter as EventEmitter2 } from "eventemitter3";
// src/protocols/nip17.ts
import {
giftUnwrap,
giftWrap,
NDKEvent,
NDKKind,
NDKRelaySet,
NDKUser as NDKUser2
} from "@nostr-dev-kit/ndk";
import { getEventHash } from "nostr-tools/pure";
var NIP17Protocol = class {
constructor(ndk, signer) {
this.ndk = ndk;
this.signer = signer;
}
/**
* Send a NIP-17 direct message
*/
async sendMessage(recipient, content) {
const sender = await this.signer.user();
const rumor = new NDKEvent(this.ndk);
rumor.kind = NDKKind.PrivateDirectMessage;
rumor.content = content;
rumor.created_at = Math.floor(Date.now() / 1e3);
rumor.pubkey = sender.pubkey;
rumor.tags = [["p", recipient.pubkey]];
const wrappedEvent = await giftWrap(rumor, recipient, this.signer);
const recipientRelays = await this.getRecipientDMRelays(recipient);
const senderRelays = await this.getUserDMRelays(sender);
const allRelays = [.../* @__PURE__ */ new Set([...recipientRelays, ...senderRelays])];
if (allRelays.length > 0) {
const relaySet = NDKRelaySet.fromRelayUrls(allRelays, this.ndk);
await wrappedEvent.publish(relaySet);
} else {
await wrappedEvent.publish();
}
return wrappedEvent;
}
/**
* Unwrap a received gift-wrapped message
*/
async unwrapMessage(wrappedEvent) {
try {
const rumor = await giftUnwrap(wrappedEvent, void 0, this.signer);
if (rumor.kind !== NDKKind.PrivateDirectMessage) {
return null;
}
return rumor.rawEvent();
} catch (error) {
console.error("Failed to unwrap message:", error);
return null;
}
}
/**
* Convert a rumor event to NDKMessage format
*/
rumorToMessage(rumor, myPubkey) {
if (!rumor.id) {
rumor.id = getEventHash(rumor);
}
const isOutgoing = rumor.pubkey === myPubkey;
const otherPubkey = isOutgoing ? rumor.tags.find((t) => t[0] === "p")?.[1] || "" : rumor.pubkey;
const conversationId = [myPubkey, otherPubkey].sort().join(":");
return {
id: rumor.id,
content: rumor.content || "",
sender: new NDKUser2({ pubkey: rumor.pubkey }),
recipient: isOutgoing ? new NDKUser2({ pubkey: otherPubkey }) : new NDKUser2({ pubkey: myPubkey }),
timestamp: rumor.created_at || Math.floor(Date.now() / 1e3),
protocol: "nip17",
read: isOutgoing,
// Outgoing messages are automatically "read"
rumor,
conversationId
};
}
/**
* Get DM relays for a recipient (kind 10050)
*/
async getRecipientDMRelays(recipient) {
try {
const dmRelayList = await this.ndk.fetchEvent({
kinds: [NDKKind.DirectMessageReceiveRelayList],
authors: [recipient.pubkey]
});
if (dmRelayList) {
const relays = dmRelayList.getMatchingTags("relay").map((t) => t[1]);
if (relays.length > 0) {
return relays;
}
}
const relayList = await this.ndk.fetchEvent({
kinds: [10002],
authors: [recipient.pubkey]
});
if (relayList) {
const relays = relayList.getMatchingTags("r").map((t) => t[1]);
if (relays.length > 0) {
return relays.slice(0, 3);
}
}
return [];
} catch (error) {
console.error("Failed to fetch recipient relays:", error);
return [];
}
}
/**
* Get DM relays for the current user (kind 10050)
*/
async getUserDMRelays(user) {
try {
const dmRelayList = await this.ndk.fetchEvent({
kinds: [NDKKind.DirectMessageReceiveRelayList],
authors: [user.pubkey]
});
if (dmRelayList) {
const relays = dmRelayList.getMatchingTags("relay").map((t) => t[1]);
if (relays.length > 0) {
return relays;
}
}
const relayList = await this.ndk.fetchEvent({
kinds: [10002],
authors: [user.pubkey]
});
if (relayList) {
const relays = relayList.getMatchingTags("r").map((t) => t[1]);
if (relays.length > 0) {
return relays.slice(0, 3);
}
}
return [];
} catch (error) {
console.error("Failed to fetch user relays:", error);
return [];
}
}
/**
* Publish the user's DM relay list (kind 10050)
*/
async publishDMRelayList(relays) {
const event = new NDKEvent(this.ndk);
event.kind = NDKKind.DirectMessageReceiveRelayList;
event.tags = relays.map((relay) => ["relay", relay]);
event.created_at = Math.floor(Date.now() / 1e3);
await event.sign(this.signer);
await event.publish();
return event;
}
};
// src/storage/cache-module.ts
import { NDKUser as NDKUser3 } from "@nostr-dev-kit/ndk";
var CacheModuleStorage = class {
constructor(cache, myPubkey) {
this.cache = cache;
this.myPubkey = myPubkey;
}
messagesCollection;
conversationsCollection;
initialized = false;
/**
* Initialize the storage by registering the module and getting collections
*/
async ensureInitialized() {
if (this.initialized) return;
if (this.cache.registerModule) {
await this.cache.registerModule(messagesCacheModule);
}
if (this.cache.getModuleCollection) {
this.messagesCollection = await this.cache.getModuleCollection("messages", "messages");
this.conversationsCollection = await this.cache.getModuleCollection(
"messages",
"conversations"
);
}
this.initialized = true;
}
async saveMessage(message) {
await this.ensureInitialized();
if (!this.messagesCollection) return;
const cachedMessage = {
id: message.id,
content: message.content,
sender: message.sender.pubkey,
recipient: message.recipient?.pubkey,
timestamp: message.timestamp,
protocol: message.protocol,
read: message.read,
rumor: message.rumor,
conversationId: message.conversationId
};
await this.messagesCollection.save(cachedMessage);
await this.updateConversationForMessage(message);
}
async updateConversationForMessage(message) {
if (!this.conversationsCollection) return;
const conversation = await this.conversationsCollection.get(message.conversationId);
if (conversation) {
conversation.lastMessageAt = message.timestamp;
if (!message.read && message.sender.pubkey !== this.myPubkey) {
conversation.unreadCount++;
}
await this.conversationsCollection.save(conversation);
} else {
const participants = [message.sender.pubkey];
if (message.recipient) {
participants.push(message.recipient.pubkey);
}
const newConversation = {
id: message.conversationId,
participants: [...new Set(participants)],
// Deduplicate
lastMessageAt: message.timestamp,
unreadCount: !message.read && message.sender.pubkey !== this.myPubkey ? 1 : 0,
protocol: message.protocol
};
await this.conversationsCollection.save(newConversation);
}
}
async getMessages(conversationId, limit) {
await this.ensureInitialized();
if (!this.messagesCollection) return [];
const cachedMessages = await this.messagesCollection.findBy("conversationId", conversationId);
cachedMessages.sort((a, b) => a.timestamp - b.timestamp);
let messages = cachedMessages;
if (limit && messages.length > limit) {
messages = messages.slice(-limit);
}
return messages.map((cached) => ({
id: cached.id,
content: cached.content,
sender: new NDKUser3({ pubkey: cached.sender }),
recipient: cached.recipient ? new NDKUser3({ pubkey: cached.recipient }) : void 0,
timestamp: cached.timestamp,
protocol: cached.protocol,
read: cached.read,
rumor: cached.rumor,
conversationId: cached.conversationId
}));
}
async markAsRead(messageIds) {
await this.ensureInitialized();
if (!this.messagesCollection || !this.conversationsCollection) return;
for (const id of messageIds) {
const message = await this.messagesCollection.get(id);
if (message && !message.read) {
message.read = true;
await this.messagesCollection.save(message);
const conversation = await this.conversationsCollection.get(message.conversationId);
if (conversation && conversation.unreadCount > 0) {
conversation.unreadCount--;
await this.conversationsCollection.save(conversation);
}
}
}
}
async getConversations(userId) {
await this.ensureInitialized();
if (!this.conversationsCollection) return [];
const allConversations = await this.conversationsCollection.all();
const userConversations = allConversations.filter((conv) => conv.participants.includes(userId));
userConversations.sort((a, b) => (b.lastMessageAt || 0) - (a.lastMessageAt || 0));
return userConversations.map((conv) => ({
id: conv.id,
participants: conv.participants,
name: conv.name,
avatar: conv.avatar,
lastMessageAt: conv.lastMessageAt,
unreadCount: conv.unreadCount
}));
}
async saveConversation(conversation) {
await this.ensureInitialized();
if (!this.conversationsCollection) return;
const cachedConversation = {
id: conversation.id,
participants: conversation.participants,
name: conversation.name,
avatar: conversation.avatar,
lastMessageAt: conversation.lastMessageAt,
unreadCount: conversation.unreadCount,
protocol: "nip17"
// Default for now
};
await this.conversationsCollection.save(cachedConversation);
}
async deleteMessage(messageId) {
await this.ensureInitialized();
if (!this.messagesCollection) return;
const message = await this.messagesCollection.get(messageId);
if (message) {
if (!message.read && this.conversationsCollection) {
const conversation = await this.conversationsCollection.get(message.conversationId);
if (conversation && conversation.unreadCount > 0) {
conversation.unreadCount--;
await this.conversationsCollection.save(conversation);
}
}
await this.messagesCollection.delete(messageId);
}
}
async clear() {
await this.ensureInitialized();
if (this.messagesCollection) {
await this.messagesCollection.clear();
}
if (this.conversationsCollection) {
await this.conversationsCollection.clear();
}
}
};
// src/storage/memory.ts
var MemoryAdapter = class {
messages = /* @__PURE__ */ new Map();
conversations = /* @__PURE__ */ new Map();
messagesByConversation = /* @__PURE__ */ new Map();
async saveMessage(message) {
this.messages.set(message.id, message);
if (!this.messagesByConversation.has(message.conversationId)) {
this.messagesByConversation.set(message.conversationId, /* @__PURE__ */ new Set());
}
this.messagesByConversation.get(message.conversationId).add(message.id);
const conversation = this.conversations.get(message.conversationId);
if (conversation) {
conversation.lastMessageAt = message.timestamp;
if (!message.read) {
conversation.unreadCount++;
}
}
}
async getMessages(conversationId, limit) {
const messageIds = this.messagesByConversation.get(conversationId);
if (!messageIds) {
return [];
}
const messages = [];
for (const id of messageIds) {
const message = this.messages.get(id);
if (message) {
messages.push(message);
}
}
messages.sort((a, b) => a.timestamp - b.timestamp);
if (limit && messages.length > limit) {
return messages.slice(-limit);
}
return messages;
}
async markAsRead(messageIds) {
for (const id of messageIds) {
const message = this.messages.get(id);
if (message) {
const wasUnread = !message.read;
message.read = true;
if (wasUnread) {
const conversation = this.conversations.get(message.conversationId);
if (conversation && conversation.unreadCount > 0) {
conversation.unreadCount--;
}
}
}
}
}
async getConversations(userId) {
const userConversations = [];
for (const conversation of this.conversations.values()) {
if (conversation.participants.includes(userId)) {
userConversations.push({ ...conversation });
}
}
userConversations.sort((a, b) => (b.lastMessageAt || 0) - (a.lastMessageAt || 0));
return userConversations;
}
async saveConversation(conversation) {
this.conversations.set(conversation.id, { ...conversation });
}
async deleteMessage(messageId) {
const message = this.messages.get(messageId);
if (message) {
const messageIds = this.messagesByConversation.get(message.conversationId);
if (messageIds) {
messageIds.delete(messageId);
}
if (!message.read) {
const conversation = this.conversations.get(message.conversationId);
if (conversation && conversation.unreadCount > 0) {
conversation.unreadCount--;
}
}
this.messages.delete(messageId);
}
}
async clear() {
this.messages.clear();
this.conversations.clear();
this.messagesByConversation.clear();
}
/**
* Get a single message by ID (helper method)
*/
async getMessage(messageId) {
return this.messages.get(messageId);
}
/**
* Check if a message exists (helper method for deduplication)
*/
async hasMessage(messageId) {
return this.messages.has(messageId);
}
};
// src/messenger.ts
var NDKMessenger = class extends EventEmitter2 {
ndk;
storage;
nip17;
conversations = /* @__PURE__ */ new Map();
subscription;
myPubkey;
started = false;
constructor(ndk, options) {
super();
this.ndk = ndk;
if (!ndk.signer) {
throw new Error("NDK must have a signer configured");
}
this.storage = options?.storage || new MemoryAdapter();
this.nip17 = new NIP17Protocol(ndk, ndk.signer);
if (options?.autoStart) {
this.start().catch(console.error);
}
}
/**
* Start the messenger (begin listening for messages)
*/
async start() {
if (this.started) return;
if (!this.ndk.signer) {
throw new Error("NDK signer not configured");
}
const user = await this.ndk.signer.user();
this.myPubkey = user.pubkey;
if (this.storage instanceof MemoryAdapter && this.ndk.cacheAdapter?.registerModule) {
this.storage = new CacheModuleStorage(this.ndk.cacheAdapter, this.myPubkey);
}
await this.loadConversations();
await this.subscribeToMessages();
this.started = true;
}
/**
* Stop the messenger
*/
stop() {
if (this.subscription) {
this.subscription.stop();
this.subscription = void 0;
}
this.started = false;
}
/**
* Send a direct message to a user
*/
async sendMessage(recipient, content) {
if (!this.myPubkey) {
await this.start();
}
const conversation = await this.getConversation(recipient);
return conversation.sendMessage(content);
}
/**
* Get or create a conversation with a user
*/
async getConversation(user) {
if (!this.myPubkey) {
await this.start();
}
const conversationId = [this.myPubkey, user.pubkey].sort().join(":");
let conversation = this.conversations.get(conversationId);
if (conversation) {
return conversation;
}
conversation = new NDKConversation(
conversationId,
[new NDKUser4({ pubkey: this.myPubkey }), user],
"nip17",
// Default to NIP-17 for now
this.storage,
this.myPubkey,
this.nip17
);
const meta = {
id: conversationId,
participants: [this.myPubkey, user.pubkey],
protocol: "nip17",
unreadCount: 0
};
await this.storage.saveConversation(meta);
this.conversations.set(conversationId, conversation);
conversation.on("message", (message) => {
this.emit("message", message);
});
conversation.on("error", (error) => {
this.emit("error", error);
});
return conversation;
}
/**
* Get all conversations
*/
async getConversations() {
if (!this.myPubkey) {
await this.start();
}
await this.loadConversations();
return Array.from(this.conversations.values());
}
/**
* Publish DM relay list (kind 10050)
*/
async publishDMRelays(relays) {
return this.nip17.publishDMRelayList(relays);
}
/**
* Load conversations from storage
*/
async loadConversations() {
if (!this.myPubkey) return;
const metas = await this.storage.getConversations(this.myPubkey);
for (const meta of metas) {
if (this.conversations.has(meta.id)) {
continue;
}
const participants = meta.participants.map((pubkey) => new NDKUser4({ pubkey }));
const conversation = new NDKConversation(
meta.id,
participants,
meta.protocol,
this.storage,
this.myPubkey,
this.nip17
);
const messages = await this.storage.getMessages(meta.id);
for (const message of messages) {
message.sender = new NDKUser4({ pubkey: message.sender.pubkey });
if (message.recipient) {
message.recipient = new NDKUser4({ pubkey: message.recipient.pubkey });
}
await conversation._handleIncomingMessage(message);
}
this.conversations.set(meta.id, conversation);
conversation.on("message", (message) => {
this.emit("message", message);
});
conversation.on("error", (error) => {
this.emit("error", error);
});
}
}
/**
* Subscribe to incoming messages
*/
async subscribeToMessages() {
if (!this.myPubkey || !this.ndk.signer) return;
const user = await this.ndk.signer.user();
const userRelays = await this.nip17.getUserDMRelays(user);
const subOptions = {
closeOnEose: false
};
if (userRelays.length > 0) {
const relaySet = NDKRelaySet2.fromRelayUrls(userRelays, this.ndk);
subOptions.relaySet = relaySet;
}
this.subscription = this.ndk.subscribe(
{
kinds: [NDKKind2.GiftWrap],
"#p": [this.myPubkey]
},
subOptions
);
this.subscription.on("event", async (wrappedEvent) => {
await this.handleIncomingMessage(wrappedEvent);
});
this.subscription.on("eose", () => {
console.log("Messages subscription active");
});
}
/**
* Handle an incoming gift-wrapped message
*/
async handleIncomingMessage(wrappedEvent) {
if (!this.myPubkey || !this.ndk.signer) return;
try {
const rumor = await this.nip17.unwrapMessage(wrappedEvent);
if (!rumor) return;
const message = this.nip17.rumorToMessage(rumor, this.myPubkey);
const otherPubkey = message.sender.pubkey === this.myPubkey ? message.recipient?.pubkey : message.sender.pubkey;
if (!otherPubkey) return;
const otherUser = new NDKUser4({ pubkey: otherPubkey });
const conversation = await this.getConversation(otherUser);
await conversation._handleIncomingMessage(message);
this.emit("message", message);
if (conversation.getMessages.length === 1) {
this.emit("conversation-created", conversation);
}
} catch (error) {
const errorEvent = {
type: "decryption-failed",
message: `Failed to decrypt message: ${error}`,
error
};
this.emit("error", errorEvent);
}
}
/**
* Clean up resources
*/
destroy() {
this.stop();
this.conversations.forEach((conv) => conv.destroy());
this.conversations.clear();
this.removeAllListeners();
}
};
export {
CacheModuleStorage,
MemoryAdapter,
NDKConversation,
NDKMessenger,
NIP17Protocol,
messagesCacheModule
};
//# sourceMappingURL=index.mjs.map