diff --git a/.changeset/add-custom-content-type-ability.md b/.changeset/add-custom-content-type-ability.md new file mode 100644 index 00000000..e4c8e3e7 --- /dev/null +++ b/.changeset/add-custom-content-type-ability.md @@ -0,0 +1,5 @@ +--- +"@xmtp/react-native-sdk": patch +--- + +Add back custom content types. diff --git a/android/build.gradle b/android/build.gradle index 0dee991a..50f26041 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -98,7 +98,7 @@ repositories { dependencies { implementation project(':expo-modules-core') implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}" - implementation "org.xmtp:android:3.0.13" + implementation "org.xmtp:android:3.0.14" implementation 'com.google.code.gson:gson:2.10.1' implementation 'com.facebook.react:react-native:0.71.3' implementation "com.daveanthonythomas.moshipack:moshipack:1.0.1" @@ -109,8 +109,8 @@ dependencies { // implementation 'io.grpc:grpc-okhttp:1.62.2' // implementation 'io.grpc:grpc-protobuf-lite:1.62.2' // implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0' - // implementation 'org.web3j:crypto:5.0.0' + // implementation 'org.web3j:crypto:4.9.4' // implementation "net.java.dev.jna:jna:5.14.0@aar" // api 'com.google.protobuf:protobuf-kotlin-lite:3.22.3' - // api 'org.xmtp:proto-kotlin:3.62.1' + // api 'org.xmtp:proto-kotlin:3.71.0' } diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt index 8974d6bd..d41863b3 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt @@ -655,6 +655,26 @@ class XMTPModule : Module() { } } + AsyncFunction("sendEncodedContent") Coroutine { installationId: String, conversationId: String, encodedContentData: List -> + withContext(Dispatchers.IO) { + logV("sendEncodedContent") + 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.send(encodedContent) + } + } + AsyncFunction("sendMessage") Coroutine { installationId: String, id: String, contentJson: String -> withContext(Dispatchers.IO) { logV("sendMessage") diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index c4ec2128..b7927a00 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -448,7 +448,7 @@ PODS: - SQLCipher/standard (4.5.7): - SQLCipher/common - SwiftProtobuf (1.28.2) - - XMTP (3.0.14): + - XMTP (3.0.15): - Connect-Swift (= 1.0.0) - CryptoSwift (= 1.8.3) - CSecp256k1 (~> 0.2) @@ -459,7 +459,7 @@ PODS: - ExpoModulesCore - MessagePacker - SQLCipher (= 4.5.7) - - XMTP (= 3.0.14) + - XMTP (= 3.0.15) - Yoga (1.14.0) DEPENDENCIES: @@ -762,8 +762,8 @@ SPEC CHECKSUMS: RNSVG: d00c8f91c3cbf6d476451313a18f04d220d4f396 SQLCipher: 5e6bfb47323635c8b657b1b27d25c5f1baf63bf5 SwiftProtobuf: 4dbaffec76a39a8dc5da23b40af1a5dc01a4c02d - XMTP: 3b586fa3703640bb5fec8a64daba9e157d9e5fdc - XMTPReactNative: f3e1cbf80b7278b817bd42982703a95a9250497d + XMTP: 8b0c84096edf74642c5780e4fca9ebbc848fdcf2 + XMTPReactNative: fa98630d85a3947eccfde6062916bcf3de9c32e2 Yoga: e71803b4c1fff832ccf9b92541e00f9b873119b9 PODFILE CHECKSUM: 0e6fe50018f34e575d38dc6a1fdf1f99c9596cdd diff --git a/example/src/tests/conversationTests.ts b/example/src/tests/conversationTests.ts index 508e62ad..716c9448 100644 --- a/example/src/tests/conversationTests.ts +++ b/example/src/tests/conversationTests.ts @@ -1,4 +1,6 @@ +import { content } from '@xmtp/proto' import { Wallet } from 'ethers' +import ReactNativeBlobUtil from 'react-native-blob-util' import RNFS from 'react-native-fs' import { Test, assert, createClients, delayToPropogate } from './test-utils' @@ -8,6 +10,7 @@ import { Conversation, ConversationId, ConversationVersion, + JSContentCodec, } from '../../../src/index' export const conversationTests: Test[] = [] @@ -19,6 +22,194 @@ function test(name: string, perform: () => Promise) { }) } +type EncodedContent = content.EncodedContent +type ContentTypeId = content.ContentTypeId + +const { fs } = ReactNativeBlobUtil + +const ContentTypeNumber: ContentTypeId = { + authorityId: 'org', + typeId: 'number', + versionMajor: 1, + versionMinor: 0, +} + +const ContentTypeNumberWithUndefinedFallback: ContentTypeId = { + authorityId: 'org', + typeId: 'number_undefined_fallback', + versionMajor: 1, + versionMinor: 0, +} + +const ContentTypeNumberWithEmptyFallback: ContentTypeId = { + authorityId: 'org', + typeId: 'number_empty_fallback', + versionMajor: 1, + versionMinor: 0, +} + +export type NumberRef = { + topNumber: { + bottomNumber: number + } +} + +class NumberCodec implements JSContentCodec { + contentType = ContentTypeNumber + + // a completely absurd way of encoding number values + encode(content: NumberRef): EncodedContent { + return { + type: ContentTypeNumber, + parameters: { + test: 'test', + }, + content: new TextEncoder().encode(JSON.stringify(content)), + } + } + + decode(encodedContent: EncodedContent): NumberRef { + if (encodedContent.parameters.test !== 'test') { + throw new Error(`parameters should parse ${encodedContent.parameters}`) + } + const contentReceived = JSON.parse( + new TextDecoder().decode(encodedContent.content) + ) as NumberRef + return contentReceived + } + + fallback(content: NumberRef): string | undefined { + return 'a billion' + } +} + +class NumberCodecUndefinedFallback extends NumberCodec { + contentType = ContentTypeNumberWithUndefinedFallback + fallback(content: NumberRef): string | undefined { + return undefined + } +} + +class NumberCodecEmptyFallback extends NumberCodec { + contentType = ContentTypeNumberWithEmptyFallback + fallback(content: NumberRef): string | undefined { + return '' + } +} + +test('register and use custom content types', 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.send( + { topNumber: { bottomNumber: 12 } }, + { contentType: ContentTypeNumber } + ) + + 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, + 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 NumberCodecEmptyFallback(), + new NumberCodecUndefinedFallback(), + ], + dbEncryptionKey: keyBytes, + }) + const alice = await Client.createRandom({ + env: 'local', + dbEncryptionKey: keyBytes, + }) + bob.register(new NumberCodecEmptyFallback()) + bob.register(new NumberCodecUndefinedFallback()) + const bobConvo = await bob.conversations.newConversation(alice.address) + + // @ts-ignore + await bobConvo.send(12, { contentType: ContentTypeNumberWithEmptyFallback }) + + // @ts-ignore + await bobConvo.send(12, { + contentType: ContentTypeNumberWithUndefinedFallback, + }) + + await alice.conversations.syncAllConversations() + const aliceConvo = await alice.conversations.findConversation(bobConvo.id) + + const messages = await aliceConvo!.messages() + assert(messages.length === 2, 'did not get messages') + + const messageUndefinedFallback = messages[0] + const messageWithDefinedFallback = messages[1] + + let message1Content = undefined + try { + message1Content = messageUndefinedFallback.content() + } catch { + message1Content = messageUndefinedFallback.fallback + } + + assert( + message1Content === undefined, + 'did not get content properly when empty fallback: ' + + JSON.stringify(message1Content) + ) + + let message2Content = undefined + try { + message2Content = messageWithDefinedFallback.content() + } catch { + message2Content = messageWithDefinedFallback.fallback + } + + assert( + message2Content === '', + 'did not get content properly: ' + JSON.stringify(message2Content) + ) + + return true +}) + test('can find a conversations by id', async () => { const [alixClient, boClient] = await createClients(2) const alixGroup = await alixClient.conversations.newGroup([boClient.address]) diff --git a/ios/XMTPModule.swift b/ios/XMTPModule.swift index 5a51e554..ab45c835 100644 --- a/ios/XMTPModule.swift +++ b/ios/XMTPModule.swift @@ -721,6 +721,29 @@ public class XMTPModule: Module { return nil } } + + AsyncFunction("sendEncodedContent") { + ( + 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.send(encodedContent: encodedContent) + } AsyncFunction("sendMessage") { (installationId: String, id: String, contentJson: String) -> String diff --git a/ios/XMTPReactNative.podspec b/ios/XMTPReactNative.podspec index 4756572c..f3e0d9e2 100644 --- a/ios/XMTPReactNative.podspec +++ b/ios/XMTPReactNative.podspec @@ -26,7 +26,7 @@ Pod::Spec.new do |s| s.source_files = "**/*.{h,m,swift}" s.dependency "MessagePacker" - s.dependency "XMTP", "= 3.0.14" + s.dependency "XMTP", "= 3.0.15" s.dependency 'CSecp256k1', '~> 0.2' s.dependency "SQLCipher", "= 4.5.7" end diff --git a/src/index.ts b/src/index.ts index 00f467e1..8ce6af61 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import { content } from '@xmtp/proto' import { EventEmitter, NativeModulesProxy } from 'expo-modules-core' import { Client } from '.' @@ -5,6 +6,7 @@ import XMTPModule from './XMTPModule' import { Address, InboxId, InstallationId, XMTPEnvironment } from './lib/Client' import { ConsentRecord, ConsentState, ConsentType } from './lib/ConsentRecord' import { + ContentCodec, DecryptedLocalAttachment, EncryptedLocalAttachment, } from './lib/ContentCodec' @@ -39,6 +41,8 @@ export { StaticAttachmentCodec } from './lib/NativeCodecs/StaticAttachmentCodec' export { TextCodec } from './lib/NativeCodecs/TextCodec' export * from './lib/Signer' +const EncodedContent = content.EncodedContent + export function address(): string { return XMTPModule.address() } @@ -530,6 +534,32 @@ export async function findDmByAddress< return new Dm(client, dm) } +export async function sendWithContentType( + installationId: InboxId, + conversationId: ConversationId, + content: T, + codec: ContentCodec +): Promise { + if ('contentKey' in codec) { + const contentJson = JSON.stringify(content) + return await XMTPModule.sendMessage( + installationId, + conversationId, + contentJson + ) + } else { + const encodedContent = codec.encode(content) + encodedContent.fallback = codec.fallback(content) + const encodedContentData = EncodedContent.encode(encodedContent).finish() + + return await XMTPModule.sendEncodedContent( + installationId, + conversationId, + Array.from(encodedContentData) + ) + } +} + export async function sendMessage( installationId: InstallationId, conversationId: ConversationId, diff --git a/src/lib/Conversation.ts b/src/lib/Conversation.ts index ab472c8c..a230d673 100644 --- a/src/lib/Conversation.ts +++ b/src/lib/Conversation.ts @@ -1,5 +1,10 @@ import { ConsentState } from './ConsentRecord' -import { ConversationSendPayload, MessageId, MessagesOptions } from './types' +import { + ConversationSendPayload, + MessageId, + MessagesOptions, + SendOptions, +} from './types' import { DecodedMessageUnion } from './types/DecodedMessageUnion' import { DefaultContentTypes } from './types/DefaultContentType' import * as XMTP from '../index' @@ -20,7 +25,8 @@ export interface ConversationBase { lastMessage?: DecodedMessage send( - content: ConversationSendPayload + content: ConversationSendPayload, + opts?: SendOptions ): Promise sync() messages(opts?: MessagesOptions): Promise[]> diff --git a/src/lib/Dm.ts b/src/lib/Dm.ts index 9136898d..6b1bfaf9 100644 --- a/src/lib/Dm.ts +++ b/src/lib/Dm.ts @@ -8,6 +8,7 @@ import { DecodedMessageUnion } from './types/DecodedMessageUnion' import { DefaultContentTypes } from './types/DefaultContentType' import { EventTypes } from './types/EventTypes' import { MessageId, MessagesOptions } from './types/MessagesOptions' +import { SendOptions } from './types/SendOptions' import * as XMTP from '../index' import { ConversationId, ConversationTopic } from '../index' @@ -59,12 +60,12 @@ export class Dm * @throws {Error} Throws an error if there is an issue with sending the message. */ async send( - content: ConversationSendPayload + content: ConversationSendPayload, + opts?: SendOptions ): Promise { - // TODO: Enable other content types - // if (opts && opts.contentType) { - // return await this._sendWithJSCodec(content, opts.contentType) - // } + if (opts && opts.contentType) { + return await this._sendWithJSCodec(content, opts.contentType) + } try { if (typeof content === 'string') { @@ -82,6 +83,27 @@ export class Dm } } + private async _sendWithJSCodec( + 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.sendWithContentType( + this.client.installationId, + this.id, + content, + codec + ) + } + /** * Prepare a dm message to be sent. * diff --git a/src/lib/Group.ts b/src/lib/Group.ts index ec197464..08445f9b 100644 --- a/src/lib/Group.ts +++ b/src/lib/Group.ts @@ -9,6 +9,7 @@ import { DefaultContentTypes } from './types/DefaultContentType' import { EventTypes } from './types/EventTypes' import { MessageId, MessagesOptions } from './types/MessagesOptions' import { PermissionPolicySet } from './types/PermissionPolicySet' +import { SendOptions } from './types/SendOptions' import * as XMTP from '../index' import { Address, ConversationId, ConversationTopic } from '../index' @@ -87,12 +88,12 @@ export class Group< * @throws {Error} Throws an error if there is an issue with sending the message. */ async send( - content: ConversationSendPayload + content: ConversationSendPayload, + opts?: SendOptions ): Promise { - // TODO: Enable other content types - // if (opts && opts.contentType) { - // return await this._sendWithJSCodec(content, opts.contentType) - // } + if (opts && opts.contentType) { + return await this._sendWithJSCodec(content, opts.contentType) + } try { if (typeof content === 'string') { @@ -110,6 +111,27 @@ export class Group< } } + private async _sendWithJSCodec( + 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.sendWithContentType( + this.client.installationId, + this.id, + content, + codec + ) + } + /** * Prepare a group message to be sent. *