diff --git a/.changeset/fifty-drinks-type.md b/.changeset/fifty-drinks-type.md new file mode 100644 index 00000000..76d5590f --- /dev/null +++ b/.changeset/fifty-drinks-type.md @@ -0,0 +1,5 @@ +--- +"@xmtp/react-native-sdk": patch +--- + +Add custom content types for preparing a message diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt index d41863b3..a707c428 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt @@ -713,6 +713,26 @@ class XMTPModule : Module() { } } + AsyncFunction("prepareEncodedMessage") Coroutine { installationId: String, conversationId: String, encodedContentData: List -> + withContext(Dispatchers.IO) { + logV("prepareEncodedMessage") + val client = clients[installationId] ?: throw XMTPException("No client") + val conversation = client.findConversation(conversationId) + ?: throw XMTPException("no conversation found for $conversationId") + val encodedContentDataBytes = + encodedContentData.foldIndexed(ByteArray(encodedContentData.size)) { i, a, v -> + a.apply { + set( + i, + v.toByte() + ) + } + } + val encodedContent = EncodedContent.parseFrom(encodedContentDataBytes) + conversation.prepareMessage(encodedContent = encodedContent) + } + } + AsyncFunction("findOrCreateDm") Coroutine { installationId: String, peerAddress: String -> withContext(Dispatchers.IO) { logV("findOrCreateDm") diff --git a/example/src/tests/conversationTests.ts b/example/src/tests/conversationTests.ts index 716c9448..711bea28 100644 --- a/example/src/tests/conversationTests.ts +++ b/example/src/tests/conversationTests.ts @@ -144,6 +144,54 @@ test('register and use custom content types', async () => { return true }) +test('register and use custom content types with prepare', async () => { + const keyBytes = new Uint8Array([ + 233, 120, 198, 96, 154, 65, 132, 17, 132, 96, 250, 40, 103, 35, 125, 64, + 166, 83, 208, 224, 254, 44, 205, 227, 175, 49, 234, 129, 74, 252, 135, 145, + ]) + const bob = await Client.createRandom({ + env: 'local', + codecs: [new NumberCodec()], + dbEncryptionKey: keyBytes, + }) + const alice = await Client.createRandom({ + env: 'local', + codecs: [new NumberCodec()], + dbEncryptionKey: keyBytes, + }) + + bob.register(new NumberCodec()) + alice.register(new NumberCodec()) + + await delayToPropogate() + + const bobConvo = await bob.conversations.newConversation(alice.address) + await delayToPropogate() + await bobConvo.prepareMessage( + { topNumber: { bottomNumber: 12 } }, + { contentType: ContentTypeNumber } + ) + await bobConvo.publishPreparedMessages() + + await alice.conversations.syncAllConversations() + const aliceConvo = await alice.conversations.findConversation(bobConvo.id) + + const messages = await aliceConvo!.messages() + assert(messages.length === 1, 'did not get messages') + + const message = messages[0] + const messageContent = message.content() + + assert( + typeof messageContent === 'object' && + 'topNumber' in messageContent && + messageContent.topNumber.bottomNumber === 12, + 'did not get content properly: ' + JSON.stringify(messageContent) + ) + + return true +}) + test('handle fallback types appropriately', async () => { const keyBytes = new Uint8Array([ 233, 120, 198, 96, 154, 65, 132, 17, 132, 96, 250, 40, 103, 35, 125, 64, diff --git a/ios/XMTPModule.swift b/ios/XMTPModule.swift index ab45c835..34e36224 100644 --- a/ios/XMTPModule.swift +++ b/ios/XMTPModule.swift @@ -721,7 +721,7 @@ public class XMTPModule: Module { return nil } } - + AsyncFunction("sendEncodedContent") { ( installationId: String, conversationId: String, @@ -809,6 +809,30 @@ public class XMTPModule: Module { ) } + AsyncFunction("prepareEncodedMessage") { + ( + installationId: String, + conversationId: String, + encodedContentData: [UInt8] + ) -> String in + guard + let client = await clientsManager.getClient(key: installationId) + else { + throw Error.noClient + } + guard + let conversation = try client.findConversation( + conversationId: conversationId) + else { + throw Error.conversationNotFound( + "no conversation found for \(conversationId)") + } + let encodedContent = try EncodedContent( + serializedBytes: Data(encodedContentData)) + return try await conversation.prepareMessage( + encodedContent: encodedContent) + } + AsyncFunction("findOrCreateDm") { (installationId: String, peerAddress: String) -> String in guard diff --git a/src/index.ts b/src/index.ts index 8ce6af61..29e87b54 100644 --- a/src/index.ts +++ b/src/index.ts @@ -596,6 +596,25 @@ export async function prepareMessage( ) } +export async function prepareMessageWithContentType( + installationId: InstallationId, + conversationId: ConversationId, + content: any, + codec: ContentCodec +): Promise { + if ('contentKey' in codec) { + return prepareMessage(installationId, conversationId, content) + } + const encodedContent = codec.encode(content) + encodedContent.fallback = codec.fallback(content) + const encodedContentData = EncodedContent.encode(encodedContent).finish() + return await XMTPModule.prepareEncodedMessage( + installationId, + conversationId, + Array.from(encodedContentData) + ) +} + export async function findOrCreateDm< ContentTypes extends DefaultContentTypes = DefaultContentTypes, >( diff --git a/src/lib/Conversation.ts b/src/lib/Conversation.ts index a230d673..a801079e 100644 --- a/src/lib/Conversation.ts +++ b/src/lib/Conversation.ts @@ -28,6 +28,10 @@ export interface ConversationBase { content: ConversationSendPayload, opts?: SendOptions ): Promise + prepareMessage( + content: ConversationSendPayload, + opts?: SendOptions + ): Promise sync() messages(opts?: MessagesOptions): Promise[]> streamMessages( diff --git a/src/lib/Dm.ts b/src/lib/Dm.ts index 6b1bfaf9..9619642d 100644 --- a/src/lib/Dm.ts +++ b/src/lib/Dm.ts @@ -113,11 +113,13 @@ export class Dm */ async prepareMessage< SendContentTypes extends DefaultContentTypes = ContentTypes, - >(content: ConversationSendPayload): Promise { - // TODO: Enable other content types - // if (opts && opts.contentType) { - // return await this._sendWithJSCodec(content, opts.contentType) - // } + >( + content: ConversationSendPayload, + opts?: SendOptions + ): Promise { + if (opts && opts.contentType) { + return await this._prepareWithJSCodec(content, opts.contentType) + } try { if (typeof content === 'string') { @@ -135,6 +137,27 @@ export class Dm } } + private async _prepareWithJSCodec( + content: T, + contentType: XMTP.ContentTypeId + ): Promise { + const codec = + this.client.codecRegistry[ + `${contentType.authorityId}/${contentType.typeId}:${contentType.versionMajor}.${contentType.versionMinor}` + ] + + if (!codec) { + throw new Error(`no codec found for: ${contentType}`) + } + + return await XMTP.prepareMessageWithContentType( + this.client.installationId, + this.id, + content, + codec + ) + } + /** * Publish all prepared messages. * diff --git a/src/lib/Group.ts b/src/lib/Group.ts index 08445f9b..82e6d017 100644 --- a/src/lib/Group.ts +++ b/src/lib/Group.ts @@ -84,7 +84,7 @@ export class Group< * Sends a message to the current group. * * @param {string | MessageContent} content - The content of the message. It can be either a string or a structured MessageContent object. - * @returns {Promise} A Promise that resolves to a string identifier for the sent message. + * @returns {Promise} A Promise that resolves to a string identifier for the sent message. * @throws {Error} Throws an error if there is an issue with sending the message. */ async send( @@ -136,16 +136,18 @@ export class Group< * Prepare a group message to be sent. * * @param {string | MessageContent} content - The content of the message. It can be either a string or a structured MessageContent object. - * @returns {Promise} A Promise that resolves to a string identifier for the prepared message to be sent. + * @returns {Promise} A Promise that resolves to a string identifier for the prepared message to be sent. * @throws {Error} Throws an error if there is an issue with sending the message. */ async prepareMessage< SendContentTypes extends DefaultContentTypes = ContentTypes, - >(content: ConversationSendPayload): Promise { - // TODO: Enable other content types - // if (opts && opts.contentType) { - // return await this._sendWithJSCodec(content, opts.contentType) - // } + >( + content: ConversationSendPayload, + opts?: SendOptions + ): Promise { + if (opts && opts.contentType) { + return await this._prepareWithJSCodec(content, opts.contentType) + } try { if (typeof content === 'string') { @@ -163,6 +165,27 @@ export class Group< } } + private async _prepareWithJSCodec( + content: T, + contentType: XMTP.ContentTypeId + ): Promise { + const codec = + this.client.codecRegistry[ + `${contentType.authorityId}/${contentType.typeId}:${contentType.versionMajor}.${contentType.versionMinor}` + ] + + if (!codec) { + throw new Error(`no codec found for: ${contentType}`) + } + + return await XMTP.prepareMessageWithContentType( + this.client.installationId, + this.id, + content, + codec + ) + } + /** * Publish all prepared messages. *