diff --git a/android/build.gradle b/android/build.gradle index 74adeff0f..246ebdb52 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -95,7 +95,7 @@ repositories { dependencies { implementation project(':expo-modules-core') implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}" - implementation "org.xmtp:android:4.6.1" + implementation "org.xmtp:android:4.6.2" 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 cad9e9b3f..bd17bed65 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt @@ -26,6 +26,7 @@ import expo.modules.xmtpreactnativesdk.wrappers.DecryptedLocalAttachment import expo.modules.xmtpreactnativesdk.wrappers.DisappearingMessageSettingsWrapper import expo.modules.xmtpreactnativesdk.wrappers.DmWrapper import expo.modules.xmtpreactnativesdk.wrappers.EncryptedLocalAttachment +import expo.modules.xmtpreactnativesdk.wrappers.GroupSyncSummaryWrapper import expo.modules.xmtpreactnativesdk.wrappers.GroupWrapper import expo.modules.xmtpreactnativesdk.wrappers.InboxStateWrapper import expo.modules.xmtpreactnativesdk.wrappers.KeyPackageStatusWrapper @@ -584,7 +585,7 @@ class XMTPModule : Module() { logV("ffiRevokeAllOtherInstallationsSignatureText") val client = clients[installationId] ?: throw XMTPException("No client") val sigRequest = client.ffiRevokeAllOtherInstallations() - sigRequest.let { + sigRequest?.let { clientSignatureRequests[installationId] = it it.signatureText() } @@ -1012,7 +1013,10 @@ class XMTPModule : Module() { afterNs = queryParams.afterNs, direction = DecodedMessage.SortDirection.valueOf( queryParams.direction ?: "DESCENDING" - ) + ), + insertedAfterNs = queryParams.insertedAfterNs, + insertedBeforeNs = queryParams.insertedBeforeNs, + sortBy = getMessageSortBy(queryParams.sortBy) )?.map { MessageWrapper.encode(it) } } } @@ -1029,7 +1033,10 @@ class XMTPModule : Module() { afterNs = queryParams.afterNs, direction = DecodedMessage.SortDirection.valueOf( queryParams.direction ?: "DESCENDING" - ) + ), + insertedAfterNs = queryParams.insertedAfterNs, + insertedBeforeNs = queryParams.insertedBeforeNs, + sortBy = getMessageSortBy(queryParams.sortBy) )?.map { MessageWrapper.encode(it) } } } @@ -1224,7 +1231,8 @@ class XMTPModule : Module() { createGroupParams.groupName, createGroupParams.groupImageUrl, createGroupParams.groupDescription, - createGroupParams.disappearingMessageSettings + createGroupParams.disappearingMessageSettings, + createGroupParams.appData ) GroupWrapper.encode(client, group) } @@ -1246,7 +1254,8 @@ class XMTPModule : Module() { createGroupParams.groupName, createGroupParams.groupImageUrl, createGroupParams.groupDescription, - createGroupParams.disappearingMessageSettings + createGroupParams.disappearingMessageSettings, + createGroupParams.appData ) GroupWrapper.encode(client, group) } @@ -1270,7 +1279,8 @@ class XMTPModule : Module() { createGroupParams.groupName, createGroupParams.groupImageUrl, createGroupParams.groupDescription, - createGroupParams.disappearingMessageSettings + createGroupParams.disappearingMessageSettings, + createGroupParams.appData ) GroupWrapper.encode(client, group) } @@ -1294,7 +1304,8 @@ class XMTPModule : Module() { createGroupParams.groupName, createGroupParams.groupImageUrl, createGroupParams.groupDescription, - createGroupParams.disappearingMessageSettings + createGroupParams.disappearingMessageSettings, + createGroupParams.appData ) GroupWrapper.encode(client, group) } @@ -1315,7 +1326,8 @@ class XMTPModule : Module() { createGroupParams.groupName, createGroupParams.groupImageUrl, createGroupParams.groupDescription, - createGroupParams.disappearingMessageSettings + createGroupParams.disappearingMessageSettings, + createGroupParams.appData ) GroupWrapper.encode(client, group) } @@ -1365,9 +1377,8 @@ class XMTPModule : Module() { logV("syncAllConversations") val client = clients[installationId] ?: throw XMTPException("No client") val consentStates = consentStringStates?.let { ConsentWrapper.getConsentStates(it) } - val numGroupsSyncedInt: Int = - client.conversations.syncAllConversations(consentStates).numSynced.toInt() - numGroupsSyncedInt + val summary = client.conversations.syncAllConversations(consentStates) + GroupSyncSummaryWrapper.encode(summary) } } @@ -1485,6 +1496,26 @@ class XMTPModule : Module() { } } + AsyncFunction("groupAppData") Coroutine { installationId: String, groupId: String -> + withContext(Dispatchers.IO) { + logV("groupAppData") + val client = clients[installationId] ?: throw XMTPException("No client") + val group = client.conversations.findGroup(groupId) + ?: throw XMTPException("no group found for $groupId") + group.appData() + } + } + + AsyncFunction("updateGroupAppData") Coroutine { installationId: String, groupId: String, appData: String -> + withContext(Dispatchers.IO) { + logV("updateGroupAppData") + val client = clients[installationId] ?: throw XMTPException("No client") + val group = client.conversations.findGroup(groupId) + ?: throw XMTPException("no group found for $groupId") + group.updateAppData(appData) + } + } + AsyncFunction("disappearingMessageSettings") Coroutine { installationId: String, conversationId: String -> withContext(Dispatchers.IO) { logV("disappearingMessageSettings") @@ -2029,7 +2060,7 @@ class XMTPModule : Module() { } } - AsyncFunction("createArchive") Coroutine { installationId: String, path: String, encryptionKey: List, startNs: Int?, endNs: Int?, archiveElements: List? -> + AsyncFunction("createArchive") Coroutine { installationId: String, path: String, encryptionKey: List, startNs: Int?, endNs: Int?, archiveElements: List?, excludeDisappearingMessages: Boolean? -> withContext(Dispatchers.IO) { val client = clients[installationId] ?: throw XMTPException("No client") val encryptionKeyBytes = @@ -2037,7 +2068,7 @@ class XMTPModule : Module() { a.apply { set(i, v.toByte()) } } val elements = archiveElements?.map { getArchiveElement(it) } ?: listOf(ArchiveElement.MESSAGES, ArchiveElement.CONSENT) - val archiveOptions = ArchiveOptions(startNs?.toLong(), endNs?.toLong(), elements) + val archiveOptions = ArchiveOptions(startNs?.toLong(), endNs?.toLong(), elements, excludeDisappearingMessages ?: false) client.createArchive(path, encryptionKeyBytes, archiveOptions) } } @@ -2348,6 +2379,13 @@ class XMTPModule : Module() { } } + private fun getMessageSortBy(sortBy: String?): DecodedMessage.SortBy { + return when (sortBy) { + "INSERTED_TIME" -> DecodedMessage.SortBy.INSERTED_TIME + else -> DecodedMessage.SortBy.SENT_TIME + } + } + private val preAuthenticateToInboxCallback: suspend () -> Unit = { sendEvent("preAuthenticateToInboxCallback") preAuthenticateToInboxCallbackDeferred?.await() 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 a348490bd..e800c2308 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ContentJson.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ContentJson.kt @@ -24,6 +24,7 @@ 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.MultiRemoteAttachment +import org.xmtp.android.library.codecs.MultiRemoteAttachmentCodec import org.xmtp.android.library.codecs.Reaction import org.xmtp.android.library.codecs.ReactionCodec import org.xmtp.android.library.codecs.ReactionV2Codec @@ -31,7 +32,6 @@ import org.xmtp.android.library.codecs.ReadReceipt import org.xmtp.android.library.codecs.ReadReceiptCodec import org.xmtp.android.library.codecs.RemoteAttachment import org.xmtp.android.library.codecs.RemoteAttachmentCodec -import org.xmtp.android.library.codecs.MultiRemoteAttachmentCodec import org.xmtp.android.library.codecs.RemoteAttachmentInfo import org.xmtp.android.library.codecs.Reply import org.xmtp.android.library.codecs.ReplyCodec @@ -57,8 +57,8 @@ class ContentJson( constructor(encoded: EncodedContent) : this( type = encoded.type, content = encoded.decoded(), - encodedContent = encoded - ); + encodedContent = encoded, + ) companion object { init { @@ -79,18 +79,20 @@ class ContentJson( } else if (obj.has("attachment")) { val attachment = obj.get("attachment").asJsonObject return ContentJson( - ContentTypeAttachment, Attachment( + ContentTypeAttachment, + Attachment( filename = attachment.get("filename").asString, mimeType = attachment.get("mimeType").asString, data = ByteString.copyFrom(bytesFrom64(attachment.get("data").asString)), - ) + ), ) } else if (obj.has("remoteAttachment")) { val remoteAttachment = obj.get("remoteAttachment").asJsonObject val metadata = EncryptedAttachmentMetadata.fromJsonObj(remoteAttachment) val url = URL(remoteAttachment.get("url").asString) return ContentJson( - ContentTypeRemoteAttachment, RemoteAttachment( + ContentTypeRemoteAttachment, + RemoteAttachment( url = url, contentDigest = metadata.contentDigest, secret = metadata.secret, @@ -99,52 +101,57 @@ class ContentJson( scheme = "https://", contentLength = metadata.contentLength, filename = metadata.filename, - ) + ), ) } else if (obj.has("multiRemoteAttachment")) { val multiRemoteAttachment = obj.get("multiRemoteAttachment").asJsonObject val remoteAttachments = multiRemoteAttachment.get("attachments").asJsonArray val attachments: MutableList = ArrayList() - for(attachmentElement: JsonElement in remoteAttachments) { + for (attachmentElement: JsonElement in remoteAttachments) { val attachment = attachmentElement.asJsonObject val metadata = EncryptedAttachmentMetadata.fromJsonObj(attachment) val url = URL(attachment.get("url").asString) - val remoteAttachmentInfo = RemoteAttachmentInfo( - url = url.toString(), - contentDigest = metadata.contentDigest, - secret = metadata.secret, - salt = metadata.salt, - nonce = metadata.nonce, - scheme = "https://", - contentLength = metadata.contentLength.toLong(), - filename = metadata.filename, - ) + val remoteAttachmentInfo = + RemoteAttachmentInfo( + url = url.toString(), + contentDigest = metadata.contentDigest, + secret = metadata.secret, + salt = metadata.salt, + nonce = metadata.nonce, + scheme = "https://", + contentLength = metadata.contentLength.toLong(), + filename = metadata.filename, + ) attachments.add(remoteAttachmentInfo) } - return ContentJson(ContentTypeMultiRemoteAttachment, MultiRemoteAttachment( - remoteAttachments = attachments - )) + return ContentJson( + ContentTypeMultiRemoteAttachment, + MultiRemoteAttachment( + remoteAttachments = attachments, + ), + ) } else if (obj.has("reaction")) { val reaction = obj.get("reaction").asJsonObject return ContentJson( - ContentTypeReaction, Reaction( + ContentTypeReaction, + Reaction( reference = reaction.get("reference").asString, action = getReactionAction(reaction.get("action").asString.lowercase()), schema = getReactionSchema(reaction.get("schema").asString.lowercase()), content = reaction.get("content").asString, - ) + ), ) } else if (obj.has("reactionV2")) { val reaction = obj.get("reactionV2").asJsonObject return ContentJson( - ContentTypeReactionV2, FfiReactionPayload( + ContentTypeReactionV2, + FfiReactionPayload( reference = reaction.get("reference").asString, action = getReactionV2Action(reaction.get("action").asString.lowercase()), schema = getReactionV2Schema(reaction.get("schema").asString.lowercase()), content = reaction.get("content").asString, - // Update if we add referenceInboxId to ../src/lib/types/ContentCodec.ts#L19-L24 - referenceInboxId = "" - ) + referenceInboxId = if (reaction.has("referenceInboxId")) reaction.get("referenceInboxId").asString else "", + ), ) } else if (obj.has("reply")) { val reply = obj.get("reply").asJsonObject @@ -156,11 +163,12 @@ class ContentJson( throw Exception("Bad reply content") } return ContentJson( - ContentTypeReply, Reply( + ContentTypeReply, + Reply( reference = reply.get("reference").asString, content = nested.content, contentType = nested.type, - ) + ), ) } else if (obj.has("readReceipt")) { return ContentJson(ContentTypeReadReceipt, ReadReceipt) @@ -171,116 +179,141 @@ class ContentJson( fun fromJson(json: String): ContentJson { val obj = JsonParser.parseString(json).asJsonObject - return fromJsonObject(obj); + return fromJsonObject(obj) } private fun bytesFrom64(bytes64: String): ByteArray = Base64.decode(bytes64, Base64.NO_WRAP) + fun bytesTo64(bytes: ByteArray): String = Base64.encodeToString(bytes, Base64.NO_WRAP) } - fun toJsonMap(): Map { - return when (type.id) { - ContentTypeText.id -> mapOf( - "text" to (content as String? ?: ""), - ) + fun toJsonMap(): Map = + when (type.id) { + ContentTypeText.id -> + mapOf( + "text" to (content as String? ?: ""), + ) - ContentTypeAttachment.id -> mapOf( - "attachment" to mapOf( - "filename" to (content as Attachment).filename, - "mimeType" to content.mimeType, - "data" to bytesTo64(content.data.toByteArray()), + ContentTypeAttachment.id -> + mapOf( + "attachment" to + mapOf( + "filename" to (content as Attachment).filename, + "mimeType" to content.mimeType, + "data" to bytesTo64(content.data.toByteArray()), + ), ) - ) - ContentTypeRemoteAttachment.id -> mapOf( - "remoteAttachment" to mapOf( - "scheme" to "https://", - "url" to (content as RemoteAttachment).url.toString(), - ) + EncryptedAttachmentMetadata - .fromRemoteAttachment(content) - .toJsonMap() - ) + ContentTypeRemoteAttachment.id -> + mapOf( + "remoteAttachment" to mapOf( + "scheme" to "https://", + "url" to (content as RemoteAttachment).url.toString(), + ) + + EncryptedAttachmentMetadata + .fromRemoteAttachment(content) + .toJsonMap(), + ) ContentTypeMultiRemoteAttachment.id -> { - val multiRemoteAttachment: FfiMultiRemoteAttachment = decodeMultiRemoteAttachment(encodedContent!!.toByteArray()) - val attachmentMaps = multiRemoteAttachment.attachments.map { attachment -> - mapOf( - "scheme" to "https://", - "url" to attachment.url, - "filename" to attachment.filename, - "contentLength" to attachment.contentLength.toString(), - "contentDigest" to attachment.contentDigest, - "secret" to Hex.encodeHex(attachment.secret, false), - "salt" to Hex.encodeHex(attachment.salt, false ), - "nonce" to Hex.encodeHex(attachment.nonce, false) - ) - } - mapOf( - "multiRemoteAttachment" to mapOf( - "attachments" to attachmentMaps - ) - ) + val multiRemoteAttachment: FfiMultiRemoteAttachment = decodeMultiRemoteAttachment(encodedContent!!.toByteArray()) + val attachmentMaps = + multiRemoteAttachment.attachments.map { attachment -> + mapOf( + "scheme" to "https://", + "url" to attachment.url, + "filename" to attachment.filename, + "contentLength" to attachment.contentLength.toString(), + "contentDigest" to attachment.contentDigest, + "secret" to Hex.encodeHex(attachment.secret, false), + "salt" to Hex.encodeHex(attachment.salt, false), + "nonce" to Hex.encodeHex(attachment.nonce, false), + ) + } + mapOf( + "multiRemoteAttachment" to + mapOf( + "attachments" to attachmentMaps, + ), + ) } - ContentTypeReaction.id -> mapOf( - "reaction" to mapOf( - "reference" to (content as Reaction).reference, - "action" to content.action.javaClass.simpleName.lowercase(), - "schema" to content.schema.javaClass.simpleName.lowercase(), - "content" to content.content, + ContentTypeReaction.id -> + mapOf( + "reaction" to + mapOf( + "reference" to (content as Reaction).reference, + "action" to + content.action.javaClass.simpleName + .lowercase(), + "schema" to + content.schema.javaClass.simpleName + .lowercase(), + "content" to content.content, + ), ) - ) - ContentTypeReactionV2.id -> { + ContentTypeReactionV2.id -> { val reaction: FfiReactionPayload = decodeReaction(encodedContent!!.toByteArray()) mapOf( - "reaction" to mapOf( - "reference" to reaction.reference, - "action" to getReactionV2ActionString(reaction.action), - "schema" to getReactionV2SchemaString(reaction.schema), - "content" to reaction.content, - ) + "reaction" to + mapOf( + "reference" to reaction.reference, + "referenceInboxId" to reaction.referenceInboxId, + "action" to getReactionV2ActionString(reaction.action), + "schema" to getReactionV2SchemaString(reaction.schema), + "content" to reaction.content, + ), ) } - ContentTypeReply.id -> mapOf( - "reply" to mapOf( - "reference" to (content as Reply).reference, - "content" to ContentJson( - content.contentType, - content.content, - encodedContent - ).toJsonMap(), - "contentType" to content.contentType.description + ContentTypeReply.id -> + mapOf( + "reply" to + mapOf( + "reference" to (content as Reply).reference, + "content" to + ContentJson( + content.contentType, + content.content, + encodedContent, + ).toJsonMap(), + "contentType" to content.contentType.description, + ), ) - ) - ContentTypeReadReceipt.id -> mapOf( - "readReceipt" to "" - ) + ContentTypeReadReceipt.id -> + mapOf( + "readReceipt" to "", + ) - ContentTypeGroupUpdated.id -> mapOf( - "groupUpdated" to mapOf( - "initiatedByInboxId" to (content as GroupUpdated).initiatedByInboxId, - "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 { + ContentTypeGroupUpdated.id -> + mapOf( + "groupUpdated" to mapOf( - "oldValue" to it.oldValue, - "newValue" to it.newValue, - "fieldName" to it.fieldName, - ) - }, + "initiatedByInboxId" to (content as GroupUpdated).initiatedByInboxId, + "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() @@ -296,53 +329,48 @@ class ContentJson( json.add("parameters", JsonParser.parseString(parameters)) json.add("type", typeJson) json.addProperty("content", bytesTo64(encodedContent.content.toByteArray())) - } val encodedContentJSON = json.toString() if (encodedContentJSON.isNotBlank()) { mapOf("encoded" to encodedContentJSON) } else { mapOf( - "unknown" to mapOf( - "contentTypeId" to type.description - ) + "unknown" to + mapOf( + "contentTypeId" to type.description, + ), ) } } } - } } -fun getReactionV2Schema(schema: String): FfiReactionSchema { - return when (schema) { +fun getReactionV2Schema(schema: String): FfiReactionSchema = + when (schema) { "unicode" -> FfiReactionSchema.UNICODE "shortcode" -> FfiReactionSchema.SHORTCODE "custom" -> FfiReactionSchema.CUSTOM else -> FfiReactionSchema.UNKNOWN } -} -fun getReactionV2Action(action: String): FfiReactionAction { - return when (action) { +fun getReactionV2Action(action: String): FfiReactionAction = + when (action) { "removed" -> FfiReactionAction.REMOVED "added" -> FfiReactionAction.ADDED else -> FfiReactionAction.UNKNOWN } -} -fun getReactionV2SchemaString(schema: FfiReactionSchema): String { - return when (schema) { +fun getReactionV2SchemaString(schema: FfiReactionSchema): String = + when (schema) { FfiReactionSchema.UNICODE -> "unicode" FfiReactionSchema.SHORTCODE -> "shortcode" FfiReactionSchema.CUSTOM -> "custom" FfiReactionSchema.UNKNOWN -> "unknown" } -} -fun getReactionV2ActionString(action: FfiReactionAction): String { - return when (action) { +fun getReactionV2ActionString(action: FfiReactionAction): String = + when (action) { FfiReactionAction.REMOVED -> "removed" FfiReactionAction.ADDED -> "added" FfiReactionAction.UNKNOWN -> "unknown" } -} \ No newline at end of file diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/CreateGroupParamsWrapper.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/CreateGroupParamsWrapper.kt index 4dc18080b..85f5ce97e 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/CreateGroupParamsWrapper.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/CreateGroupParamsWrapper.kt @@ -8,26 +8,29 @@ class CreateGroupParamsWrapper( val groupImageUrl: String, val groupDescription: String, val disappearingMessageSettings: DisappearingMessageSettings?, + val appData: String?, ) { companion object { fun createGroupParamsFromJson(authParams: String): CreateGroupParamsWrapper { val jsonOptions = JsonParser.parseString(authParams).asJsonObject // Only create DisappearingMessageSettings if both values are provided - val settings = if (jsonOptions.has("disappearStartingAtNs") && jsonOptions.has("retentionDurationInNs")) { - DisappearingMessageSettings( - jsonOptions.get("disappearStartingAtNs").asLong, - jsonOptions.get("retentionDurationInNs").asLong - ) - } else { - null - } + val settings = + if (jsonOptions.has("disappearStartingAtNs") && jsonOptions.has("retentionDurationInNs")) { + DisappearingMessageSettings( + jsonOptions.get("disappearStartingAtNs").asLong, + jsonOptions.get("retentionDurationInNs").asLong, + ) + } else { + null + } return CreateGroupParamsWrapper( if (jsonOptions.has("name")) jsonOptions.get("name").asString else "", if (jsonOptions.has("imageUrl")) jsonOptions.get("imageUrl").asString else "", if (jsonOptions.has("description")) jsonOptions.get("description").asString else "", - settings + settings, + if (jsonOptions.has("appData")) jsonOptions.get("appData").asString else null, ) } } diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/GroupSyncSummaryWrapper.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/GroupSyncSummaryWrapper.kt new file mode 100644 index 000000000..5834a2c02 --- /dev/null +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/GroupSyncSummaryWrapper.kt @@ -0,0 +1,20 @@ +package expo.modules.xmtpreactnativesdk.wrappers + +import com.google.gson.GsonBuilder +import org.xmtp.android.library.GroupSyncSummary + +class GroupSyncSummaryWrapper { + companion object { + fun encode(model: GroupSyncSummary): String { + val gson = GsonBuilder().create() + val message = encodeMap(model) + return gson.toJson(message) + } + + fun encodeMap(model: GroupSyncSummary): Map = + mapOf( + "numEligible" to model.numEligible, + "numSynced" to model.numSynced, + ) + } +} diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/MessageQueryParamsWrapper.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/MessageQueryParamsWrapper.kt index 554953b28..7910b7fb0 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/MessageQueryParamsWrapper.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/MessageQueryParamsWrapper.kt @@ -9,6 +9,9 @@ class MessageQueryParamsWrapper( val direction: String?, val excludeContentTypes: List?, val excludeSenderInboxIds: List?, + val sortBy: String?, + val insertedAfterNs: Long?, + val insertedBeforeNs: Long?, ) { companion object { fun messageQueryParamsFromJson(paramsJson: String): MessageQueryParamsWrapper { @@ -20,6 +23,9 @@ class MessageQueryParamsWrapper( null, null, null, + null, + null, + null, ) } @@ -69,6 +75,27 @@ class MessageQueryParamsWrapper( null } + val sortBy = + if (jsonOptions.has("sortBy")) { + jsonOptions.get("sortBy").asString + } else { + null + } + + val insertedAfterNs = + if (jsonOptions.has("insertedAfterNs")) { + jsonOptions.get("insertedAfterNs").asLong + } else { + null + } + + val insertedBeforeNs = + if (jsonOptions.has("insertedBeforeNs")) { + jsonOptions.get("insertedBeforeNs").asLong + } else { + null + } + return MessageQueryParamsWrapper( limit, beforeNs, @@ -76,6 +103,9 @@ class MessageQueryParamsWrapper( direction, excludeContentTypes, excludeSenderInboxIds, + sortBy, + insertedAfterNs, + insertedBeforeNs, ) } } diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/MessageWrapper.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/MessageWrapper.kt index c9f079445..f86ad582e 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/MessageWrapper.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/MessageWrapper.kt @@ -5,7 +5,6 @@ import org.xmtp.android.library.codecs.description import org.xmtp.android.library.libxmtp.DecodedMessage class MessageWrapper { - companion object { fun encode(model: DecodedMessage): String { val gson = GsonBuilder().create() @@ -24,9 +23,10 @@ class MessageWrapper { "content" to ContentJson(model.encodedContent).toJsonMap(), "senderInboxId" to model.senderInboxId, "sentNs" to model.sentAtNs, + "insertedAtNs" to model.insertedAtNs, "fallback" to fallback, "deliveryStatus" to model.deliveryStatus.toString(), - "childMessages" to model.childMessages?.map { childMessage -> encodeMap(childMessage) } + "childMessages" to model.childMessages?.map { childMessage -> encodeMap(childMessage) }, ) } } diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index d836095b8..2fa916e98 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1757,16 +1757,16 @@ PODS: - SQLCipher/standard (4.5.7): - SQLCipher/common - SwiftProtobuf (1.28.2) - - XMTP (4.6.1): + - XMTP (4.6.2): - Connect-Swift (= 1.0.0) - CryptoSwift (= 1.8.3) - SQLCipher (= 4.5.7) - - XMTPReactNative (5.1.0): + - XMTPReactNative (5.2.0-rc1): - CSecp256k1 (~> 0.2) - ExpoModulesCore - MessagePacker - SQLCipher (= 4.5.7) - - XMTP (= 4.6.1) + - XMTP (= 4.6.2) - Yoga (0.0.0) DEPENDENCIES: @@ -2179,8 +2179,8 @@ SPEC CHECKSUMS: SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 SQLCipher: 5e6bfb47323635c8b657b1b27d25c5f1baf63bf5 SwiftProtobuf: 4dbaffec76a39a8dc5da23b40af1a5dc01a4c02d - XMTP: 4585965d1df0e575bf58fdd359d095b53523a5fd - XMTPReactNative: ada2f52cd459a68b0ffbcc8a5221b0ac69feca69 + XMTP: f5aa8ce5eb93392d959aec985bcb64262a3f3485 + XMTPReactNative: 1689c9aca472a142117e36219228416d0fcbee71 Yoga: feb4910aba9742cfedc059e2b2902e22ffe9954a PODFILE CHECKSUM: 5b1b93f724b9cde6043d1824960f7bfd9a7973cd diff --git a/example/src/TestScreen.tsx b/example/src/TestScreen.tsx index e60605813..17437a0ed 100644 --- a/example/src/TestScreen.tsx +++ b/example/src/TestScreen.tsx @@ -10,6 +10,7 @@ import { groupPerformanceTests } from './tests/groupPerformanceTests' import { groupPermissionsTests } from './tests/groupPermissionsTests' import { groupTests } from './tests/groupTests' import { historySyncTests } from './tests/historySyncTests' +import { messagesTests } from './tests/messagesTests' import { restartStreamTests } from './tests/restartStreamsTests' import { Test, @@ -158,6 +159,7 @@ export enum TestCategory { dm = 'dm', group = 'group', conversation = 'conversation', + messages = 'messages', restartStreans = 'restartStreams', groupPermissions = 'groupPermissions', groupPerformance = 'groupPerformance', @@ -187,6 +189,7 @@ export default function TestScreen(): JSX.Element { ...dmTests, ...groupTests, ...conversationTests, + ...messagesTests, ...restartStreamTests, ...groupPermissionsTests, ...contentTypeTests, @@ -214,6 +217,10 @@ export default function TestScreen(): JSX.Element { activeTests = conversationTests title = 'Conversation Unit Tests' break + case TestCategory.messages: + activeTests = messagesTests + title = 'Messages Unit Tests' + break case TestCategory.restartStreans: activeTests = restartStreamTests title = 'Restart Streams Unit Tests' diff --git a/example/src/tests/clientTests.ts b/example/src/tests/clientTests.ts index 080487d5f..f663933a4 100644 --- a/example/src/tests/clientTests.ts +++ b/example/src/tests/clientTests.ts @@ -984,7 +984,7 @@ test('can manage revoke manually', async () => { ) const sigText2 = await alix.ffiRevokeAllOtherInstallationsSignatureText() - const signedMessage2 = await alixSigner.signMessage(sigText2) + const signedMessage2 = await alixSigner.signMessage(sigText2!) ;({ r, s, v } = ethers.utils.splitSignature(signedMessage2.signature)) const signature2 = ethers.utils.arrayify( diff --git a/example/src/tests/contentTypeTests.ts b/example/src/tests/contentTypeTests.ts index 013a3298e..db7f9da22 100644 --- a/example/src/tests/contentTypeTests.ts +++ b/example/src/tests/contentTypeTests.ts @@ -34,12 +34,14 @@ test('DecodedMessage.from() should throw informative error on null', async () => } try { + //@ts-expect-error DecodedMessage.from(undefined) } catch (e: any) { assert(e.toString().includes('JSON Parse error'), 'Error: ' + e.toString()) } try { + //@ts-expect-error DecodedMessage.from(null) } catch (e: any) { assert( diff --git a/example/src/tests/conversationTests.ts b/example/src/tests/conversationTests.ts index e12494c7a..23a48291b 100644 --- a/example/src/tests/conversationTests.ts +++ b/example/src/tests/conversationTests.ts @@ -87,6 +87,10 @@ class NumberCodec implements JSContentCodec { fallback(content: NumberRef): string | undefined { return 'a billion' } + + shouldPush(content: NumberRef): boolean { + return true + } } class NumberCodecUndefinedFallback extends NumberCodec { @@ -640,18 +644,22 @@ test('can filter sync all by consent', async () => { // Bo denied + 1; Bo unknown - 1 await boDmWithCaro?.updateConsent('denied') - const boConvos = await boClient.conversations.syncAllConversations() - const boConvosFilteredAllowed = + const { numEligible: boConvos } = + await boClient.conversations.syncAllConversations() + const { numEligible: boConvosFilteredAllowed } = await boClient.conversations.syncAllConversations(['allowed']) - const boConvosFilteredUnknown = + const { numEligible: boConvosFilteredUnknown } = await boClient.conversations.syncAllConversations(['unknown']) - const boConvosFilteredAllowedOrDenied = + const { numEligible: boConvosFilteredAllowedOrDenied } = await boClient.conversations.syncAllConversations(['allowed', 'denied']) - const boConvosFilteredAll = await boClient.conversations.syncAllConversations( - ['allowed', 'denied', 'unknown'] - ) + const { numEligible: boConvosFilteredAll } = + await boClient.conversations.syncAllConversations([ + 'allowed', + 'denied', + 'unknown', + ]) assert(boConvos === 4, `Conversation length should be 4 but was ${boConvos}`) assert( @@ -868,8 +876,8 @@ test('can stream conversation messages', async () => { 'conversation stream should have received 1 conversation' ) assert( - dmMessageCallbacks === 1, - 'message stream should have received 1 message' + dmMessageCallbacks >= 1, + `message stream should have received 1 or more messages. Received ${dmMessageCallbacks}` ) return true diff --git a/example/src/tests/groupTests.ts b/example/src/tests/groupTests.ts index 722047c00..3b26aba19 100644 --- a/example/src/tests/groupTests.ts +++ b/example/src/tests/groupTests.ts @@ -453,7 +453,7 @@ test('can cancel streams', async () => { try { assert( - messageCallbacks === 2, + messageCallbacks === 5, `message stream should have received 2 messages, but received ${messageCallbacks}` ) } finally { @@ -741,7 +741,7 @@ test('unpublished messages handling', async () => { const preparedMessageId = await alixGroup.prepareMessage('Test text') // Verify the message count in the group - const alixMessages = await alixGroup.messages({ direction: 'DESCENDING' }) + const alixMessages = await alixGroup.messages({ direction: 'ASCENDING' }) let messageCount = alixMessages.length if (messageCount !== 2) { throw new Error(`Message count should be 2, but it is ${messageCount}`) @@ -1937,17 +1937,19 @@ test('can sync all groups', async () => { } // First syncAllConversations after removal will still sync each group to set group inactive - const numGroupsSynced2 = await bo.conversations.syncAllConversations() + const { numSynced: numGroupsSynced2 } = + await bo.conversations.syncAllConversations() assert( - numGroupsSynced2 === 51, - `should have synced 51 groups but synced ${numGroupsSynced2}` + numGroupsSynced2 === 50, + `should have synced 50 groups but synced ${numGroupsSynced2}` ) // Next syncAllConversations will not sync inactive groups - const numGroupsSynced3 = await bo.conversations.syncAllConversations() + const { numSynced: numGroupsSynced3 } = + await bo.conversations.syncAllConversations() assert( - numGroupsSynced3 === 1, - `should have synced 1 groups but synced ${numGroupsSynced3}` + numGroupsSynced3 === 0, + `should have synced 0 groups but synced ${numGroupsSynced3}` ) return true }) diff --git a/example/src/tests/messagesTests.ts b/example/src/tests/messagesTests.ts new file mode 100644 index 000000000..10089c781 --- /dev/null +++ b/example/src/tests/messagesTests.ts @@ -0,0 +1,251 @@ +import { type Group, type Dm } from 'xmtp-react-native-sdk' + +import { Test, assert, createClients, delayToPropogate } from './test-utils' + +export const messagesTests: Test[] = [] +let counter = 1 +function test(name: string, perform: () => Promise) { + messagesTests.push({ + name: String(counter++) + '. ' + name, + run: perform, + }) +} + +const getNewestMessageTime = async (conversation: Group | Dm) => { + const messages = await conversation.messages({ direction: 'DESCENDING' }) + return { + sentAtNs: messages[0].sentNs, + insertedAtNs: messages[0].insertedAtNs, + } +} + +test('can filter messages by afterNs and beforeNs', async () => { + const [alixClient, boClient] = await createClients(2) + + const alixGroup = await alixClient.conversations.newGroup([boClient.inboxId]) + + // Send first message + await alixGroup.send('message 1') + await delayToPropogate(100) + + const { sentAtNs: message1SentNs } = await getNewestMessageTime(alixGroup) + // Wait and send second message + await delayToPropogate(100) + await alixGroup.send('message 2') + await delayToPropogate(100) + + // Get all messages again to capture the second message timestamp + const { sentAtNs: message2SentNs } = await getNewestMessageTime(alixGroup) + + // Wait and send third message + await delayToPropogate(100) + await alixGroup.send('message 3') + const { sentAtNs: message3SentNs } = await getNewestMessageTime(alixGroup) + await delayToPropogate(100) + + // Test afterNs - should return messages sent after message1 + const messagesAfterMessage1 = await alixGroup.messages({ + afterNs: message1SentNs, + }) + assert( + messagesAfterMessage1.length === 2, + `Expected 2 messages after message1, got ${messagesAfterMessage1.length}` + ) + + // Test beforeNs - should return messages sent before message2 (including group creation) + const messagesBeforeMessage2 = await alixGroup.messages({ + beforeNs: message2SentNs, + }) + assert( + messagesBeforeMessage2.length === 2, + `Expected 2 messages before message2 (message1 + group creation), got ${messagesBeforeMessage2.length}` + ) + + // Test both afterNs and beforeNs together - should return only message 2 + const messagesBetween = await alixGroup.messages({ + afterNs: message1SentNs, + beforeNs: message3SentNs, + }) + + console.log(messagesBetween.length) + + // Find message 2 content + const message2 = messagesBetween.find((m) => m.content() === 'message 2') + assert(message2 !== undefined, 'Should find message 2 in the filtered range') + + return true +}) + +test('can filter messages by insertedAfterNs and insertedBeforeNs', async () => { + const [alixClient, boClient] = await createClients(2) + + const alixGroup = await alixClient.conversations.newGroup([boClient.inboxId]) + + // Send messages with delays to ensure different insertedAtNs values + await alixGroup.send('message 1') + await delayToPropogate(100) + + const { insertedAtNs: message1InsertedNs } = + await getNewestMessageTime(alixGroup) + + await delayToPropogate(100) + await alixGroup.send('message 2') + await delayToPropogate(100) + + const { insertedAtNs: message2InsertedNs } = + await getNewestMessageTime(alixGroup) + + await delayToPropogate(100) + await alixGroup.send('message 3') + await delayToPropogate(100) + + // Test insertedAfterNs - should return messages inserted after message1 + const messagesInsertedAfterMessage1 = await alixGroup.messages({ + insertedAfterNs: message1InsertedNs, + }) + assert( + messagesInsertedAfterMessage1.length === 2, + `Expected 2 messages inserted after message1, got ${messagesInsertedAfterMessage1.length}` + ) + + // Test insertedBeforeNs - should return messages inserted before message2 + const messagesInsertedBeforeMessage2 = await alixGroup.messages({ + insertedBeforeNs: message2InsertedNs, + }) + assert( + messagesInsertedBeforeMessage2.length === 2, + `Expected 2 messages inserted before message2 (message1 + group creation), got ${messagesInsertedBeforeMessage2.length}` + ) + + return true +}) + +test('can combine sentNs and insertedNs filters', async () => { + const [alixClient, boClient] = await createClients(2) + + const alixGroup = await alixClient.conversations.newGroup([boClient.inboxId]) + + // Send messages + await alixGroup.send('message 1') + await delayToPropogate(100) + + const { sentAtNs: message1SentNs, insertedAtNs: message1InsertedNs } = + await getNewestMessageTime(alixGroup) + + await delayToPropogate(100) + await alixGroup.send('message 2') + await delayToPropogate(100) + + await alixGroup.send('message 3') + await delayToPropogate(100) + + // Use afterNs to filter by sent time + const messagesAfterSent = await alixGroup.messages({ + afterNs: message1SentNs, + }) + + assert( + messagesAfterSent.length === 2, + `Expected 2 messages after sentNs, got ${messagesAfterSent.length}` + ) + + // Use insertedAfterNs to filter by insertion time + const messagesAfterInserted = await alixGroup.messages({ + insertedAfterNs: message1InsertedNs, + }) + + assert( + messagesAfterInserted.length === 2, + `Expected 2 messages after insertedNs, got ${messagesAfterInserted.length}` + ) + + return true +}) + +test('can filter dm messages by afterNs and beforeNs', async () => { + const [alixClient, boClient] = await createClients(2) + + const alixDm = await alixClient.conversations.findOrCreateDm(boClient.inboxId) + + // Send first message + await alixDm.send('dm message 1') + await delayToPropogate(100) + + const { sentAtNs: message1SentNs } = await getNewestMessageTime(alixDm) + + // Send second message + await delayToPropogate(100) + await alixDm.send('dm message 2') + await delayToPropogate(100) + + const { sentAtNs: message2SentNs } = await getNewestMessageTime(alixDm) + + // Test afterNs - should return messages sent after message1 + const messagesAfterMessage1 = await alixDm.messages({ + afterNs: message1SentNs, + }) + assert( + messagesAfterMessage1.length === 1, + `Expected 1 message after message1, got ${messagesAfterMessage1.length}` + ) + assert( + messagesAfterMessage1[0].content() === 'dm message 2', + `Expected 'dm message 2', got '${messagesAfterMessage1[0].content()}'` + ) + + // Test beforeNs - should return messages sent before message2 (message1 + dm creation) + const messagesBeforeMessage2 = await alixDm.messages({ + beforeNs: message2SentNs, + }) + assert( + messagesBeforeMessage2.length === 2, + `Expected 2 messages before message2 (message1 + dm creation), got ${messagesBeforeMessage2.length}` + ) + + return true +}) + +test('can filter dm messages by insertedAfterNs and insertedBeforeNs', async () => { + const [alixClient, boClient] = await createClients(2) + + const alixDm = await alixClient.conversations.findOrCreateDm(boClient.inboxId) + + // Send first message + await alixDm.send('dm message 1') + await delayToPropogate(100) + + const { insertedAtNs: message1InsertedNs } = + await getNewestMessageTime(alixDm) + + // Send second message + await delayToPropogate(100) + await alixDm.send('dm message 2') + await delayToPropogate(100) + + const { insertedAtNs: message2InsertedNs } = + await getNewestMessageTime(alixDm) + + // Test insertedAfterNs - should return messages inserted after message1 + const messagesInsertedAfterMessage1 = await alixDm.messages({ + insertedAfterNs: message1InsertedNs, + }) + assert( + messagesInsertedAfterMessage1.length === 1, + `Expected 1 message inserted after message1, got ${messagesInsertedAfterMessage1.length}` + ) + assert( + messagesInsertedAfterMessage1[0].content() === 'dm message 2', + `Expected 'dm message 2', got '${messagesInsertedAfterMessage1[0].content()}'` + ) + + // Test insertedBeforeNs - should return messages inserted before message2 + const messagesInsertedBeforeMessage2 = await alixDm.messages({ + insertedBeforeNs: message2InsertedNs, + }) + assert( + messagesInsertedBeforeMessage2.length === 2, + `Expected 2 messages inserted before message2 (message1 + dm creation), got ${messagesInsertedBeforeMessage2.length}` + ) + + return true +}) diff --git a/ios/Wrappers/CreateGroupParamsWrapper.swift b/ios/Wrappers/CreateGroupParamsWrapper.swift index 5e60009cc..14ae0b383 100644 --- a/ios/Wrappers/CreateGroupParamsWrapper.swift +++ b/ios/Wrappers/CreateGroupParamsWrapper.swift @@ -6,6 +6,7 @@ struct CreateGroupParamsWrapper { let groupImageUrl: String let groupDescription: String let disappearingMessageSettings: DisappearingMessageSettings? + let appData: String? static func createGroupParamsFromJson(_ authParams: String) -> CreateGroupParamsWrapper @@ -13,13 +14,14 @@ struct CreateGroupParamsWrapper { let data = authParams.data(using: .utf8) ?? Data() let jsonOptions = (try? JSONSerialization.jsonObject(with: data, options: [])) - as? [String: Any] ?? [:] + as? [String: Any] ?? [:] var settings: DisappearingMessageSettings? = nil - + // Only create DisappearingMessageSettings if both values are provided if let disappearStartingAtNs = jsonOptions["disappearStartingAtNs"] as? Int64, - let retentionDurationInNs = jsonOptions["retentionDurationInNs"] as? Int64 { + let retentionDurationInNs = jsonOptions["retentionDurationInNs"] as? Int64 + { settings = DisappearingMessageSettings( disappearStartingAtNs: disappearStartingAtNs, retentionDurationInNs: retentionDurationInNs @@ -29,12 +31,14 @@ struct CreateGroupParamsWrapper { let groupName = jsonOptions["name"] as? String ?? "" let groupImageUrl = jsonOptions["imageUrl"] as? String ?? "" let groupDescription = jsonOptions["description"] as? String ?? "" + let appData = jsonOptions["appData"] as? String return CreateGroupParamsWrapper( groupName: groupName, groupImageUrl: groupImageUrl, groupDescription: groupDescription, - disappearingMessageSettings: settings + disappearingMessageSettings: settings, + appData: appData ) } } diff --git a/ios/Wrappers/GroupSyncSummaryWrapper.swift b/ios/Wrappers/GroupSyncSummaryWrapper.swift new file mode 100644 index 000000000..8cb7f5d2a --- /dev/null +++ b/ios/Wrappers/GroupSyncSummaryWrapper.swift @@ -0,0 +1,20 @@ +import Foundation +import XMTP + +struct GroupSyncSummaryWrapper: Codable { + var numEligible: UInt64 + var numSynced: UInt64 + + init(_ summary: XMTP.GroupSyncSummary) { + self.numEligible = summary.numEligible + self.numSynced = summary.numSynced + } + + func toJson() throws -> String { + let data = try JSONEncoder().encode(self) + guard let result = String(data: data, encoding: .utf8) else { + throw WrapperError.encodeError("could not encode GroupSyncSummary") + } + return result + } +} diff --git a/ios/Wrappers/MessageQueryParamsWrapper.swift b/ios/Wrappers/MessageQueryParamsWrapper.swift index 393c143dc..99fbaeefa 100644 --- a/ios/Wrappers/MessageQueryParamsWrapper.swift +++ b/ios/Wrappers/MessageQueryParamsWrapper.swift @@ -8,6 +8,9 @@ struct MessageQueryParamsWrapper { let direction: String? let excludeContentTypes: [String]? let excludeSenderInboxIds: [String]? + let sortBy: String? + let insertedAfterNs: Int64? + let insertedBeforeNs: Int64? static func messageQueryParamsFromJson(_ paramsJson: String) -> MessageQueryParamsWrapper @@ -19,14 +22,17 @@ struct MessageQueryParamsWrapper { afterNs: nil, direction: nil, excludeContentTypes: nil, - excludeSenderInboxIds: nil + excludeSenderInboxIds: nil, + sortBy: nil, + insertedAfterNs: nil, + insertedBeforeNs: nil ) } let data = paramsJson.data(using: .utf8) ?? Data() let jsonOptions = (try? JSONSerialization.jsonObject(with: data, options: [])) - as? [String: Any] ?? [:] + as? [String: Any] ?? [:] let limit = jsonOptions["limit"] as? Int let beforeNs = jsonOptions["beforeNs"] as? Int64 @@ -34,6 +40,9 @@ struct MessageQueryParamsWrapper { let direction = jsonOptions["direction"] as? String let excludeContentTypes = jsonOptions["excludeContentTypes"] as? [String] let excludeSenderInboxIds = jsonOptions["excludeSenderInboxIds"] as? [String] + let sortBy = jsonOptions["sortBy"] as? String + let insertedAfterNs = jsonOptions["insertedAfterNs"] as? Int64 + let insertedBeforeNs = jsonOptions["insertedBeforeNs"] as? Int64 return MessageQueryParamsWrapper( limit: limit, @@ -41,7 +50,10 @@ struct MessageQueryParamsWrapper { afterNs: afterNs, direction: direction, excludeContentTypes: excludeContentTypes, - excludeSenderInboxIds: excludeSenderInboxIds + excludeSenderInboxIds: excludeSenderInboxIds, + sortBy: sortBy, + insertedAfterNs: insertedAfterNs, + insertedBeforeNs: insertedBeforeNs ) } } diff --git a/ios/Wrappers/MessageWrapper.swift b/ios/Wrappers/MessageWrapper.swift index b4fd2ac4a..0b02f7264 100644 --- a/ios/Wrappers/MessageWrapper.swift +++ b/ios/Wrappers/MessageWrapper.swift @@ -3,24 +3,24 @@ import XMTP // Wrapper around XMTP.DecodedMessage to allow passing these objects back // into react native. -struct MessageWrapper { +enum MessageWrapper { static func encodeToObj(_ model: XMTP.DecodedMessage) throws -> [String: Any] { - // Swift Protos don't support null values and will always put the default "" - // Check if there is a fallback, if there is then make it the set fallback, if not null + // Swift Protos don't support null values and will always put the default "" + // Check if there is a fallback, if there is then make it the set fallback, if not null let fallback = try model.encodedContent.hasFallback ? model.encodedContent.fallback : nil - return [ + return try [ "id": model.id, "topic": model.topic, - "contentTypeId": try model.encodedContent.type.description, - "content": try ContentJson.fromEncoded(model.encodedContent).toJsonMap() as Any, + "contentTypeId": model.encodedContent.type.description, + "content": ContentJson.fromEncoded(model.encodedContent).toJsonMap() as Any, "senderInboxId": model.senderInboxId, "sentNs": model.sentAtNs, "fallback": fallback, "deliveryStatus": model.deliveryStatus.rawValue.uppercased(), - "childMessages": model.childMessages?.map { childMessage in - try? encodeToObj(childMessage) - } - ] + "childMessages": model.childMessages?.map { childMessage in + try? encodeToObj(childMessage) + }, + ] } static func encode(_ model: XMTP.DecodedMessage) throws -> String { @@ -76,15 +76,14 @@ struct ContentJson { schema: ReactionSchema(rawValue: reaction["schema"] as? String ?? "") )) } else if let reaction = obj["reactionV2"] as? [String: Any] { - return ContentJson(type: ContentTypeReactionV2, content: FfiReactionPayload( + return ContentJson(type: ContentTypeReactionV2, content: FfiReactionPayload( reference: reaction["reference"] as? String ?? "", - // Update if we add referenceInboxId to ../src/lib/types/ContentCodec.ts#L19-L24 - referenceInboxId: "", + referenceInboxId: reaction["referenceInboxId"] as? String ?? "", action: ReactionV2Action.fromString(reaction["action"] as? String ?? ""), content: reaction["content"] as? String ?? "", schema: ReactionV2Schema.fromString(reaction["schema"] as? String ?? "") )) - }else if let reply = obj["reply"] as? [String: Any] { + } else if let reply = obj["reply"] as? [String: Any] { guard let nestedContent = reply["content"] as? [String: Any] else { throw Error.badReplyContent } @@ -123,29 +122,30 @@ struct ContentJson { content.contentLength = metadata.contentLength return ContentJson(type: ContentTypeRemoteAttachment, content: content) } else if let multiRemoteAttachment = obj["multiRemoteAttachment"] as? [String: Any] { - guard let attachmentsArray = multiRemoteAttachment["attachments"] as? [[String: Any]] else { - throw Error.badRemoteAttachmentMetadata - } - - let attachments = try attachmentsArray.map { attachment -> MultiRemoteAttachment.RemoteAttachmentInfo in - guard let metadata = try? EncryptedAttachmentMetadata.fromJsonObj(attachment), - let urlString = attachment["url"] as? String else { - throw Error.badRemoteAttachmentMetadata - } - - return MultiRemoteAttachment.RemoteAttachmentInfo( - url: urlString, - filename: metadata.filename, - contentLength: UInt32(metadata.contentLength), - contentDigest: metadata.contentDigest, - nonce: metadata.nonce, - scheme: "https", - salt: metadata.salt, - secret: metadata.secret - ) - } - return ContentJson(type: ContentTypeMultiRemoteAttachment, content: MultiRemoteAttachment(remoteAttachments: attachments)) - } else if let readReceipt = obj["readReceipt"] as? [String: Any] { + guard let attachmentsArray = multiRemoteAttachment["attachments"] as? [[String: Any]] else { + throw Error.badRemoteAttachmentMetadata + } + + let attachments = try attachmentsArray.map { attachment -> MultiRemoteAttachment.RemoteAttachmentInfo in + guard let metadata = try? EncryptedAttachmentMetadata.fromJsonObj(attachment), + let urlString = attachment["url"] as? String + else { + throw Error.badRemoteAttachmentMetadata + } + + return MultiRemoteAttachment.RemoteAttachmentInfo( + url: urlString, + filename: metadata.filename, + contentLength: UInt32(metadata.contentLength), + contentDigest: metadata.contentDigest, + nonce: metadata.nonce, + scheme: "https", + salt: metadata.salt, + secret: metadata.secret + ) + } + return ContentJson(type: ContentTypeMultiRemoteAttachment, content: MultiRemoteAttachment(remoteAttachments: attachments)) + } else if let readReceipt = obj["readReceipt"] as? [String: Any] { return ContentJson(type: ContentTypeReadReceipt, content: ReadReceipt()) } else { throw Error.unknownContentType @@ -170,29 +170,30 @@ struct ContentJson { "schema": reaction.schema.rawValue, "content": reaction.content, ]] - case ContentTypeReactionV2.id: - guard let encodedContent = encodedContent else { - return ["error": "Missing encoded content for reaction"] - } - do { - let bytes = try encodedContent.serializedData() - let reaction = try decodeReaction(bytes: bytes) - return ["reaction": [ - "reference": reaction.reference, - "action": ReactionV2Action.toString(reaction.action), - "schema": ReactionV2Schema.toString(reaction.schema), - "content": reaction.content, - ]] - } catch { - return ["error": "Failed to decode reaction: \(error.localizedDescription)"] - } + case ContentTypeReactionV2.id: + guard let encodedContent = encodedContent else { + return ["error": "Missing encoded content for reaction"] + } + do { + let bytes = try encodedContent.serializedData() + let reaction = try decodeReaction(bytes: bytes) + return ["reaction": [ + "reference": reaction.reference, + "referenceInboxId": reaction.referenceInboxId, + "action": ReactionV2Action.toString(reaction.action), + "schema": ReactionV2Schema.toString(reaction.schema), + "content": reaction.content, + ]] + } catch { + return ["error": "Failed to decode reaction: \(error.localizedDescription)"] + } case ContentTypeReply.id where content is XMTP.Reply: let reply = content as! XMTP.Reply let nested = ContentJson(type: reply.contentType, content: reply.content) return ["reply": [ "reference": reply.reference, "content": nested.toJsonMap(), - "contentType": reply.contentType.description + "contentType": reply.contentType.description, ] as [String: Any]] case ContentTypeAttachment.id where content is XMTP.Attachment: let attachment = content as! XMTP.Attachment @@ -214,30 +215,30 @@ struct ContentJson { "url": remoteAttachment.url, ]] case ContentTypeMultiRemoteAttachment.id where content is XMTP.MultiRemoteAttachment: - guard let encodedContent = encodedContent else { - return ["error": "Missing encoded content for multi remote attachment"] - } - do { - let bytes = try encodedContent.serializedData() - let multiRemoteAttachment = try decodeMultiRemoteAttachment(bytes: bytes) - let attachmentMaps = multiRemoteAttachment.attachments.map { attachment in - return [ - "scheme": "https", - "url": attachment.url, - "filename": attachment.filename ?? "", - "contentLength": String(attachment.contentLength ?? 0), - "contentDigest": attachment.contentDigest, - "secret": attachment.secret.toHex, - "salt": attachment.salt.toHex, - "nonce": attachment.nonce.toHex - ] - } - return ["multiRemoteAttachment": [ - "attachments": attachmentMaps - ]] - } catch { - return ["error": "Failed to decode multi remote attachment: \(error.localizedDescription)"] - } + guard let encodedContent = encodedContent else { + return ["error": "Missing encoded content for multi remote attachment"] + } + do { + let bytes = try encodedContent.serializedData() + let multiRemoteAttachment = try decodeMultiRemoteAttachment(bytes: bytes) + let attachmentMaps = multiRemoteAttachment.attachments.map { attachment in + [ + "scheme": "https", + "url": attachment.url, + "filename": attachment.filename ?? "", + "contentLength": String(attachment.contentLength ?? 0), + "contentDigest": attachment.contentDigest, + "secret": attachment.secret.toHex, + "salt": attachment.salt.toHex, + "nonce": attachment.nonce.toHex, + ] + } + return ["multiRemoteAttachment": [ + "attachments": attachmentMaps, + ]] + } catch { + return ["error": "Failed to decode multi remote attachment: \(error.localizedDescription)"] + } case ContentTypeReadReceipt.id where content is XMTP.ReadReceipt: return ["readReceipt": ""] case ContentTypeGroupUpdated.id where content is XMTP.GroupUpdated: @@ -260,7 +261,7 @@ struct ContentJson { "newValue": metadata.newValue, "fieldName": metadata.fieldName, ] - } + }, ]] default: if let encodedContent, let encodedContentJSON = try? encodedContent.jsonString() { @@ -270,59 +271,58 @@ struct ContentJson { } } } - } -struct ReactionV2Schema { - static func fromString(_ schema: String) -> FfiReactionSchema { - switch schema { - case "unicode": - return .unicode - case "shortcode": - return .shortcode - case "custom": - return .custom - default: - return .unknown - } - } - - static func toString(_ schema: FfiReactionSchema) -> String { - switch schema { - case .unicode: - return "unicode" - case .shortcode: - return "shortcode" - case .custom: - return "custom" - case .unknown: - return "unknown" - } - } +enum ReactionV2Schema { + static func fromString(_ schema: String) -> FfiReactionSchema { + switch schema { + case "unicode": + return .unicode + case "shortcode": + return .shortcode + case "custom": + return .custom + default: + return .unknown + } + } + + static func toString(_ schema: FfiReactionSchema) -> String { + switch schema { + case .unicode: + return "unicode" + case .shortcode: + return "shortcode" + case .custom: + return "custom" + case .unknown: + return "unknown" + } + } } -struct ReactionV2Action { - static func fromString(_ action: String) -> FfiReactionAction { - switch action { - case "removed": - return .removed - case "added": - return .added - default: - return .unknown - } - } - - static func toString(_ action: FfiReactionAction) -> String { - switch action { - case .removed: - return "removed" - case .added: - return "added" - case .unknown: - return "unknown" - } - } +enum ReactionV2Action { + static func fromString(_ action: String) -> FfiReactionAction { + switch action { + case "removed": + return .removed + case "added": + return .added + default: + return .unknown + } + } + + static func toString(_ action: FfiReactionAction) -> String { + switch action { + case .removed: + return "removed" + case .added: + return "added" + case .unknown: + return "unknown" + } + } } struct EncryptedAttachmentMetadata { @@ -354,7 +354,7 @@ struct EncryptedAttachmentMetadata { let secret = (obj["secret"] as? String ?? "").hexToData let salt = (obj["salt"] as? String ?? "").hexToData let nonce = (obj["nonce"] as? String ?? "").hexToData - + return EncryptedAttachmentMetadata( filename: obj["filename"] as? String ?? "", secret: secret, diff --git a/ios/XMTPModule.swift b/ios/XMTPModule.swift index 2a5d95c82..d1af517d6 100644 --- a/ios/XMTPModule.swift +++ b/ios/XMTPModule.swift @@ -537,13 +537,16 @@ public class XMTPModule: Module { } AsyncFunction("ffiRevokeAllOtherInstallationsSignatureText") { - (installationId: String) -> String in + (installationId: String) -> String? in guard let client = await clientsManager.getClient(key: installationId) else { throw Error.noClient } - let sigRequest = try await client.ffiRevokeAllOtherInstallations() + guard let sigRequest = try await client.ffiRevokeAllOtherInstallations() + else { + return nil + } await clientsManager.updateSignatureRequest( key: client.installationID, signatureRequest: sigRequest ) @@ -1431,7 +1434,8 @@ public class XMTPModule: Module { imageUrl: createGroupParams.groupImageUrl, description: createGroupParams.groupDescription, disappearingMessageSettings: createGroupParams - .disappearingMessageSettings + .disappearingMessageSettings, + appData: createGroupParams.appData ) return try await GroupWrapper.encode(group, client: client) } catch { @@ -1465,7 +1469,8 @@ public class XMTPModule: Module { imageUrl: createGroupParams.groupImageUrl, description: createGroupParams.groupDescription, disappearingMessageSettings: createGroupParams - .disappearingMessageSettings + .disappearingMessageSettings, + appData: createGroupParams.appData ) return try await GroupWrapper.encode(group, client: client) } catch { @@ -1510,7 +1515,8 @@ public class XMTPModule: Module { imageUrl: createGroupParams.groupImageUrl, description: createGroupParams.groupDescription, disappearingMessageSettings: createGroupParams - .disappearingMessageSettings + .disappearingMessageSettings, + appData: createGroupParams.appData ) return try await GroupWrapper.encode(group, client: client) } catch { @@ -1548,7 +1554,8 @@ public class XMTPModule: Module { imageUrl: createGroupParams.groupImageUrl, description: createGroupParams.groupDescription, disappearingMessageSettings: createGroupParams - .disappearingMessageSettings + .disappearingMessageSettings, + appData: createGroupParams.appData ) return try await GroupWrapper.encode(group, client: client) } catch { @@ -1587,7 +1594,8 @@ public class XMTPModule: Module { groupImageUrlSquare: createGroupParams.groupImageUrl, groupDescription: createGroupParams.groupDescription, disappearingMessageSettings: createGroupParams - .disappearingMessageSettings + .disappearingMessageSettings, + appData: createGroupParams.appData ) return try await GroupWrapper.encode(group, client: client) } catch { @@ -1672,7 +1680,7 @@ public class XMTPModule: Module { } AsyncFunction("syncAllConversations") { - (installationId: String, consentStringStates: [String]?) -> UInt64 + (installationId: String, consentStringStates: [String]?) -> String in guard let client = await clientsManager.getClient(key: installationId) @@ -1685,8 +1693,9 @@ public class XMTPModule: Module { } else { consentStates = nil } - return try await client.conversations.syncAllConversations( - consentStates: consentStates).numSynced + let summary = try await client.conversations.syncAllConversations( + consentStates: consentStates) + return try GroupSyncSummaryWrapper(summary).toJson() } AsyncFunction("syncConversation") { @@ -1899,6 +1908,42 @@ public class XMTPModule: Module { try await group.updateDescription(description: description) } + AsyncFunction("groupAppData") { + (installationId: String, id: String) -> String in + guard + let client = await clientsManager.getClient(key: installationId) + else { + throw Error.noClient + } + guard + let group = try await client.conversations.findGroup( + groupId: id) + else { + throw Error.conversationNotFound( + "no conversation found for \(id)") + } + + return try group.appData() + } + + AsyncFunction("updateGroupAppData") { + (installationId: String, id: String, appData: String) in + guard + let client = await clientsManager.getClient(key: installationId) + else { + throw Error.noClient + } + guard + let group = try await client.conversations.findGroup( + groupId: id) + else { + throw Error.conversationNotFound( + "no conversation found for \(id)") + } + + try await group.updateAppData(appData: appData) + } + AsyncFunction("disappearingMessageSettings") { (installationId: String, conversationId: String) -> String? in guard @@ -2855,26 +2900,26 @@ public class XMTPModule: Module { return try ArchiveMetadataWrapper.encode(metadata) } - - AsyncFunction("leaveGroup") { ( + + AsyncFunction("leaveGroup") { ( installationId: String, groupId: String ) in - guard - let client = await clientsManager.getClient( - key: installationId) - else { - throw Error.noClient - } - guard - let group = try await client.conversations.findGroup( - groupId: groupId) - else { - throw Error.conversationNotFound( - "no conversation found for \(groupId)") - } - try await group.leaveGroup() - } + guard + let client = await clientsManager.getClient( + key: installationId) + else { + throw Error.noClient + } + guard + let group = try await client.conversations.findGroup( + groupId: groupId) + else { + throw Error.conversationNotFound( + "no conversation found for \(groupId)") + } + try await group.leaveGroup() + } } // @@ -3063,7 +3108,7 @@ public class XMTPModule: Module { case revokeInstallations } - func createApiClient(env: String, customLocalUrl: String? = nil, appVersion: String? = nil, gatewayHost: String? = nil) + func createApiClient(env: String, customLocalUrl: String? = nil, appVersion: String? = nil, gatewayHost: String? = nil) -> XMTP.ClientOptions.Api { switch env { @@ -3075,21 +3120,21 @@ public class XMTPModule: Module { env: XMTP.XMTPEnvironment.local, isSecure: false, appVersion: appVersion, - gatewayHost: gatewayHost + gatewayHost: gatewayHost ) case "production": return XMTP.ClientOptions.Api( env: XMTP.XMTPEnvironment.production, isSecure: true, appVersion: appVersion, - gatewayHost: gatewayHost + gatewayHost: gatewayHost ) default: return XMTP.ClientOptions.Api( env: XMTP.XMTPEnvironment.dev, isSecure: true, appVersion: appVersion, - gatewayHost: gatewayHost + gatewayHost: gatewayHost ) } } @@ -3105,7 +3150,7 @@ public class XMTPModule: Module { env: authOptions.environment, customLocalUrl: authOptions.customLocalUrl, appVersion: authOptions.appVersion, - gatewayHost: authOptions.gatewayHost, + gatewayHost: authOptions.gatewayHost ), preAuthenticateToInboxCallback: preAuthenticateToInboxCallback, dbEncryptionKey: dbEncryptionKey, @@ -3113,7 +3158,7 @@ public class XMTPModule: Module { historySyncUrl: authOptions.historySyncUrl, deviceSyncEnabled: authOptions.deviceSyncEnabled, debugEventsEnabled: authOptions.debugEventsEnabled, - forkRecoveryOptions: authOptions.forkRecoveryOptions + forkRecoveryOptions: authOptions.forkRecoveryOptions ) } diff --git a/ios/XMTPReactNative.podspec b/ios/XMTPReactNative.podspec index bb98c02c3..157ee9114 100644 --- a/ios/XMTPReactNative.podspec +++ b/ios/XMTPReactNative.podspec @@ -26,7 +26,7 @@ Pod::Spec.new do |s| s.source_files = "**/*.{h,m,swift}" s.dependency "MessagePacker" - s.dependency "XMTP", "= 4.6.1" + s.dependency "XMTP", "= 4.6.2" s.dependency 'CSecp256k1', '~> 0.2' s.dependency "SQLCipher", "= 4.5.7" end diff --git a/package.json b/package.json index fd9633c51..22e82573d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xmtp/react-native-sdk", - "version": "5.1.0", + "version": "5.2.0", "description": "Wraps for native xmtp sdks for react native", "main": "build/index.js", "types": "build/index.d.ts", diff --git a/src/index.ts b/src/index.ts index 92d928f84..ca2428bca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,7 +41,11 @@ import { import { DecodedMessageUnion } from './lib/types/DecodedMessageUnion' import { DefaultContentTypes } from './lib/types/DefaultContentType' import { LogLevel, LogRotation } from './lib/types/LogTypes' -import { MessageId, MessageOrder } from './lib/types/MessagesOptions' +import { + MessageId, + MessageOrder, + MessageSortBy, +} from './lib/types/MessagesOptions' import { PermissionPolicySet } from './lib/types/PermissionPolicySet' export * from './context' @@ -401,7 +405,7 @@ export async function ffiRevokeInstallationsSignatureText( export async function ffiRevokeAllOtherInstallationsSignatureText( installationId: InstallationId -): Promise { +): Promise { return await XMTPModule.ffiRevokeAllOtherInstallationsSignatureText( installationId ) @@ -787,7 +791,10 @@ export async function conversationMessages< afterNs?: number | undefined, direction?: MessageOrder | undefined, excludeContentTypes?: string[] | undefined, - excludeSenderInboxIds?: string[] | undefined + excludeSenderInboxIds?: string[] | undefined, + sortBy?: MessageSortBy | undefined, + insertedAfterNs?: number | undefined, + insertedBeforeNs?: number | undefined ): Promise[]> { const queryParamsJson = JSON.stringify({ limit, @@ -796,7 +803,11 @@ export async function conversationMessages< direction, excludeContentTypes, excludeSenderInboxIds, + sortBy, + insertedAfterNs, + insertedBeforeNs, }) + const messages = await XMTPModule.conversationMessages( clientInstallationId, conversationId, @@ -817,7 +828,10 @@ export async function conversationMessagesWithReactions< afterNs?: number | undefined, direction?: MessageOrder | undefined, excludeContentTypes?: string[] | undefined, - excludeSenderInboxIds?: string[] | undefined + excludeSenderInboxIds?: string[] | undefined, + sortBy?: MessageSortBy | undefined, + insertedAfterNs?: number | undefined, + insertedBeforeNs?: number | undefined ): Promise[]> { const queryParamsJson = JSON.stringify({ limit, @@ -826,6 +840,9 @@ export async function conversationMessagesWithReactions< direction, excludeContentTypes, excludeSenderInboxIds, + sortBy, + insertedAfterNs, + insertedBeforeNs, }) const messages = await XMTPModule.conversationMessagesWithReactions( clientInstallationId, @@ -1249,11 +1266,20 @@ export async function syncConversations(installationId: InstallationId) { await XMTPModule.syncConversations(installationId) } +export interface GroupSyncSummary { + numEligible: number + numSynced: number +} + export async function syncAllConversations( installationId: InstallationId, consentStates?: ConsentState[] | undefined -): Promise { - return await XMTPModule.syncAllConversations(installationId, consentStates) +): Promise { + const json = await XMTPModule.syncAllConversations( + installationId, + consentStates + ) + return JSON.parse(json) as GroupSyncSummary } export async function syncConversation( @@ -1348,6 +1374,21 @@ export function updateGroupDescription( return XMTPModule.updateGroupDescription(installationId, id, description) } +export function groupAppData( + installationId: InstallationId, + id: ConversationId +): string | PromiseLike { + return XMTPModule.groupAppData(installationId, id) +} + +export function updateGroupAppData( + installationId: InstallationId, + id: ConversationId, + appData: string +): Promise { + return XMTPModule.updateGroupAppData(installationId, id, appData) +} + export async function disappearingMessageSettings( installationId: string, conversationId: string @@ -1811,7 +1852,8 @@ export async function createArchive( encryptionKey: Uint8Array, startNs?: number | undefined, endNs?: number | undefined, - archiveElements?: string[] | undefined + archiveElements?: string[] | undefined, + excludeDisappearingMessages?: boolean | undefined ): Promise { return await XMTPModule.createArchive( installationId, @@ -1819,7 +1861,8 @@ export async function createArchive( Array.from(encryptionKey), startNs, endNs, - archiveElements + archiveElements, + excludeDisappearingMessages ) } export async function importArchive( diff --git a/src/lib/ArchiveOptions.ts b/src/lib/ArchiveOptions.ts index a3147d661..01266102f 100644 --- a/src/lib/ArchiveOptions.ts +++ b/src/lib/ArchiveOptions.ts @@ -4,15 +4,18 @@ export class ArchiveOptions { startNs?: number endNs?: number archiveElements?: ArchiveElement[] + excludeDisappearingMessages?: boolean constructor( archiveElements?: ArchiveElement[], startNs?: number, - endNs?: number + endNs?: number, + excludeDisappearingMessages?: boolean ) { this.archiveElements = archiveElements this.startNs = startNs this.endNs = endNs + this.excludeDisappearingMessages = excludeDisappearingMessages } } diff --git a/src/lib/Client.ts b/src/lib/Client.ts index 474dfd0d8..b31eac70b 100644 --- a/src/lib/Client.ts +++ b/src/lib/Client.ts @@ -1118,9 +1118,12 @@ export class Client< /** * This function is delicate and should be used with caution. Should only be used if trying to manage the signature flow independently otherwise use `revokeAllOtherInstallations()` instead. - * Gets the signature text for the revoke installations action + * Gets the signature text for the revoke installations action. + * Returns undefined if there are no other installations to revoke. */ - async ffiRevokeAllOtherInstallationsSignatureText(): Promise { + async ffiRevokeAllOtherInstallationsSignatureText(): Promise< + string | undefined + > { console.warn( '⚠️ This function is delicate and should be used with caution. ' + 'Should only be used if trying to manage the signature flow independently otherwise use `revokeAllOtherInstallations()` instead' diff --git a/src/lib/Conversations.ts b/src/lib/Conversations.ts index 1ac3135a7..5b479c6c4 100644 --- a/src/lib/Conversations.ts +++ b/src/lib/Conversations.ts @@ -444,11 +444,11 @@ export default class Conversations< * Executes a network request to sync all active conversations associated with the client * @param {consentStates} ConsentState[] - Filter the conversations to sync by a list of consent states. * - * @returns {Promise} A Promise that resolves to the number of conversations synced. + * @returns {Promise} A Promise that resolves to a summary of the sync operation. */ async syncAllConversations( consentStates: ConsentState[] | undefined = undefined - ): Promise { + ): Promise { return await XMTPModule.syncAllConversations( this.client.installationId, consentStates diff --git a/src/lib/DecodedMessage.ts b/src/lib/DecodedMessage.ts index a89b4cbf7..68cba4f56 100644 --- a/src/lib/DecodedMessage.ts +++ b/src/lib/DecodedMessage.ts @@ -29,6 +29,7 @@ export class DecodedMessage< contentTypeId: string senderInboxId: InboxId sentNs: number // timestamp in nanoseconds + insertedAtNs: number // timestamp in nanoseconds when message was inserted locally nativeContent: NativeMessageContent fallback: string | undefined deliveryStatus: MessageDeliveryStatus = MessageDeliveryStatus.PUBLISHED @@ -56,6 +57,7 @@ export class DecodedMessage< decoded.contentTypeId, decoded.senderInboxId, decoded.sentNs, + decoded.insertedAtNs, decoded.content, decoded.fallback, decoded.deliveryStatus, @@ -72,6 +74,7 @@ export class DecodedMessage< contentTypeId: string senderInboxId: InboxId sentNs: number // timestamp in nanoseconds + insertedAtNs?: number // timestamp in nanoseconds when message was inserted locally content: any fallback: string | undefined deliveryStatus: MessageDeliveryStatus | undefined @@ -82,6 +85,7 @@ export class DecodedMessage< object.contentTypeId, object.senderInboxId, object.sentNs, + object.insertedAtNs ?? object.sentNs, object.content, object.fallback, object.deliveryStatus @@ -94,6 +98,7 @@ export class DecodedMessage< contentTypeId: string, senderInboxId: InboxId, sentNs: number, + insertedAtNs: number, content: any, fallback: string | undefined, deliveryStatus: MessageDeliveryStatus = MessageDeliveryStatus.PUBLISHED, @@ -104,6 +109,7 @@ export class DecodedMessage< this.contentTypeId = contentTypeId this.senderInboxId = senderInboxId this.sentNs = sentNs + this.insertedAtNs = insertedAtNs this.nativeContent = content // undefined comes back as null when bridged, ensure undefined so integrators don't have to add a new check for null as well this.fallback = fallback ?? undefined diff --git a/src/lib/Dm.ts b/src/lib/Dm.ts index b30ee473f..eaadab35b 100644 --- a/src/lib/Dm.ts +++ b/src/lib/Dm.ts @@ -214,7 +214,10 @@ export class Dm opts?.afterNs, opts?.direction, opts?.excludeContentTypes, - opts?.excludeSenderInboxIds + opts?.excludeSenderInboxIds, + opts?.sortBy, + opts?.insertedAfterNs, + opts?.insertedBeforeNs ) } @@ -240,7 +243,10 @@ export class Dm opts?.afterNs, opts?.direction, opts?.excludeContentTypes, - opts?.excludeSenderInboxIds + opts?.excludeSenderInboxIds, + opts?.sortBy, + opts?.insertedAfterNs, + opts?.insertedBeforeNs ) } diff --git a/src/lib/Group.ts b/src/lib/Group.ts index 36dfb0548..e81f63f74 100644 --- a/src/lib/Group.ts +++ b/src/lib/Group.ts @@ -240,7 +240,10 @@ export class Group< opts?.afterNs, opts?.direction, opts?.excludeContentTypes, - opts?.excludeSenderInboxIds + opts?.excludeSenderInboxIds, + opts?.sortBy, + opts?.insertedAfterNs, + opts?.insertedBeforeNs ) } @@ -267,7 +270,10 @@ export class Group< opts?.afterNs, opts?.direction, opts?.excludeContentTypes, - opts?.excludeSenderInboxIds + opts?.excludeSenderInboxIds, + opts?.sortBy, + opts?.insertedAfterNs, + opts?.insertedBeforeNs ) } @@ -464,6 +470,25 @@ export class Group< ) } + /** + * Returns the app-specific data for this group. + * To get the latest app data from the network, call sync() first. + * @returns {Promise} A Promise that resolves to the app data string. + */ + async appData(): Promise { + return XMTP.groupAppData(this.client.installationId, this.id) + } + + /** + * Updates the app-specific data for this group. + * Will throw if the user does not have the required permissions. + * @param {string} appData new app data + * @returns {Promise} + */ + async updateAppData(appData: string): Promise { + return XMTP.updateGroupAppData(this.client.installationId, this.id, appData) + } + /** * Returns the disappearing message settings. * To get the latest settings from the network, call sync() first. diff --git a/src/lib/types/ContentCodec.ts b/src/lib/types/ContentCodec.ts index 64d8137fe..16b05e16b 100644 --- a/src/lib/types/ContentCodec.ts +++ b/src/lib/types/ContentCodec.ts @@ -18,6 +18,7 @@ export type ReplyContent = { export type ReactionContent = { reference: string + referenceInboxId?: string action: 'added' | 'removed' | 'unknown' schema: 'unicode' | 'shortcode' | 'custom' | 'unknown' content: string diff --git a/src/lib/types/CreateGroupOptions.ts b/src/lib/types/CreateGroupOptions.ts index bfaafc2a1..05ea171ab 100644 --- a/src/lib/types/CreateGroupOptions.ts +++ b/src/lib/types/CreateGroupOptions.ts @@ -6,4 +6,5 @@ export type CreateGroupOptions = { imageUrl?: string | undefined description?: string | undefined disappearingMessageSettings?: DisappearingMessageSettings | undefined + appData?: string | undefined } diff --git a/src/lib/types/MessagesOptions.ts b/src/lib/types/MessagesOptions.ts index 0adaefb46..b0470682c 100644 --- a/src/lib/types/MessagesOptions.ts +++ b/src/lib/types/MessagesOptions.ts @@ -5,7 +5,11 @@ export type MessagesOptions = { direction?: MessageOrder | undefined excludeContentTypes?: string[] | undefined excludeSenderInboxIds?: string[] | undefined + sortBy?: MessageSortBy | undefined + insertedAfterNs?: number | undefined + insertedBeforeNs?: number | undefined } export type MessageOrder = 'ASCENDING' | 'DESCENDING' +export type MessageSortBy = 'SENT_TIME' | 'INSERTED_TIME' export type MessageId = string & { readonly brand: unique symbol }