diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt index 1b0104bd9..0298d7132 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt @@ -18,13 +18,13 @@ 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.ConversationOrder 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.DmWrapper import expo.modules.xmtpreactnativesdk.wrappers.EncryptedLocalAttachment -import expo.modules.xmtpreactnativesdk.wrappers.GroupParamsWrapper +import expo.modules.xmtpreactnativesdk.wrappers.ConversationParamsWrapper import expo.modules.xmtpreactnativesdk.wrappers.GroupWrapper import expo.modules.xmtpreactnativesdk.wrappers.InboxStateWrapper import expo.modules.xmtpreactnativesdk.wrappers.MemberWrapper @@ -42,6 +42,8 @@ import org.xmtp.android.library.Client import org.xmtp.android.library.ClientOptions import org.xmtp.android.library.ConsentState import org.xmtp.android.library.Conversation +import org.xmtp.android.library.Conversations.ConversationOrder +import org.xmtp.android.library.Dm import org.xmtp.android.library.Group import org.xmtp.android.library.PreEventCallback import org.xmtp.android.library.PreparedMessage @@ -59,7 +61,6 @@ import org.xmtp.android.library.codecs.decoded import org.xmtp.android.library.hexToByteArray 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 @@ -149,6 +150,10 @@ fun Group.cacheKey(inboxId: String): String { return "${inboxId}:${id}" } +fun Conversation.cacheKeyV3(inboxId: String): String { + return "${inboxId}:${topic}:${id}" +} + class XMTPModule : Module() { val context: Context @@ -245,17 +250,20 @@ class XMTPModule : Module() { "preCreateIdentityCallback", "preEnableIdentityCallback", "preAuthenticateToInboxCallback", - // Conversations + // ConversationV2 "conversation", - "group", "conversationContainer", "message", - "allGroupMessage", - // Conversation "conversationMessage", + // ConversationV3 + "conversationV3", + "allConversationMessage", + "conversationV3Message", // Group "groupMessage", - ) + "allGroupMessage", + "group", + ) Function("address") { inboxId: String -> logV("address") @@ -473,6 +481,7 @@ class XMTPModule : Module() { } AsyncFunction("sign") Coroutine { inboxId: String, digest: List, keyType: String, preKeyIndex: Int -> + // V2 ONLY withContext(Dispatchers.IO) { logV("sign") val client = clients[inboxId] ?: throw XMTPException("No client") @@ -498,12 +507,14 @@ class XMTPModule : Module() { } AsyncFunction("exportPublicKeyBundle") { inboxId: String -> + // V2 ONLY logV("exportPublicKeyBundle") val client = clients[inboxId] ?: throw XMTPException("No client") client.keys.getPublicKeyBundle().toByteArray().map { it.toInt() and 0xFF } } AsyncFunction("exportKeyBundle") { inboxId: String -> + // V2 ONLY logV("exportKeyBundle") val client = clients[inboxId] ?: throw XMTPException("No client") Base64.encodeToString(client.privateKeyBundle.toByteArray(), NO_WRAP) @@ -511,6 +522,7 @@ class XMTPModule : Module() { // Export the conversation's serialized topic data. AsyncFunction("exportConversationTopicData") Coroutine { inboxId: String, topic: String -> + // V2 ONLY withContext(Dispatchers.IO) { logV("exportConversationTopicData") val conversation = findConversation(inboxId, topic) @@ -529,6 +541,7 @@ class XMTPModule : Module() { // Import a conversation from its serialized topic data. AsyncFunction("importConversationTopicData") { inboxId: String, topicData: String -> + // V2 ONLY logV("importConversationTopicData") val client = clients[inboxId] ?: throw XMTPException("No client") val data = TopicData.parseFrom(Base64.decode(topicData, NO_WRAP)) @@ -543,6 +556,7 @@ class XMTPModule : Module() { // // Client API AsyncFunction("canMessage") Coroutine { inboxId: String, peerAddress: String -> + // V2 ONLY withContext(Dispatchers.IO) { logV("canMessage") @@ -561,6 +575,7 @@ class XMTPModule : Module() { } AsyncFunction("staticCanMessage") Coroutine { peerAddress: String, environment: String, appVersion: String? -> + // V2 ONLY withContext(Dispatchers.IO) { try { logV("staticCanMessage") @@ -639,6 +654,7 @@ class XMTPModule : Module() { } AsyncFunction("sendEncodedContent") Coroutine { inboxId: String, topic: String, encodedContentData: List -> + // V2 ONLY withContext(Dispatchers.IO) { val conversation = findConversation( @@ -662,6 +678,7 @@ class XMTPModule : Module() { } AsyncFunction("listConversations") Coroutine { inboxId: String -> + // V2 ONLY withContext(Dispatchers.IO) { logV("listConversations") val client = clients[inboxId] ?: throw XMTPException("No client") @@ -680,7 +697,7 @@ class XMTPModule : Module() { withContext(Dispatchers.IO) { logV("listGroups") val client = clients[inboxId] ?: throw XMTPException("No client") - val params = GroupParamsWrapper.groupParamsFromJson(groupParams ?: "") + val params = ConversationParamsWrapper.groupParamsFromJson(groupParams ?: "") val order = getConversationSortOrder(sortOrder ?: "") val sortedGroupList = if (order == ConversationOrder.LAST_MESSAGE) { client.conversations.listGroups() @@ -700,6 +717,20 @@ class XMTPModule : Module() { } } + AsyncFunction("listV3Conversations") Coroutine { inboxId: String, groupParams: String?, sortOrder: String?, limit: Int? -> + withContext(Dispatchers.IO) { + logV("listV3Conversations") + val client = clients[inboxId] ?: throw XMTPException("No client") + val params = ConversationParamsWrapper.groupParamsFromJson(groupParams ?: "") + val order = getConversationSortOrder(sortOrder ?: "") + val conversations = + client.conversations.listConversations(order = order, limit = limit) + conversations.map { conversation -> + ConversationContainerWrapper.encode(client, conversation, params) + } + } + } + AsyncFunction("listAll") Coroutine { inboxId: String -> withContext(Dispatchers.IO) { val client = clients[inboxId] ?: throw XMTPException("No client") @@ -712,6 +743,7 @@ class XMTPModule : Module() { } AsyncFunction("loadMessages") Coroutine { inboxId: String, topic: String, limit: Int?, before: Long?, after: Long?, direction: String? -> + // V2 ONLY withContext(Dispatchers.IO) { logV("loadMessages") val conversation = @@ -734,22 +766,19 @@ class XMTPModule : Module() { } } - AsyncFunction("groupMessages") Coroutine { inboxId: String, id: String, limit: Int?, before: Long?, after: Long?, direction: String?, deliveryStatus: String? -> + AsyncFunction("conversationMessages") Coroutine { inboxId: String, conversationId: String, limit: Int?, before: Long?, after: Long?, direction: String? -> withContext(Dispatchers.IO) { - logV("groupMessages") + logV("conversationMessages") 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( + val conversation = client.findConversation(conversationId) + conversation?.decryptedMessages( limit = limit, before = beforeDate, after = afterDate, direction = MessageApiOuterClass.SortDirection.valueOf( direction ?: "SORT_DIRECTION_DESCENDING" - ), - deliveryStatus = MessageDeliveryStatus.valueOf( - deliveryStatus ?: "ALL" ) )?.map { DecodedMessageWrapper.encode(it) } } @@ -777,7 +806,41 @@ class XMTPModule : Module() { } } + AsyncFunction("findConversation") Coroutine { inboxId: String, conversationId: String -> + withContext(Dispatchers.IO) { + logV("findConversation") + val client = clients[inboxId] ?: throw XMTPException("No client") + val conversation = client.findConversation(conversationId) + conversation?.let { + ConversationContainerWrapper.encode(client, conversation) + } + } + } + + AsyncFunction("findConversationByTopic") Coroutine { inboxId: String, topic: String -> + withContext(Dispatchers.IO) { + logV("findConversationByTopic") + val client = clients[inboxId] ?: throw XMTPException("No client") + val conversation = client.findConversationByTopic(topic) + conversation?.let { + ConversationContainerWrapper.encode(client, conversation) + } + } + } + + AsyncFunction("findDm") Coroutine { inboxId: String, peerAddress: String -> + withContext(Dispatchers.IO) { + logV("findDm") + val client = clients[inboxId] ?: throw XMTPException("No client") + val dm = client.findDm(peerAddress) + dm?.let { + DmWrapper.encode(client, dm) + } + } + } + AsyncFunction("loadBatchMessages") Coroutine { inboxId: String, topics: List -> + // V2 ONLY withContext(Dispatchers.IO) { logV("loadBatchMessages") val client = clients[inboxId] ?: throw XMTPException("No client") @@ -825,6 +888,7 @@ class XMTPModule : Module() { } AsyncFunction("sendMessage") Coroutine { inboxId: String, conversationTopic: String, contentJson: String -> + // V2 ONLY withContext(Dispatchers.IO) { logV("sendMessage") val conversation = @@ -841,17 +905,14 @@ class XMTPModule : Module() { } } - AsyncFunction("sendMessageToGroup") Coroutine { inboxId: String, id: String, contentJson: String -> + AsyncFunction("sendMessageToConversation") 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") + logV("sendMessageToConversation") + val client = clients[inboxId] ?: throw XMTPException("No client") + val conversation = client.findConversation(id) + ?: throw XMTPException("no conversation found for $id") val sending = ContentJson.fromJson(contentJson) - group.send( + conversation.send( content = sending.content, options = SendOptions(contentType = sending.type) ) @@ -872,17 +933,14 @@ class XMTPModule : Module() { } } - AsyncFunction("prepareGroupMessage") Coroutine { inboxId: String, id: String, contentJson: String -> + AsyncFunction("prepareConversationMessage") 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") + logV("prepareConversationMessage") + val client = clients[inboxId] ?: throw XMTPException("No client") + val conversation = client.findConversation(id) + ?: throw XMTPException("no conversation found for $id") val sending = ContentJson.fromJson(contentJson) - group.prepareMessage( + conversation.prepareMessage( content = sending.content, options = SendOptions(contentType = sending.type) ) @@ -890,6 +948,7 @@ class XMTPModule : Module() { } AsyncFunction("prepareMessage") Coroutine { inboxId: String, conversationTopic: String, contentJson: String -> + // V2 ONLY withContext(Dispatchers.IO) { logV("prepareMessage") val conversation = @@ -915,6 +974,7 @@ class XMTPModule : Module() { } AsyncFunction("prepareEncodedMessage") Coroutine { inboxId: String, conversationTopic: String, encodedContentData: List -> + // V2 ONLY withContext(Dispatchers.IO) { logV("prepareEncodedMessage") val conversation = @@ -950,6 +1010,7 @@ class XMTPModule : Module() { } AsyncFunction("sendPreparedMessage") Coroutine { inboxId: String, preparedLocalMessageJson: String -> + // V2 ONLY withContext(Dispatchers.IO) { logV("sendPreparedMessage") val client = clients[inboxId] ?: throw XMTPException("No client") @@ -970,6 +1031,7 @@ class XMTPModule : Module() { } AsyncFunction("createConversation") Coroutine { inboxId: String, peerAddress: String, contextJson: String, consentProofPayload: List -> + // V2 Only withContext(Dispatchers.IO) { logV("createConversation: $contextJson") val client = clients[inboxId] ?: throw XMTPException("No client") @@ -1017,6 +1079,7 @@ class XMTPModule : Module() { ConversationWrapper.encode(client, conversation) } } + AsyncFunction("createGroup") Coroutine { inboxId: String, peerAddresses: List, permission: String, groupOptionsJson: String -> withContext(Dispatchers.IO) { logV("createGroup") @@ -1039,6 +1102,15 @@ class XMTPModule : Module() { } } + AsyncFunction("findOrCreateDm") Coroutine { inboxId: String, peerAddress: String -> + withContext(Dispatchers.IO) { + logV("findOrCreateDm") + val client = clients[inboxId] ?: throw XMTPException("No client") + val dm = client.conversations.findOrCreateDm(peerAddress) + DmWrapper.encode(client, dm) + } + } + AsyncFunction("createGroupCustomPermissions") Coroutine { inboxId: String, peerAddresses: List, permissionPolicySetJson: String, groupOptionsJson: String -> withContext(Dispatchers.IO) { logV("createGroup") @@ -1071,43 +1143,52 @@ class XMTPModule : Module() { } } - AsyncFunction("listGroupMembers") Coroutine { inboxId: String, groupId: String -> + AsyncFunction("dmPeerInboxId") Coroutine { inboxId: String, dmId: String -> withContext(Dispatchers.IO) { - logV("listGroupMembers") + logV("listPeerInboxId") val client = clients[inboxId] ?: throw XMTPException("No client") - val group = findGroup(inboxId, groupId) - group?.members()?.map { MemberWrapper.encode(it) } + val dm = (findConversation(inboxId, dmId) as Conversation.Dm).dm + dm.peerInboxId() + } + } + + AsyncFunction("listConversationMembers") Coroutine { inboxId: String, conversationId: String -> + withContext(Dispatchers.IO) { + logV("listConversationMembers") + val client = clients[inboxId] ?: throw XMTPException("No client") + val conversation = client.findConversation(conversationId) + ?: throw XMTPException("no conversation found for $conversationId") + conversation.members().map { MemberWrapper.encode(it) } } } - AsyncFunction("syncGroups") Coroutine { inboxId: String -> + AsyncFunction("syncConversations") Coroutine { inboxId: String -> withContext(Dispatchers.IO) { - logV("syncGroups") + logV("syncConversations") val client = clients[inboxId] ?: throw XMTPException("No client") - client.conversations.syncGroups() + client.conversations.syncConversations() } } - AsyncFunction("syncAllGroups") Coroutine { inboxId: String -> + AsyncFunction("syncAllConversations") Coroutine { inboxId: String -> withContext(Dispatchers.IO) { - logV("syncAllGroups") + logV("syncAllConversations") val client = clients[inboxId] ?: throw XMTPException("No client") - client.conversations.syncAllGroups() // Expo Modules do not support UInt, so we need to convert to Int val numGroupsSyncedInt: Int = - client.conversations.syncAllGroups()?.toInt() ?: throw IllegalArgumentException( - "Value cannot be null" - ) + client.conversations.syncAllConversations()?.toInt() + ?: throw IllegalArgumentException("Value cannot be null") numGroupsSyncedInt } } - AsyncFunction("syncGroup") Coroutine { inboxId: String, id: String -> + AsyncFunction("syncConversation") Coroutine { inboxId: String, id: String -> withContext(Dispatchers.IO) { - logV("syncGroup") + logV("syncConversation") val client = clients[inboxId] ?: throw XMTPException("No client") - val group = findGroup(inboxId, id) - group?.sync() + val conversation = client.findConversation(id) + ?: throw XMTPException("no conversation found for $id") + conversation.sync() } } @@ -1434,14 +1515,13 @@ class XMTPModule : Module() { } } - AsyncFunction("processGroupMessage") Coroutine { inboxId: String, id: String, encryptedMessage: String -> + AsyncFunction("processConversationMessage") 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") + val conversation = client.findConversation(id) + ?: throw XMTPException("no conversation found for $id") + val message = conversation.processMessage(Base64.decode(encryptedMessage, NO_WRAP)) DecodedMessageWrapper.encodeMap(message.decrypt()) } } @@ -1451,15 +1531,16 @@ class XMTPModule : Module() { logV("processWelcomeMessage") val client = clients[inboxId] ?: throw XMTPException("No client") - val group = - client.conversations.fromWelcome(Base64.decode(encryptedMessage, NO_WRAP)) - GroupWrapper.encode(client, group) + val conversation = + client.conversations.conversationFromWelcome(Base64.decode(encryptedMessage, NO_WRAP)) + ConversationContainerWrapper.encode(client, conversation) } } - Function("subscribeToConversations") { inboxId: String -> + Function("subscribeToV2Conversations") { inboxId: String -> + // V2 ONLY logV("subscribeToConversations") - subscribeToConversations(inboxId = inboxId) + subscribeToV2Conversations(inboxId = inboxId) } Function("subscribeToGroups") { inboxId: String -> @@ -1483,6 +1564,7 @@ class XMTPModule : Module() { } AsyncFunction("subscribeToMessages") Coroutine { inboxId: String, topic: String -> + // V2 ONLY withContext(Dispatchers.IO) { logV("subscribeToMessages") subscribeToMessages( @@ -1503,6 +1585,7 @@ class XMTPModule : Module() { } Function("unsubscribeFromConversations") { inboxId: String -> + // V2 ONLY logV("unsubscribeFromConversations") subscriptions[getConversationsKey(inboxId)]?.cancel() } @@ -1523,6 +1606,7 @@ class XMTPModule : Module() { } AsyncFunction("unsubscribeFromMessages") Coroutine { inboxId: String, topic: String -> + // V2 ONLY withContext(Dispatchers.IO) { logV("unsubscribeFromMessages") unsubscribeFromMessages( @@ -1580,6 +1664,7 @@ class XMTPModule : Module() { } AsyncFunction("decodeMessage") Coroutine { inboxId: String, topic: String, encryptedMessage: String -> + // V2 ONLY withContext(Dispatchers.IO) { logV("decodeMessage") val encryptedMessageData = Base64.decode(encryptedMessage, NO_WRAP) @@ -1666,7 +1751,7 @@ class XMTPModule : Module() { } } - AsyncFunction("conversationConsentState") Coroutine { inboxId: String, conversationTopic: String -> + AsyncFunction("v2ConversationConsentState") Coroutine { inboxId: String, conversationTopic: String -> withContext(Dispatchers.IO) { val conversation = findConversation(inboxId, conversationTopic) ?: throw XMTPException("no conversation found for $conversationTopic") @@ -1674,7 +1759,7 @@ class XMTPModule : Module() { } } - AsyncFunction("groupConsentState") Coroutine { inboxId: String, groupId: String -> + AsyncFunction("conversationConsentState") Coroutine { inboxId: String, groupId: String -> withContext(Dispatchers.IO) { val group = findGroup(inboxId, groupId) ?: throw XMTPException("no group found for $groupId") @@ -1758,6 +1843,46 @@ class XMTPModule : Module() { } } } + + Function("subscribeToConversations") { inboxId: String -> + logV("subscribeToConversations") + subscribeToGroups(inboxId = inboxId) + } + + Function("subscribeToAllConversationMessages") { inboxId: String, includeGroups: Boolean -> + logV("subscribeToAllConversationMessages") + subscribeToAllMessages(inboxId = inboxId, includeGroups = includeGroups) + } + + AsyncFunction("subscribeToDmMessages") Coroutine { inboxId: String, id: String -> + withContext(Dispatchers.IO) { + logV("subscribeToDmMessages") + subscribeToGroupMessages( + inboxId = inboxId, + id = id + ) + } + } + + Function("unsubscribeFromAllConversationMessages") { inboxId: String -> + logV("unsubscribeFromAllConversationMessages") + subscriptions[getGroupMessagesKey(inboxId)]?.cancel() + } + + Function("unsubscribeFromConversations") { inboxId: String -> + logV("unsubscribeFromConversations") + subscriptions[getGroupMessagesKey(inboxId)]?.cancel() + } + + AsyncFunction("unsubscribeFromDmMessages") Coroutine { inboxId: String, topic: String -> + withContext(Dispatchers.IO) { + logV("unsubscribeFromDmMessages") + unsubscribeFromMessages( + inboxId = inboxId, + topic = topic + ) + } + } } // @@ -1830,11 +1955,11 @@ class XMTPModule : Module() { return null } - private fun subscribeToConversations(inboxId: String) { + private fun subscribeToV2Conversations(inboxId: String) { val client = clients[inboxId] ?: throw XMTPException("No client") - subscriptions[getConversationsKey(inboxId)]?.cancel() - subscriptions[getConversationsKey(inboxId)] = CoroutineScope(Dispatchers.IO).launch { + subscriptions[getV2ConversationsKey(inboxId)]?.cancel() + subscriptions[getV2ConversationsKey(inboxId)] = CoroutineScope(Dispatchers.IO).launch { try { client.conversations.stream().collect { conversation -> run { @@ -1855,7 +1980,7 @@ class XMTPModule : Module() { } } catch (e: Exception) { Log.e("XMTPModule", "Error in conversations subscription: $e") - subscriptions[getConversationsKey(inboxId)]?.cancel() + subscriptions[getV2ConversationsKey(inboxId)]?.cancel() } } } @@ -1882,6 +2007,28 @@ class XMTPModule : Module() { } } + private fun subscribeToConversations(inboxId: String) { + val client = clients[inboxId] ?: throw XMTPException("No client") + + subscriptions[getConversationsKey(client.inboxId)]?.cancel() + subscriptions[getConversationsKey(client.inboxId)] = CoroutineScope(Dispatchers.IO).launch { + try { + client.conversations.streamConversations().collect { conversation -> + sendEvent( + "conversationV3", + mapOf( + "inboxId" to inboxId, + "conversation" to ConversationContainerWrapper.encodeToObj(client, conversation) + ) + ) + } + } catch (e: Exception) { + Log.e("XMTPModule", "Error in group subscription: $e") + subscriptions[getConversationsKey(client.inboxId)]?.cancel() + } + } + } + private fun subscribeToAll(inboxId: String) { val client = clients[inboxId] ?: throw XMTPException("No client") @@ -1952,6 +2099,28 @@ class XMTPModule : Module() { } } + private fun subscribeToAllConversationMessages(inboxId: String) { + val client = clients[inboxId] ?: throw XMTPException("No client") + + subscriptions[getConversationMessagesKey(inboxId)]?.cancel() + subscriptions[getConversationMessagesKey(inboxId)] = CoroutineScope(Dispatchers.IO).launch { + try { + client.conversations.streamAllGroupDecryptedMessages().collect { message -> + sendEvent( + "allConversationMessages", + mapOf( + "inboxId" to inboxId, + "message" to DecodedMessageWrapper.encodeMap(message), + ) + ) + } + } catch (e: Exception) { + Log.e("XMTPModule", "Error in all group messages subscription: $e") + subscriptions[getConversationMessagesKey(inboxId)]?.cancel() + } + } + } + private suspend fun subscribeToMessages(inboxId: String, topic: String) { val conversation = findConversation( @@ -2007,6 +2176,31 @@ class XMTPModule : Module() { } } + private suspend fun subscribeToDmMessages(inboxId: String, id: String) { + val client = clients[inboxId] ?: throw XMTPException("No client") + val conversation = client.findConversation(id) + ?: throw XMTPException("no conversation found for $id") + subscriptions[conversation.cacheKeyV3(inboxId)]?.cancel() + subscriptions[conversation.cacheKeyV3(inboxId)] = + CoroutineScope(Dispatchers.IO).launch { + try { + conversation.streamDecryptedMessages().collect { message -> + sendEvent( + "conversationV3Message", + mapOf( + "inboxId" to inboxId, + "message" to DecodedMessageWrapper.encodeMap(message), + "conversationId" to id, + ) + ) + } + } catch (e: Exception) { + Log.e("XMTPModule", "Error in messages subscription: $e") + subscriptions[conversation?.cacheKey(inboxId)]?.cancel() + } + } + } + private fun getMessagesKey(inboxId: String): String { return "messages:$inboxId" } @@ -2015,7 +2209,15 @@ class XMTPModule : Module() { return "groupMessages:$inboxId" } + private fun getConversationMessagesKey(inboxId: String): String { + return "conversationMessages:$inboxId" + } + private fun getConversationsKey(inboxId: String): String { + return "conversationsV3:$inboxId" + } + + private fun getV2ConversationsKey(inboxId: String): String { return "conversations:$inboxId" } diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ConversationContainerWrapper.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ConversationContainerWrapper.kt index f8dc148d7..546fe2b16 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ConversationContainerWrapper.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ConversationContainerWrapper.kt @@ -1,6 +1,5 @@ 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 @@ -8,21 +7,36 @@ import org.xmtp.android.library.Conversation class ConversationContainerWrapper { companion object { - suspend fun encodeToObj(client: Client, conversation: Conversation): Map { - when (conversation.version) { + suspend fun encodeToObj( + client: Client, + conversation: Conversation, + conversationParams: ConversationParamsWrapper = ConversationParamsWrapper(), + ): Map { + return when (conversation.version) { Conversation.Version.GROUP -> { val group = (conversation as Conversation.Group).group - return GroupWrapper.encodeToObj(client, group) + GroupWrapper.encodeToObj(client, group, conversationParams) } + + Conversation.Version.DM -> { + val dm = (conversation as Conversation.Dm).dm + DmWrapper.encodeToObj(client, dm, conversationParams) + } + else -> { - return ConversationWrapper.encodeToObj(client, conversation) + ConversationWrapper.encodeToObj(client, conversation) } } } - suspend fun encode(client: Client, conversation: Conversation): String { + suspend fun encode( + client: Client, + conversation: Conversation, + conversationParams: ConversationParamsWrapper = ConversationParamsWrapper(), + ): String { val gson = GsonBuilder().create() - val obj = ConversationContainerWrapper.encodeToObj(client, conversation) + val obj = + ConversationContainerWrapper.encodeToObj(client, conversation, conversationParams) return gson.toJson(obj) } } diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/DmWrapper.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/DmWrapper.kt new file mode 100644 index 000000000..c2813f93e --- /dev/null +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/DmWrapper.kt @@ -0,0 +1,50 @@ +package expo.modules.xmtpreactnativesdk.wrappers + +import com.google.gson.GsonBuilder +import com.google.gson.JsonParser +import expo.modules.xmtpreactnativesdk.wrappers.ConsentWrapper.Companion.consentStateToString +import org.xmtp.android.library.Client +import org.xmtp.android.library.Dm +import org.xmtp.android.library.Group + +class DmWrapper { + companion object { + suspend fun encodeToObj( + client: Client, + dm: Dm, + dmParams: ConversationParamsWrapper = ConversationParamsWrapper(), + ): Map { + return buildMap { + put("clientAddress", client.address) + put("id", dm.id) + put("createdAt", dm.createdAt.time) + put("version", "DM") + put("topic", dm.topic) + put("peerInboxId", dm.peerInboxId()) + if (dmParams.members) { + put("members", dm.members().map { MemberWrapper.encode(it) }) + } + if (dmParams.creatorInboxId) put("creatorInboxId", dm.creatorInboxId()) + if (dmParams.consentState) { + put("consentState", consentStateToString(dm.consentState())) + } + if (dmParams.lastMessage) { + val lastMessage = dm.decryptedMessages(limit = 1).firstOrNull() + if (lastMessage != null) { + put("lastMessage", DecodedMessageWrapper.encode(lastMessage)) + } + } + } + } + + suspend fun encode( + client: Client, + dm: Dm, + dmParams: ConversationParamsWrapper = ConversationParamsWrapper(), + ): String { + val gson = GsonBuilder().create() + val obj = encodeToObj(client, dm, dmParams) + return gson.toJson(obj) + } + } +} 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 b75c58c4f..2fb641a19 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/GroupWrapper.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/GroupWrapper.kt @@ -6,17 +6,13 @@ import expo.modules.xmtpreactnativesdk.wrappers.ConsentWrapper.Companion.consent import org.xmtp.android.library.Client import org.xmtp.android.library.Group -enum class ConversationOrder { - LAST_MESSAGE, CREATED_AT -} - class GroupWrapper { companion object { suspend fun encodeToObj( client: Client, group: Group, - groupParams: GroupParamsWrapper = GroupParamsWrapper(), + groupParams: ConversationParamsWrapper = ConversationParamsWrapper(), ): Map { return buildMap { put("clientAddress", client.address) @@ -48,7 +44,7 @@ class GroupWrapper { suspend fun encode( client: Client, group: Group, - groupParams: GroupParamsWrapper = GroupParamsWrapper(), + groupParams: ConversationParamsWrapper = ConversationParamsWrapper(), ): String { val gson = GsonBuilder().create() val obj = encodeToObj(client, group, groupParams) @@ -57,7 +53,7 @@ class GroupWrapper { } } -class GroupParamsWrapper( +class ConversationParamsWrapper( val members: Boolean = true, val creatorInboxId: Boolean = true, val isActive: Boolean = true, @@ -69,10 +65,10 @@ class GroupParamsWrapper( val lastMessage: Boolean = false, ) { companion object { - fun groupParamsFromJson(groupParams: String): GroupParamsWrapper { - if (groupParams.isEmpty()) return GroupParamsWrapper() + fun groupParamsFromJson(groupParams: String): ConversationParamsWrapper { + if (groupParams.isEmpty()) return ConversationParamsWrapper() val jsonOptions = JsonParser.parseString(groupParams).asJsonObject - return GroupParamsWrapper( + return ConversationParamsWrapper( if (jsonOptions.has("members")) jsonOptions.get("members").asBoolean else true, if (jsonOptions.has("creatorInboxId")) jsonOptions.get("creatorInboxId").asBoolean else true, if (jsonOptions.has("isActive")) jsonOptions.get("isActive").asBoolean else true, diff --git a/src/lib/ConversationContainer.ts b/src/lib/ConversationContainer.ts index d91dbed3f..c461027e0 100644 --- a/src/lib/ConversationContainer.ts +++ b/src/lib/ConversationContainer.ts @@ -1,9 +1,12 @@ +import { ConsentState } from './ConsentListEntry' import { DefaultContentTypes } from './types/DefaultContentType' import * as XMTP from '../index' +import { DecodedMessage } from '../index' export enum ConversationVersion { DIRECT = 'DIRECT', GROUP = 'GROUP', + DM = 'DM', } export interface ConversationContainer< @@ -13,4 +16,7 @@ export interface ConversationContainer< createdAt: number topic: string version: ConversationVersion + id: string + state: ConsentState + lastMessage?: DecodedMessage } diff --git a/src/lib/Dm.ts b/src/lib/Dm.ts new file mode 100644 index 000000000..8f0a633bf --- /dev/null +++ b/src/lib/Dm.ts @@ -0,0 +1,258 @@ +import { InboxId } from './Client' +import { ConsentState } from './ConsentListEntry' +import { + ConversationVersion, + ConversationContainer, +} from './ConversationContainer' +import { DecodedMessage, MessageDeliveryStatus } from './DecodedMessage' +import { Member } from './Member' +import { ConversationSendPayload } from './types/ConversationCodecs' +import { DefaultContentTypes } from './types/DefaultContentType' +import { EventTypes } from './types/EventTypes' +import { MessagesOptions } from './types/MessagesOptions' +import { PermissionPolicySet } from './types/PermissionPolicySet' +import { SendOptions } from './types/SendOptions' +import * as XMTP from '../index' + +export interface DmParams { + id: string + createdAt: number + members: string[] + creatorInboxId: InboxId + topic: string + consentState: ConsentState + lastMessage?: DecodedMessage +} + +export class Dm + implements ConversationContainer +{ + client: XMTP.Client + id: string + createdAt: number + members: Member[] + version = ConversationVersion.DM + topic: string + state: ConsentState + lastMessage?: DecodedMessage + + constructor( + client: XMTP.Client, + params: DmParams, + members: Member[], + lastMessage?: DecodedMessage + ) { + this.client = client + this.id = params.id + this.createdAt = params.createdAt + this.members = members + this.topic = params.topic + this.state = params.consentState + this.lastMessage = lastMessage + } + + /** + * This method returns an array of inbox ids associated with the group. + * To get the latest member inbox ids from the network, call sync() first. + * @returns {Promise} A Promise that resolves to a InboxId. + */ + async peerInboxId(): Promise { + return XMTP.dmPeerInboxId(this.client, this.id) + } + + /** + * Sends a message to the current group. + * + * @param {string | MessageContent} content - The content of the message. It can be either a string or a structured MessageContent object. + * @returns {Promise} A Promise that resolves to a string identifier for the sent message. + * @throws {Error} Throws an error if there is an issue with sending the message. + */ + async send( + 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.sendMessageToGroup( + this.client.inboxId, + this.id, + content + ) + } catch (e) { + console.info('ERROR in send()', e.message) + throw e + } + } + + /** + * 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. + * + * @param {number | undefined} limit - Optional maximum number of messages to return. + * @param {number | Date | undefined} before - Optional filter for specifying the maximum timestamp of messages to return. + * @param {number | Date | undefined} after - Optional filter for specifying the minimum timestamp of messages to return. + * @param direction - Optional parameter to specify the time ordering of the messages to return. + * @returns {Promise[]>} A Promise that resolves to an array of DecodedMessage objects. + */ + async messages( + opts?: MessagesOptions + ): Promise[]> { + return await XMTP.groupMessages( + this.client, + this.id, + opts?.limit, + opts?.before, + opts?.after, + opts?.direction, + opts?.deliveryStatus ?? MessageDeliveryStatus.ALL + ) + } + + /** + * Executes a network request to fetch the latest messages and membership changes + * associated with the group and saves them to the local state. + */ + async sync() { + await XMTP.syncGroup(this.client.inboxId, this.id) + } + + /** + * Sets up a real-time message stream for the current group. + * + * This method subscribes to incoming messages in real-time and listens for new message events. + * When a new message is detected, the provided callback function is invoked with the details of the message. + * Additionally, this method returns a function that can be called to unsubscribe and end the message stream. + * + * @param {Function} callback - A callback function that will be invoked with the new DecodedMessage when a message is received. + * @returns {Function} A function that, when called, unsubscribes from the message stream and ends real-time updates. + */ + async streamGroupMessages( + callback: (message: DecodedMessage) => Promise + ): Promise<() => void> { + await XMTP.subscribeToGroupMessages(this.client.inboxId, this.id) + const hasSeen = {} + const messageSubscription = XMTP.emitter.addListener( + EventTypes.GroupMessage, + async ({ + inboxId, + message, + groupId, + }: { + inboxId: string + message: DecodedMessage + groupId: string + }) => { + // Long term these checks should be able to be done on the native layer as well, but additional checks in JS for safety + if (inboxId !== this.client.inboxId) { + return + } + if (groupId !== this.id) { + return + } + if (hasSeen[message.id]) { + return + } + + hasSeen[message.id] = true + + message.client = this.client + await callback(DecodedMessage.fromObject(message, this.client)) + } + ) + return async () => { + messageSubscription.remove() + await XMTP.unsubscribeFromGroupMessages(this.client.inboxId, this.id) + } + } + + async processMessage( + encryptedMessage: string + ): Promise> { + try { + return await XMTP.processGroupMessage( + this.client, + this.id, + encryptedMessage + ) + } catch (e) { + console.info('ERROR in processGroupMessage()', e) + throw e + } + } + + async consentState(): Promise { + return await XMTP.groupConsentState(this.client.inboxId, this.id) + } + + async updateConsent(state: ConsentState): Promise { + return await XMTP.updateGroupConsent(this.client.inboxId, this.id, state) + } + + /** + * + * @returns {Promise} A Promise that resolves to an array of Member objects. + * To get the latest member list from the network, call sync() first. + */ + async membersList(): Promise { + return await XMTP.listGroupMembers(this.client.inboxId, this.id) + } +}