diff --git a/android/build.gradle b/android/build.gradle index b65302888..9a50a442e 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:0.14.3" + implementation "org.xmtp:android:0.14.6" 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" diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt index e68fba3d7..0d0dc7c29 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt @@ -6,7 +6,6 @@ import android.util.Base64 import android.util.Base64.NO_WRAP import android.util.Log import androidx.core.net.toUri -import com.facebook.common.util.Hex import com.google.gson.JsonParser import com.google.protobuf.kotlin.toByteString import expo.modules.kotlin.exception.Exceptions @@ -62,8 +61,6 @@ 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.android.library.toHex -import org.xmtp.android.library.hexToByteArray 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 @@ -608,7 +605,7 @@ class XMTPModule : Module() { withContext(Dispatchers.IO) { logV("findV3Message") val client = clients[inboxId] ?: throw XMTPException("No client") - val message = client.findMessage(Hex.hexStringToByteArray(messageId)) + val message = client.findMessage(messageId) message?.let { DecodedMessageWrapper.encode(it.decrypt()) } @@ -619,7 +616,7 @@ class XMTPModule : Module() { withContext(Dispatchers.IO) { logV("findGroup") val client = clients[inboxId] ?: throw XMTPException("No client") - val group = client.findGroup(Hex.hexStringToByteArray(groupId)) + val group = client.findGroup(groupId) group?.let { GroupWrapper.encode(client, it) } @@ -707,6 +704,37 @@ class XMTPModule : Module() { } } + 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") @@ -843,7 +871,8 @@ class XMTPModule : Module() { "admin_only" -> GroupPermissionPreconfiguration.ADMIN_ONLY else -> GroupPermissionPreconfiguration.ALL_MEMBERS } - val createGroupParams = CreateGroupParamsWrapper.createGroupParamsFromJson(groupOptionsJson) + val createGroupParams = + CreateGroupParamsWrapper.createGroupParamsFromJson(groupOptionsJson) val group = client.conversations.newGroup( peerAddresses, permissionLevel, @@ -1125,7 +1154,7 @@ class XMTPModule : Module() { logV("updateAddMemberPermission") val client = clients[clientInboxId] ?: throw XMTPException("No client") val group = findGroup(clientInboxId, id) - + group?.updateAddMemberPermission(getPermissionOption(newPermission)) } } @@ -1135,7 +1164,7 @@ class XMTPModule : Module() { logV("updateRemoveMemberPermission") val client = clients[clientInboxId] ?: throw XMTPException("No client") val group = findGroup(clientInboxId, id) - + group?.updateRemoveMemberPermission(getPermissionOption(newPermission)) } } @@ -1145,7 +1174,7 @@ class XMTPModule : Module() { logV("updateAddAdminPermission") val client = clients[clientInboxId] ?: throw XMTPException("No client") val group = findGroup(clientInboxId, id) - + group?.updateAddAdminPermission(getPermissionOption(newPermission)) } } @@ -1155,7 +1184,7 @@ class XMTPModule : Module() { logV("updateRemoveAdminPermission") val client = clients[clientInboxId] ?: throw XMTPException("No client") val group = findGroup(clientInboxId, id) - + group?.updateRemoveAdminPermission(getPermissionOption(newPermission)) } } @@ -1471,29 +1500,31 @@ class XMTPModule : Module() { } AsyncFunction("allowGroups") Coroutine { inboxId: String, groupIds: List -> - logV("allowGroups") - val client = clients[inboxId] ?: throw XMTPException("No client") - val groupDataIds = groupIds.map { Hex.hexStringToByteArray(it) } - client.contacts.allowGroups(groupDataIds) + withContext(Dispatchers.IO) { + logV("allowGroups") + val client = clients[inboxId] ?: throw XMTPException("No client") + client.contacts.allowGroups(groupIds) + } } AsyncFunction("denyGroups") Coroutine { inboxId: String, groupIds: List -> - logV("denyGroups") - val client = clients[inboxId] ?: throw XMTPException("No client") - val groupDataIds = groupIds.map { Hex.hexStringToByteArray(it) } - client.contacts.denyGroups(groupDataIds) + 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.hexToByteArray()) + client.contacts.isGroupAllowed(groupId) } AsyncFunction("isGroupDenied") { inboxId: String, groupId: String -> logV("isGroupDenied") val client = clients[inboxId] ?: throw XMTPException("No client") - client.contacts.isGroupDenied(groupId.hexToByteArray()) + client.contacts.isGroupDenied(groupId) } } @@ -1532,7 +1563,7 @@ class XMTPModule : Module() { return null } - private suspend fun findGroup( + private fun findGroup( inboxId: String, id: String, ): Group? { @@ -1543,8 +1574,7 @@ class XMTPModule : Module() { if (cacheGroup != null) { return cacheGroup } else { - val group = client.conversations.listGroups() - .firstOrNull { it.id.toHex() == id } + val group = client.findGroup(id) if (group != null) { groups[group.cacheKey(inboxId)] = group return group diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/GroupWrapper.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/GroupWrapper.kt index 5edd8914c..deafbc0b6 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/GroupWrapper.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/GroupWrapper.kt @@ -11,7 +11,7 @@ class GroupWrapper { fun encodeToObj(client: Client, group: Group): Map { return mapOf( "clientAddress" to client.address, - "id" to group.id.toHex(), + "id" to group.id, "createdAt" to group.createdAt.time, "peerInboxIds" to group.peerInboxIds(), "version" to "GROUP", diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 39186dddb..a9dcd42cb 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -56,12 +56,12 @@ PODS: - hermes-engine/Pre-built (= 0.71.14) - hermes-engine/Pre-built (0.71.14) - libevent (2.1.12) - - LibXMTP (0.5.4-beta2) + - LibXMTP (0.5.4-beta4) - Logging (1.0.0) - MessagePacker (0.4.7) - - MMKV (1.3.5): - - MMKVCore (~> 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 @@ -449,16 +449,16 @@ PODS: - GenericJSON (~> 2.0) - Logging (~> 1.0.0) - secp256k1.swift (~> 0.1) - - XMTP (0.13.3): + - XMTP (0.13.5): - Connect-Swift (= 0.12.0) - GzipSwift - - LibXMTP (= 0.5.4-beta2) + - LibXMTP (= 0.5.4-beta4) - web3.swift - XMTPReactNative (0.1.0): - ExpoModulesCore - MessagePacker - secp256k1.swift - - XMTP (= 0.13.3) + - XMTP (= 0.13.5) - Yoga (1.14.0) DEPENDENCIES: @@ -711,11 +711,11 @@ SPEC CHECKSUMS: GzipSwift: 893f3e48e597a1a4f62fafcb6514220fcf8287fa hermes-engine: d7cc127932c89c53374452d6f93473f1970d8e88 libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 - LibXMTP: 16096f324c99d44712ed40876fe25150f694feab + LibXMTP: 97cafc8cdde820552c9960739397fef256b635fa Logging: 9ef4ecb546ad3169398d5a723bc9bea1c46bef26 MessagePacker: ab2fe250e86ea7aedd1a9ee47a37083edd41fd02 - MMKV: 506311d0494023c2f7e0b62cc1f31b7370fa3cfb - MMKVCore: 9e2e5fd529b64a9fe15f1a7afb3d73b2e27b4db9 + MMKV: 36a22a9ec84c9bb960613a089ddf6f48be9312b0 + MMKVCore: 158e61c8516401a9fac730288acb29e6fc19bbf9 OpenSSL-Universal: 6e1ae0555546e604dbc632a2b9a24a9c46c41ef6 RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1 RCTRequired: e9df143e880d0e879e7a498dc06923d728809c79 @@ -763,8 +763,8 @@ SPEC CHECKSUMS: secp256k1.swift: a7e7a214f6db6ce5db32cc6b2b45e5c4dd633634 SwiftProtobuf: 407a385e97fd206c4fbe880cc84123989167e0d1 web3.swift: 2263d1e12e121b2c42ffb63a5a7beb1acaf33959 - XMTP: ca70dd11d709df02999325663e677fe775623d1e - XMTPReactNative: 1b79a6c8748387062ebcebe2c345f77f4998cae2 + XMTP: 476b406c10c2c19183794b670790912545cc7699 + XMTPReactNative: d172e052907d373f40348ed091cdbf7c6da4a331 Yoga: e71803b4c1fff832ccf9b92541e00f9b873119b9 PODFILE CHECKSUM: 95d6ace79946933ecf80684613842ee553dd76a2 diff --git a/example/src/tests/groupTests.ts b/example/src/tests/groupTests.ts index 4a6dfbfa0..09bafb78b 100644 --- a/example/src/tests/groupTests.ts +++ b/example/src/tests/groupTests.ts @@ -497,6 +497,102 @@ test('can message in a group', async () => { return true }) +test('unpublished messages handling', async () => { + // Initialize fixture clients + const [alixClient, boClient] = await createClients(3) + + // Create a new group with Bob and Alice + const boGroup = await boClient.conversations.newGroup([alixClient.address]) + + // Sync Alice's client to get the new group + await alixClient.conversations.syncGroups() + const alixGroup = await alixClient.conversations.findGroup(boGroup.id) + if (!alixGroup) { + throw new Error(`Group not found for id: ${boGroup.id}`) + } + + // Check if the group is allowed initially + let isGroupAllowed = await alixClient.contacts.isGroupAllowed(boGroup.id) + if (isGroupAllowed) { + throw new Error('Group should not be allowed initially') + } + + // Prepare a message in the group + const preparedMessageId = await alixGroup.prepareMessage('Test text') + + // Check if the group is allowed after preparing the message + isGroupAllowed = await alixClient.contacts.isGroupAllowed(boGroup.id) + if (!isGroupAllowed) { + throw new Error('Group should be allowed after preparing a message') + } + + // Verify the message count in the group + let messageCount = (await alixGroup.messages()).length + if (messageCount !== 1) { + throw new Error(`Message count should be 1, but it is ${messageCount}`) + } + + // Verify the count of published and unpublished messages + let messageCountPublished = ( + await alixGroup.messages({ + deliveryStatus: MessageDeliveryStatus.PUBLISHED, + }) + ).length + let messageCountUnpublished = ( + await alixGroup.messages({ + deliveryStatus: MessageDeliveryStatus.UNPUBLISHED, + }) + ).length + if (messageCountPublished !== 0) { + throw new Error( + `Published message count should be 0, but it is ${messageCountPublished}` + ) + } + if (messageCountUnpublished !== 1) { + throw new Error( + `Unpublished message count should be 1, but it is ${messageCountUnpublished}` + ) + } + + // Publish the prepared message + await alixGroup.publishPreparedMessages() + + // Sync the group after publishing the message + await alixGroup.sync() + + // Verify the message counts again + messageCountPublished = ( + await alixGroup.messages({ + deliveryStatus: MessageDeliveryStatus.PUBLISHED, + }) + ).length + messageCountUnpublished = ( + await alixGroup.messages({ + deliveryStatus: MessageDeliveryStatus.UNPUBLISHED, + }) + ).length + messageCount = (await alixGroup.messages()).length + if (messageCountPublished !== 1) { + throw new Error( + `Published message count should be 1, but it is ${messageCountPublished}` + ) + } + if (messageCountUnpublished !== 0) { + throw new Error( + `Unpublished message count should be 0, but it is ${messageCountUnpublished}` + ) + } + if (messageCount !== 1) { + throw new Error(`Message count should be 1, but it is ${messageCount}`) + } + + // Retrieve all messages and verify the prepared message ID + const messages = await alixGroup.messages() + if (preparedMessageId !== messages[0].id) { + throw new Error(`Message ID should match the prepared message ID`) + } +}) + test('can add members to a group', async () => { // Create three MLS enabled Clients const [alixClient, boClient, caroClient] = await createClients(3) diff --git a/ios/Wrappers/GroupWrapper.swift b/ios/Wrappers/GroupWrapper.swift index 47d49f547..80144dcc0 100644 --- a/ios/Wrappers/GroupWrapper.swift +++ b/ios/Wrappers/GroupWrapper.swift @@ -13,7 +13,7 @@ struct GroupWrapper { static func encodeToObj(_ group: XMTP.Group, client: XMTP.Client) throws -> [String: Any] { return [ "clientAddress": client.address, - "id": group.id.toHex, + "id": group.id, "createdAt": UInt64(group.createdAt.timeIntervalSince1970 * 1000), "peerInboxIds": try group.peerInboxIds, "version": "GROUP", diff --git a/ios/XMTPModule.swift b/ios/XMTPModule.swift index ea1401ffa..977079665 100644 --- a/ios/XMTPModule.swift +++ b/ios/XMTPModule.swift @@ -18,7 +18,7 @@ extension XMTP.Group { } func cacheKey(_ inboxId: String) -> String { - return XMTP.Group.cacheKeyForId(inboxId: inboxId, id: id.toHex) + return XMTP.Group.cacheKeyForId(inboxId: inboxId, id: id) } } @@ -492,7 +492,7 @@ public class XMTPModule: Module { guard let client = await clientsManager.getClient(key: inboxId) else { throw Error.noClient } - if let message = try client.findMessage(messageId: Data(hex: messageId) ?? Data()) { + if let message = try client.findMessage(messageId: messageId) { return try DecodedMessageWrapper.encode(message.decrypt(), client: client) } else { return nil @@ -503,7 +503,7 @@ public class XMTPModule: Module { guard let client = await clientsManager.getClient(key: inboxId) else { throw Error.noClient } - if let group = try client.findGroup(groupId: Data(hex: groupId) ?? Data()) { + if let group = try client.findGroup(groupId: groupId) { return try GroupWrapper.encode(group, client: client) } else { return nil @@ -592,6 +592,26 @@ public class XMTPModule: Module { options: SendOptions(contentType: sending.type) ) } + + AsyncFunction("publishPreparedGroupMessages") { (inboxId: String, id: String) in + guard let group = try await findGroup(inboxId: inboxId, id: id) else { + throw Error.conversationNotFound("no group found for \(id)") + } + + try await group.publishMessages() + } + + AsyncFunction("prepareGroupMessage") { (inboxId: String, id: String, contentJson: String) -> String in + guard let group = try await findGroup(inboxId: inboxId, id: id) else { + throw Error.conversationNotFound("no group found for \(id)") + } + + let sending = try ContentJson.fromJson(contentJson) + return try await group.prepareMessage( + content: sending.content, + options: SendOptions(contentType: sending.type) + ) + } AsyncFunction("prepareMessage") { ( inboxId: String, @@ -1364,40 +1384,32 @@ public class XMTPModule: Module { } } - AsyncFunction("allowGroups") { (inboxId: String, groupIds: [String]) in - guard let client = await clientsManager.getClient(key: inboxId) else { - throw Error.noClient - } - let groupDataIds = groupIds.compactMap { $0.hexToData } - try await client.contacts.allowGroups(groupIds: groupDataIds) - } - - AsyncFunction("denyGroups") { (inboxId: String, groupIds: [String]) in - guard let client = await clientsManager.getClient(key: inboxId) else { - throw Error.noClient - } - let groupDataIds = groupIds.compactMap { $0.hexToData } - try await client.contacts.denyGroups(groupIds: groupDataIds) - } + AsyncFunction("allowGroups") { (inboxId: String, groupIds: [String]) in + guard let client = await clientsManager.getClient(key: inboxId) else { + throw Error.noClient + } + try await client.contacts.allowGroups(groupIds: groupIds) + } + + AsyncFunction("denyGroups") { (inboxId: String, groupIds: [String]) in + guard let client = await clientsManager.getClient(key: inboxId) else { + throw Error.noClient + } + try await client.contacts.denyGroups(groupIds: groupIds) + } - AsyncFunction("isGroupAllowed") { (inboxId: String, groupId: String) -> Bool in - guard let client = await clientsManager.getClient(key: inboxId) else { - throw Error.noClient - } - guard let groupDataId = Data(hex: groupId) else { - throw Error.invalidString - } - return await client.contacts.isGroupAllowed(groupId: groupDataId) - } - - AsyncFunction("isGroupDenied") { (inboxId: String, groupId: String) -> Bool in - guard let client = await clientsManager.getClient(key: inboxId) else { - throw Error.invalidString - } - guard let groupDataId = Data(hex: groupId) else { - throw Error.invalidString - } - return await client.contacts.isGroupDenied(groupId: groupDataId) + AsyncFunction("isGroupAllowed") { (inboxId: String, groupId: String) -> Bool in + guard let client = await clientsManager.getClient(key: inboxId) else { + throw Error.noClient + } + return await client.contacts.isGroupAllowed(groupId: groupId) + } + + AsyncFunction("isGroupDenied") { (inboxId: String, groupId: String) -> Bool in + guard let client = await clientsManager.getClient(key: inboxId) else { + throw Error.invalidString + } + return await client.contacts.isGroupDenied(groupId: groupId) } } @@ -1468,7 +1480,7 @@ public class XMTPModule: Module { let cacheKey = XMTP.Group.cacheKeyForId(inboxId: client.inboxID, id: id) if let group = await groupsManager.get(cacheKey) { return group - } else if let group = try await client.conversations.groups().first(where: { $0.id.toHex == id }) { + } else if let group = try client.findGroup(groupId: id) { await groupsManager.set(cacheKey, group) return group } diff --git a/ios/XMTPReactNative.podspec b/ios/XMTPReactNative.podspec index 6cbed859f..8c63e8189 100644 --- a/ios/XMTPReactNative.podspec +++ b/ios/XMTPReactNative.podspec @@ -26,5 +26,5 @@ Pod::Spec.new do |s| s.source_files = "**/*.{h,m,swift}" s.dependency 'secp256k1.swift' s.dependency "MessagePacker" - s.dependency "XMTP", "= 0.13.3" + s.dependency "XMTP", "= 0.13.5" end diff --git a/src/index.ts b/src/index.ts index 4e0fd2e13..33668b5a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -216,6 +216,15 @@ export async function listGroupMembers( }) } +export async function prepareGroupMessage( + inboxId: string, + groupId: string, + content: any +): Promise { + const contentJson = JSON.stringify(content) + return await XMTPModule.prepareGroupMessage(inboxId, groupId, contentJson) +} + export async function sendMessageToGroup( inboxId: string, groupId: string, @@ -225,6 +234,13 @@ export async function sendMessageToGroup( return await XMTPModule.sendMessageToGroup(inboxId, groupId, contentJson) } +export async function publishPreparedGroupMessages( + inboxId: string, + groupId: string +) { + return await XMTPModule.publishPreparedGroupMessages(inboxId, groupId) +} + export async function groupMessages< ContentTypes extends DefaultContentTypes = DefaultContentTypes, >( diff --git a/src/lib/Group.ts b/src/lib/Group.ts index 3333a1881..237bf5fa1 100644 --- a/src/lib/Group.ts +++ b/src/lib/Group.ts @@ -69,8 +69,6 @@ export class Group< * @param {string | MessageContent} content - The content of the message. It can be either a string or a structured MessageContent object. * @returns {Promise} A Promise that resolves to a string identifier for the sent message. * @throws {Error} Throws an error if there is an issue with sending the message. - * - * @todo Support specifying a conversation ID in future implementations. */ async send( content: ConversationSendPayload, @@ -97,6 +95,57 @@ export class Group< } } + /** + * Prepare a group message to be sent. + * + * @param {string | MessageContent} content - The content of the message. It can be either a string or a structured MessageContent object. + * @returns {Promise} A Promise that resolves to a string identifier for the prepared message to be sent. + * @throws {Error} Throws an error if there is an issue with sending the message. + */ + async prepareMessage< + SendContentTypes extends DefaultContentTypes = ContentTypes, + >( + content: ConversationSendPayload, + opts?: SendOptions + ): Promise { + // TODO: Enable other content types + // if (opts && opts.contentType) { + // return await this._sendWithJSCodec(content, opts.contentType) + // } + + try { + if (typeof content === 'string') { + content = { text: content } + } + + return await XMTP.prepareGroupMessage( + this.client.inboxId, + this.id, + content + ) + } catch (e) { + console.info('ERROR in prepareGroupMessage()', e.message) + throw e + } + } + + /** + * Publish all prepared messages. + * + * @throws {Error} Throws an error if there is an issue finding the unpublished message + */ + async publishPreparedMessages() { + try { + return await XMTP.publishPreparedGroupMessages( + this.client.inboxId, + this.id + ) + } catch (e) { + console.info('ERROR in publishPreparedMessages()', e.message) + throw e + } + } + /** * This method returns an array of messages associated with the group. * To get the latest messages from the network, call sync() first.