diff --git a/.eslintrc.js b/.eslintrc.js index c4b0b9ab3..e42a86b98 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,9 +1,17 @@ module.exports = { root: true, + parser: "@typescript-eslint/parser", + parserOptions: { + project: ["./tsconfig.json", "./example/tsconfig.json"] + }, + plugins: ["@typescript-eslint"], extends: ['universe/native', 'universe/web'], ignorePatterns: ['build'], plugins: ['prettier'], globals: { __dirname: true, }, + rules: { + "@typescript-eslint/no-floating-promises": ["error"], + }, } diff --git a/.github/workflows/tsc.yml b/.github/workflows/tsc.yml new file mode 100644 index 000000000..5d64a66f5 --- /dev/null +++ b/.github/workflows/tsc.yml @@ -0,0 +1,13 @@ +name: Typescript +on: + pull_request: +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - uses: actions/setup-node@v3 + - run: yarn + - run: yarn tsc diff --git a/.gitignore b/.gitignore index 84068a05d..b9ec20389 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,4 @@ android/keystores/debug.keystore # Typedocs docs/ +**/.yarn/* \ No newline at end of file diff --git a/README.md b/README.md index 6ff2146ce..42020f41c 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,10 @@ In the `ios` directory, update your `Podfile` file as follows: - Set this value: `platform :ios, '16.0'`. This is required by XMTP. +If you get the error `The SQLCipher Sqlite extension is not present, but an encryption key is given` + +- At the project configuration level in XCode make sure that xmtpV3 is loaded before all other packages by setting `Other Linker Flags` first item to `-l"xmtpv3"` + ```bash npx pod-install ``` @@ -437,3 +441,7 @@ The `env` parameter accepts one of three valid values: `dev`, `production`, or ` - `local`: Use to have a client communicate with an XMTP node you are running locally. For example, an XMTP node developer can set `env` to `local` to generate client traffic to test a node running locally. The `production` network is configured to store messages indefinitely. XMTP may occasionally delete messages and keys from the `dev` network, and will provide advance notice in the [XMTP Discord community](https://discord.gg/xmtp). + +## Enabling group chat + +Coming soon... \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index 38d62dd2d..988c5a454 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -98,7 +98,19 @@ repositories { dependencies { implementation project(':expo-modules-core') implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}" - implementation "org.xmtp:android:0.14.1" + implementation "org.xmtp:android:0.14.9" 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"} + implementation "com.daveanthonythomas.moshipack:moshipack:1.0.1" + // xmtp-android local testing setup below (comment org.xmtp:android above) + // implementation files('/xmtp-android/library/build/outputs/aar/library-debug.aar') + // implementation 'com.google.crypto.tink:tink-android:1.8.0' + // implementation 'io.grpc:grpc-kotlin-stub:1.4.1' + // 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 "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' +} diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt index d43acaaf8..9c3263a12 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt @@ -1,5 +1,6 @@ package expo.modules.xmtpreactnativesdk +import android.content.Context import android.net.Uri import android.util.Base64 import android.util.Base64.NO_WRAP @@ -7,16 +8,24 @@ import android.util.Log import androidx.core.net.toUri import com.google.gson.JsonParser import com.google.protobuf.kotlin.toByteString +import expo.modules.kotlin.exception.Exceptions import expo.modules.kotlin.functions.Coroutine import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.xmtpreactnativesdk.wrappers.AuthParamsWrapper +import expo.modules.xmtpreactnativesdk.wrappers.ClientWrapper import expo.modules.xmtpreactnativesdk.wrappers.ConsentWrapper import expo.modules.xmtpreactnativesdk.wrappers.ConsentWrapper.Companion.consentStateToString import expo.modules.xmtpreactnativesdk.wrappers.ContentJson +import expo.modules.xmtpreactnativesdk.wrappers.ConversationContainerWrapper import expo.modules.xmtpreactnativesdk.wrappers.ConversationWrapper +import expo.modules.xmtpreactnativesdk.wrappers.CreateGroupParamsWrapper import expo.modules.xmtpreactnativesdk.wrappers.DecodedMessageWrapper import expo.modules.xmtpreactnativesdk.wrappers.DecryptedLocalAttachment import expo.modules.xmtpreactnativesdk.wrappers.EncryptedLocalAttachment +import expo.modules.xmtpreactnativesdk.wrappers.GroupWrapper +import expo.modules.xmtpreactnativesdk.wrappers.MemberWrapper +import expo.modules.xmtpreactnativesdk.wrappers.PermissionPolicySetWrapper import expo.modules.xmtpreactnativesdk.wrappers.PreparedLocalMessage import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -30,6 +39,7 @@ import org.json.JSONObject import org.xmtp.android.library.Client import org.xmtp.android.library.ClientOptions import org.xmtp.android.library.Conversation +import org.xmtp.android.library.Group import org.xmtp.android.library.PreEventCallback import org.xmtp.android.library.PreparedMessage import org.xmtp.android.library.SendOptions @@ -44,15 +54,19 @@ import org.xmtp.android.library.codecs.RemoteAttachment import org.xmtp.android.library.codecs.decoded import org.xmtp.android.library.messages.EnvelopeBuilder import org.xmtp.android.library.messages.InvitationV1ContextBuilder +import org.xmtp.android.library.messages.MessageDeliveryStatus import org.xmtp.android.library.messages.Pagination import org.xmtp.android.library.messages.PrivateKeyBuilder import org.xmtp.android.library.messages.Signature import org.xmtp.android.library.messages.getPublicKeyBundle +import org.xmtp.android.library.push.Service import org.xmtp.android.library.push.XMTPPush import org.xmtp.proto.keystore.api.v1.Keystore.TopicMap.TopicData import org.xmtp.proto.message.api.v1.MessageApiOuterClass import org.xmtp.proto.message.contents.Invitation.ConsentProofPayload import org.xmtp.proto.message.contents.PrivateKeyOuterClass +import uniffi.xmtpv3.org.xmtp.android.library.libxmtp.GroupPermissionPreconfiguration +import uniffi.xmtpv3.org.xmtp.android.library.libxmtp.PermissionOption import java.io.File import java.util.Date import java.util.UUID @@ -98,11 +112,19 @@ data class SignatureRequest( var message: String, ) -fun Conversation.cacheKey(clientAddress: String): String { - return "${clientAddress}:${topic}" +fun Conversation.cacheKey(inboxId: String): String { + return "${inboxId}:${topic}" +} + +fun Group.cacheKey(inboxId: String): String { + return "${inboxId}:${id}" } class XMTPModule : Module() { + + val context: Context + get() = appContext.reactContext ?: throw Exceptions.ReactContextLost() + private fun apiEnvironments(env: String, appVersion: String?): ClientOptions.Api { return when (env) { "local" -> ClientOptions.Api( @@ -130,6 +152,7 @@ class XMTPModule : Module() { private var signer: ReactNativeSigner? = null private val isDebugEnabled = BuildConfig.DEBUG // TODO: consider making this configurable private val conversations: MutableMap = mutableMapOf() + private val groups: MutableMap = mutableMapOf() private val subscriptions: MutableMap = mutableMapOf() private var preEnableIdentityCallbackDeferred: CompletableDeferred? = null private var preCreateIdentityCallbackDeferred: CompletableDeferred? = null @@ -138,45 +161,111 @@ class XMTPModule : Module() { override fun definition() = ModuleDefinition { Name("XMTP") Events( + // Auth "sign", "authed", + "preCreateIdentityCallback", + "preEnableIdentityCallback", + // Conversations "conversation", + "group", + "conversationContainer", "message", - "preEnableIdentityCallback", - "preCreateIdentityCallback" + "allGroupMessage", + // Conversation + "conversationMessage", + // Group + "groupMessage" + ) - Function("address") { clientAddress: String -> + Function("address") { inboxId: String -> logV("address") - val client = clients[clientAddress] + val client = clients[inboxId] client?.address ?: "No Client." } + Function("inboxId") { inboxId: String -> + logV("inboxId") + val client = clients[inboxId] + client?.inboxId ?: "No Client." + } + + AsyncFunction("findInboxIdFromAddress") Coroutine { inboxId: String, address: String -> + withContext(Dispatchers.IO) { + logV("findInboxIdFromAddress") + val client = clients[inboxId] ?: throw XMTPException("No client") + client.inboxIdFromAddress(address) + } + } + + AsyncFunction("deleteLocalDatabase") { inboxId: String -> + logV(inboxId) + logV(clients.toString()) + val client = clients[inboxId] ?: throw XMTPException("No client") + client.deleteLocalDatabase() + } + + Function("dropLocalDatabaseConnection") { inboxId: String -> + val client = clients[inboxId] ?: throw XMTPException("No client") + client.dropLocalDatabaseConnection() + } + + AsyncFunction("reconnectLocalDatabase") Coroutine { inboxId: String -> + withContext(Dispatchers.IO) { + val client = clients[inboxId] ?: throw XMTPException("No client") + client.reconnectLocalDatabase() + } + } + + AsyncFunction("requestMessageHistorySync") Coroutine { inboxId: String -> + withContext(Dispatchers.IO) { + val client = clients[inboxId] ?: throw XMTPException("No client") + client.requestMessageHistorySync() + } + } + // // Auth functions // - AsyncFunction("auth") { address: String, environment: String, appVersion: String?, hasCreateIdentityCallback: Boolean?, hasEnableIdentityCallback: Boolean? -> - logV("auth") - val reactSigner = ReactNativeSigner(module = this@XMTPModule, address = address) - signer = reactSigner - - if (hasCreateIdentityCallback == true) - preCreateIdentityCallbackDeferred = CompletableDeferred() - if (hasEnableIdentityCallback == true) - preEnableIdentityCallbackDeferred = CompletableDeferred() - val preCreateIdentityCallback: PreEventCallback? = - preCreateIdentityCallback.takeIf { hasCreateIdentityCallback == true } - val preEnableIdentityCallback: PreEventCallback? = - preEnableIdentityCallback.takeIf { hasEnableIdentityCallback == true } - val options = ClientOptions( - api = apiEnvironments(environment, appVersion), - preCreateIdentityCallback = preCreateIdentityCallback, - preEnableIdentityCallback = preEnableIdentityCallback - ) - clients[address] = Client().create(account = reactSigner, options = options) - ContentJson.Companion - signer = null - sendEvent("authed") + AsyncFunction("auth") Coroutine { address: String, hasCreateIdentityCallback: Boolean?, hasEnableIdentityCallback: Boolean?, dbEncryptionKey: List?, authParams: String -> + withContext(Dispatchers.IO) { + + logV("auth") + val reactSigner = ReactNativeSigner(module = this@XMTPModule, address = address) + signer = reactSigner + val authOptions = AuthParamsWrapper.authParamsFromJson(authParams) + + if (hasCreateIdentityCallback == true) + preCreateIdentityCallbackDeferred = CompletableDeferred() + if (hasEnableIdentityCallback == true) + preEnableIdentityCallbackDeferred = CompletableDeferred() + val preCreateIdentityCallback: PreEventCallback? = + preCreateIdentityCallback.takeIf { hasCreateIdentityCallback == true } + val preEnableIdentityCallback: PreEventCallback? = + preEnableIdentityCallback.takeIf { hasEnableIdentityCallback == true } + val context = if (authOptions.enableV3) context else null + val encryptionKeyBytes = + dbEncryptionKey?.foldIndexed(ByteArray(dbEncryptionKey.size)) { i, a, v -> + a.apply { set(i, v.toByte()) } + } + + val options = ClientOptions( + api = apiEnvironments(authOptions.environment, authOptions.appVersion), + preCreateIdentityCallback = preCreateIdentityCallback, + preEnableIdentityCallback = preEnableIdentityCallback, + enableV3 = authOptions.enableV3, + appContext = context, + dbEncryptionKey = encryptionKeyBytes, + dbDirectory = authOptions.dbDirectory, + historySyncUrl = authOptions.historySyncUrl + ) + val client = Client().create(account = reactSigner, options = options) + clients[client.inboxId] = client + ContentJson.Companion + signer = null + sendEvent("authed", ClientWrapper.encodeToObj(client)) + } } Function("receiveSignature") { requestID: String, signature: String -> @@ -185,53 +274,84 @@ class XMTPModule : Module() { } // Generate a random wallet and set the client to that - AsyncFunction("createRandom") { environment: String, appVersion: String?, hasCreateIdentityCallback: Boolean?, hasEnableIdentityCallback: Boolean? -> - logV("createRandom") - val privateKey = PrivateKeyBuilder() - - if (hasCreateIdentityCallback == true) - preCreateIdentityCallbackDeferred = CompletableDeferred() - if (hasEnableIdentityCallback == true) - preEnableIdentityCallbackDeferred = CompletableDeferred() - val preCreateIdentityCallback: PreEventCallback? = - preCreateIdentityCallback.takeIf { hasCreateIdentityCallback == true } - val preEnableIdentityCallback: PreEventCallback? = - preEnableIdentityCallback.takeIf { hasEnableIdentityCallback == true } - - val options = ClientOptions( - api = apiEnvironments(environment, appVersion), - preCreateIdentityCallback = preCreateIdentityCallback, - preEnableIdentityCallback = preEnableIdentityCallback - ) - val randomClient = Client().create(account = privateKey, options = options) - ContentJson.Companion - clients[randomClient.address] = randomClient - randomClient.address + AsyncFunction("createRandom") Coroutine { hasCreateIdentityCallback: Boolean?, hasEnableIdentityCallback: Boolean?, dbEncryptionKey: List?, authParams: String -> + withContext(Dispatchers.IO) { + logV("createRandom") + val privateKey = PrivateKeyBuilder() + + if (hasCreateIdentityCallback == true) + preCreateIdentityCallbackDeferred = CompletableDeferred() + if (hasEnableIdentityCallback == true) + preEnableIdentityCallbackDeferred = CompletableDeferred() + val preCreateIdentityCallback: PreEventCallback? = + preCreateIdentityCallback.takeIf { hasCreateIdentityCallback == true } + val preEnableIdentityCallback: PreEventCallback? = + preEnableIdentityCallback.takeIf { hasEnableIdentityCallback == true } + + val authOptions = AuthParamsWrapper.authParamsFromJson(authParams) + val context = if (authOptions.enableV3) context else null + val encryptionKeyBytes = + dbEncryptionKey?.foldIndexed(ByteArray(dbEncryptionKey.size)) { i, a, v -> + a.apply { set(i, v.toByte()) } + } + + val options = ClientOptions( + api = apiEnvironments(authOptions.environment, authOptions.appVersion), + preCreateIdentityCallback = preCreateIdentityCallback, + preEnableIdentityCallback = preEnableIdentityCallback, + enableV3 = authOptions.enableV3, + appContext = context, + dbEncryptionKey = encryptionKeyBytes, + dbDirectory = authOptions.dbDirectory, + historySyncUrl = authOptions.historySyncUrl + + ) + val randomClient = Client().create(account = privateKey, options = options) + + ContentJson.Companion + clients[randomClient.inboxId] = randomClient + ClientWrapper.encodeToObj(randomClient) + } } - AsyncFunction("createFromKeyBundle") { keyBundle: String, environment: String, appVersion: String? -> - try { + AsyncFunction("createFromKeyBundle") Coroutine { keyBundle: String, dbEncryptionKey: List?, authParams: String -> + withContext(Dispatchers.IO) { logV("createFromKeyBundle") - val options = ClientOptions(api = apiEnvironments(environment, appVersion)) - val bundle = - PrivateKeyOuterClass.PrivateKeyBundle.parseFrom( - Base64.decode( - keyBundle, - NO_WRAP - ) + val authOptions = AuthParamsWrapper.authParamsFromJson(authParams) + try { + val context = if (authOptions.enableV3) context else null + val encryptionKeyBytes = + dbEncryptionKey?.foldIndexed(ByteArray(dbEncryptionKey.size)) { i, a, v -> + a.apply { set(i, v.toByte()) } + } + val options = ClientOptions( + api = apiEnvironments(authOptions.environment, authOptions.appVersion), + enableV3 = authOptions.enableV3, + appContext = context, + dbEncryptionKey = encryptionKeyBytes, + dbDirectory = authOptions.dbDirectory, + historySyncUrl = authOptions.historySyncUrl ) - val client = Client().buildFromBundle(bundle = bundle, options = options) - ContentJson.Companion - clients[client.address] = client - client.address - } catch (e: Exception) { - throw XMTPException("Failed to create client: $e") + val bundle = + PrivateKeyOuterClass.PrivateKeyBundle.parseFrom( + Base64.decode( + keyBundle, + NO_WRAP + ) + ) + val client = Client().buildFromBundle(bundle = bundle, options = options) + ContentJson.Companion + clients[client.inboxId] = client + ClientWrapper.encodeToObj(client) + } catch (e: Exception) { + throw XMTPException("Failed to create client: $e") + } } } - AsyncFunction("sign") { clientAddress: String, digest: List, keyType: String, preKeyIndex: Int -> + AsyncFunction("sign") { inboxId: String, digest: List, keyType: String, preKeyIndex: Int -> logV("sign") - val client = clients[clientAddress] ?: throw XMTPException("No client") + val client = clients[inboxId] ?: throw XMTPException("No client") val digestBytes = digest.foldIndexed(ByteArray(digest.size)) { i, a, v -> a.apply { @@ -254,43 +374,43 @@ class XMTPModule : Module() { signature.toByteArray().map { it.toInt() and 0xFF } } - AsyncFunction("exportPublicKeyBundle") { clientAddress: String -> + AsyncFunction("exportPublicKeyBundle") { inboxId: String -> logV("exportPublicKeyBundle") - val client = clients[clientAddress] ?: throw XMTPException("No client") + val client = clients[inboxId] ?: throw XMTPException("No client") client.keys.getPublicKeyBundle().toByteArray().map { it.toInt() and 0xFF } } - AsyncFunction("exportKeyBundle") { clientAddress: String -> + AsyncFunction("exportKeyBundle") { inboxId: String -> logV("exportKeyBundle") - val client = clients[clientAddress] ?: throw XMTPException("No client") + val client = clients[inboxId] ?: throw XMTPException("No client") Base64.encodeToString(client.privateKeyBundle.toByteArray(), NO_WRAP) } // Export the conversation's serialized topic data. - AsyncFunction("exportConversationTopicData") Coroutine { clientAddress: String, topic: String -> + AsyncFunction("exportConversationTopicData") Coroutine { inboxId: String, topic: String -> withContext(Dispatchers.IO) { logV("exportConversationTopicData") - val conversation = findConversation(clientAddress, topic) + val conversation = findConversation(inboxId, topic) ?: throw XMTPException("no conversation found for $topic") Base64.encodeToString(conversation.toTopicData().toByteArray(), NO_WRAP) } } - AsyncFunction("getHmacKeys") { clientAddress: String -> + AsyncFunction("getHmacKeys") { inboxId: String -> logV("getHmacKeys") - val client = clients[clientAddress] ?: throw XMTPException("No client") + val client = clients[inboxId] ?: throw XMTPException("No client") val hmacKeys = client.conversations.getHmacKeys() logV("$hmacKeys") hmacKeys.toByteArray().map { it.toInt() and 0xFF } } // Import a conversation from its serialized topic data. - AsyncFunction("importConversationTopicData") { clientAddress: String, topicData: String -> + AsyncFunction("importConversationTopicData") { inboxId: String, topicData: String -> logV("importConversationTopicData") - val client = clients[clientAddress] ?: throw XMTPException("No client") + val client = clients[inboxId] ?: throw XMTPException("No client") val data = TopicData.parseFrom(Base64.decode(topicData, NO_WRAP)) val conversation = client.conversations.importTopicData(data) - conversations[conversation.cacheKey(clientAddress)] = conversation + conversations[conversation.cacheKey(inboxId)] = conversation if (conversation.keyMaterial == null) { logV("Null key material before encode conversation") } @@ -299,15 +419,24 @@ class XMTPModule : Module() { // // Client API - AsyncFunction("canMessage") Coroutine { clientAddress: String, peerAddress: String -> + AsyncFunction("canMessage") Coroutine { inboxId: String, peerAddress: String -> withContext(Dispatchers.IO) { logV("canMessage") - val client = clients[clientAddress] ?: throw XMTPException("No client") + + val client = clients[inboxId] ?: throw XMTPException("No client") client.canMessage(peerAddress) } } + AsyncFunction("canGroupMessage") Coroutine { inboxId: String, peerAddresses: List -> + withContext(Dispatchers.IO) { + logV("canGroupMessage") + val client = clients[inboxId] ?: throw XMTPException("No client") + client.canMessageV3(peerAddresses) + } + } + AsyncFunction("staticCanMessage") Coroutine { peerAddress: String, environment: String, appVersion: String? -> withContext(Dispatchers.IO) { try { @@ -320,9 +449,9 @@ class XMTPModule : Module() { } } - AsyncFunction("encryptAttachment") { clientAddress: String, fileJson: String -> + AsyncFunction("encryptAttachment") { inboxId: String, fileJson: String -> logV("encryptAttachment") - val client = clients[clientAddress] ?: throw XMTPException("No client") + val client = clients[inboxId] ?: throw XMTPException("No client") val file = DecryptedLocalAttachment.fromJson(fileJson) val uri = Uri.parse(file.fileUri) val data = appContext.reactContext?.contentResolver @@ -347,9 +476,9 @@ class XMTPModule : Module() { ).toJson() } - AsyncFunction("decryptAttachment") { clientAddress: String, encryptedFileJson: String -> + AsyncFunction("decryptAttachment") { inboxId: String, encryptedFileJson: String -> logV("decryptAttachment") - val client = clients[clientAddress] ?: throw XMTPException("No client") + val client = clients[inboxId] ?: throw XMTPException("No client") val encryptedFile = EncryptedLocalAttachment.fromJson(encryptedFileJson) val encryptedData = appContext.reactContext?.contentResolver ?.openInputStream(Uri.parse(encryptedFile.encryptedLocalFileUri)) @@ -374,11 +503,11 @@ class XMTPModule : Module() { ).toJson() } - AsyncFunction("sendEncodedContent") Coroutine { clientAddress: String, topic: String, encodedContentData: List -> + AsyncFunction("sendEncodedContent") Coroutine { inboxId: String, topic: String, encodedContentData: List -> withContext(Dispatchers.IO) { val conversation = findConversation( - clientAddress = clientAddress, + inboxId = inboxId, topic = topic ) ?: throw XMTPException("no conversation found for $topic") @@ -397,13 +526,13 @@ class XMTPModule : Module() { } } - AsyncFunction("listConversations") Coroutine { clientAddress: String -> + AsyncFunction("listConversations") Coroutine { inboxId: String -> withContext(Dispatchers.IO) { logV("listConversations") - val client = clients[clientAddress] ?: throw XMTPException("No client") + val client = clients[inboxId] ?: throw XMTPException("No client") val conversationList = client.conversations.list() conversationList.map { conversation -> - conversations[conversation.cacheKey(clientAddress)] = conversation + conversations[conversation.cacheKey(inboxId)] = conversation if (conversation.keyMaterial == null) { logV("Null key material before encode conversation") } @@ -412,12 +541,35 @@ class XMTPModule : Module() { } } - AsyncFunction("loadMessages") Coroutine { clientAddress: String, topic: String, limit: Int?, before: Long?, after: Long?, direction: String? -> + AsyncFunction("listGroups") Coroutine { inboxId: String -> + withContext(Dispatchers.IO) { + logV("listGroups") + val client = clients[inboxId] ?: throw XMTPException("No client") + val groupList = client.conversations.listGroups() + groupList.map { group -> + groups[group.cacheKey(inboxId)] = group + GroupWrapper.encode(client, group) + } + } + } + + AsyncFunction("listAll") Coroutine { inboxId: String -> + withContext(Dispatchers.IO) { + val client = clients[inboxId] ?: throw XMTPException("No client") + val conversationContainerList = client.conversations.list(includeGroups = true) + conversationContainerList.map { conversation -> + conversations[conversation.cacheKey(inboxId)] = conversation + ConversationContainerWrapper.encode(client, conversation) + } + } + } + + AsyncFunction("loadMessages") Coroutine { inboxId: String, topic: String, limit: Int?, before: Long?, after: Long?, direction: String? -> withContext(Dispatchers.IO) { logV("loadMessages") val conversation = findConversation( - clientAddress = clientAddress, + inboxId = inboxId, topic = topic, ) ?: throw XMTPException("no conversation found for $topic") val beforeDate = if (before != null) Date(before) else null @@ -435,10 +587,53 @@ class XMTPModule : Module() { } } - AsyncFunction("loadBatchMessages") Coroutine { clientAddress: String, topics: List -> + AsyncFunction("groupMessages") Coroutine { inboxId: String, id: String, limit: Int?, before: Long?, after: Long?, direction: String?, deliveryStatus: String? -> + withContext(Dispatchers.IO) { + logV("groupMessages") + val client = clients[inboxId] ?: throw XMTPException("No client") + val beforeDate = if (before != null) Date(before) else null + val afterDate = if (after != null) Date(after) else null + val group = findGroup(inboxId, id) + group?.decryptedMessages( + limit = limit, + before = beforeDate, + after = afterDate, + direction = MessageApiOuterClass.SortDirection.valueOf( + direction ?: "SORT_DIRECTION_DESCENDING" + ), + deliveryStatus = MessageDeliveryStatus.valueOf( + deliveryStatus ?: "ALL" + ) + )?.map { DecodedMessageWrapper.encode(it) } + } + } + + AsyncFunction("findV3Message") Coroutine { inboxId: String, messageId: String -> + withContext(Dispatchers.IO) { + logV("findV3Message") + val client = clients[inboxId] ?: throw XMTPException("No client") + val message = client.findMessage(messageId) + message?.let { + DecodedMessageWrapper.encode(it.decrypt()) + } + } + } + + AsyncFunction("findGroup") Coroutine { inboxId: String, groupId: String -> + withContext(Dispatchers.IO) { + logV("findGroup") + val client = clients[inboxId] ?: throw XMTPException("No client") + val group = client.findGroup(groupId) + group?.let { + GroupWrapper.encode(client, it) + } + } + } + + AsyncFunction("loadBatchMessages") Coroutine { inboxId: String, topics: List -> withContext(Dispatchers.IO) { logV("loadBatchMessages") - val client = clients[clientAddress] ?: throw XMTPException("No client") + val client = clients[inboxId] ?: throw XMTPException("No client") val topicsList = mutableListOf>() topics.forEach { val jsonObj = JSONObject(it) @@ -482,12 +677,12 @@ class XMTPModule : Module() { } } - AsyncFunction("sendMessage") Coroutine { clientAddress: String, conversationTopic: String, contentJson: String -> + AsyncFunction("sendMessage") Coroutine { inboxId: String, conversationTopic: String, contentJson: String -> withContext(Dispatchers.IO) { logV("sendMessage") val conversation = findConversation( - clientAddress = clientAddress, + inboxId = inboxId, topic = conversationTopic ) ?: throw XMTPException("no conversation found for $conversationTopic") @@ -499,12 +694,60 @@ class XMTPModule : Module() { } } - AsyncFunction("prepareMessage") Coroutine { clientAddress: String, conversationTopic: String, contentJson: String -> + AsyncFunction("sendMessageToGroup") Coroutine { inboxId: String, id: String, contentJson: String -> + withContext(Dispatchers.IO) { + logV("sendMessageToGroup") + val group = + findGroup( + inboxId = inboxId, + id = id + ) + ?: throw XMTPException("no group found for $id") + val sending = ContentJson.fromJson(contentJson) + group.send( + content = sending.content, + options = SendOptions(contentType = sending.type) + ) + } + } + + AsyncFunction("publishPreparedGroupMessages") Coroutine { inboxId: String, groupId: String -> + withContext(Dispatchers.IO) { + logV("publishPreparedGroupMessages") + val group = + findGroup( + inboxId = inboxId, + id = groupId + ) + ?: throw XMTPException("no group found for $groupId") + + group.publishMessages() + } + } + + AsyncFunction("prepareGroupMessage") Coroutine { inboxId: String, id: String, contentJson: String -> + withContext(Dispatchers.IO) { + logV("prepareGroupMessage") + val group = + findGroup( + inboxId = inboxId, + id = id + ) + ?: throw XMTPException("no group found for $id") + val sending = ContentJson.fromJson(contentJson) + group.prepareMessage( + content = sending.content, + options = SendOptions(contentType = sending.type) + ) + } + } + + AsyncFunction("prepareMessage") Coroutine { inboxId: String, conversationTopic: String, contentJson: String -> withContext(Dispatchers.IO) { logV("prepareMessage") val conversation = findConversation( - clientAddress = clientAddress, + inboxId = inboxId, topic = conversationTopic ) ?: throw XMTPException("no conversation found for $conversationTopic") @@ -524,12 +767,12 @@ class XMTPModule : Module() { } } - AsyncFunction("prepareEncodedMessage") Coroutine { clientAddress: String, conversationTopic: String, encodedContentData: List -> + AsyncFunction("prepareEncodedMessage") Coroutine { inboxId: String, conversationTopic: String, encodedContentData: List -> withContext(Dispatchers.IO) { logV("prepareEncodedMessage") val conversation = findConversation( - clientAddress = clientAddress, + inboxId = inboxId, topic = conversationTopic ) ?: throw XMTPException("no conversation found for $conversationTopic") @@ -559,10 +802,10 @@ class XMTPModule : Module() { } } - AsyncFunction("sendPreparedMessage") Coroutine { clientAddress: String, preparedLocalMessageJson: String -> + AsyncFunction("sendPreparedMessage") Coroutine { inboxId: String, preparedLocalMessageJson: String -> withContext(Dispatchers.IO) { logV("sendPreparedMessage") - val client = clients[clientAddress] ?: throw XMTPException("No client") + val client = clients[inboxId] ?: throw XMTPException("No client") val local = PreparedLocalMessage.fromJson(preparedLocalMessageJson) val preparedFileUrl = Uri.parse(local.preparedFileUri) val contentResolver = appContext.reactContext?.contentResolver!! @@ -579,10 +822,10 @@ class XMTPModule : Module() { } } - AsyncFunction("createConversation") Coroutine { clientAddress: String, peerAddress: String, contextJson: String, consentProofPayload: List -> + AsyncFunction("createConversation") Coroutine { inboxId: String, peerAddress: String, contextJson: String, consentProofPayload: List -> withContext(Dispatchers.IO) { logV("createConversation: $contextJson") - val client = clients[clientAddress] ?: throw XMTPException("No client") + val client = clients[inboxId] ?: throw XMTPException("No client") val context = JsonParser.parseString(contextJson).asJsonObject var consentProof: ConsentProofPayload? = null @@ -627,71 +870,540 @@ class XMTPModule : Module() { ConversationWrapper.encode(client, conversation) } } + AsyncFunction("createGroup") Coroutine { inboxId: String, peerAddresses: List, permission: String, groupOptionsJson: String -> + withContext(Dispatchers.IO) { + logV("createGroup") + val client = clients[inboxId] ?: throw XMTPException("No client") + val permissionLevel = when (permission) { + "admin_only" -> GroupPermissionPreconfiguration.ADMIN_ONLY + else -> GroupPermissionPreconfiguration.ALL_MEMBERS + } + val createGroupParams = + CreateGroupParamsWrapper.createGroupParamsFromJson(groupOptionsJson) + val group = client.conversations.newGroup( + peerAddresses, + permissionLevel, + createGroupParams.groupName, + createGroupParams.groupImageUrlSquare, + createGroupParams.groupDescription, + createGroupParams.groupPinnedFrameUrl + ) + GroupWrapper.encode(client, group) + } + } + + + AsyncFunction("listMemberInboxIds") Coroutine { inboxId: String, groupId: String -> + withContext(Dispatchers.IO) { + logV("listMembers") + val client = clients[inboxId] ?: throw XMTPException("No client") + val group = findGroup(inboxId, groupId) + group?.members()?.map { it.inboxId } + } + } + + AsyncFunction("listGroupMembers") Coroutine { inboxId: String, groupId: String -> + withContext(Dispatchers.IO) { + logV("listGroupMembers") + val client = clients[inboxId] ?: throw XMTPException("No client") + val group = findGroup(inboxId, groupId) + group?.members()?.map { MemberWrapper.encode(it) } + } + } + + AsyncFunction("syncGroups") Coroutine { inboxId: String -> + withContext(Dispatchers.IO) { + logV("syncGroups") + val client = clients[inboxId] ?: throw XMTPException("No client") + client.conversations.syncGroups() + } + } + + AsyncFunction("syncGroup") Coroutine { inboxId: String, id: String -> + withContext(Dispatchers.IO) { + logV("syncGroup") + val client = clients[inboxId] ?: throw XMTPException("No client") + val group = findGroup(inboxId, id) + group?.sync() + } + } + + AsyncFunction("addGroupMembers") Coroutine { inboxId: String, id: String, peerAddresses: List -> + withContext(Dispatchers.IO) { + logV("addGroupMembers") + val client = clients[inboxId] ?: throw XMTPException("No client") + val group = findGroup(inboxId, id) + + group?.addMembers(peerAddresses) + } + } + + AsyncFunction("removeGroupMembers") Coroutine { inboxId: String, id: String, peerAddresses: List -> + withContext(Dispatchers.IO) { + logV("removeGroupMembers") + val client = clients[inboxId] ?: throw XMTPException("No client") + val group = findGroup(inboxId, id) + + group?.removeMembers(peerAddresses) + } + } + + AsyncFunction("addGroupMembersByInboxId") Coroutine { inboxId: String, id: String, peerInboxIds: List -> + withContext(Dispatchers.IO) { + logV("addGroupMembersByInboxId") + val client = clients[inboxId] ?: throw XMTPException("No client") + val group = findGroup(inboxId, id) + + group?.addMembersByInboxId(peerInboxIds) + } + } + + AsyncFunction("removeGroupMembersByInboxId") Coroutine { inboxId: String, id: String, peerInboxIds: List -> + withContext(Dispatchers.IO) { + logV("removeGroupMembersByInboxId") + val client = clients[inboxId] ?: throw XMTPException("No client") + val group = findGroup(inboxId, id) + + group?.removeMembersByInboxId(peerInboxIds) + } + } + + AsyncFunction("groupName") Coroutine { inboxId: String, id: String -> + withContext(Dispatchers.IO) { + logV("groupName") + val client = clients[inboxId] ?: throw XMTPException("No client") + val group = findGroup(inboxId, id) + + group?.name + } + } + + AsyncFunction("updateGroupName") Coroutine { inboxId: String, id: String, groupName: String -> + withContext(Dispatchers.IO) { + logV("updateGroupName") + val client = clients[inboxId] ?: throw XMTPException("No client") + val group = findGroup(inboxId, id) - Function("subscribeToConversations") { clientAddress: String -> + group?.updateGroupName(groupName) + } + } + + AsyncFunction("groupImageUrlSquare") Coroutine { inboxId: String, id: String -> + withContext(Dispatchers.IO) { + logV("groupImageUrlSquare") + val client = clients[inboxId] ?: throw XMTPException("No client") + val group = findGroup(inboxId, id) + + group?.imageUrlSquare + } + } + + AsyncFunction("updateGroupImageUrlSquare") Coroutine { inboxId: String, id: String, groupImageUrl: String -> + withContext(Dispatchers.IO) { + logV("updateGroupImageUrlSquare") + val client = clients[inboxId] ?: throw XMTPException("No client") + val group = findGroup(inboxId, id) + + group?.updateGroupImageUrlSquare(groupImageUrl) + } + } + + AsyncFunction("groupDescription") Coroutine { inboxId: String, id: String -> + withContext(Dispatchers.IO) { + logV("groupDescription") + val client = clients[inboxId] ?: throw XMTPException("No client") + val group = findGroup(inboxId, id) + + group?.description + } + } + + AsyncFunction("updateGroupDescription") Coroutine { inboxId: String, id: String, groupDescription: String -> + withContext(Dispatchers.IO) { + logV("updateGroupDescription") + val client = clients[inboxId] ?: throw XMTPException("No client") + val group = findGroup(inboxId, id) + + group?.updateGroupDescription(groupDescription) + } + } + + AsyncFunction("groupPinnedFrameUrl") Coroutine { inboxId: String, id: String -> + withContext(Dispatchers.IO) { + logV("groupPinnedFrameUrl") + val client = clients[inboxId] ?: throw XMTPException("No client") + val group = findGroup(inboxId, id) + + group?.pinnedFrameUrl + } + } + + AsyncFunction("updateGroupPinnedFrameUrl") Coroutine { inboxId: String, id: String, pinnedFrameUrl: String -> + withContext(Dispatchers.IO) { + logV("updateGroupPinnedFrameUrl") + val client = clients[inboxId] ?: throw XMTPException("No client") + val group = findGroup(inboxId, id) + + group?.updateGroupPinnedFrameUrl(pinnedFrameUrl) + } + } + + AsyncFunction("isGroupActive") Coroutine { inboxId: String, id: String -> + withContext(Dispatchers.IO) { + logV("isGroupActive") + val client = clients[inboxId] ?: throw XMTPException("No client") + val group = findGroup(inboxId, id) + + group?.isActive() + } + } + + AsyncFunction("addedByInboxId") Coroutine { inboxId: String, id: String -> + withContext(Dispatchers.IO) { + logV("addedByInboxId") + val group = findGroup(inboxId, id) ?: throw XMTPException("No group found") + + group.addedByInboxId() + } + } + + AsyncFunction("creatorInboxId") Coroutine { inboxId: String, id: String -> + withContext(Dispatchers.IO) { + logV("creatorInboxId") + val client = clients[inboxId] ?: throw XMTPException("No client") + val group = findGroup(inboxId, id) + + group?.creatorInboxId() + } + } + + AsyncFunction("isAdmin") Coroutine { clientInboxId: String, id: String, inboxId: String -> + withContext(Dispatchers.IO) { + logV("isGroupAdmin") + val client = clients[clientInboxId] ?: throw XMTPException("No client") + val group = findGroup(clientInboxId, id) + + group?.isAdmin(inboxId) + } + } + + AsyncFunction("isSuperAdmin") Coroutine { clientInboxId: String, id: String, inboxId: String -> + withContext(Dispatchers.IO) { + logV("isSuperAdmin") + val client = clients[clientInboxId] ?: throw XMTPException("No client") + val group = findGroup(clientInboxId, id) + + group?.isSuperAdmin(inboxId) + } + } + + AsyncFunction("listAdmins") Coroutine { inboxId: String, id: String -> + withContext(Dispatchers.IO) { + logV("listAdmins") + val client = clients[inboxId] ?: throw XMTPException("No client") + val group = findGroup(inboxId, id) + + group?.listAdmins() + } + } + + AsyncFunction("listSuperAdmins") Coroutine { inboxId: String, id: String -> + withContext(Dispatchers.IO) { + logV("listSuperAdmins") + val client = clients[inboxId] ?: throw XMTPException("No client") + val group = findGroup(inboxId, id) + + group?.listSuperAdmins() + } + } + + AsyncFunction("addAdmin") Coroutine { clientInboxId: String, id: String, inboxId: String -> + withContext(Dispatchers.IO) { + logV("addAdmin") + val client = clients[clientInboxId] ?: throw XMTPException("No client") + val group = findGroup(clientInboxId, id) + group?.addAdmin(inboxId) + } + } + + AsyncFunction("addSuperAdmin") Coroutine { clientInboxId: String, id: String, inboxId: String -> + withContext(Dispatchers.IO) { + logV("addSuperAdmin") + val client = clients[clientInboxId] ?: throw XMTPException("No client") + val group = findGroup(clientInboxId, id) + + group?.addSuperAdmin(inboxId) + } + } + + AsyncFunction("removeAdmin") Coroutine { clientInboxId: String, id: String, inboxId: String -> + withContext(Dispatchers.IO) { + logV("removeAdmin") + val client = clients[clientInboxId] ?: throw XMTPException("No client") + val group = findGroup(clientInboxId, id) + + group?.removeAdmin(inboxId) + } + } + + AsyncFunction("removeSuperAdmin") Coroutine { clientInboxId: String, id: String, inboxId: String -> + withContext(Dispatchers.IO) { + logV("removeSuperAdmin") + val client = clients[clientInboxId] ?: throw XMTPException("No client") + val group = findGroup(clientInboxId, id) + + group?.removeSuperAdmin(inboxId) + } + } + + AsyncFunction("updateAddMemberPermission") Coroutine { clientInboxId: String, id: String, newPermission: String -> + withContext(Dispatchers.IO) { + logV("updateAddMemberPermission") + val client = clients[clientInboxId] ?: throw XMTPException("No client") + val group = findGroup(clientInboxId, id) + + group?.updateAddMemberPermission(getPermissionOption(newPermission)) + } + } + + AsyncFunction("updateRemoveMemberPermission") Coroutine { clientInboxId: String, id: String, newPermission: String -> + withContext(Dispatchers.IO) { + logV("updateRemoveMemberPermission") + val client = clients[clientInboxId] ?: throw XMTPException("No client") + val group = findGroup(clientInboxId, id) + + group?.updateRemoveMemberPermission(getPermissionOption(newPermission)) + } + } + + AsyncFunction("updateAddAdminPermission") Coroutine { clientInboxId: String, id: String, newPermission: String -> + withContext(Dispatchers.IO) { + logV("updateAddAdminPermission") + val client = clients[clientInboxId] ?: throw XMTPException("No client") + val group = findGroup(clientInboxId, id) + + group?.updateAddAdminPermission(getPermissionOption(newPermission)) + } + } + + AsyncFunction("updateRemoveAdminPermission") Coroutine { clientInboxId: String, id: String, newPermission: String -> + withContext(Dispatchers.IO) { + logV("updateRemoveAdminPermission") + val client = clients[clientInboxId] ?: throw XMTPException("No client") + val group = findGroup(clientInboxId, id) + + group?.updateRemoveAdminPermission(getPermissionOption(newPermission)) + } + } + + AsyncFunction("updateGroupNamePermission") Coroutine { clientInboxId: String, id: String, newPermission: String -> + withContext(Dispatchers.IO) { + logV("updateGroupNamePermission") + val client = clients[clientInboxId] ?: throw XMTPException("No client") + val group = findGroup(clientInboxId, id) + + group?.updateGroupNamePermission(getPermissionOption(newPermission)) + } + } + + AsyncFunction("updateGroupImageUrlSquarePermission") Coroutine { clientInboxId: String, id: String, newPermission: String -> + withContext(Dispatchers.IO) { + logV("updateGroupImageUrlSquarePermission") + val client = clients[clientInboxId] ?: throw XMTPException("No client") + val group = findGroup(clientInboxId, id) + + group?.updateGroupImageUrlSquarePermission(getPermissionOption(newPermission)) + } + } + + AsyncFunction("updateGroupDescriptionPermission") Coroutine { clientInboxId: String, id: String, newPermission: String -> + withContext(Dispatchers.IO) { + logV("updateGroupDescriptionPermission") + val client = clients[clientInboxId] ?: throw XMTPException("No client") + val group = findGroup(clientInboxId, id) + + group?.updateGroupDescriptionPermission(getPermissionOption(newPermission)) + } + } + + AsyncFunction("updateGroupPinnedFrameUrlPermission") Coroutine { clientInboxId: String, id: String, newPermission: String -> + withContext(Dispatchers.IO) { + logV("updateGroupPinnedFrameUrlPermission") + val client = clients[clientInboxId] ?: throw XMTPException("No client") + val group = findGroup(clientInboxId, id) + + group?.updateGroupPinnedFrameUrlPermission(getPermissionOption(newPermission)) + } + } + + AsyncFunction("permissionPolicySet") Coroutine { inboxId: String, id: String -> + withContext(Dispatchers.IO) { + logV("groupImageUrlSquare") + val client = clients[inboxId] ?: throw XMTPException("No client") + val group = findGroup(inboxId, id) + + val permissionPolicySet = group?.permissionPolicySet() + if (permissionPolicySet != null) { + PermissionPolicySetWrapper.encodeToJsonString(permissionPolicySet) + } else { + throw XMTPException("Permission policy set not found for group: $id") + } + } + } + + AsyncFunction("processGroupMessage") Coroutine { inboxId: String, id: String, encryptedMessage: String -> + withContext(Dispatchers.IO) { + logV("processGroupMessage") + val client = clients[inboxId] ?: throw XMTPException("No client") + val group = findGroup(inboxId, id) + + val message = group?.processMessage(Base64.decode(encryptedMessage, NO_WRAP)) + ?: throw XMTPException("could not decrypt message for $id") + DecodedMessageWrapper.encodeMap(message.decrypt()) + } + } + + AsyncFunction("processWelcomeMessage") Coroutine { inboxId: String, encryptedMessage: String -> + withContext(Dispatchers.IO) { + logV("processWelcomeMessage") + val client = clients[inboxId] ?: throw XMTPException("No client") + + val group = + client.conversations.fromWelcome(Base64.decode(encryptedMessage, NO_WRAP)) + GroupWrapper.encode(client, group) + } + } + + Function("subscribeToConversations") { inboxId: String -> logV("subscribeToConversations") - subscribeToConversations(clientAddress = clientAddress) + subscribeToConversations(inboxId = inboxId) + } + + Function("subscribeToGroups") { inboxId: String -> + logV("subscribeToGroups") + subscribeToGroups(inboxId = inboxId) + } + + Function("subscribeToAll") { inboxId: String -> + logV("subscribeToAll") + subscribeToAll(inboxId = inboxId) } - Function("subscribeToAllMessages") { clientAddress: String -> + Function("subscribeToAllMessages") { inboxId: String, includeGroups: Boolean -> logV("subscribeToAllMessages") - subscribeToAllMessages(clientAddress = clientAddress) + subscribeToAllMessages(inboxId = inboxId, includeGroups = includeGroups) } - AsyncFunction("subscribeToMessages") Coroutine { clientAddress: String, topic: String -> + Function("subscribeToAllGroupMessages") { inboxId: String -> + logV("subscribeToAllGroupMessages") + subscribeToAllGroupMessages(inboxId = inboxId) + } + + AsyncFunction("subscribeToMessages") Coroutine { inboxId: String, topic: String -> withContext(Dispatchers.IO) { logV("subscribeToMessages") subscribeToMessages( - clientAddress = clientAddress, + inboxId = inboxId, topic = topic ) } } - Function("unsubscribeFromConversations") { clientAddress: String -> + AsyncFunction("subscribeToGroupMessages") Coroutine { inboxId: String, id: String -> + withContext(Dispatchers.IO) { + logV("subscribeToGroupMessages") + subscribeToGroupMessages( + inboxId = inboxId, + id = id + ) + } + } + + Function("unsubscribeFromConversations") { inboxId: String -> logV("unsubscribeFromConversations") - subscriptions[getConversationsKey(clientAddress)]?.cancel() + subscriptions[getConversationsKey(inboxId)]?.cancel() } - Function("unsubscribeFromAllMessages") { clientAddress: String -> + Function("unsubscribeFromGroups") { inboxId: String -> + logV("unsubscribeFromGroups") + subscriptions[getGroupsKey(inboxId)]?.cancel() + } + + Function("unsubscribeFromAllMessages") { inboxId: String -> logV("unsubscribeFromAllMessages") - subscriptions[getMessagesKey(clientAddress)]?.cancel() + subscriptions[getMessagesKey(inboxId)]?.cancel() + } + + Function("unsubscribeFromAllGroupMessages") { inboxId: String -> + logV("unsubscribeFromAllGroupMessages") + subscriptions[getGroupMessagesKey(inboxId)]?.cancel() } - AsyncFunction("unsubscribeFromMessages") Coroutine { clientAddress: String, topic: String -> + AsyncFunction("unsubscribeFromMessages") Coroutine { inboxId: String, topic: String -> withContext(Dispatchers.IO) { logV("unsubscribeFromMessages") unsubscribeFromMessages( - clientAddress = clientAddress, + inboxId = inboxId, topic = topic ) } } + AsyncFunction("unsubscribeFromGroupMessages") Coroutine { inboxId: String, id: String -> + withContext(Dispatchers.IO) { + logV("unsubscribeFromGroupMessages") + unsubscribeFromGroupMessages( + inboxId = inboxId, + id = id + ) + } + } + Function("registerPushToken") { pushServer: String, token: String -> logV("registerPushToken") xmtpPush = XMTPPush(appContext.reactContext!!, pushServer) xmtpPush?.register(token) } - Function("subscribePushTopics") { topics: List -> + Function("subscribePushTopics") { inboxId: String, topics: List -> logV("subscribePushTopics") if (topics.isNotEmpty()) { if (xmtpPush == null) { throw XMTPException("Push server not registered") } - xmtpPush?.subscribe(topics) + val client = clients[inboxId] ?: throw XMTPException("No client") + + val hmacKeysResult = client.conversations.getHmacKeys() + val subscriptions = topics.map { + val hmacKeys = hmacKeysResult.hmacKeysMap + val result = hmacKeys[it]?.valuesList?.map { hmacKey -> + Service.Subscription.HmacKey.newBuilder().also { sub_key -> + sub_key.key = hmacKey.hmacKey + sub_key.thirtyDayPeriodsSinceEpoch = hmacKey.thirtyDayPeriodsSinceEpoch + }.build() + } + + Service.Subscription.newBuilder().also { sub -> + sub.addAllHmacKeys(result) + if (!result.isNullOrEmpty()) { + sub.addAllHmacKeys(result) + } + sub.topic = it + }.build() + } + + xmtpPush?.subscribeWithMetadata(subscriptions) } } - AsyncFunction("decodeMessage") Coroutine { clientAddress: String, topic: String, encryptedMessage: String -> + AsyncFunction("decodeMessage") Coroutine { inboxId: String, topic: String, encryptedMessage: String -> withContext(Dispatchers.IO) { logV("decodeMessage") val encryptedMessageData = Base64.decode(encryptedMessage, NO_WRAP) val envelope = EnvelopeBuilder.buildFromString(topic, Date(), encryptedMessageData) val conversation = findConversation( - clientAddress = clientAddress, + inboxId = inboxId, topic = topic ) ?: throw XMTPException("no conversation found for $topic") @@ -700,51 +1412,87 @@ class XMTPModule : Module() { } } - AsyncFunction("isAllowed") { clientAddress: String, address: String -> + AsyncFunction("isAllowed") { inboxId: String, address: String -> logV("isAllowed") - val client = clients[clientAddress] ?: throw XMTPException("No client") + val client = clients[inboxId] ?: throw XMTPException("No client") client.contacts.isAllowed(address) } - Function("isDenied") { clientAddress: String, address: String -> + Function("isDenied") { inboxId: String, address: String -> logV("isDenied") - val client = clients[clientAddress] ?: throw XMTPException("No client") + val client = clients[inboxId] ?: throw XMTPException("No client") client.contacts.isDenied(address) } - AsyncFunction("denyContacts") Coroutine { clientAddress: String, addresses: List -> + AsyncFunction("denyContacts") Coroutine { inboxId: String, addresses: List -> withContext(Dispatchers.IO) { logV("denyContacts") - val client = clients[clientAddress] ?: throw XMTPException("No client") + val client = clients[inboxId] ?: throw XMTPException("No client") client.contacts.deny(addresses) } } - AsyncFunction("allowContacts") Coroutine { clientAddress: String, addresses: List -> + AsyncFunction("allowContacts") Coroutine { inboxId: String, addresses: List -> withContext(Dispatchers.IO) { - val client = clients[clientAddress] ?: throw XMTPException("No client") + val client = clients[inboxId] ?: throw XMTPException("No client") client.contacts.allow(addresses) } } - AsyncFunction("refreshConsentList") Coroutine { clientAddress: String -> + AsyncFunction("isInboxAllowed") { clientInboxId: String, inboxId: String -> + logV("isInboxIdAllowed") + val client = clients[clientInboxId] ?: throw XMTPException("No client") + client.contacts.isInboxAllowed(inboxId) + } + + AsyncFunction("isInboxDenied") { clientInboxId: String, inboxId: String -> + logV("isInboxIdDenied") + val client = clients[clientInboxId] ?: throw XMTPException("No client") + client.contacts.isInboxDenied(inboxId) + } + + AsyncFunction("denyInboxes") Coroutine { inboxId: String, inboxIds: List -> + withContext(Dispatchers.IO) { + logV("denyInboxIds") + val client = clients[inboxId] ?: throw XMTPException("No client") + client.contacts.denyInboxes(inboxIds) + } + } + + AsyncFunction("allowInboxes") Coroutine { inboxId: String, inboxIds: List -> withContext(Dispatchers.IO) { - val client = clients[clientAddress] ?: throw XMTPException("No client") + logV("allowInboxIds") + val client = clients[inboxId] ?: throw XMTPException("No client") + client.contacts.allowInboxes(inboxIds) + } + } + + AsyncFunction("refreshConsentList") Coroutine { inboxId: String -> + withContext(Dispatchers.IO) { + val client = clients[inboxId] ?: throw XMTPException("No client") val consentList = client.contacts.refreshConsentList() consentList.entries.map { ConsentWrapper.encode(it.value) } } } - AsyncFunction("conversationConsentState") Coroutine { clientAddress: String, conversationTopic: String -> + AsyncFunction("conversationConsentState") Coroutine { inboxId: String, conversationTopic: String -> withContext(Dispatchers.IO) { - val conversation = findConversation(clientAddress, conversationTopic) + val conversation = findConversation(inboxId, conversationTopic) ?: throw XMTPException("no conversation found for $conversationTopic") consentStateToString(conversation.consentState()) } } - AsyncFunction("consentList") { clientAddress: String -> - val client = clients[clientAddress] ?: throw XMTPException("No client") + AsyncFunction("groupConsentState") Coroutine { inboxId: String, groupId: String -> + withContext(Dispatchers.IO) { + val group = findGroup(inboxId, groupId) + ?: throw XMTPException("no group found for $groupId") + consentStateToString(Conversation.Group(group).consentState()) + } + } + + AsyncFunction("consentList") { inboxId: String -> + val client = clients[inboxId] ?: throw XMTPException("No client") client.contacts.consentList.entries.map { ConsentWrapper.encode(it.value) } } @@ -757,19 +1505,57 @@ class XMTPModule : Module() { logV("preEnableIdentityCallbackCompleted") preEnableIdentityCallbackDeferred?.complete(Unit) } + + AsyncFunction("allowGroups") Coroutine { inboxId: String, groupIds: List -> + withContext(Dispatchers.IO) { + logV("allowGroups") + val client = clients[inboxId] ?: throw XMTPException("No client") + client.contacts.allowGroups(groupIds) + } + } + + AsyncFunction("denyGroups") Coroutine { inboxId: String, groupIds: List -> + withContext(Dispatchers.IO) { + logV("denyGroups") + val client = clients[inboxId] ?: throw XMTPException("No client") + client.contacts.denyGroups(groupIds) + } + } + + AsyncFunction("isGroupAllowed") { inboxId: String, groupId: String -> + logV("isGroupAllowed") + val client = clients[inboxId] ?: throw XMTPException("No client") + client.contacts.isGroupAllowed(groupId) + } + + AsyncFunction("isGroupDenied") { inboxId: String, groupId: String -> + logV("isGroupDenied") + val client = clients[inboxId] ?: throw XMTPException("No client") + client.contacts.isGroupDenied(groupId) + } } // // Helpers // + private suspend fun getPermissionOption(permissionString: String): PermissionOption { + return when (permissionString) { + "allow" -> PermissionOption.Allow + "deny" -> PermissionOption.Deny + "admin" -> PermissionOption.Admin + "super_admin" -> PermissionOption.SuperAdmin + else -> throw XMTPException("Invalid permission option: $permissionString") + } + } + private suspend fun findConversation( - clientAddress: String, + inboxId: String, topic: String, ): Conversation? { - val client = clients[clientAddress] ?: throw XMTPException("No client") + val client = clients[inboxId] ?: throw XMTPException("No client") - val cacheKey = "${clientAddress}:${topic}" + val cacheKey = "${inboxId}:${topic}" val cacheConversation = conversations[cacheKey] if (cacheConversation != null) { return cacheConversation @@ -777,18 +1563,38 @@ class XMTPModule : Module() { val conversation = client.conversations.list() .firstOrNull { it.topic == topic } if (conversation != null) { - conversations[conversation.cacheKey(clientAddress)] = conversation + conversations[conversation.cacheKey(inboxId)] = conversation return conversation } } return null } - private fun subscribeToConversations(clientAddress: String) { - val client = clients[clientAddress] ?: throw XMTPException("No client") + private fun findGroup( + inboxId: String, + id: String, + ): Group? { + val client = clients[inboxId] ?: throw XMTPException("No client") + + val cacheKey = "${inboxId}:${id}" + val cacheGroup = groups[cacheKey] + if (cacheGroup != null) { + return cacheGroup + } else { + val group = client.findGroup(id) + if (group != null) { + groups[group.cacheKey(inboxId)] = group + return group + } + } + return null + } - subscriptions[getConversationsKey(clientAddress)]?.cancel() - subscriptions[getConversationsKey(clientAddress)] = CoroutineScope(Dispatchers.IO).launch { + private fun subscribeToConversations(inboxId: String) { + val client = clients[inboxId] ?: throw XMTPException("No client") + + subscriptions[getConversationsKey(inboxId)]?.cancel() + subscriptions[getConversationsKey(inboxId)] = CoroutineScope(Dispatchers.IO).launch { try { client.conversations.stream().collect { conversation -> run { @@ -798,7 +1604,7 @@ class XMTPModule : Module() { sendEvent( "conversation", mapOf( - "clientAddress" to clientAddress, + "inboxId" to inboxId, "conversation" to ConversationWrapper.encodeToObj( client, conversation @@ -809,77 +1615,198 @@ class XMTPModule : Module() { } } catch (e: Exception) { Log.e("XMTPModule", "Error in conversations subscription: $e") - subscriptions[getConversationsKey(clientAddress)]?.cancel() + subscriptions[getConversationsKey(inboxId)]?.cancel() } } } - private fun subscribeToAllMessages(clientAddress: String) { - val client = clients[clientAddress] ?: throw XMTPException("No client") + private fun subscribeToGroups(inboxId: String) { + val client = clients[inboxId] ?: throw XMTPException("No client") - subscriptions[getMessagesKey(clientAddress)]?.cancel() - subscriptions[getMessagesKey(clientAddress)] = CoroutineScope(Dispatchers.IO).launch { + subscriptions[getGroupsKey(client.inboxId)]?.cancel() + subscriptions[getGroupsKey(client.inboxId)] = CoroutineScope(Dispatchers.IO).launch { try { - client.conversations.streamAllDecryptedMessages().collect { message -> + client.conversations.streamGroups().collect { group -> sendEvent( - "message", + "group", mapOf( - "clientAddress" to clientAddress, - "message" to DecodedMessageWrapper.encodeMap(message), + "inboxId" to inboxId, + "group" to GroupWrapper.encodeToObj(client, group) + ) + ) + } + } catch (e: Exception) { + Log.e("XMTPModule", "Error in group subscription: $e") + subscriptions[getGroupsKey(client.inboxId)]?.cancel() + } + } + } + + private fun subscribeToAll(inboxId: String) { + val client = clients[inboxId] ?: throw XMTPException("No client") + + subscriptions[getConversationsKey(inboxId)]?.cancel() + subscriptions[getConversationsKey(inboxId)] = CoroutineScope(Dispatchers.IO).launch { + try { + client.conversations.streamAll().collect { conversation -> + sendEvent( + "conversationContainer", + mapOf( + "inboxId" to inboxId, + "conversationContainer" to ConversationContainerWrapper.encodeToObj( + client, + conversation + ) ) ) } + } catch (e: Exception) { + Log.e("XMTPModule", "Error in subscription to groups + conversations: $e") + subscriptions[getConversationsKey(inboxId)]?.cancel() + } + } + } + + private fun subscribeToAllMessages(inboxId: String, includeGroups: Boolean = false) { + val client = clients[inboxId] ?: throw XMTPException("No client") + + subscriptions[getMessagesKey(inboxId)]?.cancel() + subscriptions[getMessagesKey(inboxId)] = CoroutineScope(Dispatchers.IO).launch { + try { + client.conversations.streamAllDecryptedMessages(includeGroups = includeGroups) + .collect { message -> + sendEvent( + "message", + mapOf( + "inboxId" to inboxId, + "message" to DecodedMessageWrapper.encodeMap(message), + ) + ) + } } catch (e: Exception) { Log.e("XMTPModule", "Error in all messages subscription: $e") - subscriptions[getMessagesKey(clientAddress)]?.cancel() + subscriptions[getMessagesKey(inboxId)]?.cancel() + } + } + } + + private fun subscribeToAllGroupMessages(inboxId: String) { + val client = clients[inboxId] ?: throw XMTPException("No client") + + subscriptions[getGroupMessagesKey(inboxId)]?.cancel() + subscriptions[getGroupMessagesKey(inboxId)] = CoroutineScope(Dispatchers.IO).launch { + try { + client.conversations.streamAllGroupDecryptedMessages().collect { message -> + sendEvent( + "allGroupMessage", + mapOf( + "inboxId" to inboxId, + "message" to DecodedMessageWrapper.encodeMap(message), + ) + ) + } + } catch (e: Exception) { + Log.e("XMTPModule", "Error in all group messages subscription: $e") + subscriptions[getGroupMessagesKey(inboxId)]?.cancel() } } } - private suspend fun subscribeToMessages(clientAddress: String, topic: String) { + private suspend fun subscribeToMessages(inboxId: String, topic: String) { val conversation = findConversation( - clientAddress = clientAddress, + inboxId = inboxId, topic = topic ) ?: return - subscriptions[conversation.cacheKey(clientAddress)]?.cancel() - subscriptions[conversation.cacheKey(clientAddress)] = + subscriptions[conversation.cacheKey(inboxId)]?.cancel() + subscriptions[conversation.cacheKey(inboxId)] = CoroutineScope(Dispatchers.IO).launch { try { conversation.streamDecryptedMessages().collect { message -> sendEvent( - "message", + "conversationMessage", mapOf( - "clientAddress" to clientAddress, + "inboxId" to inboxId, "message" to DecodedMessageWrapper.encodeMap(message), + "topic" to topic, ) ) } } catch (e: Exception) { Log.e("XMTPModule", "Error in messages subscription: $e") - subscriptions[conversation.cacheKey(clientAddress)]?.cancel() + subscriptions[conversation.cacheKey(inboxId)]?.cancel() } } } - private fun getMessagesKey(clientAddress: String): String { - return "messages:$clientAddress" + private suspend fun subscribeToGroupMessages(inboxId: String, id: String) { + val client = clients[inboxId] ?: throw XMTPException("No client") + val group = + findGroup( + inboxId = inboxId, + id = id + ) ?: return + subscriptions[group.cacheKey(inboxId)]?.cancel() + subscriptions[group.cacheKey(inboxId)] = + CoroutineScope(Dispatchers.IO).launch { + try { + group.streamDecryptedMessages().collect { message -> + sendEvent( + "groupMessage", + mapOf( + "inboxId" to inboxId, + "message" to DecodedMessageWrapper.encodeMap(message), + "groupId" to id, + ) + ) + } + } catch (e: Exception) { + Log.e("XMTPModule", "Error in messages subscription: $e") + subscriptions[group.cacheKey(inboxId)]?.cancel() + } + } + } + + private fun getMessagesKey(inboxId: String): String { + return "messages:$inboxId" } - private fun getConversationsKey(clientAddress: String): String { - return "conversations:$clientAddress" + private fun getGroupMessagesKey(inboxId: String): String { + return "groupMessages:$inboxId" + } + + private fun getConversationsKey(inboxId: String): String { + return "conversations:$inboxId" + } + + private fun getGroupsKey(inboxId: String): String { + return "groups:$inboxId" } private suspend fun unsubscribeFromMessages( - clientAddress: String, + inboxId: String, topic: String, ) { val conversation = findConversation( - clientAddress = clientAddress, + inboxId = inboxId, topic = topic ) ?: return - subscriptions[conversation.cacheKey(clientAddress)]?.cancel() + subscriptions[conversation.cacheKey(inboxId)]?.cancel() + } + + private suspend fun unsubscribeFromGroupMessages( + inboxId: String, + id: String, + ) { + val client = clients[inboxId] ?: throw XMTPException("No client") + + val group = + findGroup( + inboxId = inboxId, + id = id + ) ?: return + subscriptions[group.cacheKey(inboxId)]?.cancel() } private fun logV(msg: String) { diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/AuthParamsWrapper.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/AuthParamsWrapper.kt new file mode 100644 index 000000000..99146a715 --- /dev/null +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/AuthParamsWrapper.kt @@ -0,0 +1,24 @@ +package expo.modules.xmtpreactnativesdk.wrappers + +import com.google.gson.JsonParser + +class AuthParamsWrapper( + val environment: String, + val appVersion: String?, + val enableV3: Boolean = false, + val dbDirectory: String?, + val historySyncUrl: String?, +) { + companion object { + fun authParamsFromJson(authParams: String): AuthParamsWrapper { + val jsonOptions = JsonParser.parseString(authParams).asJsonObject + return AuthParamsWrapper( + jsonOptions.get("environment").asString, + if (jsonOptions.has("appVersion")) jsonOptions.get("appVersion").asString else null, + if (jsonOptions.has("enableV3")) jsonOptions.get("enableV3").asBoolean else false, + if (jsonOptions.has("dbDirectory")) jsonOptions.get("dbDirectory").asString else null, + if (jsonOptions.has("historySyncUrl")) jsonOptions.get("historySyncUrl").asString else null + ) + } + } +} diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ClientWrapper.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ClientWrapper.kt new file mode 100644 index 000000000..cb9f2e958 --- /dev/null +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ClientWrapper.kt @@ -0,0 +1,22 @@ +package expo.modules.xmtpreactnativesdk.wrappers + +import com.google.gson.GsonBuilder +import org.xmtp.android.library.Client + +class ClientWrapper { + companion object { + fun encodeToObj(client: Client): Map { + return mapOf( + "inboxId" to client.inboxId, + "address" to client.address, + "installationId" to client.installationId + ) + } + + fun encode(client: Client): String { + val gson = GsonBuilder().create() + val obj = encodeToObj(client) + return gson.toJson(obj) + } + } +} \ No newline at end of file diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ContentJson.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ContentJson.kt index b46015697..2ea93c690 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ContentJson.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ContentJson.kt @@ -9,6 +9,7 @@ import org.xmtp.android.library.Client import org.xmtp.android.library.codecs.Attachment import org.xmtp.android.library.codecs.AttachmentCodec import org.xmtp.android.library.codecs.ContentTypeAttachment +import org.xmtp.android.library.codecs.ContentTypeGroupUpdated import org.xmtp.android.library.codecs.ContentTypeId import org.xmtp.android.library.codecs.ContentTypeReaction import org.xmtp.android.library.codecs.ContentTypeReadReceipt @@ -16,6 +17,8 @@ import org.xmtp.android.library.codecs.ContentTypeRemoteAttachment import org.xmtp.android.library.codecs.ContentTypeReply import org.xmtp.android.library.codecs.ContentTypeText import org.xmtp.android.library.codecs.EncodedContent +import org.xmtp.android.library.codecs.GroupUpdated +import org.xmtp.android.library.codecs.GroupUpdatedCodec import org.xmtp.android.library.codecs.Reaction import org.xmtp.android.library.codecs.ReactionCodec import org.xmtp.android.library.codecs.ReadReceipt @@ -51,6 +54,7 @@ class ContentJson( Client.register(RemoteAttachmentCodec()) Client.register(ReplyCodec()) Client.register(ReadReceiptCodec()) + Client.register(GroupUpdatedCodec()) } fun fromJsonObject(obj: JsonObject): ContentJson { @@ -171,6 +175,29 @@ class ContentJson( "readReceipt" to "" ) + ContentTypeGroupUpdated.id -> mapOf( + "initiatedByInboxId" to (content as GroupUpdated).initiatedByInboxId, + "groupUpdated" to mapOf( + "membersAdded" to content.addedInboxesList.map { + mapOf( + "inboxId" to it.inboxId + ) + }, + "membersRemoved" to content.removedInboxesList.map { + mapOf( + "inboxId" to it.inboxId + ) + }, + "metadataFieldsChanged" to content.metadataFieldChangesList.map { + mapOf( + "oldValue" to it.oldValue, + "newValue" to it.newValue, + "fieldName" to it.fieldName, + ) + }, + ) + ) + else -> { val json = JsonObject() encodedContent?.let { diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ConversationContainerWrapper.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ConversationContainerWrapper.kt new file mode 100644 index 000000000..c1b04c08c --- /dev/null +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ConversationContainerWrapper.kt @@ -0,0 +1,29 @@ +package expo.modules.xmtpreactnativesdk.wrappers + +import android.util.Base64 +import com.google.gson.GsonBuilder +import org.xmtp.android.library.Client +import org.xmtp.android.library.Conversation + +class ConversationContainerWrapper { + + companion object { + fun encodeToObj(client: Client, conversation: Conversation): Map { + when (conversation.version) { + Conversation.Version.GROUP -> { + val group = (conversation as Conversation.Group).group + return GroupWrapper.encodeToObj(client, group) + } + else -> { + return ConversationWrapper.encodeToObj(client, conversation) + } + } + } + + fun encode(client: Client, conversation: Conversation): String { + val gson = GsonBuilder().create() + val obj = ConversationContainerWrapper.encodeToObj(client, conversation) + return gson.toJson(obj) + } + } +} diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ConversationWrapper.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ConversationWrapper.kt index 97af24141..09854f46d 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ConversationWrapper.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ConversationWrapper.kt @@ -24,7 +24,7 @@ class ConversationWrapper { "context" to context, "topic" to conversation.topic, "peerAddress" to conversation.peerAddress, - "version" to if (conversation.version == Conversation.Version.V1) "v1" else "v2", + "version" to "DIRECT", "conversationID" to (conversation.conversationId ?: ""), "keyMaterial" to (conversation.keyMaterial?.let { Base64.encodeToString(it, Base64.NO_WRAP) } ?: ""), "consentProof" to if (conversation.consentProof != null) Base64.encodeToString(conversation.consentProof?.toByteArray(), Base64.NO_WRAP) else null diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/CreateGroupParamsWrapper.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/CreateGroupParamsWrapper.kt new file mode 100644 index 000000000..d857ffb49 --- /dev/null +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/CreateGroupParamsWrapper.kt @@ -0,0 +1,22 @@ +package expo.modules.xmtpreactnativesdk.wrappers + +import com.google.gson.JsonParser + +class CreateGroupParamsWrapper( + val groupName: String, + val groupImageUrlSquare: String, + val groupDescription: String, + val groupPinnedFrameUrl: String, +) { + companion object { + fun createGroupParamsFromJson(authParams: String): CreateGroupParamsWrapper { + val jsonOptions = JsonParser.parseString(authParams).asJsonObject + return CreateGroupParamsWrapper( + if (jsonOptions.has("name")) jsonOptions.get("name").asString else "", + if (jsonOptions.has("imageUrlSquare")) jsonOptions.get("imageUrlSquare").asString else "", + if (jsonOptions.has("description")) jsonOptions.get("description").asString else "", + if (jsonOptions.has("pinnedFrameUrl")) jsonOptions.get("pinnedFrameUrl").asString else "", + ) + } + } +} diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/DecodedMessageWrapper.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/DecodedMessageWrapper.kt index 6b87624f7..ab871f023 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/DecodedMessageWrapper.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/DecodedMessageWrapper.kt @@ -24,7 +24,8 @@ class DecodedMessageWrapper { "content" to ContentJson(model.encodedContent).toJsonMap(), "senderAddress" to model.senderAddress, "sent" to model.sentAt.time, - "fallback" to fallback + "fallback" to fallback, + "deliveryStatus" to model.deliveryStatus.toString() ) } } diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/GroupWrapper.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/GroupWrapper.kt new file mode 100644 index 000000000..deafbc0b6 --- /dev/null +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/GroupWrapper.kt @@ -0,0 +1,30 @@ +package expo.modules.xmtpreactnativesdk.wrappers + +import com.google.gson.GsonBuilder +import org.xmtp.android.library.Client +import org.xmtp.android.library.Group +import org.xmtp.android.library.toHex + +class GroupWrapper { + + companion object { + fun encodeToObj(client: Client, group: Group): Map { + return mapOf( + "clientAddress" to client.address, + "id" to group.id, + "createdAt" to group.createdAt.time, + "peerInboxIds" to group.peerInboxIds(), + "version" to "GROUP", + "topic" to group.topic, + "creatorInboxId" to group.creatorInboxId(), + "isActive" to group.isActive(), + ) + } + + fun encode(client: Client, group: Group): String { + val gson = GsonBuilder().create() + val obj = encodeToObj(client, group) + return gson.toJson(obj) + } + } +} diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/MemberWrapper.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/MemberWrapper.kt new file mode 100644 index 000000000..dfd92f672 --- /dev/null +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/MemberWrapper.kt @@ -0,0 +1,28 @@ +package expo.modules.xmtpreactnativesdk.wrappers + +import com.google.gson.GsonBuilder +import org.xmtp.android.library.libxmtp.Member +import org.xmtp.android.library.libxmtp.PermissionLevel + +class MemberWrapper { + companion object { + fun encodeToObj(member: Member): Map { + val permissionString = when (member.permissionLevel) { + PermissionLevel.MEMBER -> "member" + PermissionLevel.ADMIN -> "admin" + PermissionLevel.SUPER_ADMIN -> "super_admin" + } + return mapOf( + "inboxId" to member.inboxId, + "addresses" to member.addresses, + "permissionLevel" to permissionString + ) + } + + fun encode(member: Member): String { + val gson = GsonBuilder().create() + val obj = encodeToObj(member) + return gson.toJson(obj) + } + } +} \ No newline at end of file diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/PermissionPolicySetWrapper.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/PermissionPolicySetWrapper.kt new file mode 100644 index 000000000..0922d33a9 --- /dev/null +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/PermissionPolicySetWrapper.kt @@ -0,0 +1,38 @@ +package expo.modules.xmtpreactnativesdk.wrappers + +import com.google.gson.GsonBuilder +import uniffi.xmtpv3.org.xmtp.android.library.libxmtp.PermissionOption +import uniffi.xmtpv3.org.xmtp.android.library.libxmtp.PermissionPolicySet + +class PermissionPolicySetWrapper { + + companion object { + fun fromPermissionOption(permissionOption: PermissionOption): String { + return when (permissionOption) { + PermissionOption.Allow -> "allow" + PermissionOption.Deny -> "deny" + PermissionOption.Admin -> "admin" + PermissionOption.SuperAdmin -> "superAdmin" + PermissionOption.Unknown -> "unknown" + } + } + fun encodeToObj(policySet: PermissionPolicySet): Map { + return mapOf( + "addMemberPolicy" to fromPermissionOption(policySet.addMemberPolicy), + "removeMemberPolicy" to fromPermissionOption(policySet.removeMemberPolicy), + "addAdminPolicy" to fromPermissionOption(policySet.addAdminPolicy), + "removeAdminPolicy" to fromPermissionOption(policySet.removeAdminPolicy), + "updateGroupNamePolicy" to fromPermissionOption(policySet.updateGroupNamePolicy), + "updateGroupDescriptionPolicy" to fromPermissionOption(policySet.updateGroupDescriptionPolicy), + "updateGroupImagePolicy" to fromPermissionOption(policySet.updateGroupImagePolicy), + "updateGroupPinnedFrameUrlPolicy" to fromPermissionOption(policySet.updateGroupPinnedFrameUrlPolicy), + ) + } + + fun encodeToJsonString(policySet: PermissionPolicySet): String { + val gson = GsonBuilder().create() + val obj = encodeToObj(policySet) + return gson.toJson(obj) + } + } +} diff --git a/example/App.tsx b/example/App.tsx index 2a7f8b531..f6c70c434 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -14,6 +14,7 @@ import { XmtpProvider } from 'xmtp-react-native-sdk' import ConversationCreateScreen from './src/ConversationCreateScreen' import ConversationScreen from './src/ConversationScreen' +import GroupScreen from './src/GroupScreen' import HomeScreen from './src/HomeScreen' import LaunchScreen from './src/LaunchScreen' import { Navigator } from './src/Navigation' @@ -90,6 +91,11 @@ export default function App() { options={{ title: 'Conversation' }} initialParams={{ topic: '' }} /> + 1.3.5) - - MMKVCore (1.3.5) + - MMKV (1.3.7): + - MMKVCore (~> 1.3.7) + - MMKVCore (1.3.7) - OpenSSL-Universal (1.1.2200) - RCT-Folly (2021.07.22.00): - boost @@ -344,6 +344,8 @@ PODS: - RCTTypeSafety - React-Core - ReactCommon/turbomodule/core + - react-native-sqlite-storage (6.0.1): + - React-Core - react-native-webview (13.8.1): - RCT-Folly (= 2021.07.22.00) - React-Core @@ -433,6 +435,8 @@ PODS: - React-perflogger (= 0.71.14) - RNCAsyncStorage (1.21.0): - React-Core + - RNFS (2.20.0): + - React-Core - RNScreens (3.20.0): - React-Core - React-RCTImage @@ -445,16 +449,16 @@ PODS: - GenericJSON (~> 2.0) - Logging (~> 1.0.0) - secp256k1.swift (~> 0.1) - - XMTP (0.13.0): + - XMTP (0.13.8): - Connect-Swift (= 0.12.0) - GzipSwift - - LibXMTP (= 0.5.4-beta0) + - LibXMTP (= 0.5.6-beta0) - web3.swift - XMTPReactNative (0.1.0): - ExpoModulesCore - MessagePacker - secp256k1.swift - - XMTP (= 0.13.0) + - XMTP (= 0.13.8) - Yoga (1.14.0) DEPENDENCIES: @@ -505,6 +509,7 @@ DEPENDENCIES: - react-native-quick-crypto (from `../node_modules/react-native-quick-crypto`) - react-native-randombytes (from `../node_modules/react-native-randombytes`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) + - react-native-sqlite-storage (from `../node_modules/react-native-sqlite-storage`) - react-native-webview (from `../node_modules/react-native-webview`) - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`) - React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`) @@ -520,6 +525,7 @@ DEPENDENCIES: - React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`) - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" + - RNFS (from `../node_modules/react-native-fs`) - RNScreens (from `../node_modules/react-native-screens`) - RNSVG (from `../node_modules/react-native-svg`) - XMTPReactNative (from `../../ios`) @@ -634,6 +640,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-randombytes" react-native-safe-area-context: :path: "../node_modules/react-native-safe-area-context" + react-native-sqlite-storage: + :path: "../node_modules/react-native-sqlite-storage" react-native-webview: :path: "../node_modules/react-native-webview" React-perflogger: @@ -664,6 +672,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon" RNCAsyncStorage: :path: "../node_modules/@react-native-async-storage/async-storage" + RNFS: + :path: "../node_modules/react-native-fs" RNScreens: :path: "../node_modules/react-native-screens" RNSVG: @@ -701,11 +711,11 @@ SPEC CHECKSUMS: GzipSwift: 893f3e48e597a1a4f62fafcb6514220fcf8287fa hermes-engine: d7cc127932c89c53374452d6f93473f1970d8e88 libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 - LibXMTP: 549e85c40f00957be3e114a823a2887cdcf5817a + LibXMTP: e7682dedb10e18343c011280d494a8e4a43d9eb7 Logging: 9ef4ecb546ad3169398d5a723bc9bea1c46bef26 MessagePacker: ab2fe250e86ea7aedd1a9ee47a37083edd41fd02 - MMKV: 506311d0494023c2f7e0b62cc1f31b7370fa3cfb - MMKVCore: 9e2e5fd529b64a9fe15f1a7afb3d73b2e27b4db9 + MMKV: 36a22a9ec84c9bb960613a089ddf6f48be9312b0 + MMKVCore: 158e61c8516401a9fac730288acb29e6fc19bbf9 OpenSSL-Universal: 6e1ae0555546e604dbc632a2b9a24a9c46c41ef6 RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1 RCTRequired: e9df143e880d0e879e7a498dc06923d728809c79 @@ -731,6 +741,7 @@ SPEC CHECKSUMS: react-native-quick-crypto: 455c1b411db006dba1026a30681ececb19180187 react-native-randombytes: 421f1c7d48c0af8dbcd471b0324393ebf8fe7846 react-native-safe-area-context: 39c2d8be3328df5d437ac1700f4f3a4f75716acc + react-native-sqlite-storage: f6d515e1c446d1e6d026aa5352908a25d4de3261 react-native-webview: bdc091de8cf7f8397653e30182efcd9f772e03b3 React-perflogger: 4987ad83731c23d11813c84263963b0d3028c966 React-RCTActionSheet: 5ad952b2a9740d87a5bd77280c4bc23f6f89ea0c @@ -746,13 +757,14 @@ SPEC CHECKSUMS: React-runtimeexecutor: ffe826b7b1cfbc32a35ed5b64d5886c0ff75f501 ReactCommon: 7f3dd5e98a9ec627c6b03d26c062bf37ea9fc888 RNCAsyncStorage: 618d03a5f52fbccb3d7010076bc54712844c18ef + RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 RNScreens: 218801c16a2782546d30bd2026bb625c0302d70f RNSVG: d00c8f91c3cbf6d476451313a18f04d220d4f396 secp256k1.swift: a7e7a214f6db6ce5db32cc6b2b45e5c4dd633634 SwiftProtobuf: 407a385e97fd206c4fbe880cc84123989167e0d1 web3.swift: 2263d1e12e121b2c42ffb63a5a7beb1acaf33959 - XMTP: fbcb4e36d906d220778a5a0727cab6fd912bbb1d - XMTPReactNative: 44fd251a95a87f5ced57cce617f0748f2d056940 + XMTP: 7d0a3f3b22916acfbb0ae67f1ca6bbd3f5956138 + XMTPReactNative: 51e5b1b8669dab2ad5e2d74b518146388f5f425e Yoga: e71803b4c1fff832ccf9b92541e00f9b873119b9 PODFILE CHECKSUM: 95d6ace79946933ecf80684613842ee553dd76a2 diff --git a/example/ios/xmtpreactnativesdkexample.xcodeproj/project.pbxproj b/example/ios/xmtpreactnativesdkexample.xcodeproj/project.pbxproj index 311bc6e1e..3388e7aa2 100644 --- a/example/ios/xmtpreactnativesdkexample.xcodeproj/project.pbxproj +++ b/example/ios/xmtpreactnativesdkexample.xcodeproj/project.pbxproj @@ -515,6 +515,7 @@ MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; OTHER_LDFLAGS = ( + "-l\"xmtpv3\"", "$(inherited)", "-Wl", "-ld_classic", @@ -574,6 +575,7 @@ LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = NO; OTHER_LDFLAGS = ( + "-l\"xmtpv3\"", "$(inherited)", "-Wl", "-ld_classic", diff --git a/example/package.json b/example/package.json index 3a1e0c87a..5fac9e8f2 100644 --- a/example/package.json +++ b/example/package.json @@ -31,13 +31,16 @@ "react-native-config": "^1.5.1", "react-native-crypto": "^2.2.0", "react-native-encrypted-storage": "^4.0.3", - "react-native-get-random-values": "^1.10.0", + "react-native-fs": "^2.20.0", + "react-native-get-random-values": "^1.11.0", "react-native-mmkv": "^2.8.0", + "react-native-modal-selector": "^2.1.2", "react-native-quick-base64": "^2.0.8", "react-native-quick-crypto": "^0.6.1", "react-native-randombytes": "^3.6.1", "react-native-safe-area-context": "4.5.0", "react-native-screens": "~3.20.0", + "react-native-sqlite-storage": "^6.0.1", "react-native-svg": "^13.9.0", "react-native-url-polyfill": "^2.0.0", "react-native-webview": "^13.8.1", diff --git a/example/src/ConversationCreateScreen.tsx b/example/src/ConversationCreateScreen.tsx index 9f0676dc9..6973bb6ac 100644 --- a/example/src/ConversationCreateScreen.tsx +++ b/example/src/ConversationCreateScreen.tsx @@ -1,6 +1,6 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' import React, { useState } from 'react' -import { Button, ScrollView, Text, TextInput } from 'react-native' +import { Button, ScrollView, Switch, Text, TextInput, View } from 'react-native' import { useXmtp } from 'xmtp-react-native-sdk' import { NavigationParamList } from './Navigation' @@ -13,19 +13,33 @@ export default function ConversationCreateScreen({ const [alert, setAlert] = useState('') const [isCreating, setCreating] = useState(false) const { client } = useXmtp() + const [groupsEnabled, setGroupsEnabled] = useState(false) + const startNewConversation = async (toAddress: string) => { if (!client) { setAlert('Client not initialized') return } - const canMessage = await client.canMessage(toAddress) - if (!canMessage) { - setAlert(`${toAddress} is not on the XMTP network yet`) - return + if (groupsEnabled) { + const toAddresses = toAddress.split(',') + const canMessage = await client.canGroupMessage(toAddresses) + if (!canMessage) { + setAlert(`${toAddress} cannot be added to a group conversation yet`) + return + } + const group = await client.conversations.newGroup(toAddresses) + navigation.navigate('group', { id: group.id }) + } else { + const canMessage = await client.canMessage(toAddress) + if (!canMessage) { + setAlert(`${toAddress} is not on the XMTP network yet`) + return + } + const convo = await client.conversations.newConversation(toAddress) + navigation.navigate('conversation', { topic: convo.topic }) } - const convo = await client.conversations.newConversation(toAddress) - navigation.navigate('conversation', { topic: convo.topic }) } + return ( <> @@ -48,6 +62,15 @@ export default function ConversationCreateScreen({ opacity: isCreating ? 0.5 : 1, }} /> + + + setGroupsEnabled((previousState) => !previousState) + } + /> + Create Group: {groupsEnabled ? 'ON' : 'OFF'} +