diff --git a/.changeset/warm-suits-cover.md b/.changeset/warm-suits-cover.md new file mode 100644 index 00000000..751a6049 --- /dev/null +++ b/.changeset/warm-suits-cover.md @@ -0,0 +1,9 @@ +--- +"@xmtp/mls-client": patch +--- + +- Added conversation descriptions +- Fixed DB locking issues +- Fixed invalid policy error +- Removed Admin status from group creators (Super Admin only) +- Made content type optional when sending messages diff --git a/packages/mls-client/package.json b/packages/mls-client/package.json index 2c1f27ad..f005fc65 100644 --- a/packages/mls-client/package.json +++ b/packages/mls-client/package.json @@ -54,7 +54,7 @@ "dependencies": { "@xmtp/content-type-primitives": "^1.0.1", "@xmtp/content-type-text": "^1.0.0", - "@xmtp/mls-client-bindings-node": "^0.0.7", + "@xmtp/mls-client-bindings-node": "^0.0.8", "@xmtp/proto": "^3.61.1" }, "devDependencies": { diff --git a/packages/mls-client/src/Conversation.ts b/packages/mls-client/src/Conversation.ts index 2c44f132..ddba4bd6 100644 --- a/packages/mls-client/src/Conversation.ts +++ b/packages/mls-client/src/Conversation.ts @@ -1,4 +1,5 @@ import type { ContentTypeId } from '@xmtp/content-type-primitives' +import { ContentTypeText } from '@xmtp/content-type-text' import type { NapiGroup, NapiListMessagesOptions, @@ -37,6 +38,14 @@ export class Conversation { return this.#group.updateGroupImageUrlSquare(imageUrl) } + get description() { + return this.#group.groupDescription() + } + + async updateDescription(description: string) { + return this.#group.updateGroupDescription(description) + } + get isActive() { return this.#group.isActive() } @@ -137,8 +146,19 @@ export class Conversation { return this.#group.removeSuperAdmin(inboxId) } - async send(content: any, contentType: ContentTypeId) { - return this.#group.send(this.#client.encodeContent(content, contentType)) + async send(content: any, contentType?: ContentTypeId) { + if (typeof content !== 'string' && !contentType) { + throw new Error( + 'Content type is required when sending content other than text' + ) + } + + const encodedContent = + typeof content === 'string' + ? this.#client.encodeContent(content, contentType ?? ContentTypeText) + : this.#client.encodeContent(content, contentType!) + + return this.#group.send(encodedContent) } messages(options?: NapiListMessagesOptions): DecodedMessage[] { diff --git a/packages/mls-client/test/Conversation.test.ts b/packages/mls-client/test/Conversation.test.ts index e72d5d1c..9939dab2 100644 --- a/packages/mls-client/test/Conversation.test.ts +++ b/packages/mls-client/test/Conversation.test.ts @@ -1,6 +1,10 @@ -import { ContentTypeText } from '@xmtp/content-type-text' import { describe, expect, it } from 'vitest' -import { createRegisteredClient, createUser } from '@test/helpers' +import { + ContentTypeTest, + createRegisteredClient, + createUser, + TestCodec, +} from '@test/helpers' describe('Conversation', () => { it('should update conversation name', async () => { @@ -55,6 +59,32 @@ describe('Conversation', () => { expect(conversation2.messages().length).toBe(1) }) + it('should update conversation description', async () => { + const user1 = createUser() + const user2 = createUser() + const client1 = await createRegisteredClient(user1) + const client2 = await createRegisteredClient(user2) + const conversation = await client1.conversations.newConversation([ + user2.account.address, + ]) + const newDescription = 'foo' + await conversation.updateDescription(newDescription) + expect(conversation.description).toBe(newDescription) + const messages = conversation.messages() + expect(messages.length).toBe(2) + + await client2.conversations.sync() + const conversations = await client2.conversations.list() + expect(conversations.length).toBe(1) + + const conversation2 = conversations[0] + expect(conversation2).toBeDefined() + await conversation2.sync() + expect(conversation2.id).toBe(conversation.id) + expect(conversation2.description).toBe(newDescription) + expect(conversation2.messages().length).toBe(1) + }) + it('should add and remove members', async () => { const user1 = createUser() const user2 = createUser() @@ -131,7 +161,7 @@ describe('Conversation', () => { ]) const text = 'gm' - await conversation.send(text, ContentTypeText) + await conversation.send(text) const messages = conversation.messages() expect(messages.length).toBe(2) @@ -151,6 +181,38 @@ describe('Conversation', () => { expect(messages2[0].content).toBe(text) }) + it('should require content type when sending non-string content', async () => { + const user1 = createUser() + const user2 = createUser() + const client1 = await createRegisteredClient(user1, { + codecs: [new TestCodec()], + }) + await createRegisteredClient(user2) + const conversation = await client1.conversations.newConversation([ + user2.account.address, + ]) + + await expect(() => conversation.send(1)).rejects.toThrow() + await expect(() => conversation.send({ foo: 'bar' })).rejects.toThrow() + await expect( + conversation.send({ foo: 'bar' }, ContentTypeTest) + ).resolves.not.toThrow() + }) + + it('should throw when sending content without a codec', async () => { + const user1 = createUser() + const user2 = createUser() + const client1 = await createRegisteredClient(user1) + await createRegisteredClient(user2) + const conversation = await client1.conversations.newConversation([ + user2.account.address, + ]) + + await expect( + conversation.send({ foo: 'bar' }, ContentTypeTest) + ).rejects.toThrow() + }) + it('should stream messages', async () => { const user1 = createUser() const user2 = createUser() @@ -167,8 +229,8 @@ describe('Conversation', () => { const stream = conversation2[0].stream() - await conversation.send('gm', ContentTypeText) - await conversation.send('gm2', ContentTypeText) + await conversation.send('gm') + await conversation.send('gm2') let count = 0 for await (const message of stream) { @@ -194,21 +256,21 @@ describe('Conversation', () => { user2.account.address, ]) - expect(conversation.isAdmin(client1.inboxId)).toBe(true) + expect(conversation.isSuperAdmin(client1.inboxId)).toBe(true) + expect(conversation.superAdmins.length).toBe(1) + expect(conversation.superAdmins).toContain(client1.inboxId) + expect(conversation.isAdmin(client1.inboxId)).toBe(false) expect(conversation.isAdmin(client2.inboxId)).toBe(false) - expect(conversation.admins.length).toBe(1) - expect(conversation.admins).toContain(client1.inboxId) + expect(conversation.admins.length).toBe(0) await conversation.addAdmin(client2.inboxId) expect(conversation.isAdmin(client2.inboxId)).toBe(true) - expect(conversation.admins.length).toBe(2) - expect(conversation.admins).toContain(client1.inboxId) + expect(conversation.admins.length).toBe(1) expect(conversation.admins).toContain(client2.inboxId) await conversation.removeAdmin(client2.inboxId) expect(conversation.isAdmin(client2.inboxId)).toBe(false) - expect(conversation.admins.length).toBe(1) - expect(conversation.admins).toContain(client1.inboxId) + expect(conversation.admins.length).toBe(0) }) it('should add and remove super admins', async () => { diff --git a/packages/mls-client/test/Conversations.test.ts b/packages/mls-client/test/Conversations.test.ts index 2df8cb9a..97230741 100644 --- a/packages/mls-client/test/Conversations.test.ts +++ b/packages/mls-client/test/Conversations.test.ts @@ -1,4 +1,3 @@ -import { ContentTypeText } from '@xmtp/content-type-text' import { GroupPermissions } from '@xmtp/mls-client-bindings-node' import { describe, expect, it } from 'vitest' import { createRegisteredClient, createUser } from '@test/helpers' @@ -78,7 +77,7 @@ describe('Conversations', () => { const group = await client1.conversations.newConversation([ user2.account.address, ]) - const messageId = await group.send('gm!', ContentTypeText) + const messageId = await group.send('gm!') expect(messageId).toBeDefined() const message = client1.conversations.getMessageById(messageId) @@ -138,6 +137,17 @@ describe('Conversations', () => { expect(groupWithPermissions.permissions.policyType).toBe( GroupPermissions.GroupCreatorIsAdmin ) + + const groupWithDescription = await client1.conversations.newConversation( + [user2.account.address], + { + groupDescription: 'foo', + } + ) + expect(groupWithDescription).toBeDefined() + expect(groupWithDescription.name).toBe('') + expect(groupWithDescription.imageUrl).toBe('') + expect(groupWithDescription.description).toBe('foo') }) it('should stream new conversations', async () => { @@ -193,8 +203,8 @@ describe('Conversations', () => { await client3.conversations.sync() const groups3 = await client3.conversations.list() - await groups2[0].send('gm!', ContentTypeText) - await groups3[0].send('gm2!', ContentTypeText) + await groups2[0].send('gm!') + await groups3[0].send('gm2!') let count = 0 diff --git a/packages/mls-client/test/helpers.ts b/packages/mls-client/test/helpers.ts index 0c5e5cfd..5ecd45a3 100644 --- a/packages/mls-client/test/helpers.ts +++ b/packages/mls-client/test/helpers.ts @@ -1,9 +1,14 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' +import { + ContentTypeId, + type ContentCodec, + type EncodedContent, +} from '@xmtp/content-type-primitives' import { createWalletClient, http, toBytes } from 'viem' import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import { sepolia } from 'viem/chains' -import { Client, type XmtpEnv } from '@/Client' +import { Client, type ClientOptions } from '@/Client' const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -33,14 +38,22 @@ export const getSignature = async (client: Client, user: User) => { return null } -export const createClient = async (user: User, env?: XmtpEnv) => - Client.create(user.account.address, { - env: env ?? 'local', +export const createClient = async (user: User, options?: ClientOptions) => { + const opts = { + ...options, + env: options?.env ?? 'local', + } + return Client.create(user.account.address, { + ...opts, dbPath: join(__dirname, `./test-${user.account.address}.db3`), }) +} -export const createRegisteredClient = async (user: User, env?: XmtpEnv) => { - const client = await createClient(user, env) +export const createRegisteredClient = async ( + user: User, + options?: ClientOptions +) => { + const client = await createClient(user, options) if (!client.isRegistered) { const signature = await getSignature(client, user) if (signature) { @@ -50,3 +63,37 @@ export const createRegisteredClient = async (user: User, env?: XmtpEnv) => { } return client } + +export const ContentTypeTest = new ContentTypeId({ + authorityId: 'xmtp.org', + typeId: 'test', + versionMajor: 1, + versionMinor: 0, +}) + +export class TestCodec implements ContentCodec> { + get contentType(): ContentTypeId { + return ContentTypeTest + } + + encode(content: Record): EncodedContent { + return { + type: this.contentType, + parameters: {}, + content: new TextEncoder().encode(JSON.stringify(content)), + } + } + + decode(content: EncodedContent) { + const decoded = new TextDecoder().decode(content.content) + return JSON.parse(decoded) + } + + fallback() { + return undefined + } + + shouldPush() { + return false + } +} diff --git a/yarn.lock b/yarn.lock index 9eebb1d3..3ca27254 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2881,10 +2881,10 @@ __metadata: languageName: node linkType: hard -"@xmtp/mls-client-bindings-node@npm:^0.0.7": - version: 0.0.7 - resolution: "@xmtp/mls-client-bindings-node@npm:0.0.7" - checksum: 10/5730868ebac704b967cb0763affc38e2116db69568307d97d77eaf1301718a39c308be9b9aa5f3a2550123e0df8e9ff351a06d9f4bedcd4d4723e5764dfec786 +"@xmtp/mls-client-bindings-node@npm:^0.0.8": + version: 0.0.8 + resolution: "@xmtp/mls-client-bindings-node@npm:0.0.8" + checksum: 10/58bbe484844e08ae1bd9d22388fb9081662ef96debd29fcb375c88de66d27da1dc94da465add6c54ab926f8b100b0c6e717f6b500706705c7b08ea1021e70e34 languageName: node linkType: hard @@ -2901,7 +2901,7 @@ __metadata: "@vitest/coverage-v8": "npm:^1.6.0" "@xmtp/content-type-primitives": "npm:^1.0.1" "@xmtp/content-type-text": "npm:^1.0.0" - "@xmtp/mls-client-bindings-node": "npm:^0.0.7" + "@xmtp/mls-client-bindings-node": "npm:^0.0.8" "@xmtp/proto": "npm:^3.61.1" "@xmtp/xmtp-js": "workspace:^" eslint: "npm:^8.57.0"