From e67267c5d8f634c4f38d6e9dee28bb288d40a4af Mon Sep 17 00:00:00 2001 From: mchenani Date: Thu, 8 Jan 2026 16:42:05 +0100 Subject: [PATCH 1/8] add delete message --- .../xmtp/android/library/DeleteMessageTest.kt | 229 ++++ .../org/xmtp/android/library/Conversation.kt | 17 + .../main/java/org/xmtp/android/library/Dm.kt | 18 + .../java/org/xmtp/android/library/Group.kt | 18 + .../library/libxmtp/PermissionPolicySet.kt | 9 + library/src/main/java/xmtpv3.kt | 1040 ++++++----------- 6 files changed, 641 insertions(+), 690 deletions(-) create mode 100644 library/src/androidTest/java/org/xmtp/android/library/DeleteMessageTest.kt diff --git a/library/src/androidTest/java/org/xmtp/android/library/DeleteMessageTest.kt b/library/src/androidTest/java/org/xmtp/android/library/DeleteMessageTest.kt new file mode 100644 index 000000000..af82b8933 --- /dev/null +++ b/library/src/androidTest/java/org/xmtp/android/library/DeleteMessageTest.kt @@ -0,0 +1,229 @@ +package org.xmtp.android.library + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.xmtp.android.library.codecs.ContentTypeReaction +import org.xmtp.android.library.codecs.Reaction +import org.xmtp.android.library.codecs.ReactionAction +import org.xmtp.android.library.codecs.ReactionCodec +import org.xmtp.android.library.codecs.ReactionSchema +import org.xmtp.android.library.codecs.ContentTypeReply +import org.xmtp.android.library.codecs.Reply +import org.xmtp.android.library.codecs.ReplyCodec +import org.xmtp.android.library.libxmtp.GroupPermissionPreconfiguration +import uniffi.xmtpv3.GenericException + +@RunWith(AndroidJUnit4::class) +class DeleteMessageTest : BaseInstrumentedTest() { + private lateinit var fixtures: TestFixtures + private lateinit var alixClient: Client + private lateinit var boClient: Client + private lateinit var caroClient: Client + + @Before + override fun setUp() { + super.setUp() + fixtures = runBlocking { createFixtures() } + alixClient = fixtures.alixClient + boClient = fixtures.boClient + caroClient = fixtures.caroClient + } + + @Test + fun testSenderCanDeleteOwnMessage() { + // Create a group with alix and bo + val alixGroup = runBlocking { + alixClient.conversations.newGroup(listOf(boClient.inboxId)) + } + + // Alix sends a message + val messageId = runBlocking { + alixGroup.send("Hello, this message will be deleted") + } + + // Verify message exists + runBlocking { alixGroup.sync() } + var messages = runBlocking { alixGroup.messages() } + assertTrue(messages.any { it.id == messageId }) + + // Alix deletes own message + val deletionMessageId = runBlocking { + alixGroup.deleteMessage(messageId) + } + assertNotNull(deletionMessageId) + + // Sync and verify deletion + runBlocking { alixGroup.sync() } + messages = runBlocking { alixGroup.messages() } + + // The deletion message should exist + assertTrue(messages.any { it.id == deletionMessageId }) + } + + @Test + fun testSuperAdminCanDeleteOthersMessage() { + // Alix creates a group (becomes super admin) with bo + val alixGroup = runBlocking { + alixClient.conversations.newGroup(listOf(boClient.inboxId)) + } + + // Bo syncs and gets the group + runBlocking { boClient.conversations.sync() } + val boGroup = runBlocking { + boClient.conversations.listGroups().first { it.id == alixGroup.id } + } + + // Bo sends a message + val messageId = runBlocking { + boGroup.send("Hello from Bo") + } + + // Sync both + runBlocking { + alixGroup.sync() + boGroup.sync() + } + + // Verify alix is super admin + assertTrue(runBlocking { alixGroup.isSuperAdmin(alixClient.inboxId) }) + + // Alix (super admin) deletes Bo's message + val deletionMessageId = runBlocking { + alixGroup.deleteMessage(messageId) + } + assertNotNull(deletionMessageId) + } + + @Test + fun testRegularUserCannotDeleteOthersMessage() { + // Alix creates a group with bo + val alixGroup = runBlocking { + alixClient.conversations.newGroup(listOf(boClient.inboxId)) + } + + // Bo syncs and gets the group + runBlocking { boClient.conversations.sync() } + val boGroup = runBlocking { + boClient.conversations.listGroups().first { it.id == alixGroup.id } + } + + // Alix sends a message + val messageId = runBlocking { + alixGroup.send("Hello from Alix") + } + + // Sync both + runBlocking { + alixGroup.sync() + boGroup.sync() + } + + // Bo is not super admin + assertTrue(!runBlocking { boGroup.isSuperAdmin(boClient.inboxId) }) + + // Bo tries to delete Alix's message - should fail + assertThrows(XMTPException::class.java) { + runBlocking { + boGroup.deleteMessage(messageId) + } + } + } + + @Test + fun testCannotDeleteAlreadyDeletedMessage() { + // Create a group + val alixGroup = runBlocking { + alixClient.conversations.newGroup(listOf(boClient.inboxId)) + } + + // Send and delete a message + val messageId = runBlocking { + alixGroup.send("Message to delete twice") + } + + runBlocking { + alixGroup.deleteMessage(messageId) + alixGroup.sync() + } + + // Try to delete the same message again - should fail + assertThrows(XMTPException::class.java) { + runBlocking { + alixGroup.deleteMessage(messageId) + } + } + } + + @Test + fun testDeleteMessageInDm() { + // Create a DM between alix and bo + val alixDm = runBlocking { + alixClient.conversations.findOrCreateDm(boClient.inboxId) + } + + // Alix sends a message + val messageId = runBlocking { + alixDm.send("Hello in DM") + } + + // Verify message exists + runBlocking { alixDm.sync() } + var messages = runBlocking { alixDm.messages() } + assertTrue(messages.any { it.id == messageId }) + + // Alix deletes own message + val deletionMessageId = runBlocking { + alixDm.deleteMessage(messageId) + } + assertNotNull(deletionMessageId) + + // Sync and verify deletion message exists + runBlocking { alixDm.sync() } + messages = runBlocking { alixDm.messages() } + assertTrue(messages.any { it.id == deletionMessageId }) + } + + @Test + fun testDeleteMessageViaConversation() { + // Create a group + val alixGroup = runBlocking { + alixClient.conversations.newGroup(listOf(boClient.inboxId)) + } + + // Get as Conversation + val conversation: Conversation = Conversation.Group(alixGroup) + + // Send a message via conversation + val messageId = runBlocking { + conversation.send("Hello via conversation") + } + + // Delete via conversation + val deletionMessageId = runBlocking { + conversation.deleteMessage(messageId) + } + assertNotNull(deletionMessageId) + } + + @Test + fun testDeleteMessageWithInvalidId() { + // Create a group + val alixGroup = runBlocking { + alixClient.conversations.newGroup(listOf(boClient.inboxId)) + } + + // Try to delete a non-existent message + assertThrows(XMTPException::class.java) { + runBlocking { + alixGroup.deleteMessage("0000000000000000000000000000000000000000000000000000000000000000") + } + } + } +} diff --git a/library/src/main/java/org/xmtp/android/library/Conversation.kt b/library/src/main/java/org/xmtp/android/library/Conversation.kt index 31b448ff5..3870240d5 100644 --- a/library/src/main/java/org/xmtp/android/library/Conversation.kt +++ b/library/src/main/java/org/xmtp/android/library/Conversation.kt @@ -223,6 +223,23 @@ sealed class Conversation { } } + /** + * Delete a message by its ID. + * + * Users can delete their own messages. In groups, super admins can delete any message. + * + * @param messageId The hex-encoded ID of the message to delete. + * @return The hex-encoded ID of the deletion message. + * @throws XMTPException if deletion fails (e.g., message not found, not authorized, already deleted). + */ + suspend fun deleteMessage(messageId: String): String = + withContext(Dispatchers.IO) { + when (this@Conversation) { + is Group -> group.deleteMessage(messageId) + is Dm -> dm.deleteMessage(messageId) + } + } + suspend fun sync() = withContext(Dispatchers.IO) { when (this@Conversation) { diff --git a/library/src/main/java/org/xmtp/android/library/Dm.kt b/library/src/main/java/org/xmtp/android/library/Dm.kt index 8ccf4ca9e..9a8036cfe 100644 --- a/library/src/main/java/org/xmtp/android/library/Dm.kt +++ b/library/src/main/java/org/xmtp/android/library/Dm.kt @@ -170,6 +170,24 @@ class Dm( suspend fun publishMessages() = withContext(Dispatchers.IO) { libXMTPGroup.publishMessages() } + /** + * Delete a message by its ID. + * + * Users can delete their own messages in a DM conversation. + * + * @param messageId The hex-encoded ID of the message to delete. + * @return The hex-encoded ID of the deletion message. + * @throws XMTPException if deletion fails (e.g., message not found, not authorized, already deleted). + */ + suspend fun deleteMessage(messageId: String): String = + withContext(Dispatchers.IO) { + try { + libXMTPGroup.deleteMessage(messageId.hexToByteArray()).toHex() + } catch (e: Exception) { + throw XMTPException("Unable to delete message: ${e.message}", e) + } + } + suspend fun sync() = withContext(Dispatchers.IO) { libXMTPGroup.sync() } suspend fun lastMessage(): DecodedMessage? = diff --git a/library/src/main/java/org/xmtp/android/library/Group.kt b/library/src/main/java/org/xmtp/android/library/Group.kt index e20091865..b70550a3a 100644 --- a/library/src/main/java/org/xmtp/android/library/Group.kt +++ b/library/src/main/java/org/xmtp/android/library/Group.kt @@ -214,6 +214,24 @@ class Group( suspend fun publishMessages() = withContext(Dispatchers.IO) { libXMTPGroup.publishMessages() } + /** + * Delete a message by its ID. + * + * Users can delete their own messages. Super admins can delete any message in the group. + * + * @param messageId The hex-encoded ID of the message to delete. + * @return The hex-encoded ID of the deletion message. + * @throws XMTPException if deletion fails (e.g., message not found, not authorized, already deleted). + */ + suspend fun deleteMessage(messageId: String): String = + withContext(Dispatchers.IO) { + try { + libXMTPGroup.deleteMessage(messageId.hexToByteArray()).toHex() + } catch (e: Exception) { + throw XMTPException("Unable to delete message: ${e.message}", e) + } + } + suspend fun sync() = withContext(Dispatchers.IO) { libXMTPGroup.sync() } suspend fun lastMessage(): DecodedMessage? = diff --git a/library/src/main/java/org/xmtp/android/library/libxmtp/PermissionPolicySet.kt b/library/src/main/java/org/xmtp/android/library/libxmtp/PermissionPolicySet.kt index 51a1d088f..1c2ce1b7b 100644 --- a/library/src/main/java/org/xmtp/android/library/libxmtp/PermissionPolicySet.kt +++ b/library/src/main/java/org/xmtp/android/library/libxmtp/PermissionPolicySet.kt @@ -57,6 +57,7 @@ data class PermissionPolicySet( val updateGroupDescriptionPolicy: PermissionOption, val updateGroupImagePolicy: PermissionOption, val updateMessageDisappearingPolicy: PermissionOption, + val updateAppDataPolicy: PermissionOption = PermissionOption.Allow, ) { companion object { fun toFfiPermissionPolicySet(permissionPolicySet: PermissionPolicySet): FfiPermissionPolicySet = @@ -81,6 +82,10 @@ data class PermissionPolicySet( PermissionOption.toFfiPermissionPolicy( permissionPolicySet.updateMessageDisappearingPolicy, ), + updateAppDataPolicy = + PermissionOption.toFfiPermissionPolicy( + permissionPolicySet.updateAppDataPolicy, + ), ) fun fromFfiPermissionPolicySet(ffiPermissionPolicySet: FfiPermissionPolicySet): PermissionPolicySet = @@ -108,6 +113,10 @@ data class PermissionPolicySet( PermissionOption.fromFfiPermissionPolicy( ffiPermissionPolicySet.updateMessageDisappearingPolicy, ), + updateAppDataPolicy = + PermissionOption.fromFfiPermissionPolicy( + ffiPermissionPolicySet.updateAppDataPolicy, + ), ) } } diff --git a/library/src/main/java/xmtpv3.kt b/library/src/main/java/xmtpv3.kt index 4b748272b..cdbee2b10 100644 --- a/library/src/main/java/xmtpv3.kt +++ b/library/src/main/java/xmtpv3.kt @@ -18,26 +18,24 @@ package uniffi.xmtpv3 // helpers directly inline like we're doing here. import com.sun.jna.Library -import com.sun.jna.IntegerType import com.sun.jna.Native import com.sun.jna.Pointer import com.sun.jna.Structure -import com.sun.jna.Callback -import com.sun.jna.ptr.* +import com.sun.jna.ptr.ByReference +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import java.nio.ByteBuffer import java.nio.ByteOrder import java.nio.CharBuffer import java.nio.charset.CodingErrorAction -import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong import kotlin.coroutines.resume -import kotlinx.coroutines.CancellableContinuation -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine // This is a helper for safely working with byte buffers returned from the Rust code. // A rust-owned buffer is represented by its capacity, its current length, and a @@ -693,684 +691,161 @@ internal interface UniffiCallbackInterfaceFfiMessageCallbackMethod0 : com.sun.jn } internal interface UniffiCallbackInterfaceFfiMessageCallbackMethod1 : com.sun.jna.Callback { fun callback(`uniffiHandle`: Long,`error`: RustBuffer.ByValue,`uniffiOutReturn`: Pointer,uniffiCallStatus: UniffiRustCallStatus,) -} -internal interface UniffiCallbackInterfaceFfiMessageCallbackMethod2 : com.sun.jna.Callback { - fun callback(`uniffiHandle`: Long,`uniffiOutReturn`: Pointer,uniffiCallStatus: UniffiRustCallStatus,) -} -internal interface UniffiCallbackInterfaceFfiMessageDeletionCallbackMethod0 : com.sun.jna.Callback { - fun callback(`uniffiHandle`: Long,`messageId`: RustBuffer.ByValue,`uniffiOutReturn`: Pointer,uniffiCallStatus: UniffiRustCallStatus,) -} -internal interface UniffiCallbackInterfaceFfiPreferenceCallbackMethod0 : com.sun.jna.Callback { - fun callback(`uniffiHandle`: Long,`preference`: RustBuffer.ByValue,`uniffiOutReturn`: Pointer,uniffiCallStatus: UniffiRustCallStatus,) -} -internal interface UniffiCallbackInterfaceFfiPreferenceCallbackMethod1 : com.sun.jna.Callback { - fun callback(`uniffiHandle`: Long,`error`: RustBuffer.ByValue,`uniffiOutReturn`: Pointer,uniffiCallStatus: UniffiRustCallStatus,) -} -internal interface UniffiCallbackInterfaceFfiPreferenceCallbackMethod2 : com.sun.jna.Callback { - fun callback(`uniffiHandle`: Long,`uniffiOutReturn`: Pointer,uniffiCallStatus: UniffiRustCallStatus,) -} -@Structure.FieldOrder("onAuthRequired", "uniffiFree") -internal open class UniffiVTableCallbackInterfaceFfiAuthCallback( - @JvmField internal var `onAuthRequired`: UniffiCallbackInterfaceFfiAuthCallbackMethod0? = null, - @JvmField internal var `uniffiFree`: UniffiCallbackInterfaceFree? = null, -) : Structure() { - class UniffiByValue( - `onAuthRequired`: UniffiCallbackInterfaceFfiAuthCallbackMethod0? = null, - `uniffiFree`: UniffiCallbackInterfaceFree? = null, - ): UniffiVTableCallbackInterfaceFfiAuthCallback(`onAuthRequired`,`uniffiFree`,), Structure.ByValue - - internal fun uniffiSetValue(other: UniffiVTableCallbackInterfaceFfiAuthCallback) { - `onAuthRequired` = other.`onAuthRequired` - `uniffiFree` = other.`uniffiFree` - } - -} -@Structure.FieldOrder("onConsentUpdate", "onError", "onClose", "uniffiFree") -internal open class UniffiVTableCallbackInterfaceFfiConsentCallback( - @JvmField internal var `onConsentUpdate`: UniffiCallbackInterfaceFfiConsentCallbackMethod0? = null, - @JvmField internal var `onError`: UniffiCallbackInterfaceFfiConsentCallbackMethod1? = null, - @JvmField internal var `onClose`: UniffiCallbackInterfaceFfiConsentCallbackMethod2? = null, - @JvmField internal var `uniffiFree`: UniffiCallbackInterfaceFree? = null, -) : Structure() { - class UniffiByValue( - `onConsentUpdate`: UniffiCallbackInterfaceFfiConsentCallbackMethod0? = null, - `onError`: UniffiCallbackInterfaceFfiConsentCallbackMethod1? = null, - `onClose`: UniffiCallbackInterfaceFfiConsentCallbackMethod2? = null, - `uniffiFree`: UniffiCallbackInterfaceFree? = null, - ): UniffiVTableCallbackInterfaceFfiConsentCallback(`onConsentUpdate`,`onError`,`onClose`,`uniffiFree`,), Structure.ByValue - - internal fun uniffiSetValue(other: UniffiVTableCallbackInterfaceFfiConsentCallback) { - `onConsentUpdate` = other.`onConsentUpdate` - `onError` = other.`onError` - `onClose` = other.`onClose` - `uniffiFree` = other.`uniffiFree` - } - -} -@Structure.FieldOrder("onConversation", "onError", "onClose", "uniffiFree") -internal open class UniffiVTableCallbackInterfaceFfiConversationCallback( - @JvmField internal var `onConversation`: UniffiCallbackInterfaceFfiConversationCallbackMethod0? = null, - @JvmField internal var `onError`: UniffiCallbackInterfaceFfiConversationCallbackMethod1? = null, - @JvmField internal var `onClose`: UniffiCallbackInterfaceFfiConversationCallbackMethod2? = null, - @JvmField internal var `uniffiFree`: UniffiCallbackInterfaceFree? = null, -) : Structure() { - class UniffiByValue( - `onConversation`: UniffiCallbackInterfaceFfiConversationCallbackMethod0? = null, - `onError`: UniffiCallbackInterfaceFfiConversationCallbackMethod1? = null, - `onClose`: UniffiCallbackInterfaceFfiConversationCallbackMethod2? = null, - `uniffiFree`: UniffiCallbackInterfaceFree? = null, - ): UniffiVTableCallbackInterfaceFfiConversationCallback(`onConversation`,`onError`,`onClose`,`uniffiFree`,), Structure.ByValue - - internal fun uniffiSetValue(other: UniffiVTableCallbackInterfaceFfiConversationCallback) { - `onConversation` = other.`onConversation` - `onError` = other.`onError` - `onClose` = other.`onClose` - `uniffiFree` = other.`uniffiFree` - } - -} -@Structure.FieldOrder("getIdentifier", "sign", "uniffiFree") -internal open class UniffiVTableCallbackInterfaceFfiInboxOwner( - @JvmField internal var `getIdentifier`: UniffiCallbackInterfaceFfiInboxOwnerMethod0? = null, - @JvmField internal var `sign`: UniffiCallbackInterfaceFfiInboxOwnerMethod1? = null, - @JvmField internal var `uniffiFree`: UniffiCallbackInterfaceFree? = null, -) : Structure() { - class UniffiByValue( - `getIdentifier`: UniffiCallbackInterfaceFfiInboxOwnerMethod0? = null, - `sign`: UniffiCallbackInterfaceFfiInboxOwnerMethod1? = null, - `uniffiFree`: UniffiCallbackInterfaceFree? = null, - ): UniffiVTableCallbackInterfaceFfiInboxOwner(`getIdentifier`,`sign`,`uniffiFree`,), Structure.ByValue - - internal fun uniffiSetValue(other: UniffiVTableCallbackInterfaceFfiInboxOwner) { - `getIdentifier` = other.`getIdentifier` - `sign` = other.`sign` - `uniffiFree` = other.`uniffiFree` - } - -} -@Structure.FieldOrder("onMessage", "onError", "onClose", "uniffiFree") -internal open class UniffiVTableCallbackInterfaceFfiMessageCallback( - @JvmField internal var `onMessage`: UniffiCallbackInterfaceFfiMessageCallbackMethod0? = null, - @JvmField internal var `onError`: UniffiCallbackInterfaceFfiMessageCallbackMethod1? = null, - @JvmField internal var `onClose`: UniffiCallbackInterfaceFfiMessageCallbackMethod2? = null, - @JvmField internal var `uniffiFree`: UniffiCallbackInterfaceFree? = null, -) : Structure() { - class UniffiByValue( - `onMessage`: UniffiCallbackInterfaceFfiMessageCallbackMethod0? = null, - `onError`: UniffiCallbackInterfaceFfiMessageCallbackMethod1? = null, - `onClose`: UniffiCallbackInterfaceFfiMessageCallbackMethod2? = null, - `uniffiFree`: UniffiCallbackInterfaceFree? = null, - ): UniffiVTableCallbackInterfaceFfiMessageCallback(`onMessage`,`onError`,`onClose`,`uniffiFree`,), Structure.ByValue - - internal fun uniffiSetValue(other: UniffiVTableCallbackInterfaceFfiMessageCallback) { - `onMessage` = other.`onMessage` - `onError` = other.`onError` - `onClose` = other.`onClose` - `uniffiFree` = other.`uniffiFree` - } - -} -@Structure.FieldOrder("onMessageDeleted", "uniffiFree") -internal open class UniffiVTableCallbackInterfaceFfiMessageDeletionCallback( - @JvmField internal var `onMessageDeleted`: UniffiCallbackInterfaceFfiMessageDeletionCallbackMethod0? = null, - @JvmField internal var `uniffiFree`: UniffiCallbackInterfaceFree? = null, -) : Structure() { - class UniffiByValue( - `onMessageDeleted`: UniffiCallbackInterfaceFfiMessageDeletionCallbackMethod0? = null, - `uniffiFree`: UniffiCallbackInterfaceFree? = null, - ): UniffiVTableCallbackInterfaceFfiMessageDeletionCallback(`onMessageDeleted`,`uniffiFree`,), Structure.ByValue - - internal fun uniffiSetValue(other: UniffiVTableCallbackInterfaceFfiMessageDeletionCallback) { - `onMessageDeleted` = other.`onMessageDeleted` - `uniffiFree` = other.`uniffiFree` - } - -} -@Structure.FieldOrder("onPreferenceUpdate", "onError", "onClose", "uniffiFree") -internal open class UniffiVTableCallbackInterfaceFfiPreferenceCallback( - @JvmField internal var `onPreferenceUpdate`: UniffiCallbackInterfaceFfiPreferenceCallbackMethod0? = null, - @JvmField internal var `onError`: UniffiCallbackInterfaceFfiPreferenceCallbackMethod1? = null, - @JvmField internal var `onClose`: UniffiCallbackInterfaceFfiPreferenceCallbackMethod2? = null, - @JvmField internal var `uniffiFree`: UniffiCallbackInterfaceFree? = null, -) : Structure() { - class UniffiByValue( - `onPreferenceUpdate`: UniffiCallbackInterfaceFfiPreferenceCallbackMethod0? = null, - `onError`: UniffiCallbackInterfaceFfiPreferenceCallbackMethod1? = null, - `onClose`: UniffiCallbackInterfaceFfiPreferenceCallbackMethod2? = null, - `uniffiFree`: UniffiCallbackInterfaceFree? = null, - ): UniffiVTableCallbackInterfaceFfiPreferenceCallback(`onPreferenceUpdate`,`onError`,`onClose`,`uniffiFree`,), Structure.ByValue - - internal fun uniffiSetValue(other: UniffiVTableCallbackInterfaceFfiPreferenceCallback) { - `onPreferenceUpdate` = other.`onPreferenceUpdate` - `onError` = other.`onError` - `onClose` = other.`onClose` - `uniffiFree` = other.`uniffiFree` - } - -} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +} +internal interface UniffiCallbackInterfaceFfiMessageCallbackMethod2 : com.sun.jna.Callback { + fun callback(`uniffiHandle`: Long,`uniffiOutReturn`: Pointer,uniffiCallStatus: UniffiRustCallStatus,) +} +internal interface UniffiCallbackInterfaceFfiMessageDeletionCallbackMethod0 : com.sun.jna.Callback { + fun callback(`uniffiHandle`: Long,`messageId`: RustBuffer.ByValue,`uniffiOutReturn`: Pointer,uniffiCallStatus: UniffiRustCallStatus,) +} +internal interface UniffiCallbackInterfaceFfiPreferenceCallbackMethod0 : com.sun.jna.Callback { + fun callback(`uniffiHandle`: Long,`preference`: RustBuffer.ByValue,`uniffiOutReturn`: Pointer,uniffiCallStatus: UniffiRustCallStatus,) +} +internal interface UniffiCallbackInterfaceFfiPreferenceCallbackMethod1 : com.sun.jna.Callback { + fun callback(`uniffiHandle`: Long,`error`: RustBuffer.ByValue,`uniffiOutReturn`: Pointer,uniffiCallStatus: UniffiRustCallStatus,) +} +internal interface UniffiCallbackInterfaceFfiPreferenceCallbackMethod2 : com.sun.jna.Callback { + fun callback(`uniffiHandle`: Long,`uniffiOutReturn`: Pointer,uniffiCallStatus: UniffiRustCallStatus,) +} +@Structure.FieldOrder("onAuthRequired", "uniffiFree") +internal open class UniffiVTableCallbackInterfaceFfiAuthCallback( + @JvmField internal var `onAuthRequired`: UniffiCallbackInterfaceFfiAuthCallbackMethod0? = null, + @JvmField internal var `uniffiFree`: UniffiCallbackInterfaceFree? = null, +) : Structure() { + class UniffiByValue( + `onAuthRequired`: UniffiCallbackInterfaceFfiAuthCallbackMethod0? = null, + `uniffiFree`: UniffiCallbackInterfaceFree? = null, + ): UniffiVTableCallbackInterfaceFfiAuthCallback(`onAuthRequired`,`uniffiFree`,), Structure.ByValue + internal fun uniffiSetValue(other: UniffiVTableCallbackInterfaceFfiAuthCallback) { + `onAuthRequired` = other.`onAuthRequired` + `uniffiFree` = other.`uniffiFree` + } +} +@Structure.FieldOrder("onConsentUpdate", "onError", "onClose", "uniffiFree") +internal open class UniffiVTableCallbackInterfaceFfiConsentCallback( + @JvmField internal var `onConsentUpdate`: UniffiCallbackInterfaceFfiConsentCallbackMethod0? = null, + @JvmField internal var `onError`: UniffiCallbackInterfaceFfiConsentCallbackMethod1? = null, + @JvmField internal var `onClose`: UniffiCallbackInterfaceFfiConsentCallbackMethod2? = null, + @JvmField internal var `uniffiFree`: UniffiCallbackInterfaceFree? = null, +) : Structure() { + class UniffiByValue( + `onConsentUpdate`: UniffiCallbackInterfaceFfiConsentCallbackMethod0? = null, + `onError`: UniffiCallbackInterfaceFfiConsentCallbackMethod1? = null, + `onClose`: UniffiCallbackInterfaceFfiConsentCallbackMethod2? = null, + `uniffiFree`: UniffiCallbackInterfaceFree? = null, + ): UniffiVTableCallbackInterfaceFfiConsentCallback(`onConsentUpdate`,`onError`,`onClose`,`uniffiFree`,), Structure.ByValue + internal fun uniffiSetValue(other: UniffiVTableCallbackInterfaceFfiConsentCallback) { + `onConsentUpdate` = other.`onConsentUpdate` + `onError` = other.`onError` + `onClose` = other.`onClose` + `uniffiFree` = other.`uniffiFree` + } +} +@Structure.FieldOrder("onConversation", "onError", "onClose", "uniffiFree") +internal open class UniffiVTableCallbackInterfaceFfiConversationCallback( + @JvmField internal var `onConversation`: UniffiCallbackInterfaceFfiConversationCallbackMethod0? = null, + @JvmField internal var `onError`: UniffiCallbackInterfaceFfiConversationCallbackMethod1? = null, + @JvmField internal var `onClose`: UniffiCallbackInterfaceFfiConversationCallbackMethod2? = null, + @JvmField internal var `uniffiFree`: UniffiCallbackInterfaceFree? = null, +) : Structure() { + class UniffiByValue( + `onConversation`: UniffiCallbackInterfaceFfiConversationCallbackMethod0? = null, + `onError`: UniffiCallbackInterfaceFfiConversationCallbackMethod1? = null, + `onClose`: UniffiCallbackInterfaceFfiConversationCallbackMethod2? = null, + `uniffiFree`: UniffiCallbackInterfaceFree? = null, + ): UniffiVTableCallbackInterfaceFfiConversationCallback(`onConversation`,`onError`,`onClose`,`uniffiFree`,), Structure.ByValue + internal fun uniffiSetValue(other: UniffiVTableCallbackInterfaceFfiConversationCallback) { + `onConversation` = other.`onConversation` + `onError` = other.`onError` + `onClose` = other.`onClose` + `uniffiFree` = other.`uniffiFree` + } +} +@Structure.FieldOrder("getIdentifier", "sign", "uniffiFree") +internal open class UniffiVTableCallbackInterfaceFfiInboxOwner( + @JvmField internal var `getIdentifier`: UniffiCallbackInterfaceFfiInboxOwnerMethod0? = null, + @JvmField internal var `sign`: UniffiCallbackInterfaceFfiInboxOwnerMethod1? = null, + @JvmField internal var `uniffiFree`: UniffiCallbackInterfaceFree? = null, +) : Structure() { + class UniffiByValue( + `getIdentifier`: UniffiCallbackInterfaceFfiInboxOwnerMethod0? = null, + `sign`: UniffiCallbackInterfaceFfiInboxOwnerMethod1? = null, + `uniffiFree`: UniffiCallbackInterfaceFree? = null, + ): UniffiVTableCallbackInterfaceFfiInboxOwner(`getIdentifier`,`sign`,`uniffiFree`,), Structure.ByValue + internal fun uniffiSetValue(other: UniffiVTableCallbackInterfaceFfiInboxOwner) { + `getIdentifier` = other.`getIdentifier` + `sign` = other.`sign` + `uniffiFree` = other.`uniffiFree` + } +} +@Structure.FieldOrder("onMessage", "onError", "onClose", "uniffiFree") +internal open class UniffiVTableCallbackInterfaceFfiMessageCallback( + @JvmField internal var `onMessage`: UniffiCallbackInterfaceFfiMessageCallbackMethod0? = null, + @JvmField internal var `onError`: UniffiCallbackInterfaceFfiMessageCallbackMethod1? = null, + @JvmField internal var `onClose`: UniffiCallbackInterfaceFfiMessageCallbackMethod2? = null, + @JvmField internal var `uniffiFree`: UniffiCallbackInterfaceFree? = null, +) : Structure() { + class UniffiByValue( + `onMessage`: UniffiCallbackInterfaceFfiMessageCallbackMethod0? = null, + `onError`: UniffiCallbackInterfaceFfiMessageCallbackMethod1? = null, + `onClose`: UniffiCallbackInterfaceFfiMessageCallbackMethod2? = null, + `uniffiFree`: UniffiCallbackInterfaceFree? = null, + ): UniffiVTableCallbackInterfaceFfiMessageCallback(`onMessage`,`onError`,`onClose`,`uniffiFree`,), Structure.ByValue + internal fun uniffiSetValue(other: UniffiVTableCallbackInterfaceFfiMessageCallback) { + `onMessage` = other.`onMessage` + `onError` = other.`onError` + `onClose` = other.`onClose` + `uniffiFree` = other.`uniffiFree` + } +} +@Structure.FieldOrder("onMessageDeleted", "uniffiFree") +internal open class UniffiVTableCallbackInterfaceFfiMessageDeletionCallback( + @JvmField internal var `onMessageDeleted`: UniffiCallbackInterfaceFfiMessageDeletionCallbackMethod0? = null, + @JvmField internal var `uniffiFree`: UniffiCallbackInterfaceFree? = null, +) : Structure() { + class UniffiByValue( + `onMessageDeleted`: UniffiCallbackInterfaceFfiMessageDeletionCallbackMethod0? = null, + `uniffiFree`: UniffiCallbackInterfaceFree? = null, + ): UniffiVTableCallbackInterfaceFfiMessageDeletionCallback(`onMessageDeleted`,`uniffiFree`,), Structure.ByValue + internal fun uniffiSetValue(other: UniffiVTableCallbackInterfaceFfiMessageDeletionCallback) { + `onMessageDeleted` = other.`onMessageDeleted` + `uniffiFree` = other.`uniffiFree` + } +} +@Structure.FieldOrder("onPreferenceUpdate", "onError", "onClose", "uniffiFree") +internal open class UniffiVTableCallbackInterfaceFfiPreferenceCallback( + @JvmField internal var `onPreferenceUpdate`: UniffiCallbackInterfaceFfiPreferenceCallbackMethod0? = null, + @JvmField internal var `onError`: UniffiCallbackInterfaceFfiPreferenceCallbackMethod1? = null, + @JvmField internal var `onClose`: UniffiCallbackInterfaceFfiPreferenceCallbackMethod2? = null, + @JvmField internal var `uniffiFree`: UniffiCallbackInterfaceFree? = null, +) : Structure() { + class UniffiByValue( + `onPreferenceUpdate`: UniffiCallbackInterfaceFfiPreferenceCallbackMethod0? = null, + `onError`: UniffiCallbackInterfaceFfiPreferenceCallbackMethod1? = null, + `onClose`: UniffiCallbackInterfaceFfiPreferenceCallbackMethod2? = null, + `uniffiFree`: UniffiCallbackInterfaceFree? = null, + ): UniffiVTableCallbackInterfaceFfiPreferenceCallback(`onPreferenceUpdate`,`onError`,`onClose`,`uniffiFree`,), Structure.ByValue + internal fun uniffiSetValue(other: UniffiVTableCallbackInterfaceFfiPreferenceCallback) { + `onPreferenceUpdate` = other.`onPreferenceUpdate` + `onError` = other.`onError` + `onClose` = other.`onClose` + `uniffiFree` = other.`uniffiFree` + } +} // For large crates we prevent `MethodTooLargeException` (see #2340) @@ -1514,6 +989,8 @@ fun uniffi_xmtpv3_checksum_method_fficonversation_count_messages( ): Short fun uniffi_xmtpv3_checksum_method_fficonversation_created_at_ns( ): Short + fun uniffi_xmtpv3_checksum_method_fficonversation_delete_message( + ): Short fun uniffi_xmtpv3_checksum_method_fficonversation_dm_peer_inbox_id( ): Short fun uniffi_xmtpv3_checksum_method_fficonversation_find_duplicate_dms( @@ -1926,6 +1403,9 @@ fun uniffi_xmtpv3_fn_method_fficonversation_count_messages(`ptr`: Pointer,`opts` ): Long fun uniffi_xmtpv3_fn_method_fficonversation_created_at_ns(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Long + fun uniffi_xmtpv3_fn_method_fficonversation_delete_message( + `ptr`: Pointer, `messageId`: RustBuffer.ByValue, uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue fun uniffi_xmtpv3_fn_method_fficonversation_dm_peer_inbox_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue fun uniffi_xmtpv3_fn_method_fficonversation_find_duplicate_dms(`ptr`: Pointer, @@ -2693,6 +2173,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_xmtpv3_checksum_method_fficonversation_created_at_ns() != 17973.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_xmtpv3_checksum_method_fficonversation_delete_message() != 54360.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_xmtpv3_checksum_method_fficonversation_dm_peer_inbox_id() != 2178.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -4692,6 +4175,11 @@ public interface FfiConversationInterface { fun `countMessages`(`opts`: FfiListMessagesOptions): kotlin.Long fun `createdAtNs`(): kotlin.Long + + /** + * Delete a message by its ID. Returns the ID of the deletion message. + */ + fun `deleteMessage`(`messageId`: kotlin.ByteArray): kotlin.ByteArray fun `dmPeerInboxId`(): kotlin.String? @@ -5073,6 +4561,23 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } + + + /** + * Delete a message by its ID. Returns the ID of the deletion message. + */ + @Throws(GenericException::class) + override fun `deleteMessage`(`messageId`: kotlin.ByteArray): kotlin.ByteArray { + return FfiConverterByteArray.lift( + callWithPointer { + uniffiRustCallWithError(GenericException) { _status -> + UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_fficonversation_delete_message( + it, FfiConverterByteArray.lower(`messageId`), _status + ) + } + } + ) + } override fun `dmPeerInboxId`(): kotlin.String? { @@ -12145,6 +11650,33 @@ public object FfiConverterTypeFfiDecodedMessageMetadata: FfiConverterRustBuffer< } +data class FfiDeletedMessage( + var `deletedBy`: FfiDeletedBy, +) { + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeFfiDeletedMessage : FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): FfiDeletedMessage { + return FfiDeletedMessage( + FfiConverterTypeFfiDeletedBy.read(buf), + ) + } + + override fun allocationSize(value: FfiDeletedMessage) = ( + FfiConverterTypeFfiDeletedBy.allocationSize(value.`deletedBy`) + ) + + override fun write(value: FfiDeletedMessage, buf: ByteBuffer) { + FfiConverterTypeFfiDeletedBy.write(value.`deletedBy`, buf) + } +} + + data class FfiEncodedContent ( var `typeId`: FfiContentTypeId?, @@ -13138,15 +12670,16 @@ public object FfiConverterTypeFfiPasskeySignature: FfiConverterRustBuffer FfiDecodedMessageBody.LeaveRequest( FfiConverterTypeFfiLeaveRequest.read(buf), ) - 14 -> FfiDecodedMessageBody.Custom( + 14 -> FfiDecodedMessageBody.DeletedMessage( + FfiConverterTypeFfiDeletedMessage.read(buf), + ) + + 15 -> FfiDecodedMessageBody.Custom( FfiConverterTypeFfiEncodedContent.read(buf), ) else -> throw RuntimeException("invalid enum value, something is very wrong!!") @@ -14290,6 +13841,13 @@ public object FfiConverterTypeFfiDecodedMessageBody : FfiConverterRustBuffer { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterTypeFfiDeletedMessage.allocationSize(value.v1) + ) + } is FfiDecodedMessageBody.Custom -> { // Add the size for the Int that specifies the variant plus the size needed for all fields ( @@ -14366,8 +13924,14 @@ public object FfiConverterTypeFfiDecodedMessageBody : FfiConverterRustBuffer { + is FfiDecodedMessageBody.DeletedMessage -> { buf.putInt(14) + FfiConverterTypeFfiDeletedMessage.write(value.v1, buf) + Unit + } + + is FfiDecodedMessageBody.Custom -> { + buf.putInt(15) FfiConverterTypeFfiEncodedContent.write(value.v1, buf) Unit } @@ -14450,6 +14014,12 @@ sealed class FfiDecodedMessageContent: Disposable { val v1: FfiLeaveRequest) : FfiDecodedMessageContent() { companion object } + + data class DeletedMessage( + val v1: FfiDeletedMessage, + ) : FfiDecodedMessageContent() { + companion object + } data class Custom( val v1: FfiEncodedContent) : FfiDecodedMessageContent() { @@ -14553,6 +14123,14 @@ sealed class FfiDecodedMessageContent: Disposable { } is FfiDecodedMessageContent.LeaveRequest -> { + + Disposable.destroy( + this.v1 + ) + + } + + is FfiDecodedMessageContent.DeletedMessage -> { Disposable.destroy( this.v1 @@ -14620,7 +14198,11 @@ public object FfiConverterTypeFfiDecodedMessageContent : FfiConverterRustBuffer< 14 -> FfiDecodedMessageContent.LeaveRequest( FfiConverterTypeFfiLeaveRequest.read(buf), ) - 15 -> FfiDecodedMessageContent.Custom( + 15 -> FfiDecodedMessageContent.DeletedMessage( + FfiConverterTypeFfiDeletedMessage.read(buf), + ) + + 16 -> FfiDecodedMessageContent.Custom( FfiConverterTypeFfiEncodedContent.read(buf), ) else -> throw RuntimeException("invalid enum value, something is very wrong!!") @@ -14726,6 +14308,13 @@ public object FfiConverterTypeFfiDecodedMessageContent : FfiConverterRustBuffer< + FfiConverterTypeFfiLeaveRequest.allocationSize(value.v1) ) } + is FfiDecodedMessageContent.DeletedMessage -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterTypeFfiDeletedMessage.allocationSize(value.v1) + ) + } is FfiDecodedMessageContent.Custom -> { // Add the size for the Int that specifies the variant plus the size needed for all fields ( @@ -14807,8 +14396,14 @@ public object FfiConverterTypeFfiDecodedMessageContent : FfiConverterRustBuffer< FfiConverterTypeFfiLeaveRequest.write(value.v1, buf) Unit } - is FfiDecodedMessageContent.Custom -> { + is FfiDecodedMessageContent.DeletedMessage -> { buf.putInt(15) + FfiConverterTypeFfiDeletedMessage.write(value.v1, buf) + Unit + } + + is FfiDecodedMessageContent.Custom -> { + buf.putInt(16) FfiConverterTypeFfiEncodedContent.write(value.v1, buf) Unit } @@ -14817,6 +14412,70 @@ public object FfiConverterTypeFfiDecodedMessageContent : FfiConverterRustBuffer< } +sealed class FfiDeletedBy { + + object Sender : FfiDeletedBy() + + + data class Admin( + val `inboxId`: kotlin.String, + ) : FfiDeletedBy() { + companion object + } + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeFfiDeletedBy : FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): FfiDeletedBy { + return when (buf.getInt()) { + 1 -> FfiDeletedBy.Sender + 2 -> FfiDeletedBy.Admin( + FfiConverterString.read(buf), + ) + + else -> throw RuntimeException("invalid enum value, something is very wrong!!") + } + } + + override fun allocationSize(value: FfiDeletedBy) = when (value) { + is FfiDeletedBy.Sender -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + + is FfiDeletedBy.Admin -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`inboxId`) + ) + } + } + + override fun write(value: FfiDeletedBy, buf: ByteBuffer) { + when (value) { + is FfiDeletedBy.Sender -> { + buf.putInt(1) + Unit + } + + is FfiDeletedBy.Admin -> { + buf.putInt(2) + FfiConverterString.write(value.`inboxId`, buf) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } +} + + @@ -15169,7 +14828,8 @@ enum class FfiMetadataField { GROUP_NAME, DESCRIPTION, - IMAGE_URL_SQUARE; + IMAGE_URL_SQUARE, + APP_DATA; companion object } From 2c66ededc46442bafe9100c8f30013b1c8327731 Mon Sep 17 00:00:00 2001 From: mchenani Date: Tue, 13 Jan 2026 18:10:15 +0100 Subject: [PATCH 2/8] fix fmt --- .../xmtp/android/library/DeleteMessageTest.kt | 271 +- .../android/library/codecs/DeletedMessage.kt | 23 + .../library/libxmtp/DecodedMessageV2.kt | 15 + library/src/main/java/xmtpv3.kt | 2329 ++++++++--------- 4 files changed, 1368 insertions(+), 1270 deletions(-) create mode 100644 library/src/main/java/org/xmtp/android/library/codecs/DeletedMessage.kt diff --git a/library/src/androidTest/java/org/xmtp/android/library/DeleteMessageTest.kt b/library/src/androidTest/java/org/xmtp/android/library/DeleteMessageTest.kt index af82b8933..5f0f5f20f 100644 --- a/library/src/androidTest/java/org/xmtp/android/library/DeleteMessageTest.kt +++ b/library/src/androidTest/java/org/xmtp/android/library/DeleteMessageTest.kt @@ -3,22 +3,16 @@ package org.xmtp.android.library import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.xmtp.android.library.codecs.ContentTypeReaction -import org.xmtp.android.library.codecs.Reaction -import org.xmtp.android.library.codecs.ReactionAction -import org.xmtp.android.library.codecs.ReactionCodec -import org.xmtp.android.library.codecs.ReactionSchema -import org.xmtp.android.library.codecs.ContentTypeReply -import org.xmtp.android.library.codecs.Reply -import org.xmtp.android.library.codecs.ReplyCodec -import org.xmtp.android.library.libxmtp.GroupPermissionPreconfiguration -import uniffi.xmtpv3.GenericException +import org.xmtp.android.library.codecs.DeletedBy +import org.xmtp.android.library.codecs.DeletedMessage @RunWith(AndroidJUnit4::class) class DeleteMessageTest : BaseInstrumentedTest() { @@ -38,97 +32,88 @@ class DeleteMessageTest : BaseInstrumentedTest() { @Test fun testSenderCanDeleteOwnMessage() { - // Create a group with alix and bo - val alixGroup = runBlocking { - alixClient.conversations.newGroup(listOf(boClient.inboxId)) - } + val alixGroup = + runBlocking { + alixClient.conversations.newGroup(listOf(boClient.inboxId)) + } - // Alix sends a message - val messageId = runBlocking { - alixGroup.send("Hello, this message will be deleted") - } + val messageId = + runBlocking { + alixGroup.send("Hello, this message will be deleted") + } - // Verify message exists runBlocking { alixGroup.sync() } var messages = runBlocking { alixGroup.messages() } assertTrue(messages.any { it.id == messageId }) - // Alix deletes own message - val deletionMessageId = runBlocking { - alixGroup.deleteMessage(messageId) - } + val deletionMessageId = + runBlocking { + alixGroup.deleteMessage(messageId) + } assertNotNull(deletionMessageId) - // Sync and verify deletion runBlocking { alixGroup.sync() } messages = runBlocking { alixGroup.messages() } - - // The deletion message should exist assertTrue(messages.any { it.id == deletionMessageId }) } @Test fun testSuperAdminCanDeleteOthersMessage() { - // Alix creates a group (becomes super admin) with bo - val alixGroup = runBlocking { - alixClient.conversations.newGroup(listOf(boClient.inboxId)) - } + val alixGroup = + runBlocking { + alixClient.conversations.newGroup(listOf(boClient.inboxId)) + } - // Bo syncs and gets the group runBlocking { boClient.conversations.sync() } - val boGroup = runBlocking { - boClient.conversations.listGroups().first { it.id == alixGroup.id } - } + val boGroup = + runBlocking { + boClient.conversations.listGroups().first { it.id == alixGroup.id } + } - // Bo sends a message - val messageId = runBlocking { - boGroup.send("Hello from Bo") - } + val messageId = + runBlocking { + boGroup.send("Hello from Bo") + } - // Sync both runBlocking { alixGroup.sync() boGroup.sync() } - // Verify alix is super admin assertTrue(runBlocking { alixGroup.isSuperAdmin(alixClient.inboxId) }) - // Alix (super admin) deletes Bo's message - val deletionMessageId = runBlocking { - alixGroup.deleteMessage(messageId) - } + val deletionMessageId = + runBlocking { + alixGroup.deleteMessage(messageId) + } assertNotNull(deletionMessageId) } @Test fun testRegularUserCannotDeleteOthersMessage() { - // Alix creates a group with bo - val alixGroup = runBlocking { - alixClient.conversations.newGroup(listOf(boClient.inboxId)) - } + val alixGroup = + runBlocking { + alixClient.conversations.newGroup(listOf(boClient.inboxId)) + } - // Bo syncs and gets the group runBlocking { boClient.conversations.sync() } - val boGroup = runBlocking { - boClient.conversations.listGroups().first { it.id == alixGroup.id } - } + val boGroup = + runBlocking { + boClient.conversations.listGroups().first { it.id == alixGroup.id } + } - // Alix sends a message - val messageId = runBlocking { - alixGroup.send("Hello from Alix") - } + val messageId = + runBlocking { + alixGroup.send("Hello from Alix") + } - // Sync both runBlocking { alixGroup.sync() boGroup.sync() } - // Bo is not super admin - assertTrue(!runBlocking { boGroup.isSuperAdmin(boClient.inboxId) }) + assertFalse(runBlocking { boGroup.isSuperAdmin(boClient.inboxId) }) - // Bo tries to delete Alix's message - should fail assertThrows(XMTPException::class.java) { runBlocking { boGroup.deleteMessage(messageId) @@ -138,22 +123,21 @@ class DeleteMessageTest : BaseInstrumentedTest() { @Test fun testCannotDeleteAlreadyDeletedMessage() { - // Create a group - val alixGroup = runBlocking { - alixClient.conversations.newGroup(listOf(boClient.inboxId)) - } + val alixGroup = + runBlocking { + alixClient.conversations.newGroup(listOf(boClient.inboxId)) + } - // Send and delete a message - val messageId = runBlocking { - alixGroup.send("Message to delete twice") - } + val messageId = + runBlocking { + alixGroup.send("Message to delete twice") + } runBlocking { alixGroup.deleteMessage(messageId) alixGroup.sync() } - // Try to delete the same message again - should fail assertThrows(XMTPException::class.java) { runBlocking { alixGroup.deleteMessage(messageId) @@ -163,28 +147,26 @@ class DeleteMessageTest : BaseInstrumentedTest() { @Test fun testDeleteMessageInDm() { - // Create a DM between alix and bo - val alixDm = runBlocking { - alixClient.conversations.findOrCreateDm(boClient.inboxId) - } + val alixDm = + runBlocking { + alixClient.conversations.findOrCreateDm(boClient.inboxId) + } - // Alix sends a message - val messageId = runBlocking { - alixDm.send("Hello in DM") - } + val messageId = + runBlocking { + alixDm.send("Hello in DM") + } - // Verify message exists runBlocking { alixDm.sync() } var messages = runBlocking { alixDm.messages() } assertTrue(messages.any { it.id == messageId }) - // Alix deletes own message - val deletionMessageId = runBlocking { - alixDm.deleteMessage(messageId) - } + val deletionMessageId = + runBlocking { + alixDm.deleteMessage(messageId) + } assertNotNull(deletionMessageId) - // Sync and verify deletion message exists runBlocking { alixDm.sync() } messages = runBlocking { alixDm.messages() } assertTrue(messages.any { it.id == deletionMessageId }) @@ -192,38 +174,125 @@ class DeleteMessageTest : BaseInstrumentedTest() { @Test fun testDeleteMessageViaConversation() { - // Create a group - val alixGroup = runBlocking { - alixClient.conversations.newGroup(listOf(boClient.inboxId)) - } + val alixGroup = + runBlocking { + alixClient.conversations.newGroup(listOf(boClient.inboxId)) + } - // Get as Conversation val conversation: Conversation = Conversation.Group(alixGroup) - // Send a message via conversation - val messageId = runBlocking { - conversation.send("Hello via conversation") - } + val messageId = + runBlocking { + conversation.send("Hello via conversation") + } - // Delete via conversation - val deletionMessageId = runBlocking { - conversation.deleteMessage(messageId) - } + val deletionMessageId = + runBlocking { + conversation.deleteMessage(messageId) + } assertNotNull(deletionMessageId) } @Test fun testDeleteMessageWithInvalidId() { - // Create a group - val alixGroup = runBlocking { - alixClient.conversations.newGroup(listOf(boClient.inboxId)) - } + val alixGroup = + runBlocking { + alixClient.conversations.newGroup(listOf(boClient.inboxId)) + } - // Try to delete a non-existent message assertThrows(XMTPException::class.java) { runBlocking { alixGroup.deleteMessage("0000000000000000000000000000000000000000000000000000000000000000") } } } + + @Test + fun testReceiverSeesDeletedMessageContentType() { + val alixGroup = + runBlocking { + alixClient.conversations.newGroup(listOf(boClient.inboxId)) + } + + runBlocking { boClient.conversations.sync() } + val boGroup = + runBlocking { + boClient.conversations.listGroups().first { it.id == alixGroup.id } + } + + val originalText = "Test message for deletion verification" + val messageId = + runBlocking { + alixGroup.send(originalText) + } + + runBlocking { + alixGroup.sync() + boGroup.sync() + } + + var boEnrichedMessages = runBlocking { boGroup.enrichedMessages() } + val boOriginalEnriched = boEnrichedMessages.find { it.id == messageId } + assertNotNull(boOriginalEnriched) + assertEquals(originalText, boOriginalEnriched?.content()) + + runBlocking { + alixGroup.deleteMessage(messageId) + alixGroup.sync() + } + + runBlocking { boGroup.sync() } + + boEnrichedMessages = runBlocking { boGroup.enrichedMessages() } + val boEnrichedAfterDeletion = boEnrichedMessages.find { it.id == messageId } + + assertNotNull(boEnrichedAfterDeletion) + + val deletedContent = boEnrichedAfterDeletion?.content() + assertNotNull(deletedContent) + assertTrue(deletedContent?.deletedBy is DeletedBy.Sender) + + val stringContent = boEnrichedAfterDeletion?.content() + assertNull(stringContent) + } + + @Test + fun testAdminDeleteShowsAdminDeletedBy() { + val alixGroup = + runBlocking { + alixClient.conversations.newGroup(listOf(boClient.inboxId)) + } + + runBlocking { boClient.conversations.sync() } + val boGroup = + runBlocking { + boClient.conversations.listGroups().first { it.id == alixGroup.id } + } + + val messageId = + runBlocking { + boGroup.send("Message from Bo") + } + + runBlocking { + alixGroup.sync() + boGroup.sync() + } + + assertTrue(runBlocking { alixGroup.isSuperAdmin(alixClient.inboxId) }) + + runBlocking { + alixGroup.deleteMessage(messageId) + alixGroup.sync() + boGroup.sync() + } + + val boEnrichedMessages = runBlocking { boGroup.enrichedMessages() } + val deletedMessage = boEnrichedMessages.find { it.id == messageId } + assertNotNull(deletedMessage) + + val deletedContent = deletedMessage?.content() + assertNotNull(deletedContent) + assertTrue(deletedContent?.deletedBy is DeletedBy.Admin) + } } diff --git a/library/src/main/java/org/xmtp/android/library/codecs/DeletedMessage.kt b/library/src/main/java/org/xmtp/android/library/codecs/DeletedMessage.kt new file mode 100644 index 000000000..4f6384ff7 --- /dev/null +++ b/library/src/main/java/org/xmtp/android/library/codecs/DeletedMessage.kt @@ -0,0 +1,23 @@ +package org.xmtp.android.library.codecs + +import org.xmtp.android.library.InboxId + +/** + * Represents a message that has been deleted. + */ +data class DeletedMessage( + val deletedBy: DeletedBy, +) + +/** + * Indicates who deleted the message. + */ +sealed class DeletedBy { + /** The original sender deleted their own message */ + object Sender : DeletedBy() + + /** An admin deleted the message */ + data class Admin( + val inboxId: InboxId, + ) : DeletedBy() +} diff --git a/library/src/main/java/org/xmtp/android/library/libxmtp/DecodedMessageV2.kt b/library/src/main/java/org/xmtp/android/library/libxmtp/DecodedMessageV2.kt index 11be054a1..26c170dd7 100644 --- a/library/src/main/java/org/xmtp/android/library/libxmtp/DecodedMessageV2.kt +++ b/library/src/main/java/org/xmtp/android/library/libxmtp/DecodedMessageV2.kt @@ -6,6 +6,8 @@ import org.xmtp.android.library.InboxId import org.xmtp.android.library.codecs.Attachment import org.xmtp.android.library.codecs.ContentTypeId import org.xmtp.android.library.codecs.ContentTypeIdBuilder +import org.xmtp.android.library.codecs.DeletedBy +import org.xmtp.android.library.codecs.DeletedMessage import org.xmtp.android.library.codecs.LeaveRequest import org.xmtp.android.library.codecs.MultiRemoteAttachment import org.xmtp.android.library.codecs.Reaction @@ -22,6 +24,8 @@ import uniffi.xmtpv3.FfiAttachment import uniffi.xmtpv3.FfiDecodedMessage import uniffi.xmtpv3.FfiDecodedMessageBody import uniffi.xmtpv3.FfiDecodedMessageContent +import uniffi.xmtpv3.FfiDeletedBy +import uniffi.xmtpv3.FfiDeletedMessage import uniffi.xmtpv3.FfiDeliveryStatus import uniffi.xmtpv3.FfiGroupUpdated import uniffi.xmtpv3.FfiInbox @@ -188,6 +192,15 @@ class DecodedMessageV2 private constructor( private fun mapLeaveRequest(ffiLeaveRequest: FfiLeaveRequest): LeaveRequest = LeaveRequest.create(authenticatedNote = ffiLeaveRequest.authenticatedNote) + private fun mapDeletedMessage(ffiDeleted: FfiDeletedMessage): DeletedMessage { + val deletedBy = + when (val ffiDeletedBy = ffiDeleted.deletedBy) { + is FfiDeletedBy.Sender -> DeletedBy.Sender + is FfiDeletedBy.Admin -> DeletedBy.Admin(ffiDeletedBy.inboxId) + } + return DeletedMessage(deletedBy) + } + // Helper functions for GroupUpdated proto mapping private fun mapFfiInboxToProto( @@ -258,6 +271,7 @@ class DecodedMessageV2 private constructor( is FfiDecodedMessageContent.GroupUpdated -> mapGroupUpdated(content.v1) is FfiDecodedMessageContent.ReadReceipt -> ReadReceipt is FfiDecodedMessageContent.LeaveRequest -> mapLeaveRequest(content.v1) + is FfiDecodedMessageContent.DeletedMessage -> mapDeletedMessage(content.v1) is FfiDecodedMessageContent.Custom -> { val encodedContent = encodedContentFromFfi(content.v1) encodedContent.decoded() @@ -280,6 +294,7 @@ class DecodedMessageV2 private constructor( is FfiDecodedMessageBody.WalletSendCalls -> body.v1 is FfiDecodedMessageBody.GroupUpdated -> mapGroupUpdated(body.v1) is FfiDecodedMessageBody.LeaveRequest -> mapLeaveRequest(body.v1) + is FfiDecodedMessageBody.DeletedMessage -> mapDeletedMessage(body.v1) is FfiDecodedMessageBody.Custom -> { val encodedContent = encodedContentFromFfi(body.v1) encodedContent.decoded() diff --git a/library/src/main/java/xmtpv3.kt b/library/src/main/java/xmtpv3.kt index cdbee2b10..57bce6e12 100644 --- a/library/src/main/java/xmtpv3.kt +++ b/library/src/main/java/xmtpv3.kt @@ -849,16 +849,16 @@ internal open class UniffiVTableCallbackInterfaceFfiPreferenceCallback( // For large crates we prevent `MethodTooLargeException` (see #2340) -// N.B. the name of the extension is very misleading, since it is -// rather `InterfaceTooLargeException`, caused by too many methods +// N.B. the name of the extension is very misleading, since it is +// rather `InterfaceTooLargeException`, caused by too many methods // in the interface for large crates. // // By splitting the otherwise huge interface into two parts -// * UniffiLib +// * UniffiLib // * IntegrityCheckingUniffiLib (this) // we allow for ~2x as many methods in the UniffiLib interface. -// -// The `ffi_uniffi_contract_version` method and all checksum methods are put +// +// The `ffi_uniffi_contract_version` method and all checksum methods are put // into `IntegrityCheckingUniffiLib` and these methods are called only once, // when the library is loaded. internal interface IntegrityCheckingUniffiLib : Library { @@ -1299,8 +1299,8 @@ internal interface UniffiLib : Library { internal val INSTANCE: UniffiLib by lazy { val componentName = "xmtpv3" // For large crates we prevent `MethodTooLargeException` (see #2340) - // N.B. the name of the extension is very misleading, since it is - // rather `InterfaceTooLargeException`, caused by too many methods + // N.B. the name of the extension is very misleading, since it is + // rather `InterfaceTooLargeException`, caused by too many methods // in the interface for large crates. // // By splitting the otherwise huge interface into two parts @@ -1308,7 +1308,7 @@ internal interface UniffiLib : Library { // * IntegrityCheckingUniffiLib // And all checksum methods are put into `IntegrityCheckingUniffiLib` // we allow for ~2x as many methods in the UniffiLib interface. - // + // // Thus we first load the library with `loadIndirect` as `IntegrityCheckingUniffiLib` // so that we can (optionally!) call `uniffiCheckApiChecksums`... loadIndirect(componentName) @@ -1323,7 +1323,7 @@ internal interface UniffiLib : Library { // to trigger this issue, the performance impact is negligible, running on // a macOS M1 machine the `loadIndirect` call takes ~50ms. val lib = loadIndirect(componentName) - // No need to check the contract version and checksums, since + // No need to check the contract version and checksums, since // we already did that with `IntegrityCheckingUniffiLib` above. uniffiCallbackInterfaceFfiAuthCallback.register(lib) uniffiCallbackInterfaceFfiConsentCallback.register(lib) @@ -1335,7 +1335,7 @@ internal interface UniffiLib : Library { // Loading of library with integrity check done. lib } - + // The Cleaner for the whole library internal val CLEANER: UniffiCleaner by lazy { UniffiCleaner.create() @@ -1343,39 +1343,39 @@ internal interface UniffiLib : Library { } // FFI functions - fun uniffi_xmtpv3_fn_clone_ffiauthcallback(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, + fun uniffi_xmtpv3_fn_clone_ffiauthcallback(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_xmtpv3_fn_free_ffiauthcallback(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_free_ffiauthcallback(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit fun uniffi_xmtpv3_fn_init_callback_vtable_ffiauthcallback(`vtable`: UniffiVTableCallbackInterfaceFfiAuthCallback, ): Unit fun uniffi_xmtpv3_fn_method_ffiauthcallback_on_auth_required(`ptr`: Pointer, ): Long -fun uniffi_xmtpv3_fn_clone_ffiauthhandle(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_clone_ffiauthhandle(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_xmtpv3_fn_free_ffiauthhandle(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_free_ffiauthhandle(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_constructor_ffiauthhandle_new(uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_constructor_ffiauthhandle_new(uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_xmtpv3_fn_method_ffiauthhandle_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffiauthhandle_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Long fun uniffi_xmtpv3_fn_method_ffiauthhandle_set(`ptr`: Pointer,`credential`: RustBuffer.ByValue, ): Long -fun uniffi_xmtpv3_fn_clone_fficonsentcallback(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_clone_fficonsentcallback(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_xmtpv3_fn_free_fficonsentcallback(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_free_fficonsentcallback(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit fun uniffi_xmtpv3_fn_init_callback_vtable_fficonsentcallback(`vtable`: UniffiVTableCallbackInterfaceFfiConsentCallback, ): Unit -fun uniffi_xmtpv3_fn_method_fficonsentcallback_on_consent_update(`ptr`: Pointer,`consent`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonsentcallback_on_consent_update(`ptr`: Pointer,`consent`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_method_fficonsentcallback_on_error(`ptr`: Pointer,`error`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonsentcallback_on_error(`ptr`: Pointer,`error`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_method_fficonsentcallback_on_close(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonsentcallback_on_close(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_clone_fficonversation(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_clone_fficonversation(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_xmtpv3_fn_free_fficonversation(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_free_fficonversation(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit fun uniffi_xmtpv3_fn_method_fficonversation_add_admin(`ptr`: Pointer,`inboxId`: RustBuffer.ByValue, ): Long @@ -1385,68 +1385,68 @@ fun uniffi_xmtpv3_fn_method_fficonversation_add_members_by_inbox_id(`ptr`: Point ): Long fun uniffi_xmtpv3_fn_method_fficonversation_add_super_admin(`ptr`: Pointer,`inboxId`: RustBuffer.ByValue, ): Long -fun uniffi_xmtpv3_fn_method_fficonversation_added_by_inbox_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_added_by_inbox_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_fficonversation_admin_list(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_admin_list(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_fficonversation_app_data(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_app_data(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_fficonversation_consent_state(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_consent_state(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue fun uniffi_xmtpv3_fn_method_fficonversation_conversation_debug_info(`ptr`: Pointer, ): Long -fun uniffi_xmtpv3_fn_method_fficonversation_conversation_message_disappearing_settings(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_conversation_message_disappearing_settings(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_fficonversation_conversation_type(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_conversation_type(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_fficonversation_count_messages(`ptr`: Pointer,`opts`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_count_messages(`ptr`: Pointer,`opts`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Long -fun uniffi_xmtpv3_fn_method_fficonversation_created_at_ns(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_created_at_ns(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Long fun uniffi_xmtpv3_fn_method_fficonversation_delete_message( `ptr`: Pointer, `messageId`: RustBuffer.ByValue, uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_fficonversation_dm_peer_inbox_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_dm_peer_inbox_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue fun uniffi_xmtpv3_fn_method_fficonversation_find_duplicate_dms(`ptr`: Pointer, ): Long -fun uniffi_xmtpv3_fn_method_fficonversation_find_enriched_messages(`ptr`: Pointer,`opts`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_find_enriched_messages(`ptr`: Pointer,`opts`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue fun uniffi_xmtpv3_fn_method_fficonversation_find_messages(`ptr`: Pointer,`opts`: RustBuffer.ByValue, ): Long -fun uniffi_xmtpv3_fn_method_fficonversation_find_messages_with_reactions(`ptr`: Pointer,`opts`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_find_messages_with_reactions(`ptr`: Pointer,`opts`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_fficonversation_get_hmac_keys(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_get_hmac_keys(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_fficonversation_get_last_read_times(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_get_last_read_times(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_fficonversation_group_description(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_group_description(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_fficonversation_group_image_url_square(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_group_image_url_square(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue fun uniffi_xmtpv3_fn_method_fficonversation_group_metadata(`ptr`: Pointer, ): Long -fun uniffi_xmtpv3_fn_method_fficonversation_group_name(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_group_name(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_fficonversation_group_permissions(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_group_permissions(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_xmtpv3_fn_method_fficonversation_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_fficonversation_is_active(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_is_active(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Byte -fun uniffi_xmtpv3_fn_method_fficonversation_is_admin(`ptr`: Pointer,`inboxId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_is_admin(`ptr`: Pointer,`inboxId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Byte -fun uniffi_xmtpv3_fn_method_fficonversation_is_conversation_message_disappearing_enabled(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_is_conversation_message_disappearing_enabled(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Byte -fun uniffi_xmtpv3_fn_method_fficonversation_is_super_admin(`ptr`: Pointer,`inboxId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_is_super_admin(`ptr`: Pointer,`inboxId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Byte fun uniffi_xmtpv3_fn_method_fficonversation_leave_group(`ptr`: Pointer, ): Long fun uniffi_xmtpv3_fn_method_fficonversation_list_members(`ptr`: Pointer, ): Long -fun uniffi_xmtpv3_fn_method_fficonversation_membership_state(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_membership_state(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_fficonversation_paused_for_version(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_paused_for_version(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue fun uniffi_xmtpv3_fn_method_fficonversation_process_streamed_conversation_message(`ptr`: Pointer,`envelopeBytes`: RustBuffer.ByValue, ): Long @@ -1464,19 +1464,19 @@ fun uniffi_xmtpv3_fn_method_fficonversation_remove_super_admin(`ptr`: Pointer,`i ): Long fun uniffi_xmtpv3_fn_method_fficonversation_send(`ptr`: Pointer,`contentBytes`: RustBuffer.ByValue,`opts`: RustBuffer.ByValue, ): Long -fun uniffi_xmtpv3_fn_method_fficonversation_send_optimistic(`ptr`: Pointer,`contentBytes`: RustBuffer.ByValue,`opts`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_send_optimistic(`ptr`: Pointer,`contentBytes`: RustBuffer.ByValue,`opts`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue fun uniffi_xmtpv3_fn_method_fficonversation_send_text(`ptr`: Pointer,`text`: RustBuffer.ByValue, ): Long fun uniffi_xmtpv3_fn_method_fficonversation_stream(`ptr`: Pointer,`messageCallback`: Pointer, ): Long -fun uniffi_xmtpv3_fn_method_fficonversation_super_admin_list(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_super_admin_list(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue fun uniffi_xmtpv3_fn_method_fficonversation_sync(`ptr`: Pointer, ): Long fun uniffi_xmtpv3_fn_method_fficonversation_update_app_data(`ptr`: Pointer,`appData`: RustBuffer.ByValue, ): Long -fun uniffi_xmtpv3_fn_method_fficonversation_update_consent_state(`ptr`: Pointer,`state`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversation_update_consent_state(`ptr`: Pointer,`state`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Unit fun uniffi_xmtpv3_fn_method_fficonversation_update_conversation_message_disappearing_settings(`ptr`: Pointer,`settings`: RustBuffer.ByValue, ): Long @@ -1488,43 +1488,43 @@ fun uniffi_xmtpv3_fn_method_fficonversation_update_group_name(`ptr`: Pointer,`gr ): Long fun uniffi_xmtpv3_fn_method_fficonversation_update_permission_policy(`ptr`: Pointer,`permissionUpdateType`: RustBuffer.ByValue,`permissionPolicyOption`: RustBuffer.ByValue,`metadataField`: RustBuffer.ByValue, ): Long -fun uniffi_xmtpv3_fn_clone_fficonversationcallback(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_clone_fficonversationcallback(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_xmtpv3_fn_free_fficonversationcallback(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_free_fficonversationcallback(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit fun uniffi_xmtpv3_fn_init_callback_vtable_fficonversationcallback(`vtable`: UniffiVTableCallbackInterfaceFfiConversationCallback, ): Unit -fun uniffi_xmtpv3_fn_method_fficonversationcallback_on_conversation(`ptr`: Pointer,`conversation`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversationcallback_on_conversation(`ptr`: Pointer,`conversation`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_method_fficonversationcallback_on_error(`ptr`: Pointer,`error`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversationcallback_on_error(`ptr`: Pointer,`error`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_method_fficonversationcallback_on_close(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversationcallback_on_close(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_clone_fficonversationlistitem(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_clone_fficonversationlistitem(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_xmtpv3_fn_free_fficonversationlistitem(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_free_fficonversationlistitem(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_method_fficonversationlistitem_conversation(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversationlistitem_conversation(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_xmtpv3_fn_method_fficonversationlistitem_is_commit_log_forked(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversationlistitem_is_commit_log_forked(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_fficonversationlistitem_last_message(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversationlistitem_last_message(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_clone_fficonversationmetadata(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_clone_fficonversationmetadata(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_xmtpv3_fn_free_fficonversationmetadata(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_free_fficonversationmetadata(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_method_fficonversationmetadata_conversation_type(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversationmetadata_conversation_type(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_fficonversationmetadata_creator_inbox_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversationmetadata_creator_inbox_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_clone_fficonversations(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_clone_fficonversations(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_xmtpv3_fn_free_fficonversations(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_free_fficonversations(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit fun uniffi_xmtpv3_fn_method_fficonversations_create_group(`ptr`: Pointer,`accountIdentities`: RustBuffer.ByValue,`opts`: RustBuffer.ByValue, ): Long -fun uniffi_xmtpv3_fn_method_fficonversations_create_group_optimistic(`ptr`: Pointer,`opts`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversations_create_group_optimistic(`ptr`: Pointer,`opts`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Pointer fun uniffi_xmtpv3_fn_method_fficonversations_create_group_with_inbox_ids(`ptr`: Pointer,`inboxIds`: RustBuffer.ByValue,`opts`: RustBuffer.ByValue, ): Long @@ -1532,13 +1532,13 @@ fun uniffi_xmtpv3_fn_method_fficonversations_find_or_create_dm(`ptr`: Pointer,`t ): Long fun uniffi_xmtpv3_fn_method_fficonversations_find_or_create_dm_by_inbox_id(`ptr`: Pointer,`inboxId`: RustBuffer.ByValue,`opts`: RustBuffer.ByValue, ): Long -fun uniffi_xmtpv3_fn_method_fficonversations_get_hmac_keys(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversations_get_hmac_keys(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_fficonversations_list(`ptr`: Pointer,`opts`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversations_list(`ptr`: Pointer,`opts`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_fficonversations_list_dms(`ptr`: Pointer,`opts`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversations_list_dms(`ptr`: Pointer,`opts`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_fficonversations_list_groups(`ptr`: Pointer,`opts`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_fficonversations_list_groups(`ptr`: Pointer,`opts`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue fun uniffi_xmtpv3_fn_method_fficonversations_process_streamed_welcome_message(`ptr`: Pointer,`envelopeBytes`: RustBuffer.ByValue, ): Long @@ -1566,95 +1566,95 @@ fun uniffi_xmtpv3_fn_method_fficonversations_sync(`ptr`: Pointer, ): Long fun uniffi_xmtpv3_fn_method_fficonversations_sync_all_conversations(`ptr`: Pointer,`consentStates`: RustBuffer.ByValue, ): Long -fun uniffi_xmtpv3_fn_clone_ffidecodedmessage(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_clone_ffidecodedmessage(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_xmtpv3_fn_free_ffidecodedmessage(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_free_ffidecodedmessage(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_method_ffidecodedmessage_content(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffidecodedmessage_content(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_ffidecodedmessage_content_type_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffidecodedmessage_content_type_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_ffidecodedmessage_conversation_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffidecodedmessage_conversation_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_ffidecodedmessage_delivery_status(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffidecodedmessage_delivery_status(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_ffidecodedmessage_expires_at_ns(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffidecodedmessage_expires_at_ns(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_ffidecodedmessage_fallback_text(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffidecodedmessage_fallback_text(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_ffidecodedmessage_has_reactions(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffidecodedmessage_has_reactions(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Byte -fun uniffi_xmtpv3_fn_method_ffidecodedmessage_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffidecodedmessage_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_ffidecodedmessage_inserted_at_ns(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffidecodedmessage_inserted_at_ns(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Long -fun uniffi_xmtpv3_fn_method_ffidecodedmessage_kind(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffidecodedmessage_kind(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_ffidecodedmessage_num_replies(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffidecodedmessage_num_replies(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Long -fun uniffi_xmtpv3_fn_method_ffidecodedmessage_reaction_count(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffidecodedmessage_reaction_count(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Long -fun uniffi_xmtpv3_fn_method_ffidecodedmessage_reactions(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffidecodedmessage_reactions(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_ffidecodedmessage_sender_inbox_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffidecodedmessage_sender_inbox_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_ffidecodedmessage_sender_installation_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffidecodedmessage_sender_installation_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_ffidecodedmessage_sent_at_ns(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffidecodedmessage_sent_at_ns(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Long -fun uniffi_xmtpv3_fn_clone_ffigrouppermissions(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_clone_ffigrouppermissions(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_xmtpv3_fn_free_ffigrouppermissions(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_free_ffigrouppermissions(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_method_ffigrouppermissions_policy_set(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffigrouppermissions_policy_set(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_ffigrouppermissions_policy_type(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffigrouppermissions_policy_type(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_clone_ffiinboxowner(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_clone_ffiinboxowner(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_xmtpv3_fn_free_ffiinboxowner(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_free_ffiinboxowner(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit fun uniffi_xmtpv3_fn_init_callback_vtable_ffiinboxowner(`vtable`: UniffiVTableCallbackInterfaceFfiInboxOwner, ): Unit -fun uniffi_xmtpv3_fn_method_ffiinboxowner_get_identifier(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffiinboxowner_get_identifier(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_ffiinboxowner_sign(`ptr`: Pointer,`text`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffiinboxowner_sign(`ptr`: Pointer,`text`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_clone_ffimessagecallback(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_clone_ffimessagecallback(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_xmtpv3_fn_free_ffimessagecallback(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_free_ffimessagecallback(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit fun uniffi_xmtpv3_fn_init_callback_vtable_ffimessagecallback(`vtable`: UniffiVTableCallbackInterfaceFfiMessageCallback, ): Unit -fun uniffi_xmtpv3_fn_method_ffimessagecallback_on_message(`ptr`: Pointer,`message`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffimessagecallback_on_message(`ptr`: Pointer,`message`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_method_ffimessagecallback_on_error(`ptr`: Pointer,`error`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffimessagecallback_on_error(`ptr`: Pointer,`error`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_method_ffimessagecallback_on_close(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffimessagecallback_on_close(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_clone_ffimessagedeletioncallback(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_clone_ffimessagedeletioncallback(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_xmtpv3_fn_free_ffimessagedeletioncallback(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_free_ffimessagedeletioncallback(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit fun uniffi_xmtpv3_fn_init_callback_vtable_ffimessagedeletioncallback(`vtable`: UniffiVTableCallbackInterfaceFfiMessageDeletionCallback, ): Unit -fun uniffi_xmtpv3_fn_method_ffimessagedeletioncallback_on_message_deleted(`ptr`: Pointer,`messageId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffimessagedeletioncallback_on_message_deleted(`ptr`: Pointer,`messageId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_clone_ffipreferencecallback(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_clone_ffipreferencecallback(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_xmtpv3_fn_free_ffipreferencecallback(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_free_ffipreferencecallback(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit fun uniffi_xmtpv3_fn_init_callback_vtable_ffipreferencecallback(`vtable`: UniffiVTableCallbackInterfaceFfiPreferenceCallback, ): Unit -fun uniffi_xmtpv3_fn_method_ffipreferencecallback_on_preference_update(`ptr`: Pointer,`preference`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffipreferencecallback_on_preference_update(`ptr`: Pointer,`preference`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_method_ffipreferencecallback_on_error(`ptr`: Pointer,`error`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffipreferencecallback_on_error(`ptr`: Pointer,`error`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_method_ffipreferencecallback_on_close(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffipreferencecallback_on_close(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_clone_ffisignaturerequest(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_clone_ffisignaturerequest(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_xmtpv3_fn_free_ffisignaturerequest(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_free_ffisignaturerequest(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit fun uniffi_xmtpv3_fn_method_ffisignaturerequest_add_ecdsa_signature(`ptr`: Pointer,`signatureBytes`: RustBuffer.ByValue, ): Long @@ -1668,37 +1668,37 @@ fun uniffi_xmtpv3_fn_method_ffisignaturerequest_missing_address_signatures(`ptr` ): Long fun uniffi_xmtpv3_fn_method_ffisignaturerequest_signature_text(`ptr`: Pointer, ): Long -fun uniffi_xmtpv3_fn_clone_ffistreamcloser(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_clone_ffistreamcloser(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_xmtpv3_fn_free_ffistreamcloser(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_free_ffistreamcloser(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_method_ffistreamcloser_end(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffistreamcloser_end(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit fun uniffi_xmtpv3_fn_method_ffistreamcloser_end_and_wait(`ptr`: Pointer, ): Long -fun uniffi_xmtpv3_fn_method_ffistreamcloser_is_closed(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffistreamcloser_is_closed(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Byte fun uniffi_xmtpv3_fn_method_ffistreamcloser_wait_for_ready(`ptr`: Pointer, ): Long -fun uniffi_xmtpv3_fn_clone_ffisyncworker(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_clone_ffisyncworker(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_xmtpv3_fn_free_ffisyncworker(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_free_ffisyncworker(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit fun uniffi_xmtpv3_fn_method_ffisyncworker_wait(`ptr`: Pointer,`metric`: RustBuffer.ByValue,`count`: Long, ): Long -fun uniffi_xmtpv3_fn_clone_ffixmtpclient(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_clone_ffixmtpclient(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_xmtpv3_fn_free_ffixmtpclient(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_free_ffixmtpclient(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit fun uniffi_xmtpv3_fn_method_ffixmtpclient_add_identity(`ptr`: Pointer,`newIdentity`: RustBuffer.ByValue, ): Long fun uniffi_xmtpv3_fn_method_ffixmtpclient_addresses_from_inbox_id(`ptr`: Pointer,`refreshFromNetwork`: Byte,`inboxIds`: RustBuffer.ByValue, ): Long -fun uniffi_xmtpv3_fn_method_ffixmtpclient_api_aggregate_statistics(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffixmtpclient_api_aggregate_statistics(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_ffixmtpclient_api_identity_statistics(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffixmtpclient_api_identity_statistics(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_ffixmtpclient_api_statistics(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffixmtpclient_api_statistics(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue fun uniffi_xmtpv3_fn_method_ffixmtpclient_apply_signature_request(`ptr`: Pointer,`signatureRequest`: Pointer, ): Long @@ -1708,21 +1708,21 @@ fun uniffi_xmtpv3_fn_method_ffixmtpclient_can_message(`ptr`: Pointer,`accountIde ): Long fun uniffi_xmtpv3_fn_method_ffixmtpclient_change_recovery_identifier(`ptr`: Pointer,`newRecoveryIdentifier`: RustBuffer.ByValue, ): Long -fun uniffi_xmtpv3_fn_method_ffixmtpclient_clear_all_statistics(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffixmtpclient_clear_all_statistics(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_method_ffixmtpclient_conversation(`ptr`: Pointer,`conversationId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffixmtpclient_conversation(`ptr`: Pointer,`conversationId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_xmtpv3_fn_method_ffixmtpclient_conversations(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffixmtpclient_conversations(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer fun uniffi_xmtpv3_fn_method_ffixmtpclient_create_archive(`ptr`: Pointer,`path`: RustBuffer.ByValue,`opts`: RustBuffer.ByValue,`key`: RustBuffer.ByValue, ): Long fun uniffi_xmtpv3_fn_method_ffixmtpclient_db_reconnect(`ptr`: Pointer, ): Long -fun uniffi_xmtpv3_fn_method_ffixmtpclient_delete_message(`ptr`: Pointer,`messageId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffixmtpclient_delete_message(`ptr`: Pointer,`messageId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Int -fun uniffi_xmtpv3_fn_method_ffixmtpclient_dm_conversation(`ptr`: Pointer,`targetInboxId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffixmtpclient_dm_conversation(`ptr`: Pointer,`targetInboxId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_xmtpv3_fn_method_ffixmtpclient_enriched_message(`ptr`: Pointer,`messageId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffixmtpclient_enriched_message(`ptr`: Pointer,`messageId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Pointer fun uniffi_xmtpv3_fn_method_ffixmtpclient_find_inbox_id(`ptr`: Pointer,`identifier`: RustBuffer.ByValue, ): Long @@ -1734,17 +1734,17 @@ fun uniffi_xmtpv3_fn_method_ffixmtpclient_get_latest_inbox_state(`ptr`: Pointer, ): Long fun uniffi_xmtpv3_fn_method_ffixmtpclient_import_archive(`ptr`: Pointer,`path`: RustBuffer.ByValue,`key`: RustBuffer.ByValue, ): Long -fun uniffi_xmtpv3_fn_method_ffixmtpclient_inbox_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffixmtpclient_inbox_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue fun uniffi_xmtpv3_fn_method_ffixmtpclient_inbox_state(`ptr`: Pointer,`refreshFromNetwork`: Byte, ): Long -fun uniffi_xmtpv3_fn_method_ffixmtpclient_installation_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffixmtpclient_installation_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_ffixmtpclient_message(`ptr`: Pointer,`messageId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffixmtpclient_message(`ptr`: Pointer,`messageId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue fun uniffi_xmtpv3_fn_method_ffixmtpclient_register_identity(`ptr`: Pointer,`signatureRequest`: Pointer, ): Long -fun uniffi_xmtpv3_fn_method_ffixmtpclient_release_db_connection(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffixmtpclient_release_db_connection(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit fun uniffi_xmtpv3_fn_method_ffixmtpclient_revoke_all_other_installations_signature_request(`ptr`: Pointer, ): Long @@ -1756,19 +1756,19 @@ fun uniffi_xmtpv3_fn_method_ffixmtpclient_send_sync_request(`ptr`: Pointer, ): Long fun uniffi_xmtpv3_fn_method_ffixmtpclient_set_consent_states(`ptr`: Pointer,`records`: RustBuffer.ByValue, ): Long -fun uniffi_xmtpv3_fn_method_ffixmtpclient_sign_with_installation_key(`ptr`: Pointer,`text`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffixmtpclient_sign_with_installation_key(`ptr`: Pointer,`text`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_method_ffixmtpclient_signature_request(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffixmtpclient_signature_request(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue fun uniffi_xmtpv3_fn_method_ffixmtpclient_sync_preferences(`ptr`: Pointer, ): Long -fun uniffi_xmtpv3_fn_method_ffixmtpclient_verify_signed_with_installation_key(`ptr`: Pointer,`signatureText`: RustBuffer.ByValue,`signatureBytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffixmtpclient_verify_signed_with_installation_key(`ptr`: Pointer,`signatureText`: RustBuffer.ByValue,`signatureBytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_method_ffixmtpclient_verify_signed_with_public_key(`ptr`: Pointer,`signatureText`: RustBuffer.ByValue,`signatureBytes`: RustBuffer.ByValue,`publicKey`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_method_ffixmtpclient_verify_signed_with_public_key(`ptr`: Pointer,`signatureText`: RustBuffer.ByValue,`signatureBytes`: RustBuffer.ByValue,`publicKey`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_clone_xmtpapiclient(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_clone_xmtpapiclient(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_xmtpv3_fn_free_xmtpapiclient(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_free_xmtpapiclient(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit fun uniffi_xmtpv3_fn_func_apply_signature_request(`api`: Pointer,`signatureRequest`: Pointer, ): Long @@ -1776,95 +1776,95 @@ fun uniffi_xmtpv3_fn_func_connect_to_backend(`v3Host`: RustBuffer.ByValue,`gatew ): Long fun uniffi_xmtpv3_fn_func_create_client(`api`: Pointer,`syncApi`: Pointer,`db`: RustBuffer.ByValue,`encryptionKey`: RustBuffer.ByValue,`inboxId`: RustBuffer.ByValue,`accountIdentifier`: RustBuffer.ByValue,`nonce`: Long,`legacySignedPrivateKeyProto`: RustBuffer.ByValue,`deviceSyncServerUrl`: RustBuffer.ByValue,`deviceSyncMode`: RustBuffer.ByValue,`allowOffline`: RustBuffer.ByValue,`forkRecoveryOpts`: RustBuffer.ByValue, ): Long -fun uniffi_xmtpv3_fn_func_decode_actions(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_decode_actions(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_decode_attachment(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_decode_attachment(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_decode_group_updated(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_decode_group_updated(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_decode_intent(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_decode_intent(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_decode_leave_request(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_decode_leave_request(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_decode_markdown(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_decode_markdown(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_decode_multi_remote_attachment(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_decode_multi_remote_attachment(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_decode_reaction(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_decode_reaction(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_decode_read_receipt(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_decode_read_receipt(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_decode_remote_attachment(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_decode_remote_attachment(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_decode_reply(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_decode_reply(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_decode_text(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_decode_text(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_decode_transaction_reference(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_decode_transaction_reference(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_decode_wallet_send_calls(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_decode_wallet_send_calls(`bytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_encode_actions(`actions`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_encode_actions(`actions`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_encode_attachment(`attachment`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_encode_attachment(`attachment`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_encode_intent(`intent`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_encode_intent(`intent`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_encode_leave_request(`request`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_encode_leave_request(`request`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_encode_markdown(`text`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_encode_markdown(`text`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_encode_multi_remote_attachment(`ffiMultiRemoteAttachment`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_encode_multi_remote_attachment(`ffiMultiRemoteAttachment`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_encode_reaction(`reaction`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_encode_reaction(`reaction`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_encode_read_receipt(`readReceipt`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_encode_read_receipt(`readReceipt`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_encode_remote_attachment(`remoteAttachment`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_encode_remote_attachment(`remoteAttachment`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_encode_reply(`reply`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_encode_reply(`reply`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_encode_text(`text`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_encode_text(`text`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_encode_transaction_reference(`reference`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_encode_transaction_reference(`reference`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_encode_wallet_send_calls(`walletSendCalls`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_encode_wallet_send_calls(`walletSendCalls`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_enter_debug_writer(`directory`: RustBuffer.ByValue,`logLevel`: RustBuffer.ByValue,`rotation`: RustBuffer.ByValue,`maxFiles`: Int,`processType`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_enter_debug_writer(`directory`: RustBuffer.ByValue,`logLevel`: RustBuffer.ByValue,`rotation`: RustBuffer.ByValue,`maxFiles`: Int,`processType`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_func_enter_debug_writer_with_level(`directory`: RustBuffer.ByValue,`rotation`: RustBuffer.ByValue,`maxFiles`: Int,`logLevel`: RustBuffer.ByValue,`processType`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_enter_debug_writer_with_level(`directory`: RustBuffer.ByValue,`rotation`: RustBuffer.ByValue,`maxFiles`: Int,`logLevel`: RustBuffer.ByValue,`processType`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_func_ethereum_address_from_pubkey(`pubkey`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_ethereum_address_from_pubkey(`pubkey`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_ethereum_generate_public_key(`privateKey32`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_ethereum_generate_public_key(`privateKey32`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_ethereum_hash_personal(`message`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_ethereum_hash_personal(`message`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_ethereum_sign_recoverable(`msg`: RustBuffer.ByValue,`privateKey32`: RustBuffer.ByValue,`hashing`: Byte,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_ethereum_sign_recoverable(`msg`: RustBuffer.ByValue,`privateKey32`: RustBuffer.ByValue,`hashing`: Byte,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_xmtpv3_fn_func_exit_debug_writer(uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_exit_debug_writer(uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_xmtpv3_fn_func_generate_inbox_id(`accountIdentifier`: RustBuffer.ByValue,`nonce`: Long,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_generate_inbox_id(`accountIdentifier`: RustBuffer.ByValue,`nonce`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue fun uniffi_xmtpv3_fn_func_get_inbox_id_for_identifier(`api`: Pointer,`accountIdentifier`: RustBuffer.ByValue, ): Long fun uniffi_xmtpv3_fn_func_get_newest_message_metadata(`api`: Pointer,`groupIds`: RustBuffer.ByValue, ): Long -fun uniffi_xmtpv3_fn_func_get_version_info(uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_get_version_info(uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue fun uniffi_xmtpv3_fn_func_inbox_state_from_inbox_ids(`api`: Pointer,`inboxIds`: RustBuffer.ByValue, ): Long fun uniffi_xmtpv3_fn_func_is_connected(`api`: Pointer, ): Long -fun uniffi_xmtpv3_fn_func_revoke_installations(`api`: Pointer,`recoveryIdentifier`: RustBuffer.ByValue,`inboxId`: RustBuffer.ByValue,`installationIds`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_xmtpv3_fn_func_revoke_installations(`api`: Pointer,`recoveryIdentifier`: RustBuffer.ByValue,`inboxId`: RustBuffer.ByValue,`installationIds`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun ffi_xmtpv3_rustbuffer_alloc(`size`: Long,uniffi_out_err: UniffiRustCallStatus, +fun ffi_xmtpv3_rustbuffer_alloc(`size`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun ffi_xmtpv3_rustbuffer_from_bytes(`bytes`: ForeignBytes.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun ffi_xmtpv3_rustbuffer_from_bytes(`bytes`: ForeignBytes.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun ffi_xmtpv3_rustbuffer_free(`buf`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun ffi_xmtpv3_rustbuffer_free(`buf`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun ffi_xmtpv3_rustbuffer_reserve(`buf`: RustBuffer.ByValue,`additional`: Long,uniffi_out_err: UniffiRustCallStatus, +fun ffi_xmtpv3_rustbuffer_reserve(`buf`: RustBuffer.ByValue,`additional`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue fun ffi_xmtpv3_rust_future_poll_u8(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, ): Unit @@ -1872,7 +1872,7 @@ fun ffi_xmtpv3_rust_future_cancel_u8(`handle`: Long, ): Unit fun ffi_xmtpv3_rust_future_free_u8(`handle`: Long, ): Unit -fun ffi_xmtpv3_rust_future_complete_u8(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +fun ffi_xmtpv3_rust_future_complete_u8(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Byte fun ffi_xmtpv3_rust_future_poll_i8(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, ): Unit @@ -1880,7 +1880,7 @@ fun ffi_xmtpv3_rust_future_cancel_i8(`handle`: Long, ): Unit fun ffi_xmtpv3_rust_future_free_i8(`handle`: Long, ): Unit -fun ffi_xmtpv3_rust_future_complete_i8(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +fun ffi_xmtpv3_rust_future_complete_i8(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Byte fun ffi_xmtpv3_rust_future_poll_u16(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, ): Unit @@ -1888,7 +1888,7 @@ fun ffi_xmtpv3_rust_future_cancel_u16(`handle`: Long, ): Unit fun ffi_xmtpv3_rust_future_free_u16(`handle`: Long, ): Unit -fun ffi_xmtpv3_rust_future_complete_u16(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +fun ffi_xmtpv3_rust_future_complete_u16(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Short fun ffi_xmtpv3_rust_future_poll_i16(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, ): Unit @@ -1896,7 +1896,7 @@ fun ffi_xmtpv3_rust_future_cancel_i16(`handle`: Long, ): Unit fun ffi_xmtpv3_rust_future_free_i16(`handle`: Long, ): Unit -fun ffi_xmtpv3_rust_future_complete_i16(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +fun ffi_xmtpv3_rust_future_complete_i16(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Short fun ffi_xmtpv3_rust_future_poll_u32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, ): Unit @@ -1904,7 +1904,7 @@ fun ffi_xmtpv3_rust_future_cancel_u32(`handle`: Long, ): Unit fun ffi_xmtpv3_rust_future_free_u32(`handle`: Long, ): Unit -fun ffi_xmtpv3_rust_future_complete_u32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +fun ffi_xmtpv3_rust_future_complete_u32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Int fun ffi_xmtpv3_rust_future_poll_i32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, ): Unit @@ -1912,7 +1912,7 @@ fun ffi_xmtpv3_rust_future_cancel_i32(`handle`: Long, ): Unit fun ffi_xmtpv3_rust_future_free_i32(`handle`: Long, ): Unit -fun ffi_xmtpv3_rust_future_complete_i32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +fun ffi_xmtpv3_rust_future_complete_i32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Int fun ffi_xmtpv3_rust_future_poll_u64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, ): Unit @@ -1920,7 +1920,7 @@ fun ffi_xmtpv3_rust_future_cancel_u64(`handle`: Long, ): Unit fun ffi_xmtpv3_rust_future_free_u64(`handle`: Long, ): Unit -fun ffi_xmtpv3_rust_future_complete_u64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +fun ffi_xmtpv3_rust_future_complete_u64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long fun ffi_xmtpv3_rust_future_poll_i64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, ): Unit @@ -1928,7 +1928,7 @@ fun ffi_xmtpv3_rust_future_cancel_i64(`handle`: Long, ): Unit fun ffi_xmtpv3_rust_future_free_i64(`handle`: Long, ): Unit -fun ffi_xmtpv3_rust_future_complete_i64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +fun ffi_xmtpv3_rust_future_complete_i64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long fun ffi_xmtpv3_rust_future_poll_f32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, ): Unit @@ -1936,7 +1936,7 @@ fun ffi_xmtpv3_rust_future_cancel_f32(`handle`: Long, ): Unit fun ffi_xmtpv3_rust_future_free_f32(`handle`: Long, ): Unit -fun ffi_xmtpv3_rust_future_complete_f32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +fun ffi_xmtpv3_rust_future_complete_f32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Float fun ffi_xmtpv3_rust_future_poll_f64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, ): Unit @@ -1944,7 +1944,7 @@ fun ffi_xmtpv3_rust_future_cancel_f64(`handle`: Long, ): Unit fun ffi_xmtpv3_rust_future_free_f64(`handle`: Long, ): Unit -fun ffi_xmtpv3_rust_future_complete_f64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +fun ffi_xmtpv3_rust_future_complete_f64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Double fun ffi_xmtpv3_rust_future_poll_pointer(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, ): Unit @@ -1952,7 +1952,7 @@ fun ffi_xmtpv3_rust_future_cancel_pointer(`handle`: Long, ): Unit fun ffi_xmtpv3_rust_future_free_pointer(`handle`: Long, ): Unit -fun ffi_xmtpv3_rust_future_complete_pointer(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +fun ffi_xmtpv3_rust_future_complete_pointer(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Pointer fun ffi_xmtpv3_rust_future_poll_rust_buffer(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, ): Unit @@ -1960,7 +1960,7 @@ fun ffi_xmtpv3_rust_future_cancel_rust_buffer(`handle`: Long, ): Unit fun ffi_xmtpv3_rust_future_free_rust_buffer(`handle`: Long, ): Unit -fun ffi_xmtpv3_rust_future_complete_rust_buffer(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +fun ffi_xmtpv3_rust_future_complete_rust_buffer(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue fun ffi_xmtpv3_rust_future_poll_void(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, ): Unit @@ -1968,7 +1968,7 @@ fun ffi_xmtpv3_rust_future_cancel_void(`handle`: Long, ): Unit fun ffi_xmtpv3_rust_future_free_void(`handle`: Long, ): Unit -fun ffi_xmtpv3_rust_future_complete_void(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +fun ffi_xmtpv3_rust_future_complete_void(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Unit } @@ -2819,7 +2819,7 @@ inline fun T.use(block: (T) -> R) = } } -/** +/** * Used to instantiate an interface without an actual pointer, for fakes in tests, mostly. * * @suppress @@ -3258,9 +3258,9 @@ public object FfiConverterByteArray: FfiConverterRustBuffer { public interface FfiAuthCallback { - + suspend fun `onAuthRequired`(): FfiCredential - + companion object } @@ -3346,7 +3346,7 @@ open class FfiAuthCallbackImpl: Disposable, AutoCloseable, FfiAuthCallback } } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `onAuthRequired`() : FfiCredential { @@ -3354,7 +3354,7 @@ open class FfiAuthCallbackImpl: Disposable, AutoCloseable, FfiAuthCallback callWithPointer { thisPtr -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_ffiauthcallback_on_auth_required( thisPtr, - + ) }, { future, callback, continuation -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_poll_rust_buffer(future, callback, continuation) }, @@ -3367,12 +3367,12 @@ open class FfiAuthCallbackImpl: Disposable, AutoCloseable, FfiAuthCallback ) } - - - + + + companion object - + } @@ -3561,11 +3561,11 @@ public object FfiConverterTypeFfiAuthCallback: FfiConverter UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - - - + + + companion object - + } /** @@ -3828,13 +3828,13 @@ public object FfiConverterTypeFfiAuthHandle: FfiConverter) - + fun `onError`(`error`: FfiSubscribeException) - + fun `onClose`() - + companion object } @@ -3921,44 +3921,44 @@ open class FfiConsentCallbackImpl: Disposable, AutoCloseable, FfiConsentCallback } override fun `onConsentUpdate`(`consent`: List) - = + = callWithPointer { uniffiRustCall() { _status -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_fficonsentcallback_on_consent_update( it, FfiConverterSequenceTypeFfiConsent.lower(`consent`),_status) } } - - + + override fun `onError`(`error`: FfiSubscribeException) - = + = callWithPointer { uniffiRustCall() { _status -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_fficonsentcallback_on_error( it, FfiConverterTypeFfiSubscribeError.lower(`error`),_status) } } - - + + override fun `onClose`() - = + = callWithPointer { uniffiRustCall() { _status -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_fficonsentcallback_on_close( it, _status) } } - - - - - + + + + + companion object - + } @@ -4149,126 +4149,126 @@ public object FfiConverterTypeFfiConsentCallback: FfiConverter): FfiUpdateGroupMembershipResult - + suspend fun `addMembersByInboxId`(`inboxIds`: List): FfiUpdateGroupMembershipResult - + suspend fun `addSuperAdmin`(`inboxId`: kotlin.String) - + fun `addedByInboxId`(): kotlin.String - + fun `adminList`(): List - + fun `appData`(): kotlin.String - + fun `consentState`(): FfiConsentState - + suspend fun `conversationDebugInfo`(): FfiConversationDebugInfo - + fun `conversationMessageDisappearingSettings`(): FfiMessageDisappearingSettings? - + fun `conversationType`(): FfiConversationType - + fun `countMessages`(`opts`: FfiListMessagesOptions): kotlin.Long - + fun `createdAtNs`(): kotlin.Long /** * Delete a message by its ID. Returns the ID of the deletion message. */ fun `deleteMessage`(`messageId`: kotlin.ByteArray): kotlin.ByteArray - + fun `dmPeerInboxId`(): kotlin.String? - + suspend fun `findDuplicateDms`(): List - + fun `findEnrichedMessages`(`opts`: FfiListMessagesOptions): List - + suspend fun `findMessages`(`opts`: FfiListMessagesOptions): List - + fun `findMessagesWithReactions`(`opts`: FfiListMessagesOptions): List - + fun `getHmacKeys`(): Map> - + fun `getLastReadTimes`(): Map - + fun `groupDescription`(): kotlin.String - + fun `groupImageUrlSquare`(): kotlin.String - + suspend fun `groupMetadata`(): FfiConversationMetadata - + fun `groupName`(): kotlin.String - + fun `groupPermissions`(): FfiGroupPermissions - + fun `id`(): kotlin.ByteArray - + fun `isActive`(): kotlin.Boolean - + fun `isAdmin`(`inboxId`: kotlin.String): kotlin.Boolean - + fun `isConversationMessageDisappearingEnabled`(): kotlin.Boolean - + fun `isSuperAdmin`(`inboxId`: kotlin.String): kotlin.Boolean - + suspend fun `leaveGroup`() - + suspend fun `listMembers`(): List - + fun `membershipState`(): FfiGroupMembershipState - + fun `pausedForVersion`(): kotlin.String? - + suspend fun `processStreamedConversationMessage`(`envelopeBytes`: kotlin.ByteArray): List - + /** * Publish all unpublished messages */ suspend fun `publishMessages`() - + suspend fun `removeAdmin`(`inboxId`: kotlin.String) - + suspend fun `removeConversationMessageDisappearingSettings`() - + suspend fun `removeMembers`(`accountIdentifiers`: List) - + suspend fun `removeMembersByInboxId`(`inboxIds`: List) - + suspend fun `removeSuperAdmin`(`inboxId`: kotlin.String) - + suspend fun `send`(`contentBytes`: kotlin.ByteArray, `opts`: FfiSendMessageOpts): kotlin.ByteArray - + /** * send a message without immediately publishing to the delivery service. */ fun `sendOptimistic`(`contentBytes`: kotlin.ByteArray, `opts`: FfiSendMessageOpts): kotlin.ByteArray - + suspend fun `sendText`(`text`: kotlin.String): kotlin.ByteArray - + suspend fun `stream`(`messageCallback`: FfiMessageCallback): FfiStreamCloser - + fun `superAdminList`(): List - + suspend fun `sync`() - + suspend fun `updateAppData`(`appData`: kotlin.String) - + fun `updateConsentState`(`state`: FfiConsentState) - + suspend fun `updateConversationMessageDisappearingSettings`(`settings`: FfiMessageDisappearingSettings) - + suspend fun `updateGroupDescription`(`groupDescription`: kotlin.String) - + suspend fun `updateGroupImageUrlSquare`(`groupImageUrlSquare`: kotlin.String) - + suspend fun `updateGroupName`(`groupName`: kotlin.String) - + suspend fun `updatePermissionPolicy`(`permissionUpdateType`: FfiPermissionUpdateType, `permissionPolicyOption`: FfiPermissionPolicy, `metadataField`: FfiMetadataField?) - + companion object } @@ -4354,7 +4354,7 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `addAdmin`(`inboxId`: kotlin.String) { @@ -4370,13 +4370,13 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `addMembers`(`accountIdentifiers`: List) : FfiUpdateGroupMembershipResult { @@ -4397,7 +4397,7 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `addMembersByInboxId`(`inboxIds`: List) : FfiUpdateGroupMembershipResult { @@ -4418,7 +4418,7 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `addSuperAdmin`(`inboxId`: kotlin.String) { @@ -4434,13 +4434,13 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + @Throws(GenericException::class)override fun `addedByInboxId`(): kotlin.String { return FfiConverterString.lift( callWithPointer { @@ -4451,9 +4451,9 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - - + + @Throws(GenericException::class)override fun `adminList`(): List { return FfiConverterSequenceString.lift( callWithPointer { @@ -4464,9 +4464,9 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - - + + @Throws(GenericException::class)override fun `appData`(): kotlin.String { return FfiConverterString.lift( callWithPointer { @@ -4477,9 +4477,9 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - - + + @Throws(GenericException::class)override fun `consentState`(): FfiConsentState { return FfiConverterTypeFfiConsentState.lift( callWithPointer { @@ -4490,9 +4490,9 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - - + + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `conversationDebugInfo`() : FfiConversationDebugInfo { @@ -4500,7 +4500,7 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface callWithPointer { thisPtr -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_fficonversation_conversation_debug_info( thisPtr, - + ) }, { future, callback, continuation -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_poll_rust_buffer(future, callback, continuation) }, @@ -4513,7 +4513,7 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface ) } - + @Throws(GenericException::class)override fun `conversationMessageDisappearingSettings`(): FfiMessageDisappearingSettings? { return FfiConverterOptionalTypeFfiMessageDisappearingSettings.lift( callWithPointer { @@ -4524,7 +4524,7 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - + override fun `conversationType`(): FfiConversationType { return FfiConverterTypeFfiConversationType.lift( @@ -4536,9 +4536,9 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - - + + @Throws(GenericException::class)override fun `countMessages`(`opts`: FfiListMessagesOptions): kotlin.Long { return FfiConverterLong.lift( callWithPointer { @@ -4549,7 +4549,7 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - + override fun `createdAtNs`(): kotlin.Long { return FfiConverterLong.lift( @@ -4578,7 +4578,7 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - + override fun `dmPeerInboxId`(): kotlin.String? { return FfiConverterOptionalString.lift( @@ -4590,9 +4590,9 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - - + + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `findDuplicateDms`() : List { @@ -4600,7 +4600,7 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface callWithPointer { thisPtr -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_fficonversation_find_duplicate_dms( thisPtr, - + ) }, { future, callback, continuation -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_poll_rust_buffer(future, callback, continuation) }, @@ -4613,7 +4613,7 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface ) } - + @Throws(GenericException::class)override fun `findEnrichedMessages`(`opts`: FfiListMessagesOptions): List { return FfiConverterSequenceTypeFfiDecodedMessage.lift( callWithPointer { @@ -4624,9 +4624,9 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - - + + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `findMessages`(`opts`: FfiListMessagesOptions) : List { @@ -4647,7 +4647,7 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface ) } - + @Throws(GenericException::class)override fun `findMessagesWithReactions`(`opts`: FfiListMessagesOptions): List { return FfiConverterSequenceTypeFfiMessageWithReactions.lift( callWithPointer { @@ -4658,9 +4658,9 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - - + + @Throws(GenericException::class)override fun `getHmacKeys`(): Map> { return FfiConverterMapByteArraySequenceTypeFfiHmacKey.lift( callWithPointer { @@ -4671,9 +4671,9 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - - + + @Throws(GenericException::class)override fun `getLastReadTimes`(): Map { return FfiConverterMapStringLong.lift( callWithPointer { @@ -4684,9 +4684,9 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - - + + @Throws(GenericException::class)override fun `groupDescription`(): kotlin.String { return FfiConverterString.lift( callWithPointer { @@ -4697,9 +4697,9 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - - + + @Throws(GenericException::class)override fun `groupImageUrlSquare`(): kotlin.String { return FfiConverterString.lift( callWithPointer { @@ -4710,9 +4710,9 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - - + + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `groupMetadata`() : FfiConversationMetadata { @@ -4720,7 +4720,7 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface callWithPointer { thisPtr -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_fficonversation_group_metadata( thisPtr, - + ) }, { future, callback, continuation -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_poll_pointer(future, callback, continuation) }, @@ -4733,7 +4733,7 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface ) } - + @Throws(GenericException::class)override fun `groupName`(): kotlin.String { return FfiConverterString.lift( callWithPointer { @@ -4744,10 +4744,10 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - - - @Throws(GenericException::class)override fun `groupPermissions`(): FfiGroupPermissions { + + + @Throws(GenericException::class)override fun `groupPermissions`(): FfiGroupPermissions { return FfiConverterTypeFfiGroupPermissions.lift( callWithPointer { uniffiRustCallWithError(GenericException) { _status -> @@ -4757,7 +4757,7 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - + override fun `id`(): kotlin.ByteArray { return FfiConverterByteArray.lift( @@ -4769,9 +4769,9 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - - + + @Throws(GenericException::class)override fun `isActive`(): kotlin.Boolean { return FfiConverterBoolean.lift( callWithPointer { @@ -4782,9 +4782,9 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - - + + @Throws(GenericException::class)override fun `isAdmin`(`inboxId`: kotlin.String): kotlin.Boolean { return FfiConverterBoolean.lift( callWithPointer { @@ -4795,9 +4795,9 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - - + + @Throws(GenericException::class)override fun `isConversationMessageDisappearingEnabled`(): kotlin.Boolean { return FfiConverterBoolean.lift( callWithPointer { @@ -4808,9 +4808,9 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - - + + @Throws(GenericException::class)override fun `isSuperAdmin`(`inboxId`: kotlin.String): kotlin.Boolean { return FfiConverterBoolean.lift( callWithPointer { @@ -4821,9 +4821,9 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - - + + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `leaveGroup`() { @@ -4831,7 +4831,7 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface callWithPointer { thisPtr -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_fficonversation_leave_group( thisPtr, - + ) }, { future, callback, continuation -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_poll_void(future, callback, continuation) }, @@ -4839,13 +4839,13 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `listMembers`() : List { @@ -4853,7 +4853,7 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface callWithPointer { thisPtr -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_fficonversation_list_members( thisPtr, - + ) }, { future, callback, continuation -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_poll_rust_buffer(future, callback, continuation) }, @@ -4866,7 +4866,7 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface ) } - + @Throws(GenericException::class)override fun `membershipState`(): FfiGroupMembershipState { return FfiConverterTypeFfiGroupMembershipState.lift( callWithPointer { @@ -4877,9 +4877,9 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - - + + @Throws(GenericException::class)override fun `pausedForVersion`(): kotlin.String? { return FfiConverterOptionalString.lift( callWithPointer { @@ -4890,9 +4890,9 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - - + + @Throws(FfiSubscribeException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `processStreamedConversationMessage`(`envelopeBytes`: kotlin.ByteArray) : List { @@ -4913,7 +4913,7 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface ) } - + /** * Publish all unpublished messages */ @@ -4924,7 +4924,7 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface callWithPointer { thisPtr -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_fficonversation_publish_messages( thisPtr, - + ) }, { future, callback, continuation -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_poll_void(future, callback, continuation) }, @@ -4932,13 +4932,13 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `removeAdmin`(`inboxId`: kotlin.String) { @@ -4954,13 +4954,13 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `removeConversationMessageDisappearingSettings`() { @@ -4968,7 +4968,7 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface callWithPointer { thisPtr -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_fficonversation_remove_conversation_message_disappearing_settings( thisPtr, - + ) }, { future, callback, continuation -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_poll_void(future, callback, continuation) }, @@ -4976,13 +4976,13 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `removeMembers`(`accountIdentifiers`: List) { @@ -4998,13 +4998,13 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `removeMembersByInboxId`(`inboxIds`: List) { @@ -5020,13 +5020,13 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `removeSuperAdmin`(`inboxId`: kotlin.String) { @@ -5042,13 +5042,13 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `send`(`contentBytes`: kotlin.ByteArray, `opts`: FfiSendMessageOpts) : kotlin.ByteArray { @@ -5069,7 +5069,7 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface ) } - + /** * send a message without immediately publishing to the delivery service. */ @@ -5083,9 +5083,9 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - - + + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `sendText`(`text`: kotlin.String) : kotlin.ByteArray { @@ -5106,7 +5106,7 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface ) } - + @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `stream`(`messageCallback`: FfiMessageCallback) : FfiStreamCloser { return uniffiRustCallAsync( @@ -5126,7 +5126,7 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface ) } - + @Throws(GenericException::class)override fun `superAdminList`(): List { return FfiConverterSequenceString.lift( callWithPointer { @@ -5137,9 +5137,9 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface } ) } - - + + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `sync`() { @@ -5147,7 +5147,7 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface callWithPointer { thisPtr -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_fficonversation_sync( thisPtr, - + ) }, { future, callback, continuation -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_poll_void(future, callback, continuation) }, @@ -5155,13 +5155,13 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `updateAppData`(`appData`: kotlin.String) { @@ -5177,25 +5177,25 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + @Throws(GenericException::class)override fun `updateConsentState`(`state`: FfiConsentState) - = + = callWithPointer { uniffiRustCallWithError(GenericException) { _status -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_fficonversation_update_consent_state( it, FfiConverterTypeFfiConsentState.lower(`state`),_status) } } - - - + + + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `updateConversationMessageDisappearingSettings`(`settings`: FfiMessageDisappearingSettings) { @@ -5211,13 +5211,13 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `updateGroupDescription`(`groupDescription`: kotlin.String) { @@ -5233,13 +5233,13 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `updateGroupImageUrlSquare`(`groupImageUrlSquare`: kotlin.String) { @@ -5255,13 +5255,13 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `updateGroupName`(`groupName`: kotlin.String) { @@ -5277,13 +5277,13 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `updatePermissionPolicy`(`permissionUpdateType`: FfiPermissionUpdateType, `permissionPolicyOption`: FfiPermissionPolicy, `metadataField`: FfiMetadataField?) { @@ -5299,18 +5299,18 @@ open class FfiConversation: Disposable, AutoCloseable, FfiConversationInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - - - + + + companion object - + } /** @@ -5441,13 +5441,13 @@ public object FfiConverterTypeFfiConversation: FfiConverter UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_fficonversationcallback_on_conversation( it, FfiConverterTypeFfiConversation.lower(`conversation`),_status) } } - - + + override fun `onError`(`error`: FfiSubscribeException) - = + = callWithPointer { uniffiRustCall() { _status -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_fficonversationcallback_on_error( it, FfiConverterTypeFfiSubscribeError.lower(`error`),_status) } } - - + + override fun `onClose`() - = + = callWithPointer { uniffiRustCall() { _status -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_fficonversationcallback_on_close( it, _status) } } - - - - - + + + + + companion object - + } @@ -5762,13 +5762,13 @@ public object FfiConverterTypeFfiConversationCallback: FfiConverter, `opts`: FfiCreateGroupOptions): FfiConversation - + fun `createGroupOptimistic`(`opts`: FfiCreateGroupOptions): FfiConversation - + suspend fun `createGroupWithInboxIds`(`inboxIds`: List, `opts`: FfiCreateGroupOptions): FfiConversation - + suspend fun `findOrCreateDm`(`targetIdentity`: FfiIdentifier, `opts`: FfiCreateDmOptions): FfiConversation - + suspend fun `findOrCreateDmByInboxId`(`inboxId`: kotlin.String, `opts`: FfiCreateDmOptions): FfiConversation - + fun `getHmacKeys`(): Map> - + fun `list`(`opts`: FfiListConversationsOptions): List - + fun `listDms`(`opts`: FfiListConversationsOptions): List - + fun `listGroups`(`opts`: FfiListConversationsOptions): List - + suspend fun `processStreamedWelcomeMessage`(`envelopeBytes`: kotlin.ByteArray): List - + suspend fun `stream`(`callback`: FfiConversationCallback): FfiStreamCloser - + suspend fun `streamAllDmMessages`(`messageCallback`: FfiMessageCallback, `consentStates`: List?): FfiStreamCloser - + suspend fun `streamAllGroupMessages`(`messageCallback`: FfiMessageCallback, `consentStates`: List?): FfiStreamCloser - + suspend fun `streamAllMessages`(`messageCallback`: FfiMessageCallback, `consentStates`: List?): FfiStreamCloser - + /** * Get notified when there is a new consent update either locally or is synced from another device * allowing the user to re-render the new state appropriately */ suspend fun `streamConsent`(`callback`: FfiConsentCallback): FfiStreamCloser - + suspend fun `streamDms`(`callback`: FfiConversationCallback): FfiStreamCloser - + suspend fun `streamGroups`(`callback`: FfiConversationCallback): FfiStreamCloser - + /** * Get notified when a message is deleted by the disappearing messages worker. * The callback receives the message ID of each deleted message. */ suspend fun `streamMessageDeletions`(`callback`: FfiMessageDeletionCallback): FfiStreamCloser - + suspend fun `streamMessages`(`messageCallback`: FfiMessageCallback, `conversationType`: FfiConversationType?, `consentStates`: List?): FfiStreamCloser - + /** * Get notified when a preference changes either locally or is synced from another device * allowing the user to re-render the new state appropriately. */ suspend fun `streamPreferences`(`callback`: FfiPreferenceCallback): FfiStreamCloser - + suspend fun `sync`() - + suspend fun `syncAllConversations`(`consentStates`: List?): FfiGroupSyncSummary - + companion object } @@ -6418,7 +6418,7 @@ open class FfiConversations: Disposable, AutoCloseable, FfiConversationsInterfac } } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `createGroup`(`accountIdentities`: List, `opts`: FfiCreateGroupOptions) : FfiConversation { @@ -6439,7 +6439,7 @@ open class FfiConversations: Disposable, AutoCloseable, FfiConversationsInterfac ) } - + @Throws(GenericException::class)override fun `createGroupOptimistic`(`opts`: FfiCreateGroupOptions): FfiConversation { return FfiConverterTypeFfiConversation.lift( callWithPointer { @@ -6450,9 +6450,9 @@ open class FfiConversations: Disposable, AutoCloseable, FfiConversationsInterfac } ) } - - + + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `createGroupWithInboxIds`(`inboxIds`: List, `opts`: FfiCreateGroupOptions) : FfiConversation { @@ -6473,7 +6473,7 @@ open class FfiConversations: Disposable, AutoCloseable, FfiConversationsInterfac ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `findOrCreateDm`(`targetIdentity`: FfiIdentifier, `opts`: FfiCreateDmOptions) : FfiConversation { @@ -6494,7 +6494,7 @@ open class FfiConversations: Disposable, AutoCloseable, FfiConversationsInterfac ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `findOrCreateDmByInboxId`(`inboxId`: kotlin.String, `opts`: FfiCreateDmOptions) : FfiConversation { @@ -6515,7 +6515,7 @@ open class FfiConversations: Disposable, AutoCloseable, FfiConversationsInterfac ) } - + @Throws(GenericException::class)override fun `getHmacKeys`(): Map> { return FfiConverterMapByteArraySequenceTypeFfiHmacKey.lift( callWithPointer { @@ -6526,9 +6526,9 @@ open class FfiConversations: Disposable, AutoCloseable, FfiConversationsInterfac } ) } - - + + @Throws(GenericException::class)override fun `list`(`opts`: FfiListConversationsOptions): List { return FfiConverterSequenceTypeFfiConversationListItem.lift( callWithPointer { @@ -6539,9 +6539,9 @@ open class FfiConversations: Disposable, AutoCloseable, FfiConversationsInterfac } ) } - - + + @Throws(GenericException::class)override fun `listDms`(`opts`: FfiListConversationsOptions): List { return FfiConverterSequenceTypeFfiConversationListItem.lift( callWithPointer { @@ -6552,9 +6552,9 @@ open class FfiConversations: Disposable, AutoCloseable, FfiConversationsInterfac } ) } - - + + @Throws(GenericException::class)override fun `listGroups`(`opts`: FfiListConversationsOptions): List { return FfiConverterSequenceTypeFfiConversationListItem.lift( callWithPointer { @@ -6565,9 +6565,9 @@ open class FfiConversations: Disposable, AutoCloseable, FfiConversationsInterfac } ) } - - + + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `processStreamedWelcomeMessage`(`envelopeBytes`: kotlin.ByteArray) : List { @@ -6588,7 +6588,7 @@ open class FfiConversations: Disposable, AutoCloseable, FfiConversationsInterfac ) } - + @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `stream`(`callback`: FfiConversationCallback) : FfiStreamCloser { return uniffiRustCallAsync( @@ -6608,7 +6608,7 @@ open class FfiConversations: Disposable, AutoCloseable, FfiConversationsInterfac ) } - + @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `streamAllDmMessages`(`messageCallback`: FfiMessageCallback, `consentStates`: List?) : FfiStreamCloser { return uniffiRustCallAsync( @@ -6628,7 +6628,7 @@ open class FfiConversations: Disposable, AutoCloseable, FfiConversationsInterfac ) } - + @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `streamAllGroupMessages`(`messageCallback`: FfiMessageCallback, `consentStates`: List?) : FfiStreamCloser { return uniffiRustCallAsync( @@ -6648,7 +6648,7 @@ open class FfiConversations: Disposable, AutoCloseable, FfiConversationsInterfac ) } - + @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `streamAllMessages`(`messageCallback`: FfiMessageCallback, `consentStates`: List?) : FfiStreamCloser { return uniffiRustCallAsync( @@ -6668,7 +6668,7 @@ open class FfiConversations: Disposable, AutoCloseable, FfiConversationsInterfac ) } - + /** * Get notified when there is a new consent update either locally or is synced from another device * allowing the user to re-render the new state appropriately @@ -6692,7 +6692,7 @@ open class FfiConversations: Disposable, AutoCloseable, FfiConversationsInterfac ) } - + @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `streamDms`(`callback`: FfiConversationCallback) : FfiStreamCloser { return uniffiRustCallAsync( @@ -6712,7 +6712,7 @@ open class FfiConversations: Disposable, AutoCloseable, FfiConversationsInterfac ) } - + @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `streamGroups`(`callback`: FfiConversationCallback) : FfiStreamCloser { return uniffiRustCallAsync( @@ -6732,7 +6732,7 @@ open class FfiConversations: Disposable, AutoCloseable, FfiConversationsInterfac ) } - + /** * Get notified when a message is deleted by the disappearing messages worker. * The callback receives the message ID of each deleted message. @@ -6756,7 +6756,7 @@ open class FfiConversations: Disposable, AutoCloseable, FfiConversationsInterfac ) } - + @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `streamMessages`(`messageCallback`: FfiMessageCallback, `conversationType`: FfiConversationType?, `consentStates`: List?) : FfiStreamCloser { return uniffiRustCallAsync( @@ -6776,7 +6776,7 @@ open class FfiConversations: Disposable, AutoCloseable, FfiConversationsInterfac ) } - + /** * Get notified when a preference changes either locally or is synced from another device * allowing the user to re-render the new state appropriately. @@ -6800,7 +6800,7 @@ open class FfiConversations: Disposable, AutoCloseable, FfiConversationsInterfac ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `sync`() { @@ -6808,7 +6808,7 @@ open class FfiConversations: Disposable, AutoCloseable, FfiConversationsInterfac callWithPointer { thisPtr -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_fficonversations_sync( thisPtr, - + ) }, { future, callback, continuation -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_poll_void(future, callback, continuation) }, @@ -6816,13 +6816,13 @@ open class FfiConversations: Disposable, AutoCloseable, FfiConversationsInterfac { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `syncAllConversations`(`consentStates`: List?) : FfiGroupSyncSummary { @@ -6843,12 +6843,12 @@ open class FfiConversations: Disposable, AutoCloseable, FfiConversationsInterfac ) } - - - + + + companion object - + } /** @@ -6979,39 +6979,39 @@ public object FfiConverterTypeFfiConversations: FfiConverter - + fun `senderInboxId`(): kotlin.String - + fun `senderInstallationId`(): kotlin.ByteArray - + fun `sentAtNs`(): kotlin.Long - + companion object } @@ -7107,7 +7107,7 @@ open class FfiDecodedMessage: Disposable, AutoCloseable, FfiDecodedMessageInterf } ) } - + override fun `contentTypeId`(): FfiContentTypeId { return FfiConverterTypeFfiContentTypeId.lift( @@ -7119,7 +7119,7 @@ open class FfiDecodedMessage: Disposable, AutoCloseable, FfiDecodedMessageInterf } ) } - + override fun `conversationId`(): kotlin.ByteArray { return FfiConverterByteArray.lift( @@ -7131,7 +7131,7 @@ open class FfiDecodedMessage: Disposable, AutoCloseable, FfiDecodedMessageInterf } ) } - + override fun `deliveryStatus`(): FfiDeliveryStatus { return FfiConverterTypeFfiDeliveryStatus.lift( @@ -7143,7 +7143,7 @@ open class FfiDecodedMessage: Disposable, AutoCloseable, FfiDecodedMessageInterf } ) } - + override fun `expiresAtNs`(): kotlin.Long? { return FfiConverterOptionalLong.lift( @@ -7155,7 +7155,7 @@ open class FfiDecodedMessage: Disposable, AutoCloseable, FfiDecodedMessageInterf } ) } - + override fun `fallbackText`(): kotlin.String? { return FfiConverterOptionalString.lift( @@ -7167,7 +7167,7 @@ open class FfiDecodedMessage: Disposable, AutoCloseable, FfiDecodedMessageInterf } ) } - + override fun `hasReactions`(): kotlin.Boolean { return FfiConverterBoolean.lift( @@ -7179,7 +7179,7 @@ open class FfiDecodedMessage: Disposable, AutoCloseable, FfiDecodedMessageInterf } ) } - + override fun `id`(): kotlin.ByteArray { return FfiConverterByteArray.lift( @@ -7191,7 +7191,7 @@ open class FfiDecodedMessage: Disposable, AutoCloseable, FfiDecodedMessageInterf } ) } - + override fun `insertedAtNs`(): kotlin.Long { return FfiConverterLong.lift( @@ -7203,7 +7203,7 @@ open class FfiDecodedMessage: Disposable, AutoCloseable, FfiDecodedMessageInterf } ) } - + override fun `kind`(): FfiGroupMessageKind { return FfiConverterTypeFfiGroupMessageKind.lift( @@ -7215,7 +7215,7 @@ open class FfiDecodedMessage: Disposable, AutoCloseable, FfiDecodedMessageInterf } ) } - + override fun `numReplies`(): kotlin.ULong { return FfiConverterULong.lift( @@ -7227,7 +7227,7 @@ open class FfiDecodedMessage: Disposable, AutoCloseable, FfiDecodedMessageInterf } ) } - + override fun `reactionCount`(): kotlin.ULong { return FfiConverterULong.lift( @@ -7239,7 +7239,7 @@ open class FfiDecodedMessage: Disposable, AutoCloseable, FfiDecodedMessageInterf } ) } - + override fun `reactions`(): List { return FfiConverterSequenceTypeFfiDecodedMessage.lift( @@ -7251,7 +7251,7 @@ open class FfiDecodedMessage: Disposable, AutoCloseable, FfiDecodedMessageInterf } ) } - + override fun `senderInboxId`(): kotlin.String { return FfiConverterString.lift( @@ -7263,7 +7263,7 @@ open class FfiDecodedMessage: Disposable, AutoCloseable, FfiDecodedMessageInterf } ) } - + override fun `senderInstallationId`(): kotlin.ByteArray { return FfiConverterByteArray.lift( @@ -7275,7 +7275,7 @@ open class FfiDecodedMessage: Disposable, AutoCloseable, FfiDecodedMessageInterf } ) } - + override fun `sentAtNs`(): kotlin.Long { return FfiConverterLong.lift( @@ -7287,14 +7287,14 @@ open class FfiDecodedMessage: Disposable, AutoCloseable, FfiDecodedMessageInterf } ) } - - - - + + + + companion object - + } /** @@ -7425,11 +7425,11 @@ public object FfiConverterTypeFfiDecodedMessage: FfiConverter UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_ffimessagecallback_on_message( it, FfiConverterTypeFfiMessage.lower(`message`),_status) } } - - + + override fun `onError`(`error`: FfiSubscribeException) - = + = callWithPointer { uniffiRustCall() { _status -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_ffimessagecallback_on_error( it, FfiConverterTypeFfiSubscribeError.lower(`error`),_status) } } - - + + override fun `onClose`() - = + = callWithPointer { uniffiRustCall() { _status -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_ffimessagecallback_on_close( it, _status) } } - - - - - + + + + + companion object - + } @@ -8307,9 +8307,9 @@ public object FfiConverterTypeFfiMessageCallback: FfiConverter UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_ffimessagedeletioncallback_on_message_deleted( it, FfiConverterByteArray.lower(`messageId`),_status) } } - - - - - + + + + + companion object - + } @@ -8577,13 +8577,13 @@ public object FfiConverterTypeFfiMessageDeletionCallback: FfiConverter) - + fun `onError`(`error`: FfiSubscribeException) - + fun `onClose`() - + companion object } @@ -8670,44 +8670,44 @@ open class FfiPreferenceCallbackImpl: Disposable, AutoCloseable, FfiPreferenceCa } override fun `onPreferenceUpdate`(`preference`: List) - = + = callWithPointer { uniffiRustCall() { _status -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_ffipreferencecallback_on_preference_update( it, FfiConverterSequenceTypeFfiPreferenceUpdate.lower(`preference`),_status) } } - - + + override fun `onError`(`error`: FfiSubscribeException) - = + = callWithPointer { uniffiRustCall() { _status -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_ffipreferencecallback_on_error( it, FfiConverterTypeFfiSubscribeError.lower(`error`),_status) } } - - + + override fun `onClose`() - = + = callWithPointer { uniffiRustCall() { _status -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_ffipreferencecallback_on_close( it, _status) } } - - - - - + + + + + companion object - + } @@ -8898,22 +8898,22 @@ public object FfiConverterTypeFfiPreferenceCallback: FfiConverter - + suspend fun `signatureText`(): kotlin.String - + companion object } @@ -8999,7 +8999,7 @@ open class FfiSignatureRequest: Disposable, AutoCloseable, FfiSignatureRequestIn } } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `addEcdsaSignature`(`signatureBytes`: kotlin.ByteArray) { @@ -9015,13 +9015,13 @@ open class FfiSignatureRequest: Disposable, AutoCloseable, FfiSignatureRequestIn { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `addPasskeySignature`(`signature`: FfiPasskeySignature) { @@ -9037,13 +9037,13 @@ open class FfiSignatureRequest: Disposable, AutoCloseable, FfiSignatureRequestIn { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `addScwSignature`(`signatureBytes`: kotlin.ByteArray, `address`: kotlin.String, `chainId`: kotlin.ULong, `blockNumber`: kotlin.ULong?) { @@ -9059,20 +9059,20 @@ open class FfiSignatureRequest: Disposable, AutoCloseable, FfiSignatureRequestIn { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `isReady`() : kotlin.Boolean { return uniffiRustCallAsync( callWithPointer { thisPtr -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_ffisignaturerequest_is_ready( thisPtr, - + ) }, { future, callback, continuation -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_poll_i8(future, callback, continuation) }, @@ -9085,7 +9085,7 @@ open class FfiSignatureRequest: Disposable, AutoCloseable, FfiSignatureRequestIn ) } - + /** * missing signatures that are from `MemberKind::Address` */ @@ -9096,7 +9096,7 @@ open class FfiSignatureRequest: Disposable, AutoCloseable, FfiSignatureRequestIn callWithPointer { thisPtr -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_ffisignaturerequest_missing_address_signatures( thisPtr, - + ) }, { future, callback, continuation -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_poll_rust_buffer(future, callback, continuation) }, @@ -9109,7 +9109,7 @@ open class FfiSignatureRequest: Disposable, AutoCloseable, FfiSignatureRequestIn ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `signatureText`() : kotlin.String { @@ -9117,7 +9117,7 @@ open class FfiSignatureRequest: Disposable, AutoCloseable, FfiSignatureRequestIn callWithPointer { thisPtr -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_ffisignaturerequest_signature_text( thisPtr, - + ) }, { future, callback, continuation -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_poll_rust_buffer(future, callback, continuation) }, @@ -9130,12 +9130,12 @@ open class FfiSignatureRequest: Disposable, AutoCloseable, FfiSignatureRequestIn ) } - - - + + + companion object - + } /** @@ -9266,22 +9266,22 @@ public object FfiConverterTypeFfiSignatureRequest: FfiConverter UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_ffistreamcloser_end( it, _status) } } - - - + + + /** * End the stream and asynchronously wait for it to shutdown */ @@ -9393,7 +9393,7 @@ open class FfiStreamCloser: Disposable, AutoCloseable, FfiStreamCloserInterface callWithPointer { thisPtr -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_ffistreamcloser_end_and_wait( thisPtr, - + ) }, { future, callback, continuation -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_poll_void(future, callback, continuation) }, @@ -9401,7 +9401,7 @@ open class FfiStreamCloser: Disposable, AutoCloseable, FfiStreamCloserInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) @@ -9417,16 +9417,16 @@ open class FfiStreamCloser: Disposable, AutoCloseable, FfiStreamCloserInterface } ) } - - + + @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `waitForReady`() { return uniffiRustCallAsync( callWithPointer { thisPtr -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_ffistreamcloser_wait_for_ready( thisPtr, - + ) }, { future, callback, continuation -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_poll_void(future, callback, continuation) }, @@ -9434,18 +9434,18 @@ open class FfiStreamCloser: Disposable, AutoCloseable, FfiStreamCloserInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter UniffiNullRustCallStatusErrorHandler, ) } - - - + + + companion object - + } /** @@ -9576,9 +9576,9 @@ public object FfiConverterTypeFfiStreamCloser: FfiConverter UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - - - + + + companion object - + } /** @@ -9822,12 +9822,12 @@ public object FfiConverterTypeFfiSyncWorker: FfiConverter): List - + fun `apiAggregateStatistics`(): kotlin.String - + fun `apiIdentityStatistics`(): FfiIdentityStats - + fun `apiStatistics`(): FfiApiStats - + suspend fun `applySignatureRequest`(`signatureRequest`: FfiSignatureRequest) - + /** * Load the metadata for an archive to see what it contains. * Reads only the metadata without loading the entire file, so this function is quick. */ suspend fun `archiveMetadata`(`path`: kotlin.String, `key`: kotlin.ByteArray): FfiBackupMetadata - + suspend fun `canMessage`(`accountIdentifiers`: List): Map - + /** * * Change the recovery identifier for your inboxId */ suspend fun `changeRecoveryIdentifier`(`newRecoveryIdentifier`: FfiIdentifier): FfiSignatureRequest - + fun `clearAllStatistics`() - + fun `conversation`(`conversationId`: kotlin.ByteArray): FfiConversation - + fun `conversations`(): FfiConversations - + /** * Archive application elements to file for later restoration. */ suspend fun `createArchive`(`path`: kotlin.String, `opts`: FfiArchiveOptions, `key`: kotlin.ByteArray) - + suspend fun `dbReconnect`() - + fun `deleteMessage`(`messageId`: kotlin.ByteArray): kotlin.UInt - + fun `dmConversation`(`targetInboxId`: kotlin.String): FfiConversation - + fun `enrichedMessage`(`messageId`: kotlin.ByteArray): FfiDecodedMessage - + suspend fun `findInboxId`(`identifier`: FfiIdentifier): kotlin.String? - + suspend fun `getConsentState`(`entityType`: FfiConsentEntityType, `entity`: kotlin.String): FfiConsentState - + suspend fun `getKeyPackageStatusesForInstallationIds`(`installationIds`: List): Map - + suspend fun `getLatestInboxState`(`inboxId`: kotlin.String): FfiInboxState - + /** * Import a previous archive */ suspend fun `importArchive`(`path`: kotlin.String, `key`: kotlin.ByteArray) - + fun `inboxId`(): kotlin.String - + /** * * Get the client's inbox state. * * @@ -9898,59 +9898,59 @@ public interface FfiXmtpClientInterface { * * Otherwise, the state will be read from the local database. */ suspend fun `inboxState`(`refreshFromNetwork`: kotlin.Boolean): FfiInboxState - + fun `installationId`(): kotlin.ByteArray - + fun `message`(`messageId`: kotlin.ByteArray): FfiMessage - + suspend fun `registerIdentity`(`signatureRequest`: FfiSignatureRequest) - + fun `releaseDbConnection`() - + /** * * Revokes all installations except the one the client is currently using * * Returns Some FfiSignatureRequest if we have installations to revoke. * * If we have no other installations to revoke, returns None. */ suspend fun `revokeAllOtherInstallationsSignatureRequest`(): FfiSignatureRequest? - + /** * Revokes or removes an identity from the existing client */ suspend fun `revokeIdentity`(`identifier`: FfiIdentifier): FfiSignatureRequest - + /** * * Revoke a list of installations */ suspend fun `revokeInstallations`(`installationIds`: List): FfiSignatureRequest - + /** * Manually trigger a device sync request to sync records from another active device on this account. */ suspend fun `sendSyncRequest`() - + suspend fun `setConsentStates`(`records`: List) - + /** * A utility function to sign a piece of text with this installation's private key. */ fun `signWithInstallationKey`(`text`: kotlin.String): kotlin.ByteArray - + fun `signatureRequest`(): FfiSignatureRequest? - + suspend fun `syncPreferences`(): FfiGroupSyncSummary - + /** * A utility function to easily verify that a piece of text was signed by this installation. */ fun `verifySignedWithInstallationKey`(`signatureText`: kotlin.String, `signatureBytes`: kotlin.ByteArray) - + /** * A utility function to easily verify that a string has been signed by another libXmtp installation. * Only works for verifying libXmtp public context signatures. */ fun `verifySignedWithPublicKey`(`signatureText`: kotlin.String, `signatureBytes`: kotlin.ByteArray, `publicKey`: kotlin.ByteArray) - + companion object } @@ -10036,7 +10036,7 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface } } - + /** * Adds a wallet address to the existing client */ @@ -10060,7 +10060,7 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface ) } - + /** * * Get the inbox state for each `inbox_id`. * * @@ -10097,7 +10097,7 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface } ) } - + override fun `apiIdentityStatistics`(): FfiIdentityStats { return FfiConverterTypeFfiIdentityStats.lift( @@ -10109,7 +10109,7 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface } ) } - + override fun `apiStatistics`(): FfiApiStats { return FfiConverterTypeFfiApiStats.lift( @@ -10121,9 +10121,9 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface } ) } - - + + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `applySignatureRequest`(`signatureRequest`: FfiSignatureRequest) { @@ -10139,13 +10139,13 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + /** * Load the metadata for an archive to see what it contains. * Reads only the metadata without loading the entire file, so this function is quick. @@ -10170,7 +10170,7 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `canMessage`(`accountIdentifiers`: List) : Map { @@ -10191,7 +10191,7 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface ) } - + /** * * Change the recovery identifier for your inboxId */ @@ -10216,17 +10216,17 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface } override fun `clearAllStatistics`() - = + = callWithPointer { uniffiRustCall() { _status -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_ffixmtpclient_clear_all_statistics( it, _status) } } - - - + + + @Throws(GenericException::class)override fun `conversation`(`conversationId`: kotlin.ByteArray): FfiConversation { return FfiConverterTypeFfiConversation.lift( callWithPointer { @@ -10237,7 +10237,7 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface } ) } - + override fun `conversations`(): FfiConversations { return FfiConverterTypeFfiConversations.lift( @@ -10249,9 +10249,9 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface } ) } - - + + /** * Archive application elements to file for later restoration. */ @@ -10270,13 +10270,13 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `dbReconnect`() { @@ -10284,7 +10284,7 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface callWithPointer { thisPtr -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_ffixmtpclient_db_reconnect( thisPtr, - + ) }, { future, callback, continuation -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_poll_void(future, callback, continuation) }, @@ -10292,13 +10292,13 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + @Throws(GenericException::class)override fun `deleteMessage`(`messageId`: kotlin.ByteArray): kotlin.UInt { return FfiConverterUInt.lift( callWithPointer { @@ -10309,9 +10309,9 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface } ) } - - + + @Throws(GenericException::class)override fun `dmConversation`(`targetInboxId`: kotlin.String): FfiConversation { return FfiConverterTypeFfiConversation.lift( callWithPointer { @@ -10322,9 +10322,9 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface } ) } - - + + @Throws(GenericException::class)override fun `enrichedMessage`(`messageId`: kotlin.ByteArray): FfiDecodedMessage { return FfiConverterTypeFfiDecodedMessage.lift( callWithPointer { @@ -10335,9 +10335,9 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface } ) } - - + + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `findInboxId`(`identifier`: FfiIdentifier) : kotlin.String? { @@ -10358,7 +10358,7 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `getConsentState`(`entityType`: FfiConsentEntityType, `entity`: kotlin.String) : FfiConsentState { @@ -10379,7 +10379,7 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `getKeyPackageStatusesForInstallationIds`(`installationIds`: List) : Map { @@ -10400,7 +10400,7 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `getLatestInboxState`(`inboxId`: kotlin.String) : FfiInboxState { @@ -10421,7 +10421,7 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface ) } - + /** * Import a previous archive */ @@ -10440,7 +10440,7 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) @@ -10456,9 +10456,9 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface } ) } - - + + /** * * Get the client's inbox state. * * @@ -10495,9 +10495,9 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface } ) } - - + + @Throws(GenericException::class)override fun `message`(`messageId`: kotlin.ByteArray): FfiMessage { return FfiConverterTypeFfiMessage.lift( callWithPointer { @@ -10508,9 +10508,9 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface } ) } - - + + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `registerIdentity`(`signatureRequest`: FfiSignatureRequest) { @@ -10526,25 +10526,25 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + @Throws(GenericException::class)override fun `releaseDbConnection`() - = + = callWithPointer { uniffiRustCallWithError(GenericException) { _status -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_ffixmtpclient_release_db_connection( it, _status) } } - - - + + + /** * * Revokes all installations except the one the client is currently using * * Returns Some FfiSignatureRequest if we have installations to revoke. @@ -10557,7 +10557,7 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface callWithPointer { thisPtr -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_ffixmtpclient_revoke_all_other_installations_signature_request( thisPtr, - + ) }, { future, callback, continuation -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_poll_rust_buffer(future, callback, continuation) }, @@ -10570,7 +10570,7 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface ) } - + /** * Revokes or removes an identity from the existing client */ @@ -10594,7 +10594,7 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface ) } - + /** * * Revoke a list of installations */ @@ -10618,7 +10618,7 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface ) } - + /** * Manually trigger a device sync request to sync records from another active device on this account. */ @@ -10629,7 +10629,7 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface callWithPointer { thisPtr -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_ffixmtpclient_send_sync_request( thisPtr, - + ) }, { future, callback, continuation -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_poll_void(future, callback, continuation) }, @@ -10637,13 +10637,13 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `setConsentStates`(`records`: List) { @@ -10659,13 +10659,13 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface { future -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) } - + /** * A utility function to sign a piece of text with this installation's private key. */ @@ -10679,7 +10679,7 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface } ) } - + override fun `signatureRequest`(): FfiSignatureRequest? { return FfiConverterOptionalTypeFfiSignatureRequest.lift( @@ -10691,9 +10691,9 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface } ) } - - + + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `syncPreferences`() : FfiGroupSyncSummary { @@ -10701,7 +10701,7 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface callWithPointer { thisPtr -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_ffixmtpclient_sync_preferences( thisPtr, - + ) }, { future, callback, continuation -> UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_poll_rust_buffer(future, callback, continuation) }, @@ -10714,43 +10714,43 @@ open class FfiXmtpClient: Disposable, AutoCloseable, FfiXmtpClientInterface ) } - + /** * A utility function to easily verify that a piece of text was signed by this installation. */ @Throws(GenericException::class)override fun `verifySignedWithInstallationKey`(`signatureText`: kotlin.String, `signatureBytes`: kotlin.ByteArray) - = + = callWithPointer { uniffiRustCallWithError(GenericException) { _status -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_ffixmtpclient_verify_signed_with_installation_key( it, FfiConverterString.lower(`signatureText`),FfiConverterByteArray.lower(`signatureBytes`),_status) } } - - - + + + /** * A utility function to easily verify that a string has been signed by another libXmtp installation. * Only works for verifying libXmtp public context signatures. */ @Throws(GenericException::class)override fun `verifySignedWithPublicKey`(`signatureText`: kotlin.String, `signatureBytes`: kotlin.ByteArray, `publicKey`: kotlin.ByteArray) - = + = callWithPointer { uniffiRustCallWithError(GenericException) { _status -> UniffiLib.INSTANCE.uniffi_xmtpv3_fn_method_ffixmtpclient_verify_signed_with_public_key( it, FfiConverterString.lower(`signatureText`),FfiConverterByteArray.lower(`signatureBytes`),FfiConverterByteArray.lower(`publicKey`),_status) } } - - - - - + + + + + companion object - + } /** @@ -10884,7 +10884,7 @@ public object FfiConverterTypeFfiXmtpClient: FfiConverter { data class FfiActions ( - var `id`: kotlin.String, - var `description`: kotlin.String, - var `actions`: List, + var `id`: kotlin.String, + var `description`: kotlin.String, + var `actions`: List, var `expiresAtNs`: kotlin.Long? ) { - + companion object } @@ -11096,16 +11096,16 @@ public object FfiConverterTypeFfiActions: FfiConverterRustBuffer { data class FfiApiStats ( - var `uploadKeyPackage`: kotlin.ULong, - var `fetchKeyPackage`: kotlin.ULong, - var `sendGroupMessages`: kotlin.ULong, - var `sendWelcomeMessages`: kotlin.ULong, - var `queryGroupMessages`: kotlin.ULong, - var `queryWelcomeMessages`: kotlin.ULong, - var `subscribeMessages`: kotlin.ULong, + var `uploadKeyPackage`: kotlin.ULong, + var `fetchKeyPackage`: kotlin.ULong, + var `sendGroupMessages`: kotlin.ULong, + var `sendWelcomeMessages`: kotlin.ULong, + var `queryGroupMessages`: kotlin.ULong, + var `queryWelcomeMessages`: kotlin.ULong, + var `subscribeMessages`: kotlin.ULong, var `subscribeWelcomes`: kotlin.ULong ) { - + companion object } @@ -11152,12 +11152,12 @@ public object FfiConverterTypeFfiApiStats: FfiConverterRustBuffer { data class FfiArchiveOptions ( - var `startNs`: kotlin.Long?, - var `endNs`: kotlin.Long?, - var `elements`: List, + var `startNs`: kotlin.Long?, + var `endNs`: kotlin.Long?, + var `elements`: List, var `excludeDisappearingMessages`: kotlin.Boolean ) { - + companion object } @@ -11192,11 +11192,11 @@ public object FfiConverterTypeFfiArchiveOptions: FfiConverterRustBuffer, - var `exportedAtNs`: kotlin.Long, - var `startNs`: kotlin.Long?, + var `backupVersion`: kotlin.UShort, + var `elements`: List, + var `exportedAtNs`: kotlin.Long, + var `startNs`: kotlin.Long?, var `endNs`: kotlin.Long? ) { - + companion object } @@ -11272,11 +11272,11 @@ public object FfiConverterTypeFfiBackupMetadata: FfiConverterRustBuffer { data class FfiContentTypeId ( - var `authorityId`: kotlin.String, - var `typeId`: kotlin.String, - var `versionMajor`: kotlin.UInt, + var `authorityId`: kotlin.String, + var `typeId`: kotlin.String, + var `versionMajor`: kotlin.UInt, var `versionMinor`: kotlin.UInt ) { - + companion object } @@ -11348,15 +11348,15 @@ public object FfiConverterTypeFfiContentTypeId: FfiConverterRustBuffer ) { - + companion object } @@ -11400,13 +11400,13 @@ public object FfiConverterTypeFfiConversationDebugInfo: FfiConverterRustBuffer, - var `installationIds`: List, - var `permissionLevel`: FfiPermissionLevel, + var `inboxId`: kotlin.String, + var `accountIdentifiers`: List, + var `installationIds`: List, + var `permissionLevel`: FfiPermissionLevel, var `consentState`: FfiConsentState ) { - + companion object } @@ -11446,7 +11446,7 @@ public object FfiConverterTypeFfiConversationMember: FfiConverterRustBuffer { data class FfiDecodedMessageMetadata ( - var `id`: kotlin.ByteArray, - var `sentAtNs`: kotlin.Long, - var `kind`: FfiGroupMessageKind, - var `senderInstallationId`: kotlin.ByteArray, - var `senderInboxId`: kotlin.String, - var `contentType`: FfiContentTypeId, - var `conversationId`: kotlin.ByteArray, - var `insertedAtNs`: kotlin.Long, + var `id`: kotlin.ByteArray, + var `sentAtNs`: kotlin.Long, + var `kind`: FfiGroupMessageKind, + var `senderInstallationId`: kotlin.ByteArray, + var `senderInboxId`: kotlin.String, + var `contentType`: FfiContentTypeId, + var `conversationId`: kotlin.ByteArray, + var `insertedAtNs`: kotlin.Long, var `expiresAtNs`: kotlin.Long? ) { - + companion object } @@ -11679,13 +11679,13 @@ public object FfiConverterTypeFfiDeletedMessage : FfiConverterRustBuffer, - var `fallback`: kotlin.String?, - var `compression`: kotlin.Int?, + var `typeId`: FfiContentTypeId?, + var `parameters`: Map, + var `fallback`: kotlin.String?, + var `compression`: kotlin.Int?, var `content`: kotlin.ByteArray ) { - + companion object } @@ -11723,21 +11723,21 @@ public object FfiConverterTypeFfiEncodedContent: FfiConverterRustBuffer, - var `disableRecoveryResponses`: kotlin.Boolean?, + var `enableRecoveryRequests`: FfiForkRecoveryPolicy, + var `groupsToRequestRecovery`: List, + var `disableRecoveryResponses`: kotlin.Boolean?, var `workerIntervalNs`: kotlin.ULong? ) { - + companion object } @@ -11809,10 +11809,10 @@ public object FfiConverterTypeFfiForkRecoveryOpts: FfiConverterRustBuffer, - var `removedInboxes`: List, - var `leftInboxes`: List, - var `metadataFieldChanges`: List, - var `addedAdminInboxes`: List, - var `removedAdminInboxes`: List, - var `addedSuperAdminInboxes`: List, + var `initiatedByInboxId`: kotlin.String, + var `addedInboxes`: List, + var `removedInboxes`: List, + var `leftInboxes`: List, + var `metadataFieldChanges`: List, + var `addedAdminInboxes`: List, + var `removedAdminInboxes`: List, + var `addedSuperAdminInboxes`: List, var `removedSuperAdminInboxes`: List ) { - + companion object } @@ -11901,10 +11901,10 @@ public object FfiConverterTypeFfiGroupUpdated: FfiConverterRustBuffer { data class FfiIdentifier ( - var `identifier`: kotlin.String, + var `identifier`: kotlin.String, var `identifierKind`: FfiIdentifierKind ) { - + companion object } @@ -11965,12 +11965,12 @@ public object FfiConverterTypeFfiIdentifier: FfiConverterRustBuffer { data class FfiInboxState ( - var `inboxId`: kotlin.String, - var `recoveryIdentity`: FfiIdentifier, - var `installations`: List, - var `accountIdentities`: List, + var `inboxId`: kotlin.String, + var `recoveryIdentity`: FfiIdentifier, + var `installations`: List, + var `accountIdentities`: List, var `creationSignatureKind`: FfiSignatureKind? ) { - + companion object } @@ -12077,10 +12077,10 @@ public object FfiConverterTypeFfiInboxState: FfiConverterRustBuffer { data class FfiKeyPackageStatus ( - var `lifetime`: FfiLifetime?, + var `lifetime`: FfiLifetime?, var `validationError`: kotlin.String? ) { - + companion object } @@ -12185,7 +12185,7 @@ data class FfiLeaveRequest ( */ var `authenticatedNote`: kotlin.ByteArray? ) { - + companion object } @@ -12211,10 +12211,10 @@ public object FfiConverterTypeFfiLeaveRequest: FfiConverterRustBuffer { data class FfiListConversationsOptions ( - var `createdAfterNs`: kotlin.Long?, - var `createdBeforeNs`: kotlin.Long?, - var `lastActivityBeforeNs`: kotlin.Long?, - var `lastActivityAfterNs`: kotlin.Long?, - var `orderBy`: FfiGroupQueryOrderBy?, - var `limit`: kotlin.Long?, - var `consentStates`: List?, + var `createdAfterNs`: kotlin.Long?, + var `createdBeforeNs`: kotlin.Long?, + var `lastActivityBeforeNs`: kotlin.Long?, + var `lastActivityAfterNs`: kotlin.Long?, + var `orderBy`: FfiGroupQueryOrderBy?, + var `limit`: kotlin.Long?, + var `consentStates`: List?, var `includeDuplicateDms`: kotlin.Boolean ) { - + companion object } @@ -12299,19 +12299,19 @@ public object FfiConverterTypeFfiListConversationsOptions: FfiConverterRustBuffe data class FfiListMessagesOptions ( - var `sentBeforeNs`: kotlin.Long?, - var `sentAfterNs`: kotlin.Long?, - var `limit`: kotlin.Long?, - var `deliveryStatus`: FfiDeliveryStatus?, - var `direction`: FfiDirection?, - var `contentTypes`: List?, - var `excludeContentTypes`: List?, - var `excludeSenderInboxIds`: List?, - var `sortBy`: FfiSortBy?, - var `insertedAfterNs`: kotlin.Long?, + var `sentBeforeNs`: kotlin.Long?, + var `sentAfterNs`: kotlin.Long?, + var `limit`: kotlin.Long?, + var `deliveryStatus`: FfiDeliveryStatus?, + var `direction`: FfiDirection?, + var `contentTypes`: List?, + var `excludeContentTypes`: List?, + var `excludeSenderInboxIds`: List?, + var `sortBy`: FfiSortBy?, + var `insertedAfterNs`: kotlin.Long?, var `insertedBeforeNs`: kotlin.Long? ) { - + companion object } @@ -12369,7 +12369,7 @@ public object FfiConverterTypeFfiListMessagesOptions: FfiConverterRustBuffer { * * `in_ns` - The duration (in nanoseconds) after which tracked messages will be deleted. */ data class FfiMessageDisappearingSettings ( - var `fromNs`: kotlin.Long, + var `fromNs`: kotlin.Long, var `inNs`: kotlin.Long ) { - + companion object } @@ -12503,10 +12503,10 @@ public object FfiConverterTypeFfiMessageDisappearingSettings: FfiConverterRustBu data class FfiMessageMetadata ( - var `cursor`: FfiCursor, + var `cursor`: FfiCursor, var `createdNs`: kotlin.Long ) { - + companion object } @@ -12535,10 +12535,10 @@ public object FfiConverterTypeFfiMessageMetadata: FfiConverterRustBuffer ) { - + companion object } @@ -12567,11 +12567,11 @@ public object FfiConverterTypeFfiMessageWithReactions: FfiConverterRustBuffer ) { - + companion object } @@ -12631,12 +12631,12 @@ public object FfiConverterTypeFfiMultiRemoteAttachment: FfiConverterRustBuffer { data class FfiSendMessageOpts ( var `shouldPush`: kotlin.Boolean ) { - + companion object } @@ -12981,7 +12980,7 @@ public object FfiConverterTypeFfiSendMessageOpts: FfiConverterRustBuffer, - var `removedMembers`: List, + var `addedMembers`: Map, + var `removedMembers`: List, var `failedInstallations`: List ) { - + companion object } @@ -13131,13 +13130,13 @@ public object FfiConverterTypeFfiUpdateGroupMembershipResult: FfiConverterRustBu data class FfiWalletCall ( - var `to`: kotlin.String?, - var `data`: kotlin.String?, - var `value`: kotlin.String?, - var `gas`: kotlin.String?, + var `to`: kotlin.String?, + var `data`: kotlin.String?, + var `value`: kotlin.String?, + var `gas`: kotlin.String?, var `metadata`: FfiWalletCallMetadata? ) { - + companion object } @@ -13175,11 +13174,11 @@ public object FfiConverterTypeFfiWalletCall: FfiConverterRustBuffer ) { - + companion object } @@ -13211,13 +13210,13 @@ public object FfiConverterTypeFfiWalletCallMetadata: FfiConverterRustBuffer, + var `version`: kotlin.String, + var `chainId`: kotlin.String, + var `from`: kotlin.String, + var `calls`: List, var `capabilities`: Map? ) { - + companion object } @@ -13256,7 +13255,7 @@ public object FfiConverterTypeFfiWalletSendCalls: FfiConverterRustBuffer { override fun lift(error_buf: RustBuffer.ByValue): FfiCryptoException = FfiConverterTypeFfiCryptoError.lift(error_buf) } - + } /** @@ -13553,7 +13552,7 @@ sealed class FfiCryptoException: kotlin.Exception() { */ public object FfiConverterTypeFfiCryptoError : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): FfiCryptoException { - + return when(buf.getInt()) { 1 -> FfiCryptoException.InvalidLength() @@ -13611,67 +13610,67 @@ public object FfiConverterTypeFfiCryptoError : FfiConverterRustBuffer FfiDecodedMessageBody.DeletedMessage( FfiConverterTypeFfiDeletedMessage.read(buf), ) - 15 -> FfiDecodedMessageBody.Custom( FfiConverterTypeFfiEncodedContent.read(buf), ) @@ -13929,7 +13927,6 @@ public object FfiConverterTypeFfiDecodedMessageBody : FfiConverterRustBuffer { buf.putInt(15) FfiConverterTypeFfiEncodedContent.write(value.v1, buf) @@ -13944,72 +13941,72 @@ public object FfiConverterTypeFfiDecodedMessageBody : FfiConverterRustBuffer { - + Disposable.destroy( this.v1 ) - + } is FfiDecodedMessageContent.Markdown -> { - + Disposable.destroy( this.v1 ) - + } is FfiDecodedMessageContent.Reply -> { - + Disposable.destroy( this.v1 ) - + } is FfiDecodedMessageContent.Reaction -> { - + Disposable.destroy( this.v1 ) - + } is FfiDecodedMessageContent.Attachment -> { - + Disposable.destroy( this.v1 ) - + } is FfiDecodedMessageContent.RemoteAttachment -> { - + Disposable.destroy( this.v1 ) - + } is FfiDecodedMessageContent.MultiRemoteAttachment -> { - + Disposable.destroy( this.v1 ) - + } is FfiDecodedMessageContent.TransactionReference -> { - + Disposable.destroy( this.v1 ) - + } is FfiDecodedMessageContent.GroupUpdated -> { - + Disposable.destroy( this.v1 ) - + } is FfiDecodedMessageContent.ReadReceipt -> { - + Disposable.destroy( this.v1 ) - + } is FfiDecodedMessageContent.WalletSendCalls -> { - + Disposable.destroy( this.v1 ) - + } is FfiDecodedMessageContent.Intent -> { - + Disposable.destroy( this.v1 ) - + } is FfiDecodedMessageContent.Actions -> { - + Disposable.destroy( this.v1 ) - + } is FfiDecodedMessageContent.LeaveRequest -> { @@ -14129,24 +14126,23 @@ sealed class FfiDecodedMessageContent: Disposable { ) } - is FfiDecodedMessageContent.DeletedMessage -> { - + Disposable.destroy( this.v1 ) - + } is FfiDecodedMessageContent.Custom -> { - + Disposable.destroy( this.v1 ) - + } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } - + companion object } @@ -14201,7 +14197,6 @@ public object FfiConverterTypeFfiDecodedMessageContent : FfiConverterRustBuffer< 15 -> FfiDecodedMessageContent.DeletedMessage( FfiConverterTypeFfiDeletedMessage.read(buf), ) - 16 -> FfiDecodedMessageContent.Custom( FfiConverterTypeFfiEncodedContent.read(buf), ) @@ -14401,7 +14396,6 @@ public object FfiConverterTypeFfiDecodedMessageContent : FfiConverterRustBuffer< FfiConverterTypeFfiDeletedMessage.write(value.v1, buf) Unit } - is FfiDecodedMessageContent.Custom -> { buf.putInt(16) FfiConverterTypeFfiEncodedContent.write(value.v1, buf) @@ -14437,7 +14431,6 @@ public object FfiConverterTypeFfiDeletedBy : FfiConverterRustBuffer FfiDeletedBy.Admin( FfiConverterString.read(buf), ) - else -> throw RuntimeException("invalid enum value, something is very wrong!!") } } @@ -14449,7 +14442,6 @@ public object FfiConverterTypeFfiDeletedBy : FfiConverterRustBuffer { // Add the size for the Int that specifies the variant plus the size needed for all fields ( @@ -14465,7 +14457,6 @@ public object FfiConverterTypeFfiDeletedBy : FfiConverterRustBuffer { buf.putInt(2) FfiConverterString.write(value.`inboxId`, buf) @@ -14481,7 +14472,7 @@ public object FfiConverterTypeFfiDeletedBy : FfiConverterRustBuffer enum class FfiForkRecoveryPolicy { - + NONE, ALLOWLISTED_GROUPS, ALL; @@ -14573,7 +14564,7 @@ public object FfiConverterTypeFfiForkRecoveryPolicy: FfiConverterRustBuffer { */ enum class FfiLogRotation { - + /** * Rotate log files every minute */ @@ -14825,7 +14816,7 @@ public object FfiConverterTypeFfiLogRotation: FfiConverterRustBuffer { sealed class FfiSubscribeException(message: String): kotlin.Exception(message) { - + class Subscribe(message: String) : FfiSubscribeException(message) - + class Storage(message: String) : FfiSubscribeException(message) - + companion object ErrorHandler : UniffiRustCallStatusErrorHandler { override fun lift(error_buf: RustBuffer.ByValue): FfiSubscribeException = FfiConverterTypeFfiSubscribeError.lift(error_buf) @@ -15205,13 +15196,13 @@ sealed class FfiSubscribeException(message: String): kotlin.Exception(message) { */ public object FfiConverterTypeFfiSubscribeError : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): FfiSubscribeException { - + return when(buf.getInt()) { 1 -> FfiSubscribeException.Subscribe(FfiConverterString.read(buf)) 2 -> FfiSubscribeException.Storage(FfiConverterString.read(buf)) else -> throw RuntimeException("invalid error enum value, something is very wrong!!") } - + } override fun allocationSize(value: FfiSubscribeException): ULong { @@ -15237,7 +15228,7 @@ public object FfiConverterTypeFfiSubscribeError : FfiConverterRustBuffer { override fun lift(error_buf: RustBuffer.ByValue): GenericException = FfiConverterTypeGenericError.lift(error_buf) @@ -15378,7 +15369,7 @@ sealed class GenericException(message: String): kotlin.Exception(message) { */ public object FfiConverterTypeGenericError : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): GenericException { - + return when(buf.getInt()) { 1 -> GenericException.Client(FfiConverterString.read(buf)) 2 -> GenericException.ClientBuilder(FfiConverterString.read(buf)) @@ -15410,7 +15401,7 @@ public object FfiConverterTypeGenericError : FfiConverterRustBuffer GenericException.Enrich(FfiConverterString.read(buf)) else -> throw RuntimeException("invalid error enum value, something is very wrong!!") } - + } override fun allocationSize(value: GenericException): ULong { @@ -15541,21 +15532,21 @@ public object FfiConverterTypeGenericError : FfiConverterRustBuffer { override fun lift(error_buf: RustBuffer.ByValue): IdentityValidationException = FfiConverterTypeIdentityValidationError.lift(error_buf) } - + } /** @@ -15563,7 +15554,7 @@ sealed class IdentityValidationException: kotlin.Exception() { */ public object FfiConverterTypeIdentityValidationError : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): IdentityValidationException { - + return when(buf.getInt()) { 1 -> IdentityValidationException.Generic( @@ -15600,19 +15591,19 @@ public object FfiConverterTypeIdentityValidationError : FfiConverterRustBuffer { override fun lift(error_buf: RustBuffer.ByValue): SigningException = FfiConverterTypeSigningError.lift(error_buf) } - + } /** @@ -15620,7 +15611,7 @@ sealed class SigningException: kotlin.Exception() { */ public object FfiConverterTypeSigningError : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): SigningException { - + return when(buf.getInt()) { 1 -> SigningException.Generic() @@ -17769,7 +17760,7 @@ public object FfiConverterMapTypeFfiIdentifierBoolean: FfiConverterRustBuffer UniffiLib.INSTANCE.ffi_xmtpv3_rust_future_free_void(future) }, // lift function { Unit }, - + // Error FFI converter GenericException.ErrorHandler, ) @@ -17840,7 +17831,7 @@ public object FfiConverterMapTypeFfiIdentifierBoolean: FfiConverterRustBuffer UniffiLib.INSTANCE.uniffi_xmtpv3_fn_func_enter_debug_writer( FfiConverterString.lower(`directory`),FfiConverterTypeFfiLogLevel.lower(`logLevel`),FfiConverterTypeFfiLogRotation.lower(`rotation`),FfiConverterUInt.lower(`maxFiles`),FfiConverterTypeFfiProcessType.lower(`processType`),_status) } - - + + /** * turns on logging to a file on-disk with a specified log level. @@ -18124,13 +18115,13 @@ public object FfiConverterMapTypeFfiIdentifierBoolean: FfiConverterRustBuffer UniffiLib.INSTANCE.uniffi_xmtpv3_fn_func_enter_debug_writer_with_level( FfiConverterString.lower(`directory`),FfiConverterTypeFfiLogRotation.lower(`rotation`),FfiConverterUInt.lower(`maxFiles`),FfiConverterTypeFfiLogLevel.lower(`logLevel`),FfiConverterTypeFfiProcessType.lower(`processType`),_status) } - - + + /** * 3) Ethereum address from public key (accepts 65-byte 0x04||XY or 64-byte XY). @@ -18143,7 +18134,7 @@ public object FfiConverterMapTypeFfiIdentifierBoolean: FfiConverterRustBuffer UniffiLib.INSTANCE.uniffi_xmtpv3_fn_func_exit_debug_writer( _status) } - - + + @Throws(GenericException::class) fun `generateInboxId`(`accountIdentifier`: FfiIdentifier, `nonce`: kotlin.ULong): kotlin.String { return FfiConverterString.lift( @@ -18214,7 +18205,7 @@ public object FfiConverterMapTypeFfiIdentifierBoolean: FfiConverterRustBuffer Date: Tue, 13 Jan 2026 18:31:38 +0100 Subject: [PATCH 3/8] add message deletion and overhaul the example app --- example/src/main/AndroidManifest.xml | 9 + .../org/xmtp/android/example/ClientManager.kt | 69 +- .../org/xmtp/android/example/MainActivity.kt | 260 +++++-- .../org/xmtp/android/example/MainViewModel.kt | 108 ++- .../example/connect/ConnectWalletFragment.kt | 64 +- .../example/connect/ConnectWalletViewModel.kt | 47 +- .../ConversationDetailActivity.kt | 713 ++++++++++++++++-- .../ConversationDetailViewModel.kt | 264 ++++++- .../ConversationFooterViewHolder.kt | 66 -- .../conversation/ConversationViewHolder.kt | 125 ++- .../conversation/ConversationsAdapter.kt | 59 +- .../ConversationsClickListener.kt | 2 - .../conversation/GroupManagementActivity.kt | 299 ++++++++ .../conversation/GroupManagementViewModel.kt | 178 +++++ .../example/conversation/MemberAdapter.kt | 216 ++++++ .../conversation/NewConversationActivity.kt | 352 +++++++++ .../NewConversationBottomSheet.kt | 46 +- .../conversation/NewConversationViewModel.kt | 88 ++- .../conversation/NewGroupBottomSheet.kt | 118 ++- .../conversation/NewMessageBottomSheet.kt | 280 +++++++ .../conversation/RecentContactsAdapter.kt | 177 +++++ .../conversation/UserProfileActivity.kt | 249 ++++++ .../example/message/EmojiPickerAdapter.kt | 399 ++++++++++ .../android/example/message/MessageAdapter.kt | 116 ++- .../example/message/MessageViewHolder.kt | 58 -- .../message/ReceivedMessageViewHolder.kt | 173 +++++ .../example/message/SentMessageViewHolder.kt | 161 ++++ .../message/SystemMessageViewHolder.kt | 22 + .../org/xmtp/android/example/utils/KeyUtil.kt | 68 +- .../example/wallet/WalletInfoBottomSheet.kt | 122 +++ .../src/main/res/color/switch_thumb_color.xml | 5 + .../src/main/res/color/switch_track_color.xml | 5 + .../main/res/drawable/bottom_sheet_handle.xml | 6 + .../res/drawable/drawer_avatar_background.xml | 5 + .../drawable/file_attachment_background.xml | 6 + .../file_attachment_background_received.xml | 6 + .../res/drawable/ic_account_circle_24.xml | 10 + .../main/res/drawable/ic_arrow_back_24.xml | 10 + .../main/res/drawable/ic_attach_file_24.xml | 10 + .../main/res/drawable/ic_bug_report_24.xml | 10 + .../src/main/res/drawable/ic_camera_24.xml | 12 + example/src/main/res/drawable/ic_check_24.xml | 10 + .../src/main/res/drawable/ic_check_double.xml | 11 + example/src/main/res/drawable/ic_close_24.xml | 10 + .../main/res/drawable/ic_content_copy_24.xml | 10 + example/src/main/res/drawable/ic_copy_24.xml | 10 + .../src/main/res/drawable/ic_delete_24.xml | 10 + example/src/main/res/drawable/ic_emoji_24.xml | 10 + example/src/main/res/drawable/ic_gif_24.xml | 9 + example/src/main/res/drawable/ic_group_24.xml | 10 + example/src/main/res/drawable/ic_image_24.xml | 9 + example/src/main/res/drawable/ic_info_24.xml | 11 + .../src/main/res/drawable/ic_keyboard_24.xml | 10 + .../src/main/res/drawable/ic_logout_24.xml | 10 + example/src/main/res/drawable/ic_menu_24.xml | 10 + example/src/main/res/drawable/ic_mic_24.xml | 10 + .../src/main/res/drawable/ic_more_vert_24.xml | 10 + example/src/main/res/drawable/ic_reply_24.xml | 10 + .../res/drawable/ic_visibility_off_24.xml | 10 + .../res/drawable/message_bubble_received.xml | 33 + .../main/res/drawable/message_bubble_sent.xml | 33 + .../drawable/reaction_button_background.xml | 9 + .../reaction_button_selected_background.xml | 12 + .../main/res/drawable/reply_background.xml | 6 + .../drawable/reply_background_received.xml | 21 + .../res/drawable/reply_background_sent.xml | 21 + .../drawable/system_message_background.xml | 6 + .../layout/activity_conversation_detail.xml | 313 +++++++- .../res/layout/activity_group_management.xml | 255 +++++++ example/src/main/res/layout/activity_main.xml | 142 ++-- .../res/layout/activity_new_conversation.xml | 301 ++++++++ .../main/res/layout/activity_user_profile.xml | 253 +++++++ .../layout/bottom_sheet_new_conversation.xml | 183 ++++- .../res/layout/bottom_sheet_new_group.xml | 228 ++++-- .../res/layout/bottom_sheet_new_message.xml | 223 ++++++ .../res/layout/bottom_sheet_wallet_info.xml | 341 +++++++++ .../res/layout/dialog_attachment_picker.xml | 175 +++++ .../res/layout/dialog_message_options.xml | 173 +++++ .../res/layout/fragment_connect_wallet.xml | 44 ++ .../res/layout/list_item_conversation.xml | 78 +- .../layout/list_item_conversation_footer.xml | 18 - .../src/main/res/layout/list_item_emoji.xml | 17 + .../src/main/res/layout/list_item_member.xml | 95 +++ .../res/layout/list_item_message_received.xml | 216 ++++++ .../res/layout/list_item_message_sent.xml | 214 ++++++ .../res/layout/list_item_message_system.xml | 44 ++ .../res/layout/list_item_recent_contact.xml | 98 +++ .../src/main/res/layout/nav_drawer_header.xml | 44 ++ .../res/menu/menu_conversation_detail.xml | 9 + example/src/main/res/menu/menu_drawer.xml | 60 ++ example/src/main/res/menu/menu_member.xml | 12 + example/src/main/res/values/colors.xml | 46 +- example/src/main/res/values/strings.xml | 125 ++- example/src/main/res/values/themes.xml | 67 +- example/src/main/res/xml/file_paths.xml | 1 + 95 files changed, 8466 insertions(+), 623 deletions(-) delete mode 100644 example/src/main/java/org/xmtp/android/example/conversation/ConversationFooterViewHolder.kt create mode 100644 example/src/main/java/org/xmtp/android/example/conversation/GroupManagementActivity.kt create mode 100644 example/src/main/java/org/xmtp/android/example/conversation/GroupManagementViewModel.kt create mode 100644 example/src/main/java/org/xmtp/android/example/conversation/MemberAdapter.kt create mode 100644 example/src/main/java/org/xmtp/android/example/conversation/NewConversationActivity.kt create mode 100644 example/src/main/java/org/xmtp/android/example/conversation/NewMessageBottomSheet.kt create mode 100644 example/src/main/java/org/xmtp/android/example/conversation/RecentContactsAdapter.kt create mode 100644 example/src/main/java/org/xmtp/android/example/conversation/UserProfileActivity.kt create mode 100644 example/src/main/java/org/xmtp/android/example/message/EmojiPickerAdapter.kt delete mode 100644 example/src/main/java/org/xmtp/android/example/message/MessageViewHolder.kt create mode 100644 example/src/main/java/org/xmtp/android/example/message/ReceivedMessageViewHolder.kt create mode 100644 example/src/main/java/org/xmtp/android/example/message/SentMessageViewHolder.kt create mode 100644 example/src/main/java/org/xmtp/android/example/message/SystemMessageViewHolder.kt create mode 100644 example/src/main/java/org/xmtp/android/example/wallet/WalletInfoBottomSheet.kt create mode 100644 example/src/main/res/color/switch_thumb_color.xml create mode 100644 example/src/main/res/color/switch_track_color.xml create mode 100644 example/src/main/res/drawable/bottom_sheet_handle.xml create mode 100644 example/src/main/res/drawable/drawer_avatar_background.xml create mode 100644 example/src/main/res/drawable/file_attachment_background.xml create mode 100644 example/src/main/res/drawable/file_attachment_background_received.xml create mode 100644 example/src/main/res/drawable/ic_account_circle_24.xml create mode 100644 example/src/main/res/drawable/ic_arrow_back_24.xml create mode 100644 example/src/main/res/drawable/ic_attach_file_24.xml create mode 100644 example/src/main/res/drawable/ic_bug_report_24.xml create mode 100644 example/src/main/res/drawable/ic_camera_24.xml create mode 100644 example/src/main/res/drawable/ic_check_24.xml create mode 100644 example/src/main/res/drawable/ic_check_double.xml create mode 100644 example/src/main/res/drawable/ic_close_24.xml create mode 100644 example/src/main/res/drawable/ic_content_copy_24.xml create mode 100644 example/src/main/res/drawable/ic_copy_24.xml create mode 100644 example/src/main/res/drawable/ic_delete_24.xml create mode 100644 example/src/main/res/drawable/ic_emoji_24.xml create mode 100644 example/src/main/res/drawable/ic_gif_24.xml create mode 100644 example/src/main/res/drawable/ic_group_24.xml create mode 100644 example/src/main/res/drawable/ic_image_24.xml create mode 100644 example/src/main/res/drawable/ic_info_24.xml create mode 100644 example/src/main/res/drawable/ic_keyboard_24.xml create mode 100644 example/src/main/res/drawable/ic_logout_24.xml create mode 100644 example/src/main/res/drawable/ic_menu_24.xml create mode 100644 example/src/main/res/drawable/ic_mic_24.xml create mode 100644 example/src/main/res/drawable/ic_more_vert_24.xml create mode 100644 example/src/main/res/drawable/ic_reply_24.xml create mode 100644 example/src/main/res/drawable/ic_visibility_off_24.xml create mode 100644 example/src/main/res/drawable/message_bubble_received.xml create mode 100644 example/src/main/res/drawable/message_bubble_sent.xml create mode 100644 example/src/main/res/drawable/reaction_button_background.xml create mode 100644 example/src/main/res/drawable/reaction_button_selected_background.xml create mode 100644 example/src/main/res/drawable/reply_background.xml create mode 100644 example/src/main/res/drawable/reply_background_received.xml create mode 100644 example/src/main/res/drawable/reply_background_sent.xml create mode 100644 example/src/main/res/drawable/system_message_background.xml create mode 100644 example/src/main/res/layout/activity_group_management.xml create mode 100644 example/src/main/res/layout/activity_new_conversation.xml create mode 100644 example/src/main/res/layout/activity_user_profile.xml create mode 100644 example/src/main/res/layout/bottom_sheet_new_message.xml create mode 100644 example/src/main/res/layout/bottom_sheet_wallet_info.xml create mode 100644 example/src/main/res/layout/dialog_attachment_picker.xml create mode 100644 example/src/main/res/layout/dialog_message_options.xml delete mode 100644 example/src/main/res/layout/list_item_conversation_footer.xml create mode 100644 example/src/main/res/layout/list_item_emoji.xml create mode 100644 example/src/main/res/layout/list_item_member.xml create mode 100644 example/src/main/res/layout/list_item_message_received.xml create mode 100644 example/src/main/res/layout/list_item_message_sent.xml create mode 100644 example/src/main/res/layout/list_item_message_system.xml create mode 100644 example/src/main/res/layout/list_item_recent_contact.xml create mode 100644 example/src/main/res/layout/nav_drawer_header.xml create mode 100644 example/src/main/res/menu/menu_conversation_detail.xml create mode 100644 example/src/main/res/menu/menu_drawer.xml create mode 100644 example/src/main/res/menu/menu_member.xml diff --git a/example/src/main/AndroidManifest.xml b/example/src/main/AndroidManifest.xml index f09ae12de..ba5a8329c 100644 --- a/example/src/main/AndroidManifest.xml +++ b/example/src/main/AndroidManifest.xml @@ -63,6 +63,15 @@ + + + (R.id.drawerWalletAddress).text = + client.publicIdentity.identifier + headerView.findViewById(R.id.drawerEnvironment).text = + client.environment.name + + // Update toggle states + val menu = binding.navigationView.menu + menu.findItem(R.id.nav_toggle_logs)?.isChecked = isLogsActivated() + + // Update hide deleted messages toggle state + val keyUtil = KeyUtil(this) + val hideDeleted = keyUtil.getHideDeletedMessages() + menu.findItem(R.id.nav_hide_deleted_messages)?.isChecked = hideDeleted + ConversationDetailViewModel.hideDeletedMessages = hideDeleted + } + + override fun onNavigationItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.nav_wallet_info -> { + openWalletInfoBottomSheet() + } + R.id.nav_new_conversation -> { + openNewConversation() + } + R.id.nav_new_group -> { + openNewConversation() + } + R.id.nav_view_logs -> { + openLogsViewer() + } + R.id.nav_toggle_logs -> { + val newState = !item.isChecked + item.isChecked = newState + onLogsToggled(newState) + return true // Don't close drawer for toggle + } + R.id.nav_hide_deleted_messages -> { + val newState = !item.isChecked + item.isChecked = newState + onHideDeletedMessagesToggled(newState) + return true // Don't close drawer for toggle + } + R.id.nav_copy_address -> { + copyWalletAddress() + } + R.id.nav_disconnect -> { + disconnectWallet() + } + } + binding.drawerLayout.closeDrawer(GravityCompat.START) + return true + } + + @Deprecated("Deprecated in Java") + override fun onBackPressed() { + if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) { + binding.drawerLayout.closeDrawer(GravityCompat.START) + } else { + @Suppress("DEPRECATION") + super.onBackPressed() + } + } + private var retryJob: Job? = null private fun retryCreateClientWithBackoff() { @@ -180,47 +269,11 @@ class MainActivity : } override fun onDestroy() { - bottomSheet?.dismiss() - groupBottomSheet?.dismiss() logsBottomSheet?.dismiss() + walletInfoBottomSheet?.dismiss() super.onDestroy() } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_main, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean = - when (item.itemId) { - R.id.disconnect -> { - disconnectWallet() - true - } - R.id.copy_address -> { - copyWalletAddress() - true - } - R.id.activate_logs -> { - Client.activatePersistentLibXMTPLogWriter( - applicationContext, - FfiLogLevel.DEBUG, - FfiLogRotation.MINUTELY, - 3, - ) - setLogsActivated(true) - Toast.makeText(this, "Persistent logs activated", Toast.LENGTH_SHORT).show() - true - } - R.id.deactivate_logs -> { - Client.deactivatePersistentLibXMTPLogWriter() - setLogsActivated(false) - Toast.makeText(this, "Persistent logs deactivated", Toast.LENGTH_SHORT).show() - true - } - else -> super.onOptionsItemSelected(item) - } - override fun onConversationClick(conversation: Conversation) { startActivity( ConversationDetailActivity.intent( @@ -231,29 +284,54 @@ class MainActivity : ) } - override fun onFooterClick(address: String) { - copyWalletAddress() - } - private fun ensureClientState(clientState: ClientManager.ClientState) { + Timber.d("ensureClientState: $clientState") when (clientState) { is ClientManager.ClientState.Ready -> { + Timber.d("ensureClientState: Ready, fetching conversations...") viewModel.fetchConversations() binding.fab.visibility = View.VISIBLE - binding.groupFab.visibility = View.VISIBLE - binding.logsFab.visibility = View.VISIBLE + updateDrawerHeader() + } + is ClientManager.ClientState.Error -> { + Timber.e("ensureClientState: Error - ${clientState.message}") + // If there's no wallet key, clear the account and redirect to sign-in + if (clientState.message.contains("No wallet key found")) { + val accounts = accountManager.getAccountsByType(resources.getString(R.string.account_type)) + accounts.forEach { account -> + accountManager.removeAccount(account, null, null, null) + } + showSignIn() + } else { + showError(clientState.message) + } + } + is ClientManager.ClientState.Unknown -> { + Timber.d("ensureClientState: Unknown") } - is ClientManager.ClientState.Error -> showError(clientState.message) - is ClientManager.ClientState.Unknown -> Unit } } + private fun openWalletInfoBottomSheet() { + walletInfoBottomSheet = WalletInfoBottomSheet.newInstance() + walletInfoBottomSheet?.show( + supportFragmentManager, + WalletInfoBottomSheet.TAG, + ) + } + private fun addStreamedItem(item: MainViewModel.MainListItem?) { item?.let { adapter.addItem(item) } } + private fun handleMessageUpdate(update: MainViewModel.MessageUpdate?) { + update?.let { + adapter.updateConversationMessage(it.topic, it.message) + } + } + private fun ensureUiState(uiState: MainViewModel.UiState) { binding.progress.visibility = View.GONE when (uiState) { @@ -287,6 +365,12 @@ class MainActivity : } private fun disconnectWallet() { + // Clear the stored private key and environment before clearing the client + val keyUtil = KeyUtil(this) + val address = ClientManager.client.publicIdentity.identifier + keyUtil.clearPrivateKey(address) + keyUtil.clearEnvironment() + ClientManager.clearClient() PushNotificationTokenManager.clearXMTPPush() val accounts = accountManager.getAccountsByType(resources.getString(R.string.account_type)) @@ -298,24 +382,14 @@ class MainActivity : private fun copyWalletAddress() { val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("inboxId", ClientManager.client.inboxId) + val walletAddress = ClientManager.client.publicIdentity.identifier + val clip = ClipData.newPlainText("wallet_address", walletAddress) clipboard.setPrimaryClip(clip) + Toast.makeText(this, "Wallet address copied", Toast.LENGTH_SHORT).show() } - private fun openConversationDetail() { - bottomSheet = NewConversationBottomSheet.newInstance() - bottomSheet?.show( - supportFragmentManager, - NewConversationBottomSheet.TAG, - ) - } - - private fun openGroupDetail() { - groupBottomSheet = NewGroupBottomSheet.newInstance() - groupBottomSheet?.show( - supportFragmentManager, - NewGroupBottomSheet.TAG, - ) + private fun openNewConversation() { + startActivity(NewConversationActivity.intent(this)) } private fun openLogsViewer() { @@ -350,4 +424,40 @@ class MainActivity : val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) prefs.edit().putBoolean(KEY_LOGS_ACTIVATED, activated).apply() } + + // WalletInfoListener implementation + override fun onLogsToggled(enabled: Boolean) { + if (enabled) { + Client.activatePersistentLibXMTPLogWriter( + applicationContext, + FfiLogLevel.DEBUG, + FfiLogRotation.MINUTELY, + 3, + ) + setLogsActivated(true) + Toast.makeText(this, "Persistent logs activated", Toast.LENGTH_SHORT).show() + } else { + Client.deactivatePersistentLibXMTPLogWriter() + setLogsActivated(false) + Toast.makeText(this, "Persistent logs deactivated", Toast.LENGTH_SHORT).show() + } + // Update drawer menu item state + binding.navigationView.menu + .findItem(R.id.nav_toggle_logs) + ?.isChecked = enabled + } + + override fun onDisconnectClicked() { + disconnectWallet() + } + + override fun isLogsEnabled(): Boolean = isLogsActivated() + + private fun onHideDeletedMessagesToggled(enabled: Boolean) { + val keyUtil = KeyUtil(this) + keyUtil.setHideDeletedMessages(enabled) + ConversationDetailViewModel.hideDeletedMessages = enabled + val message = if (enabled) "Deleted messages will be hidden" else "Deleted messages will be shown" + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } } diff --git a/example/src/main/java/org/xmtp/android/example/MainViewModel.kt b/example/src/main/java/org/xmtp/android/example/MainViewModel.kt index 640ba9511..00f38d578 100644 --- a/example/src/main/java/org/xmtp/android/example/MainViewModel.kt +++ b/example/src/main/java/org/xmtp/android/example/MainViewModel.kt @@ -12,17 +12,20 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import org.xmtp.android.example.extension.flowWhileShared -import org.xmtp.android.example.extension.stateFlow import org.xmtp.android.example.pushnotifications.PushNotificationTokenManager import org.xmtp.android.library.Conversation import org.xmtp.android.library.Topic import org.xmtp.android.library.libxmtp.DecodedMessage import org.xmtp.android.library.push.Service +import timber.log.Timber class MainViewModel : ViewModel() { private val _uiState = MutableStateFlow(UiState.Loading(null)) @@ -44,9 +47,12 @@ class MainViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { val listItems = mutableListOf() try { + Timber.d("fetchConversations: starting, clientState=${ClientManager.clientState.value}") val conversations = ClientManager.client.conversations // Ensure we fetch the latest conversations from the network before listing + Timber.d("fetchConversations: syncing conversations...") conversations.sync() + Timber.d("fetchConversations: sync complete") val subscriptions = conversations .allPushTopics() @@ -82,25 +88,25 @@ class MainViewModel : ViewModel() { subscriptions.add(welcomeTopic) PushNotificationTokenManager.xmtpPush.subscribeWithMetadata(subscriptions) + val conversationList = conversations.list() + Timber.d("fetchConversations: found ${conversationList.size} conversations") listItems.addAll( - conversations.list().map { conversation -> + conversationList.map { conversation -> val lastMessage = fetchMostRecentMessage(conversation) + val (displayName, peerAddress) = getConversationDisplayInfo(conversation) MainListItem.ConversationItem( id = conversation.topic, - conversation, - lastMessage, + conversation = conversation, + mostRecentMessage = lastMessage, + displayName = displayName, + peerAddress = peerAddress, ) }, ) - listItems.add( - MainListItem.Footer( - id = "footer", - ClientManager.client.inboxId, - ClientManager.client.environment.name, - ), - ) + Timber.d("fetchConversations: success, total items=${listItems.size}") _uiState.value = UiState.Success(listItems) } catch (e: Exception) { + Timber.e(e, "fetchConversations: error") _uiState.value = UiState.Error(e.localizedMessage.orEmpty()) } } @@ -110,25 +116,68 @@ class MainViewModel : ViewModel() { private fun fetchMostRecentMessage(conversation: Conversation): DecodedMessage? = runBlocking { conversation.lastMessage() } + @WorkerThread + private fun getConversationDisplayInfo(conversation: Conversation): Pair = + runBlocking { + when (conversation) { + is Conversation.Group -> { + val groupName = conversation.group.name() + val displayName = if (groupName.isNotBlank()) groupName else conversation.id + Pair(displayName, null) + } + is Conversation.Dm -> { + val peerInboxId = conversation.dm.peerInboxId + val members = conversation.dm.members() + val peerMember = members.find { it.inboxId == peerInboxId } + val peerAddress = peerMember?.identities?.firstOrNull()?.identifier + val displayName = peerAddress ?: conversation.id + Pair(displayName, peerAddress) + } + } + } + + // Stream for new conversations - reacts to client state changes @OptIn(ExperimentalCoroutinesApi::class) val stream: StateFlow = - stateFlow(viewModelScope, null) { subscriptionCount -> - if (ClientManager.clientState.value is ClientManager.ClientState.Ready) { + ClientManager.clientState + .filterIsInstance() + .flatMapLatest { ClientManager.client.conversations .stream() - .flowWhileShared( - subscriptionCount, - SharingStarted.WhileSubscribed(1000L), - ).flowOn(Dispatchers.IO) .distinctUntilChanged() - .mapLatest { conversation -> + .mapLatest { conversation -> val lastMessage = fetchMostRecentMessage(conversation) - MainListItem.ConversationItem(conversation.topic, conversation, lastMessage) - }.catch { emptyFlow() } - } else { - emptyFlow() - } - } + val (displayName, peerAddress) = getConversationDisplayInfo(conversation) + MainListItem.ConversationItem( + id = conversation.topic, + conversation = conversation, + mostRecentMessage = lastMessage, + displayName = displayName, + peerAddress = peerAddress, + ) + }.catch { emptyFlow() } + }.flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), null) + + // Stream for message updates - triggers when any conversation receives a new message + // Uses flatMapLatest to react to client state changes + @OptIn(ExperimentalCoroutinesApi::class) + val messageStream: StateFlow = + ClientManager.clientState + .filterIsInstance() + .flatMapLatest { + ClientManager.client.conversations + .streamAllMessages() + .map { message -> + MessageUpdate(message.topic, message) + }.catch { emptyFlow() } + }.flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), null) + + data class MessageUpdate( + val topic: String, + val message: DecodedMessage, + ) sealed class UiState { data class Loading( @@ -150,19 +199,14 @@ class MainViewModel : ViewModel() { ) { companion object { const val ITEM_TYPE_CONVERSATION = 1 - const val ITEM_TYPE_FOOTER = 2 } data class ConversationItem( override val id: String, val conversation: Conversation, val mostRecentMessage: DecodedMessage?, + val displayName: String, + val peerAddress: String? = null, ) : MainListItem(id, ITEM_TYPE_CONVERSATION) - - data class Footer( - override val id: String, - val address: String, - val environment: String, - ) : MainListItem(id, ITEM_TYPE_FOOTER) } } diff --git a/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletFragment.kt b/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletFragment.kt index 2c0e360b1..c369437c7 100644 --- a/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletFragment.kt +++ b/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletFragment.kt @@ -8,6 +8,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.ArrayAdapter import android.widget.Toast import androidx.core.view.isVisible import androidx.fragment.app.Fragment @@ -22,7 +23,9 @@ import kotlinx.coroutines.launch import org.xmtp.android.example.MainActivity import org.xmtp.android.example.R import org.xmtp.android.example.databinding.FragmentConnectWalletBinding +import org.xmtp.android.library.XMTPEnvironment import timber.log.Timber +import uniffi.xmtpv3.FfiLogLevel class ConnectWalletFragment : Fragment() { private val viewModel: ConnectWalletViewModel by viewModels() @@ -57,11 +60,62 @@ class ConnectWalletFragment : Fragment() { } } + setupEnvironmentSpinner() + setupLogLevelSpinner() + binding.generateButton.setOnClickListener { - viewModel.generateWallet() + val selectedEnvironment = getSelectedEnvironment() + val selectedLogLevel = getSelectedLogLevel() + viewModel.generateWallet(selectedEnvironment, selectedLogLevel) } } + private fun setupEnvironmentSpinner() { + val environments = resources.getStringArray(R.array.environment_options) + val adapter = + ArrayAdapter( + requireContext(), + android.R.layout.simple_spinner_item, + environments, + ) + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + binding.environmentSpinner.adapter = adapter + // Default to Dev (index 0) + binding.environmentSpinner.setSelection(0) + } + + private fun setupLogLevelSpinner() { + val logLevels = resources.getStringArray(R.array.log_level_options) + val adapter = + ArrayAdapter( + requireContext(), + android.R.layout.simple_spinner_item, + logLevels, + ) + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + binding.logLevelSpinner.adapter = adapter + // Default to Off (index 0) + binding.logLevelSpinner.setSelection(0) + } + + private fun getSelectedEnvironment(): XMTPEnvironment = + when (binding.environmentSpinner.selectedItemPosition) { + 0 -> XMTPEnvironment.DEV + 1 -> XMTPEnvironment.PRODUCTION + else -> XMTPEnvironment.DEV + } + + private fun getSelectedLogLevel(): FfiLogLevel? = + when (binding.logLevelSpinner.selectedItemPosition) { + 0 -> null // Off + 1 -> FfiLogLevel.ERROR + 2 -> FfiLogLevel.WARN + 3 -> FfiLogLevel.INFO + 4 -> FfiLogLevel.DEBUG + 5 -> FfiLogLevel.TRACE + else -> null + } + private fun ensureUiState(uiState: ConnectWalletViewModel.ConnectUiState) { when (uiState) { is ConnectWalletViewModel.ConnectUiState.Error -> showError(uiState.message) @@ -97,6 +151,10 @@ class ConnectWalletFragment : Fragment() { private fun showError(message: String) { binding.progress.visibility = View.GONE binding.generateButton.visibility = View.VISIBLE + binding.environmentSpinner.visibility = View.VISIBLE + binding.environmentLabel.visibility = View.VISIBLE + binding.logLevelSpinner.visibility = View.VISIBLE + binding.logLevelLabel.visibility = View.VISIBLE Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() } @@ -105,6 +163,10 @@ class ConnectWalletFragment : Fragment() { binding.generateButton.visibility = View.GONE binding.connectButton.visibility = View.GONE binding.connectError.visibility = View.GONE + binding.environmentSpinner.visibility = View.GONE + binding.environmentLabel.visibility = View.GONE + binding.logLevelSpinner.visibility = View.GONE + binding.logLevelLabel.visibility = View.GONE } override fun onDestroyView() { diff --git a/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletViewModel.kt b/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletViewModel.kt index 35c88e24e..e977744d5 100644 --- a/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletViewModel.kt +++ b/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletViewModel.kt @@ -16,10 +16,13 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.xmtp.android.example.ClientManager +import org.xmtp.android.example.utils.KeyUtil import org.xmtp.android.library.Client +import org.xmtp.android.library.XMTPEnvironment import org.xmtp.android.library.XMTPException -import org.xmtp.android.library.codecs.GroupUpdatedCodec import org.xmtp.android.library.messages.PrivateKeyBuilder +import uniffi.xmtpv3.FfiLogLevel +import uniffi.xmtpv3.FfiLogRotation class ConnectWalletViewModel( application: Application, @@ -32,20 +35,54 @@ class ConnectWalletViewModel( val uiState: StateFlow = _uiState @UiThread - fun generateWallet() { + fun generateWallet( + environment: XMTPEnvironment, + logLevel: FfiLogLevel?, + ) { viewModelScope.launch(Dispatchers.IO) { _uiState.value = ConnectUiState.Loading try { + // Store the selected environment and log level + ClientManager.selectedEnvironment = environment + ClientManager.selectedLogLevel = logLevel + + // Activate logging if a log level is selected + if (logLevel != null) { + Client.activatePersistentLibXMTPLogWriter( + getApplication(), + logLevel, + FfiLogRotation.MINUTELY, + 3, + ) + } + val wallet = PrivateKeyBuilder() + val address = wallet.publicIdentity.identifier + + // Store the private key and environment for later use on app restart + val keyUtil = KeyUtil(getApplication()) + val privateKeyBytes = + wallet + .getPrivateKey() + .secp256K1.bytes + .toByteArray() + keyUtil.storePrivateKey(address, privateKeyBytes) + keyUtil.storeEnvironment(environment.name) + val client = Client.create( wallet, - ClientManager.clientOptions(getApplication(), wallet.publicIdentity.identifier), + ClientManager.clientOptions( + getApplication(), + address, + environment, + ), ) - Client.register(codec = GroupUpdatedCodec()) + // Store the client in ClientManager so it can be used by MainActivity + ClientManager.setClient(client) _uiState.value = ConnectUiState.Success( - wallet.publicIdentity.identifier, + address, ) } catch (e: XMTPException) { _uiState.value = ConnectUiState.Error(e.message.orEmpty()) diff --git a/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailActivity.kt b/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailActivity.kt index a442f8355..0718d462e 100644 --- a/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailActivity.kt +++ b/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailActivity.kt @@ -1,38 +1,105 @@ package org.xmtp.android.example.conversation -import android.R.id.home -import android.content.ClipData -import android.content.ClipboardManager +import android.content.ContentResolver import android.content.Context import android.content.Intent +import android.graphics.Color +import android.net.Uri import android.os.Bundle -import android.view.Menu -import android.view.MenuItem +import android.provider.OpenableColumns import android.view.View +import android.webkit.MimeTypeMap import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.FileProvider import androidx.core.widget.addTextChangedListener import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.xmtp.android.example.ClientManager import org.xmtp.android.example.R import org.xmtp.android.example.databinding.ActivityConversationDetailBinding import org.xmtp.android.example.extension.truncatedAddress +import org.xmtp.android.example.message.EmojiPickerAdapter import org.xmtp.android.example.message.MessageAdapter +import org.xmtp.android.example.message.MessageClickListener +import org.xmtp.android.library.Conversation +import org.xmtp.android.library.codecs.Reaction +import org.xmtp.android.library.codecs.ReactionAction +import org.xmtp.android.library.libxmtp.DecodedMessageV2 +import java.io.File +import kotlin.math.abs -class ConversationDetailActivity : AppCompatActivity() { +class ConversationDetailActivity : + AppCompatActivity(), + MessageClickListener { private lateinit var binding: ActivityConversationDetailBinding private lateinit var adapter: MessageAdapter + private lateinit var emojiAdapter: EmojiPickerAdapter + private var isEmojiPickerVisible = false private val viewModel: ConversationDetailViewModel by viewModels() + // Attachment handling + private var cameraImageUri: Uri? = null + + private val galleryLauncher = + registerForActivityResult( + ActivityResultContracts.GetContent(), + ) { uri: Uri? -> + uri?.let { handleSelectedAttachment(it) } + } + + private val cameraLauncher = + registerForActivityResult( + ActivityResultContracts.TakePicture(), + ) { success: Boolean -> + if (success) { + cameraImageUri?.let { handleSelectedAttachment(it) } + } + } + + private val fileLauncher = + registerForActivityResult( + ActivityResultContracts.OpenDocument(), + ) { uri: Uri? -> + uri?.let { handleSelectedAttachment(it) } + } + private val peerAddress get() = intent.extras?.getString(EXTRA_PEER_ADDRESS) + private val conversationTopic + get() = intent.extras?.getString(EXTRA_CONVERSATION_TOPIC) + + private var conversationType: Conversation.Type? = null + private var peerWalletAddress: String? = null + private var peerInboxId: String? = null + private var groupName: String? = null + private var memberCount: Int = 0 + private var isSuperAdmin: Boolean = false + + private val avatarColors = + listOf( + Color.parseColor("#FC4F37"), + Color.parseColor("#5856D6"), + Color.parseColor("#34C759"), + Color.parseColor("#FF9500"), + Color.parseColor("#007AFF"), + Color.parseColor("#AF52DE"), + Color.parseColor("#00C7BE"), + Color.parseColor("#FF2D55"), + ) + companion object { const val EXTRA_CONVERSATION_TOPIC = "EXTRA_CONVERSATION_TOPIC" private const val EXTRA_PEER_ADDRESS = "EXTRA_PEER_ADDRESS" @@ -54,19 +121,15 @@ class ConversationDetailActivity : AppCompatActivity() { binding = ActivityConversationDetailBinding.inflate(layoutInflater) setContentView(binding.root) - setSupportActionBar(binding.toolbar) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - supportActionBar?.subtitle = - if (peerAddress != null && peerAddress!!.contains(",")) { - val addresses = peerAddress?.split(",")?.toMutableList() - addresses?.joinToString(" & ") { - it.truncatedAddress() - } - } else { - peerAddress?.truncatedAddress() - } - adapter = MessageAdapter() + // Setup header + binding.backButton.setOnClickListener { finish() } + binding.headerInfoArea.setOnClickListener { openConversationInfo() } + + // Set initial header values + setupInitialHeader() + + adapter = MessageAdapter(this) binding.list.layoutManager = LinearLayoutManager(this, RecyclerView.VERTICAL, true) binding.list.adapter = adapter @@ -79,22 +142,36 @@ class ConversationDetailActivity : AppCompatActivity() { binding.messageEditText.requestFocus() binding.messageEditText.addTextChangedListener { - val sendEnabled = !binding.messageEditText.text.isNullOrBlank() - binding.sendButton.isEnabled = sendEnabled + val hasText = !binding.messageEditText.text.isNullOrBlank() + binding.sendButton.isEnabled = hasText } + // Initialize send button as disabled (no text) + binding.sendButton.isEnabled = false + binding.sendButton.setOnClickListener { - val flow = viewModel.sendMessage(binding.messageEditText.text.toString()) - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - flow.collect(::ensureSendState) + val text = binding.messageEditText.text.toString() + if (text.isNotBlank()) { + val flow = viewModel.sendMessage(text) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + flow.collect(::ensureSendState) + } } } } + // Setup emoji picker + setupEmojiPicker() + + // Setup attachment button + binding.attachButton.setOnClickListener { + showAttachmentPicker() + } + lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.streamMessages.collect(::addStreamedItem) + viewModel.streamMessages.collect(::handleStreamedResult) } } @@ -103,32 +180,215 @@ class ConversationDetailActivity : AppCompatActivity() { } viewModel.fetchMessages() + + // Load conversation info for header + loadConversationInfo() + + // Setup reply preview + setupReplyPreview() } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_main, menu) - return true + private fun setupEmojiPicker() { + // Setup emoji picker RecyclerView + emojiAdapter = + EmojiPickerAdapter { emoji -> + // Insert emoji at cursor position + val start = binding.messageEditText.selectionStart.coerceAtLeast(0) + val end = binding.messageEditText.selectionEnd.coerceAtLeast(0) + binding.messageEditText.text?.replace( + start.coerceAtMost(end), + start.coerceAtLeast(end), + emoji, + ) + } + + binding.emojiPickerRecyclerView.layoutManager = GridLayoutManager(this, 8) + binding.emojiPickerRecyclerView.adapter = emojiAdapter + + // Toggle emoji picker visibility + binding.emojiButton.setOnClickListener { + toggleEmojiPicker() + } + + // Hide emoji picker when text input is focused and keyboard appears + binding.messageEditText.setOnFocusChangeListener { _, hasFocus -> + if (hasFocus && isEmojiPickerVisible) { + hideEmojiPicker() + } + } } - override fun onOptionsItemSelected(item: MenuItem): Boolean = - when (item.itemId) { - home -> { - finish() - true + private fun toggleEmojiPicker() { + if (isEmojiPickerVisible) { + hideEmojiPicker() + } else { + showEmojiPicker() + } + } + + private fun showEmojiPicker() { + isEmojiPickerVisible = true + binding.emojiPickerRecyclerView.visibility = View.VISIBLE + binding.emojiButton.setImageResource(R.drawable.ic_keyboard_24) + // Hide soft keyboard + binding.messageEditText.clearFocus() + val imm = getSystemService(INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager + imm.hideSoftInputFromWindow(binding.messageEditText.windowToken, 0) + } + + private fun hideEmojiPicker() { + isEmojiPickerVisible = false + binding.emojiPickerRecyclerView.visibility = View.GONE + binding.emojiButton.setImageResource(R.drawable.ic_emoji_24) + } + + private fun setupReplyPreview() { + // Close button clears the reply + binding.replyPreviewClose.setOnClickListener { + viewModel.clearReply() + } + + // Observe reply state + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.replyToMessage.collect { message -> + if (message != null) { + binding.replyPreviewContainer.visibility = View.VISIBLE + binding.replyPreviewAuthor.text = message.senderInboxId.take(8) + "..." + val content = message.content() + val messageText = + when (content) { + is String -> content + else -> message.fallbackText ?: "Message" + } + binding.replyPreviewText.text = messageText + binding.messageEditText.requestFocus() + } else { + binding.replyPreviewContainer.visibility = View.GONE + } + } } + } + } - R.id.copy_address -> { - copyWalletAddress() - true + private fun setupInitialHeader() { + // Set initial values based on peerAddress + val displayAddress = peerAddress ?: "" + binding.headerTitle.text = displayAddress.truncatedAddress() + binding.headerSubtitle.text = getString(R.string.loading) + + // Set avatar + val avatarText = + displayAddress + .removePrefix("0x") + .take(2) + .uppercase() + binding.headerAvatarText.text = avatarText + + val colorIndex = abs(displayAddress.hashCode()) % avatarColors.size + binding.headerAvatarCard.setCardBackgroundColor(avatarColors[colorIndex]) + } + + private fun loadConversationInfo() { + lifecycleScope.launch { + try { + val conversation = + withContext(Dispatchers.IO) { + conversationTopic?.let { + ClientManager.client.conversations.findConversationByTopic(it) + } + } + + conversation?.let { conv -> + conversationType = conv.type + + when (conv) { + is Conversation.Group -> { + groupName = withContext(Dispatchers.IO) { conv.group.name() } + val members = withContext(Dispatchers.IO) { conv.group.members() } + memberCount = members.size + + // Check if current user is a super admin + isSuperAdmin = + withContext(Dispatchers.IO) { + conv.group.isSuperAdmin(ClientManager.client.inboxId) + } + + // Update header for group + val displayName = + if (groupName.isNullOrBlank()) { + conv.id.truncatedAddress() + } else { + groupName!! + } + binding.headerTitle.text = displayName + binding.headerSubtitle.text = getString(R.string.members_count_value, memberCount) + + // Update avatar for group + val avatarText = + displayName + .removePrefix("0x") + .take(2) + .uppercase() + binding.headerAvatarText.text = avatarText + } + is Conversation.Dm -> { + // Get the peer's info for DMs + val members = withContext(Dispatchers.IO) { conv.dm.members() } + val peerMember = members.find { it.inboxId != ClientManager.client.inboxId } + peerMember?.let { member -> + peerInboxId = member.inboxId + peerWalletAddress = member.identities.firstOrNull()?.identifier + + // Update header for DM + val displayAddress = peerWalletAddress ?: conv.id + binding.headerTitle.text = displayAddress.truncatedAddress() + binding.headerSubtitle.text = getString(R.string.direct_message) + + // Update avatar + val avatarText = + displayAddress + .removePrefix("0x") + .take(2) + .uppercase() + binding.headerAvatarText.text = avatarText + + val colorIndex = abs(displayAddress.hashCode()) % avatarColors.size + binding.headerAvatarCard.setCardBackgroundColor(avatarColors[colorIndex]) + } + } + } + } + } catch (e: Exception) { + // Silently fail - header will just show initial values + binding.headerSubtitle.text = getString(R.string.direct_message) } + } + } - else -> super.onOptionsItemSelected(item) + private fun openConversationInfo() { + when (conversationType) { + Conversation.Type.GROUP -> { + conversationTopic?.let { topic -> + startActivity(GroupManagementActivity.intent(this, topic)) + } + } + Conversation.Type.DM -> { + peerWalletAddress?.let { address -> + startActivity(UserProfileActivity.intent(this, address, peerInboxId)) + } + } + null -> { + // Conversation info not loaded yet + Toast.makeText(this, R.string.loading, Toast.LENGTH_SHORT).show() + } } + } - private fun copyWalletAddress() { - val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("peer_address", peerAddress) - clipboard.setPrimaryClip(clip) + override fun onResume() { + super.onResume() + // Reload conversation info in case something changed (e.g., group name updated) + loadConversationInfo() } private fun ensureUiState(uiState: ConversationDetailViewModel.UiState) { @@ -174,9 +434,16 @@ class ConversationDetailActivity : AppCompatActivity() { } } - private fun addStreamedItem(item: ConversationDetailViewModel.MessageListItem?) { - item?.let { - adapter.addItem(it) + private fun handleStreamedResult(result: ConversationDetailViewModel.StreamedMessageResult?) { + when (result) { + is ConversationDetailViewModel.StreamedMessageResult.NewMessage -> { + adapter.addItem(result.item) + } + is ConversationDetailViewModel.StreamedMessageResult.RefreshNeeded -> { + // A delete message was received, refresh the list to show updated state + viewModel.fetchMessages() + } + null -> { /* ignore */ } } } @@ -184,4 +451,362 @@ class ConversationDetailActivity : AppCompatActivity() { val error = message.ifBlank { resources.getString(R.string.error) } Toast.makeText(this, error, Toast.LENGTH_SHORT).show() } + + override fun onMessageLongClick(message: DecodedMessageV2) { + showMessageOptionsDialog(message) + } + + private fun showMessageOptionsDialog(message: DecodedMessageV2) { + val isFromMe = ClientManager.client.inboxId == message.senderInboxId + // Allow delete if it's my message OR if I'm a super admin in a group + val canDelete = isFromMe || (conversationType == Conversation.Type.GROUP && isSuperAdmin) + + // Find user's existing active reaction on this message + // We need to aggregate Added/Removed to find the current state + val myInboxId = ClientManager.client.inboxId + var myExistingReaction: String? = null + if (message.hasReactions) { + // Sort reactions by timestamp to ensure correct chronological processing + val sortedReactions = message.reactions.sortedBy { it.sentAtNs } + // Track my reactions: emoji -> isActive + val myReactions = mutableMapOf() + for (reactionMsg in sortedReactions) { + val reaction = reactionMsg.content() ?: continue + if (reactionMsg.senderInboxId == myInboxId) { + when (reaction.action) { + ReactionAction.Added -> myReactions[reaction.content] = true + ReactionAction.Removed -> myReactions[reaction.content] = false + else -> {} + } + } + } + // Find the first active reaction (should only be one per user typically) + myExistingReaction = myReactions.entries.firstOrNull { it.value }?.key + } + + // Create custom dialog with reaction picker + val dialogView = layoutInflater.inflate(R.layout.dialog_message_options, null) + val dialog = + AlertDialog + .Builder(this) + .setView(dialogView) + .create() + + // Setup quick reaction buttons + val reactionEmojis = + listOf("\uD83D\uDC4D", "\u2764\uFE0F", "\uD83D\uDE02", "\uD83D\uDE2E", "\uD83D\uDE22", "\uD83D\uDE21") + val reactionButtons = + listOf( + dialogView.findViewById(R.id.reaction1), + dialogView.findViewById(R.id.reaction2), + dialogView.findViewById(R.id.reaction3), + dialogView.findViewById(R.id.reaction4), + dialogView.findViewById(R.id.reaction5), + dialogView.findViewById(R.id.reaction6), + ) + + reactionButtons.forEachIndexed { index, button -> + val emoji = reactionEmojis[index] + (button as? android.widget.TextView)?.text = emoji + + // Highlight if this is user's current reaction + if (myExistingReaction == emoji) { + button.setBackgroundResource(R.drawable.reaction_button_selected_background) + } + + button.setOnClickListener { + dialog.dismiss() + when { + // Same emoji clicked - remove reaction + myExistingReaction == emoji -> { + removeReaction(message.id, emoji) + } + // Different emoji clicked when user has existing reaction - remove old, add new + myExistingReaction != null -> { + removeReaction(message.id, myExistingReaction) + sendReaction(message.id, emoji) + } + // No existing reaction - add new + else -> { + sendReaction(message.id, emoji) + } + } + } + } + + // Setup action buttons + dialogView.findViewById(R.id.replyButton).setOnClickListener { + dialog.dismiss() + viewModel.setReplyToMessage(message) + } + + val deleteButton = dialogView.findViewById(R.id.deleteButton) + val divider = dialogView.findViewById(R.id.divider) + if (canDelete) { + deleteButton.visibility = View.VISIBLE + divider.visibility = View.VISIBLE + deleteButton.setOnClickListener { + dialog.dismiss() + showDeleteConfirmationDialog(message) + } + } else { + deleteButton.visibility = View.GONE + divider.visibility = View.GONE + } + + // Make dialog background transparent for better visual appearance + dialog.window?.setBackgroundDrawableResource(android.R.color.transparent) + dialog.show() + } + + override fun onReplyClick(referenceMessageId: String) { + scrollToMessage(referenceMessageId) + } + + override fun onReactionClick( + messageId: String, + emoji: String, + ) { + sendReaction(messageId, emoji) + } + + private fun sendReaction( + messageId: String, + emoji: String, + ) { + lifecycleScope.launch { + when (val result = viewModel.sendReaction(messageId, emoji, isRemoving = false)) { + is ConversationDetailViewModel.ReactionState.Error -> { + showError(result.message) + } + ConversationDetailViewModel.ReactionState.Success -> { + viewModel.fetchMessages() + } + else -> {} + } + } + } + + private fun removeReaction( + messageId: String, + emoji: String, + ) { + lifecycleScope.launch { + when (val result = viewModel.sendReaction(messageId, emoji, isRemoving = true)) { + is ConversationDetailViewModel.ReactionState.Error -> { + showError(result.message) + } + ConversationDetailViewModel.ReactionState.Success -> { + viewModel.fetchMessages() + } + else -> {} + } + } + } + + private fun scrollToMessage(messageId: String) { + // Find the position of the message with the given ID + val currentState = viewModel.uiState.value + val items = + when (currentState) { + is ConversationDetailViewModel.UiState.Success -> currentState.listItems + is ConversationDetailViewModel.UiState.Loading -> currentState.listItems + else -> null + } + + items?.let { list -> + val position = list.indexOfFirst { it.id == messageId } + if (position != -1) { + // Scroll to the position with smooth animation + binding.list.smoothScrollToPosition(position) + + // Highlight the message briefly + binding.list.postDelayed({ + val viewHolder = binding.list.findViewHolderForAdapterPosition(position) + viewHolder?.itemView?.let { view -> + // Flash highlight effect + view.alpha = 0.5f + view + .animate() + .alpha(1f) + .setDuration(500) + .start() + } + }, 300) + } else { + Toast.makeText(this, "Message not found", Toast.LENGTH_SHORT).show() + } + } + } + + private fun showDeleteConfirmationDialog(message: DecodedMessageV2) { + AlertDialog + .Builder(this) + .setTitle("Delete Message") + .setMessage("Are you sure you want to delete this message?") + .setPositiveButton("Delete") { _, _ -> + deleteMessage(message.id) + }.setNegativeButton("Cancel", null) + .show() + } + + private fun deleteMessage(messageId: String) { + val flow = viewModel.deleteMessage(messageId) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + flow.collect(::ensureDeleteState) + } + } + } + + private fun ensureDeleteState(deleteState: ConversationDetailViewModel.DeleteMessageState) { + when (deleteState) { + is ConversationDetailViewModel.DeleteMessageState.Error -> { + showError(deleteState.message) + } + + ConversationDetailViewModel.DeleteMessageState.Loading -> { + // Could show a loading indicator + } + + ConversationDetailViewModel.DeleteMessageState.Success -> { + Toast.makeText(this, "Message deleted", Toast.LENGTH_SHORT).show() + viewModel.fetchMessages() + } + } + } + + private fun showAttachmentPicker() { + val dialogView = layoutInflater.inflate(R.layout.dialog_attachment_picker, null) + val dialog = + AlertDialog + .Builder(this) + .setView(dialogView) + .create() + + dialogView.findViewById(R.id.cameraOption).setOnClickListener { + dialog.dismiss() + openCamera() + } + + dialogView.findViewById(R.id.galleryOption).setOnClickListener { + dialog.dismiss() + openGallery() + } + + dialogView.findViewById(R.id.fileOption).setOnClickListener { + dialog.dismiss() + openFilePicker() + } + + dialogView.findViewById(R.id.gifOption).setOnClickListener { + dialog.dismiss() + openGifPicker() + } + + dialog.window?.setBackgroundDrawableResource(android.R.color.transparent) + dialog.show() + } + + private fun openCamera() { + val photoFile = File(cacheDir, "camera_${System.currentTimeMillis()}.jpg") + cameraImageUri = + FileProvider.getUriForFile( + this, + "$packageName.fileprovider", + photoFile, + ) + cameraLauncher.launch(cameraImageUri) + } + + private fun openGallery() { + galleryLauncher.launch("image/*") + } + + private fun openFilePicker() { + fileLauncher.launch(arrayOf("*/*")) + } + + private fun openGifPicker() { + // Use the gallery picker with GIF mime type filter + galleryLauncher.launch("image/gif") + } + + private fun handleSelectedAttachment(uri: Uri) { + lifecycleScope.launch { + try { + val (filename, mimeType, data) = + withContext(Dispatchers.IO) { + readAttachmentFromUri(uri) + } + + // Check file size (max 10MB for inline attachments) + val maxSize = 10 * 1024 * 1024 // 10MB + if (data.size > maxSize) { + Toast + .makeText( + this@ConversationDetailActivity, + R.string.attachment_too_large, + Toast.LENGTH_SHORT, + ).show() + return@launch + } + + // Send attachment + Toast + .makeText( + this@ConversationDetailActivity, + R.string.sending_attachment, + Toast.LENGTH_SHORT, + ).show() + + when (val result = viewModel.sendAttachment(filename, mimeType, data)) { + is ConversationDetailViewModel.SendAttachmentState.Success -> { + viewModel.fetchMessages() + } + is ConversationDetailViewModel.SendAttachmentState.Error -> { + showError(result.message) + } + else -> {} + } + } catch (e: Exception) { + Toast + .makeText( + this@ConversationDetailActivity, + R.string.attachment_error, + Toast.LENGTH_SHORT, + ).show() + } + } + } + + private fun readAttachmentFromUri(uri: Uri): Triple { + val contentResolver: ContentResolver = contentResolver + + // Get filename + var filename = "attachment" + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0) { + filename = cursor.getString(nameIndex) ?: "attachment" + } + } + } + + // Get MIME type + val mimeType = + contentResolver.getType(uri) + ?: MimeTypeMap.getSingleton().getMimeTypeFromExtension( + filename.substringAfterLast('.', ""), + ) + ?: "application/octet-stream" + + // Read data + val data = + contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: throw IllegalStateException("Could not read attachment") + + return Triple(filename, mimeType, data) + } } diff --git a/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailViewModel.kt b/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailViewModel.kt index 9c8c24d09..2b4ad86ce 100644 --- a/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailViewModel.kt +++ b/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailViewModel.kt @@ -4,6 +4,7 @@ import androidx.annotation.UiThread import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.google.protobuf.kotlin.toByteString import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -16,10 +17,25 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import org.xmtp.android.example.ClientManager import org.xmtp.android.example.extension.flowWhileShared import org.xmtp.android.example.extension.stateFlow import org.xmtp.android.library.Conversation +import org.xmtp.android.library.SendOptions +import org.xmtp.android.library.codecs.Attachment +import org.xmtp.android.library.codecs.ContentTypeAttachment +import org.xmtp.android.library.codecs.ContentTypeReaction +import org.xmtp.android.library.codecs.ContentTypeReply +import org.xmtp.android.library.codecs.ContentTypeText +import org.xmtp.android.library.codecs.DeletedBy +import org.xmtp.android.library.codecs.DeletedMessage +import org.xmtp.android.library.codecs.Reaction +import org.xmtp.android.library.codecs.ReactionAction +import org.xmtp.android.library.codecs.ReactionSchema +import org.xmtp.android.library.codecs.Reply +import org.xmtp.android.library.libxmtp.DecodedMessageV2 +import org.xmtp.proto.mls.message.contents.TranscriptMessages.GroupUpdated class ConversationDetailViewModel( private val savedStateHandle: SavedStateHandle, @@ -39,8 +55,19 @@ class ConversationDetailViewModel( private val _uiState = MutableStateFlow(UiState.Loading(null)) val uiState: StateFlow = _uiState + private val _replyToMessage = MutableStateFlow(null) + val replyToMessage: StateFlow = _replyToMessage + private var conversation: Conversation? = null + fun setReplyToMessage(message: DecodedMessageV2?) { + _replyToMessage.value = message + } + + fun clearReply() { + _replyToMessage.value = null + } + @UiThread fun fetchMessages() { when (val uiState = uiState.value) { @@ -54,12 +81,24 @@ class ConversationDetailViewModel( conversation = ClientManager.client.conversations.findConversationByTopic(conversationTopic!!) } conversation?.let { - if (conversation is Conversation.Group) { - (conversation as Conversation.Group).group.sync() + // Sync conversation to get latest messages (including deletions) + when (it) { + is Conversation.Group -> it.group.sync() + is Conversation.Dm -> it.dm.sync() } listItems.addAll( - it.messages().map { message -> - MessageListItem.Message(message.id, message) + it.enrichedMessages().mapNotNull { message -> + message?.let { msg -> + val item = classifyMessage(msg) + // Filter out deleted messages if hideDeletedMessages is enabled + if (hideDeletedMessages && item is MessageListItem.SystemMessage) { + val content = msg.content() + if (content is DeletedMessage) { + return@mapNotNull null + } + } + item + } }, ) } @@ -71,7 +110,7 @@ class ConversationDetailViewModel( } @OptIn(ExperimentalCoroutinesApi::class) - val streamMessages: StateFlow = + val streamMessages: StateFlow = stateFlow(viewModelScope, null) { subscriptionCount -> if (conversation == null) { conversation = @@ -88,19 +127,62 @@ class ConversationDetailViewModel( ).flowOn(Dispatchers.IO) .distinctUntilChanged() .mapLatest { message -> - MessageListItem.Message(message.id, message) - }.catch { emptyFlow() } + // Check if this is a delete or reaction message - if so, signal a refresh is needed + val contentTypeId = message.encodedContent.type + val isDeleteMessage = contentTypeId?.typeId == "deleteMessage" + val isReactionMessage = contentTypeId?.typeId == "reaction" + + if (isDeleteMessage || isReactionMessage) { + // Return a signal to refresh the message list + // Reactions and deletes modify existing messages, so we need a full refresh + StreamedMessageResult.RefreshNeeded + } else { + // Convert streamed DecodedMessage to DecodedMessageV2 using findEnrichedMessage + val enrichedMessage = + ClientManager.client.conversations.findEnrichedMessage(message.id) + enrichedMessage?.let { + StreamedMessageResult.NewMessage(classifyMessage(it)) + } + } + }.catch { _ -> + emptyFlow() + } } else { emptyFlow() } } + sealed class StreamedMessageResult { + data class NewMessage( + val item: MessageListItem, + ) : StreamedMessageResult() + + object RefreshNeeded : StreamedMessageResult() + } + @UiThread fun sendMessage(body: String): StateFlow { val flow = MutableStateFlow(SendMessageState.Loading) + val replyTo = _replyToMessage.value viewModelScope.launch(Dispatchers.IO) { try { - conversation?.send(body) + if (replyTo != null) { + // Send as reply using Reply codec + val replyContent = + Reply( + reference = replyTo.id, + content = body, + contentType = ContentTypeText, + ) + conversation?.send( + content = replyContent, + options = SendOptions(contentType = ContentTypeReply), + ) + _replyToMessage.value = null + } else { + // Send as regular message + conversation?.send(body) + } flow.value = SendMessageState.Success } catch (e: Exception) { flow.value = SendMessageState.Error(e.localizedMessage.orEmpty()) @@ -109,6 +191,74 @@ class ConversationDetailViewModel( return flow } + @UiThread + fun deleteMessage(messageId: String): StateFlow { + val flow = MutableStateFlow(DeleteMessageState.Loading) + viewModelScope.launch(Dispatchers.IO) { + try { + conversation?.deleteMessage(messageId) + flow.value = DeleteMessageState.Success + } catch (e: Exception) { + flow.value = DeleteMessageState.Error(e.localizedMessage.orEmpty()) + } + } + return flow + } + + suspend fun sendReaction( + messageId: String, + emoji: String, + isRemoving: Boolean = false, + ): ReactionState = + withContext(Dispatchers.IO) { + try { + val reaction = + Reaction( + reference = messageId, + action = if (isRemoving) ReactionAction.Removed else ReactionAction.Added, + content = emoji, + schema = ReactionSchema.Unicode, + ) + conversation?.send( + content = reaction, + options = SendOptions(contentType = ContentTypeReaction), + ) + // Sync to ensure the reaction is persisted and available for enrichedMessages + conversation?.let { + when (it) { + is Conversation.Group -> it.group.sync() + is Conversation.Dm -> it.dm.sync() + } + } + ReactionState.Success + } catch (e: Exception) { + ReactionState.Error(e.localizedMessage.orEmpty()) + } + } + + suspend fun sendAttachment( + filename: String, + mimeType: String, + data: ByteArray, + ): SendAttachmentState = + withContext(Dispatchers.IO) { + try { + val attachment = + Attachment( + filename = filename, + mimeType = mimeType, + data = data.toByteString(), + ) + conversation?.send( + content = attachment, + options = SendOptions(contentType = ContentTypeAttachment), + ) + SendAttachmentState.Success + } catch (e: Exception) { + SendAttachmentState.Error(e.localizedMessage.orEmpty()) + } + } + sealed class UiState { data class Loading( val listItems: List?, @@ -133,17 +283,107 @@ class ConversationDetailViewModel( ) : SendMessageState() } + sealed class DeleteMessageState { + object Loading : DeleteMessageState() + + object Success : DeleteMessageState() + + data class Error( + val message: String, + ) : DeleteMessageState() + } + + sealed class ReactionState { + object Loading : ReactionState() + + object Success : ReactionState() + + data class Error( + val message: String, + ) : ReactionState() + } + + sealed class SendAttachmentState { + object Loading : SendAttachmentState() + + object Success : SendAttachmentState() + + data class Error( + val message: String, + ) : SendAttachmentState() + } + sealed class MessageListItem( open val id: String, val itemType: Int, ) { companion object { - const val ITEM_TYPE_MESSAGE = 1 + const val ITEM_TYPE_SENT = 1 + const val ITEM_TYPE_RECEIVED = 2 + const val ITEM_TYPE_SYSTEM = 3 } - data class Message( + data class SentMessage( override val id: String, - val message: org.xmtp.android.library.libxmtp.DecodedMessage, - ) : MessageListItem(id, ITEM_TYPE_MESSAGE) + val message: DecodedMessageV2, + ) : MessageListItem(id, ITEM_TYPE_SENT) + + data class ReceivedMessage( + override val id: String, + val message: DecodedMessageV2, + ) : MessageListItem(id, ITEM_TYPE_RECEIVED) + + data class SystemMessage( + override val id: String, + val message: DecodedMessageV2, + val text: String, + ) : MessageListItem(id, ITEM_TYPE_SYSTEM) + } + + companion object { + // Flag to hide deleted messages entirely (set from Activity) + var hideDeletedMessages: Boolean = false + + fun classifyMessage(message: DecodedMessageV2): MessageListItem { + val content = message.content() + val isFromMe = ClientManager.client.inboxId == message.senderInboxId + + // Check for system messages (deleted, group updates) + return when (content) { + is DeletedMessage -> { + val deletedByText = + when (content.deletedBy) { + is DeletedBy.Sender -> "sender" + is DeletedBy.Admin -> "admin" + } + MessageListItem.SystemMessage( + message.id, + message, + "This message was deleted by $deletedByText", + ) + } + is GroupUpdated -> { + val addedText = + content.addedInboxesList + ?.mapNotNull { it.inboxId } + ?.takeIf { it.isNotEmpty() } + ?.let { "Added: ${it.joinToString(", ") { id -> id.take(8) + "..." }}" } + val removedText = + content.removedInboxesList + ?.mapNotNull { it.inboxId } + ?.takeIf { it.isNotEmpty() } + ?.let { "Removed: ${it.joinToString(", ") { id -> id.take(8) + "..." }}" } + val text = listOfNotNull(addedText, removedText).joinToString("\n").ifEmpty { "Group updated" } + MessageListItem.SystemMessage(message.id, message, text) + } + else -> { + if (isFromMe) { + MessageListItem.SentMessage(message.id, message) + } else { + MessageListItem.ReceivedMessage(message.id, message) + } + } + } + } } } diff --git a/example/src/main/java/org/xmtp/android/example/conversation/ConversationFooterViewHolder.kt b/example/src/main/java/org/xmtp/android/example/conversation/ConversationFooterViewHolder.kt deleted file mode 100644 index 0142c7dad..000000000 --- a/example/src/main/java/org/xmtp/android/example/conversation/ConversationFooterViewHolder.kt +++ /dev/null @@ -1,66 +0,0 @@ -package org.xmtp.android.example.conversation - -import android.graphics.Color -import android.graphics.Typeface -import android.text.Spannable -import android.text.SpannableString -import android.text.style.ForegroundColorSpan -import android.text.style.StyleSpan -import androidx.recyclerview.widget.RecyclerView -import org.xmtp.android.example.MainViewModel -import org.xmtp.android.example.R -import org.xmtp.android.example.databinding.ListItemConversationFooterBinding - -class ConversationFooterViewHolder( - private val binding: ListItemConversationFooterBinding, - onFooterClickListener: ConversationsClickListener, -) : RecyclerView.ViewHolder(binding.root) { - private var address: String? = null - - init { - binding.root.setOnClickListener { - address?.let { - onFooterClickListener.onFooterClick(it) - } - } - } - - fun bind(item: MainViewModel.MainListItem.Footer) { - address = item.address - val spannable = - SpannableString( - binding.root.resources.getString( - R.string.conversation_footer, - item.address, - item.environment, - ), - ) - val addressStart = spannable.indexOf(item.address) - val envStart = spannable.indexOf(item.environment) - spannable.setSpan( - StyleSpan(Typeface.BOLD), - addressStart, - addressStart + item.address.length, - Spannable.SPAN_INCLUSIVE_EXCLUSIVE, - ) - spannable.setSpan( - ForegroundColorSpan(Color.BLACK), - addressStart, - addressStart + item.address.length, - Spannable.SPAN_INCLUSIVE_EXCLUSIVE, - ) - spannable.setSpan( - StyleSpan(Typeface.BOLD), - envStart, - envStart + item.environment.length, - Spannable.SPAN_INCLUSIVE_EXCLUSIVE, - ) - spannable.setSpan( - ForegroundColorSpan(Color.BLACK), - envStart, - envStart + item.environment.length, - Spannable.SPAN_INCLUSIVE_EXCLUSIVE, - ) - binding.footer.text = spannable - } -} diff --git a/example/src/main/java/org/xmtp/android/example/conversation/ConversationViewHolder.kt b/example/src/main/java/org/xmtp/android/example/conversation/ConversationViewHolder.kt index 681debbc2..d304d8399 100644 --- a/example/src/main/java/org/xmtp/android/example/conversation/ConversationViewHolder.kt +++ b/example/src/main/java/org/xmtp/android/example/conversation/ConversationViewHolder.kt @@ -1,5 +1,7 @@ package org.xmtp.android.example.conversation +import android.graphics.Color +import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView import org.xmtp.android.example.ClientManager import org.xmtp.android.example.MainViewModel @@ -7,7 +9,13 @@ import org.xmtp.android.example.R import org.xmtp.android.example.databinding.ListItemConversationBinding import org.xmtp.android.example.extension.truncatedAddress import org.xmtp.android.library.Conversation +import org.xmtp.android.library.codecs.DeletedMessage import org.xmtp.proto.mls.message.contents.TranscriptMessages.GroupUpdated +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import kotlin.math.abs class ConversationViewHolder( private val binding: ListItemConversationBinding, @@ -15,6 +23,19 @@ class ConversationViewHolder( ) : RecyclerView.ViewHolder(binding.root) { private var conversation: Conversation? = null + // Avatar colors based on address hash + private val avatarColors = + listOf( + Color.parseColor("#FC4F37"), // XMTP Red + Color.parseColor("#5856D6"), // Purple + Color.parseColor("#34C759"), // Green + Color.parseColor("#FF9500"), // Orange + Color.parseColor("#007AFF"), // Blue + Color.parseColor("#AF52DE"), // Magenta + Color.parseColor("#00C7BE"), // Teal + Color.parseColor("#FF2D55"), // Pink + ) + init { binding.root.setOnClickListener { conversation?.let { @@ -25,32 +46,108 @@ class ConversationViewHolder( fun bind(item: MainViewModel.MainListItem.ConversationItem) { conversation = item.conversation - binding.peerAddress.text = item.conversation.id.truncatedAddress() + // Use the display name from the item (group name or peer address) + val displayText = + when (item.conversation.type) { + Conversation.Type.GROUP -> item.displayName + Conversation.Type.DM -> item.displayName.truncatedAddress() + } + binding.peerAddress.text = displayText + + // Set avatar text based on conversation type + val avatarChars = + when (item.conversation.type) { + Conversation.Type.GROUP -> { + // For groups, use first 2 chars of group name (or ID if no name) + item.displayName + .removePrefix("0x") + .take(2) + .uppercase() + } + Conversation.Type.DM -> { + // For DMs, use first 2 chars of peer address + (item.peerAddress ?: item.conversation.id) + .removePrefix("0x") + .take(2) + .uppercase() + } + } + binding.avatarText.text = avatarChars + + // Set avatar color based on conversation ID hash + val colorIndex = abs(item.conversation.id.hashCode()) % avatarColors.size + binding.avatarCard.setCardBackgroundColor(avatarColors[colorIndex]) + + // Set message time + item.mostRecentMessage?.let { message -> + binding.messageTime.text = formatMessageTime(message.sentAtNs / 1_000_000) + } ?: run { + binding.messageTime.text = "" + } + + // Set message preview val messageBody: String = - if (item.mostRecentMessage?.content() is String) { - item.mostRecentMessage.body.orEmpty() - } else if (item.mostRecentMessage?.content() is GroupUpdated) { - val changes = item.mostRecentMessage.content() as? GroupUpdated - "Membership Changed ${ - changes?.addedInboxesList?.mapNotNull { it.inboxId } - }" - } else { - "" + when (val content = item.mostRecentMessage?.content()) { + is String -> content + is GroupUpdated -> { + val added = content.addedInboxesList?.size ?: 0 + val removed = content.removedInboxesList?.size ?: 0 + when { + added > 0 && removed > 0 -> "$added added, $removed removed" + added > 0 -> "$added member${if (added > 1) "s" else ""} added" + removed > 0 -> "$removed member${if (removed > 1) "s" else ""} removed" + else -> "Group updated" + } + } + is DeletedMessage -> "Message deleted" + else -> item.mostRecentMessage?.body ?: "" } + val isMe = item.mostRecentMessage?.senderInboxId == ClientManager.client.inboxId if (messageBody.isNotBlank()) { binding.messageBody.text = if (isMe) { - binding.root.resources.getString( - R.string.your_message_body, - messageBody, - ) + "You: $messageBody" } else { messageBody } + binding.messageBody.setTextColor( + ContextCompat.getColor(binding.root.context, R.color.text_secondary), + ) } else { binding.messageBody.text = binding.root.resources.getString(R.string.empty_message) + binding.messageBody.setTextColor( + ContextCompat.getColor(binding.root.context, R.color.text_tertiary), + ) + } + } + + private fun formatMessageTime(timestampMs: Long): String { + val messageDate = Date(timestampMs) + val now = Calendar.getInstance() + val messageCalendar = Calendar.getInstance().apply { time = messageDate } + + return when { + // Today - show time + now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) && + now.get(Calendar.DAY_OF_YEAR) == messageCalendar.get(Calendar.DAY_OF_YEAR) -> { + SimpleDateFormat("HH:mm", Locale.getDefault()).format(messageDate) + } + // Yesterday + now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) && + now.get(Calendar.DAY_OF_YEAR) - messageCalendar.get(Calendar.DAY_OF_YEAR) == 1 -> { + "Yesterday" + } + // Within the last week - show day name + now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) && + now.get(Calendar.DAY_OF_YEAR) - messageCalendar.get(Calendar.DAY_OF_YEAR) < 7 -> { + SimpleDateFormat("EEE", Locale.getDefault()).format(messageDate) + } + // Older - show date + else -> { + SimpleDateFormat("MMM d", Locale.getDefault()).format(messageDate) + } } } } diff --git a/example/src/main/java/org/xmtp/android/example/conversation/ConversationsAdapter.kt b/example/src/main/java/org/xmtp/android/example/conversation/ConversationsAdapter.kt index c9843bf31..251d61e65 100644 --- a/example/src/main/java/org/xmtp/android/example/conversation/ConversationsAdapter.kt +++ b/example/src/main/java/org/xmtp/android/example/conversation/ConversationsAdapter.kt @@ -5,65 +5,64 @@ import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import org.xmtp.android.example.MainViewModel import org.xmtp.android.example.databinding.ListItemConversationBinding -import org.xmtp.android.example.databinding.ListItemConversationFooterBinding class ConversationsAdapter( private val clickListener: ConversationsClickListener, -) : RecyclerView.Adapter() { +) : RecyclerView.Adapter() { init { setHasStableIds(true) } - private val listItems = mutableListOf() + private val listItems = mutableListOf() fun setData(newItems: List) { listItems.clear() - listItems.addAll(newItems) + listItems.addAll(newItems.filterIsInstance()) notifyDataSetChanged() } fun addItem(item: MainViewModel.MainListItem) { + if (item !is MainViewModel.MainListItem.ConversationItem) return + // Check if item already exists and update it instead of adding duplicate + val existingIndex = listItems.indexOfFirst { it.id == item.id } + if (existingIndex >= 0) { + listItems.removeAt(existingIndex) + } listItems.add(0, item) notifyDataSetChanged() } + fun updateConversationMessage( + topic: String, + message: org.xmtp.android.library.libxmtp.DecodedMessage, + ) { + val index = listItems.indexOfFirst { it.id == topic } + if (index >= 0) { + val existingItem = listItems[index] + val updatedItem = existingItem.copy(mostRecentMessage = message) + listItems.removeAt(index) + listItems.add(0, updatedItem) // Move to top + notifyDataSetChanged() + } + } + override fun onCreateViewHolder( parent: ViewGroup, viewType: Int, - ): RecyclerView.ViewHolder { + ): ConversationViewHolder { val inflater = LayoutInflater.from(parent.context) - return when (viewType) { - MainViewModel.MainListItem.ITEM_TYPE_CONVERSATION -> { - val binding = ListItemConversationBinding.inflate(inflater, parent, false) - ConversationViewHolder(binding, clickListener) - } - MainViewModel.MainListItem.ITEM_TYPE_FOOTER -> { - val binding = ListItemConversationFooterBinding.inflate(inflater, parent, false) - ConversationFooterViewHolder(binding, clickListener) - } - else -> throw IllegalArgumentException("Unsupported view type $viewType") - } + val binding = ListItemConversationBinding.inflate(inflater, parent, false) + return ConversationViewHolder(binding, clickListener) } override fun onBindViewHolder( - holder: RecyclerView.ViewHolder, + holder: ConversationViewHolder, position: Int, ) { - val item = listItems[position] - when (holder) { - is ConversationViewHolder -> { - holder.bind(item as MainViewModel.MainListItem.ConversationItem) - } - is ConversationFooterViewHolder -> { - holder.bind(item as MainViewModel.MainListItem.Footer) - } - else -> throw IllegalArgumentException("Unsupported view holder") - } + holder.bind(listItems[position]) } - override fun getItemViewType(position: Int) = listItems[position].itemType - - override fun getItemCount() = listItems.count() + override fun getItemCount() = listItems.size override fun getItemId(position: Int) = listItems[position].id.hashCode().toLong() } diff --git a/example/src/main/java/org/xmtp/android/example/conversation/ConversationsClickListener.kt b/example/src/main/java/org/xmtp/android/example/conversation/ConversationsClickListener.kt index 0e2d9db86..b7f32f0f8 100644 --- a/example/src/main/java/org/xmtp/android/example/conversation/ConversationsClickListener.kt +++ b/example/src/main/java/org/xmtp/android/example/conversation/ConversationsClickListener.kt @@ -4,6 +4,4 @@ import org.xmtp.android.library.Conversation interface ConversationsClickListener { fun onConversationClick(conversation: Conversation) - - fun onFooterClick(address: String) } diff --git a/example/src/main/java/org/xmtp/android/example/conversation/GroupManagementActivity.kt b/example/src/main/java/org/xmtp/android/example/conversation/GroupManagementActivity.kt new file mode 100644 index 000000000..2463b0289 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/conversation/GroupManagementActivity.kt @@ -0,0 +1,299 @@ +package org.xmtp.android.example.conversation + +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.widget.EditText +import android.widget.Toast +import androidx.activity.viewModels +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import kotlinx.coroutines.launch +import org.xmtp.android.example.R +import org.xmtp.android.example.databinding.ActivityGroupManagementBinding +import org.xmtp.android.example.extension.truncatedAddress +import org.xmtp.android.library.libxmtp.PermissionLevel +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.regex.Pattern +import kotlin.math.abs + +class GroupManagementActivity : + AppCompatActivity(), + MemberClickListener { + private lateinit var binding: ActivityGroupManagementBinding + private lateinit var memberAdapter: MemberAdapter + + private val viewModel: GroupManagementViewModel by viewModels() + + private val avatarColors = + listOf( + Color.parseColor("#FC4F37"), + Color.parseColor("#5856D6"), + Color.parseColor("#34C759"), + Color.parseColor("#FF9500"), + Color.parseColor("#007AFF"), + Color.parseColor("#AF52DE"), + Color.parseColor("#00C7BE"), + Color.parseColor("#FF2D55"), + ) + + companion object { + const val EXTRA_CONVERSATION_TOPIC = "EXTRA_CONVERSATION_TOPIC" + private val ADDRESS_PATTERN = Pattern.compile("^0x[a-fA-F0-9]{40}$") + + fun intent( + context: Context, + topic: String, + ): Intent = + Intent(context, GroupManagementActivity::class.java).apply { + putExtra(EXTRA_CONVERSATION_TOPIC, topic) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.setConversationTopic(intent.extras?.getString(EXTRA_CONVERSATION_TOPIC)) + + binding = ActivityGroupManagementBinding.inflate(layoutInflater) + setContentView(binding.root) + setSupportActionBar(binding.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + memberAdapter = MemberAdapter(this, canManageMembers = false) + binding.membersList.layoutManager = LinearLayoutManager(this) + binding.membersList.adapter = memberAdapter + + binding.addMemberButton.setOnClickListener { + showAddMemberDialog() + } + + binding.leaveGroupButton.setOnClickListener { + showLeaveGroupConfirmation() + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect(::ensureUiState) + } + } + + viewModel.loadGroupInfo() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = + when (item.itemId) { + android.R.id.home -> { + finish() + true + } + else -> super.onOptionsItemSelected(item) + } + + private fun ensureUiState(uiState: GroupManagementViewModel.UiState) { + when (uiState) { + is GroupManagementViewModel.UiState.Loading -> { + binding.progress.visibility = View.VISIBLE + } + is GroupManagementViewModel.UiState.Success -> { + binding.progress.visibility = View.GONE + + // Set group avatar + val colorIndex = abs(uiState.groupId.hashCode()) % avatarColors.size + binding.groupAvatarCard.setCardBackgroundColor(avatarColors[colorIndex]) + binding.groupAvatarText.text = "G" + + // Set group ID + binding.groupId.text = uiState.groupId.truncatedAddress() + + // Set created date + uiState.createdAt?.let { date -> + val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) + binding.createdAt.text = dateFormat.format(date) + } + + // Set member count + binding.membersCount.text = getString(R.string.members_count_value, uiState.members.size) + + // Set current user role + binding.yourRole.text = + when (uiState.currentUserRole) { + PermissionLevel.SUPER_ADMIN -> getString(R.string.role_super_admin) + PermissionLevel.ADMIN -> getString(R.string.role_admin) + PermissionLevel.MEMBER -> getString(R.string.role_member) + } + + // Show/hide add member button + binding.addMemberButton.visibility = + if (uiState.canManageMembers) View.VISIBLE else View.GONE + + // Update adapter with new data and permissions + memberAdapter = MemberAdapter(this, uiState.canManageMembers) + binding.membersList.adapter = memberAdapter + memberAdapter.setData(uiState.members) + } + is GroupManagementViewModel.UiState.Error -> { + binding.progress.visibility = View.GONE + showError(uiState.message) + } + } + } + + private fun showAddMemberDialog() { + val input = + EditText(this).apply { + hint = getString(R.string.enter_wallet_address) + setPadding(48, 32, 48, 32) + } + + AlertDialog + .Builder(this) + .setTitle(R.string.add_member) + .setView(input) + .setPositiveButton(R.string.add) { _, _ -> + val address = input.text.toString().trim() + if (ADDRESS_PATTERN.matcher(address).matches()) { + addMember(address) + } else { + showError(getString(R.string.invalid_address)) + } + }.setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun addMember(address: String) { + lifecycleScope.launch { + viewModel.addMember(address).collect { state -> + when (state) { + is GroupManagementViewModel.ActionState.Loading -> { + binding.progress.visibility = View.VISIBLE + } + is GroupManagementViewModel.ActionState.Success -> { + binding.progress.visibility = View.GONE + Toast.makeText(this@GroupManagementActivity, state.message, Toast.LENGTH_SHORT).show() + } + is GroupManagementViewModel.ActionState.Error -> { + binding.progress.visibility = View.GONE + showError(state.message) + } + } + } + } + } + + private fun showLeaveGroupConfirmation() { + AlertDialog + .Builder(this) + .setTitle(R.string.leave_group) + .setMessage(R.string.leave_group_confirmation) + .setPositiveButton(R.string.leave) { _, _ -> + // TODO: Implement leave group when API supports it + Toast.makeText(this, "Leave group not yet implemented", Toast.LENGTH_SHORT).show() + }.setNegativeButton(android.R.string.cancel, null) + .show() + } + + override fun onMemberClick(member: MemberItem) { + // Could navigate to user profile + member.displayAddress?.let { address -> + startActivity(UserProfileActivity.intent(this, address, member.member.inboxId)) + } + } + + override fun onPromoteToAdmin(member: MemberItem) { + AlertDialog + .Builder(this) + .setTitle(R.string.promote_to_admin) + .setMessage( + getString( + R.string.promote_confirmation, + member.displayAddress?.truncatedAddress() ?: member.member.inboxId.truncatedAddress(), + ), + ).setPositiveButton(R.string.promote) { _, _ -> + promoteToAdmin(member.member.inboxId) + }.setNegativeButton(android.R.string.cancel, null) + .show() + } + + override fun onDemoteFromAdmin(member: MemberItem) { + AlertDialog + .Builder(this) + .setTitle(R.string.demote_from_admin) + .setMessage( + getString( + R.string.demote_confirmation, + member.displayAddress?.truncatedAddress() ?: member.member.inboxId.truncatedAddress(), + ), + ).setPositiveButton(R.string.demote) { _, _ -> + demoteFromAdmin(member.member.inboxId) + }.setNegativeButton(android.R.string.cancel, null) + .show() + } + + override fun onRemoveMember(member: MemberItem) { + AlertDialog + .Builder(this) + .setTitle(R.string.remove_member) + .setMessage( + getString( + R.string.remove_confirmation, + member.displayAddress?.truncatedAddress() ?: member.member.inboxId.truncatedAddress(), + ), + ).setPositiveButton(R.string.remove) { _, _ -> + removeMember(member.member.inboxId) + }.setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun promoteToAdmin(inboxId: String) { + lifecycleScope.launch { + viewModel.promoteToAdmin(inboxId).collect { state -> + handleActionState(state) + } + } + } + + private fun demoteFromAdmin(inboxId: String) { + lifecycleScope.launch { + viewModel.demoteFromAdmin(inboxId).collect { state -> + handleActionState(state) + } + } + } + + private fun removeMember(inboxId: String) { + lifecycleScope.launch { + viewModel.removeMember(inboxId).collect { state -> + handleActionState(state) + } + } + } + + private fun handleActionState(state: GroupManagementViewModel.ActionState) { + when (state) { + is GroupManagementViewModel.ActionState.Loading -> { + binding.progress.visibility = View.VISIBLE + } + is GroupManagementViewModel.ActionState.Success -> { + binding.progress.visibility = View.GONE + Toast.makeText(this, state.message, Toast.LENGTH_SHORT).show() + } + is GroupManagementViewModel.ActionState.Error -> { + binding.progress.visibility = View.GONE + showError(state.message) + } + } + } + + private fun showError(message: String) { + val error = message.ifBlank { getString(R.string.error) } + Toast.makeText(this, error, Toast.LENGTH_SHORT).show() + } +} diff --git a/example/src/main/java/org/xmtp/android/example/conversation/GroupManagementViewModel.kt b/example/src/main/java/org/xmtp/android/example/conversation/GroupManagementViewModel.kt new file mode 100644 index 000000000..ddbd1bf50 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/conversation/GroupManagementViewModel.kt @@ -0,0 +1,178 @@ +package org.xmtp.android.example.conversation + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import org.xmtp.android.example.ClientManager +import org.xmtp.android.library.Conversation +import org.xmtp.android.library.Group +import org.xmtp.android.library.libxmtp.IdentityKind +import org.xmtp.android.library.libxmtp.PermissionLevel +import org.xmtp.android.library.libxmtp.PublicIdentity + +class GroupManagementViewModel( + private val savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val conversationTopicFlow = + savedStateHandle.getStateFlow( + GroupManagementActivity.EXTRA_CONVERSATION_TOPIC, + null, + ) + + fun setConversationTopic(topic: String?) { + savedStateHandle[GroupManagementActivity.EXTRA_CONVERSATION_TOPIC] = topic + } + + private val _uiState = MutableStateFlow(UiState.Loading) + val uiState: StateFlow = _uiState + + private var group: Group? = null + + fun loadGroupInfo() { + viewModelScope.launch(Dispatchers.IO) { + try { + val topic = conversationTopicFlow.value ?: throw Exception("No topic provided") + val conversation = + ClientManager.client.conversations.findConversationByTopic(topic) + ?: throw Exception("Conversation not found") + + if (conversation !is Conversation.Group) { + throw Exception("Not a group conversation") + } + + group = conversation.group + group?.sync() + + val members = group?.members() ?: emptyList() + val currentUserInboxId = ClientManager.client.inboxId + + // Get addresses for members + val memberItems = + members + .map { member -> + val addresses = member.identities.mapNotNull { it.identifier } + MemberItem( + member = member, + isCurrentUser = member.inboxId == currentUserInboxId, + displayAddress = addresses.firstOrNull(), + ) + }.sortedWith( + compareBy { !it.isCurrentUser } + .thenBy { + when (it.member.permissionLevel) { + PermissionLevel.SUPER_ADMIN -> 0 + PermissionLevel.ADMIN -> 1 + PermissionLevel.MEMBER -> 2 + } + }, + ) + + val currentUserMember = members.find { it.inboxId == currentUserInboxId } + val currentUserRole = currentUserMember?.permissionLevel ?: PermissionLevel.MEMBER + val canManageMembers = + currentUserRole == PermissionLevel.SUPER_ADMIN || + currentUserRole == PermissionLevel.ADMIN + + _uiState.value = + UiState.Success( + groupId = group?.id ?: "", + createdAt = group?.createdAt, + members = memberItems, + currentUserRole = currentUserRole, + canManageMembers = canManageMembers, + ) + } catch (e: Exception) { + _uiState.value = UiState.Error(e.localizedMessage ?: "Unknown error") + } + } + } + + fun addMember(address: String): StateFlow { + val flow = MutableStateFlow(ActionState.Loading) + viewModelScope.launch(Dispatchers.IO) { + try { + val publicIdentity = PublicIdentity(IdentityKind.ETHEREUM, address) + group?.addMembersByIdentity(listOf(publicIdentity)) + flow.value = ActionState.Success("Member added successfully") + loadGroupInfo() // Refresh + } catch (e: Exception) { + flow.value = ActionState.Error(e.localizedMessage ?: "Failed to add member") + } + } + return flow + } + + fun removeMember(inboxId: String): StateFlow { + val flow = MutableStateFlow(ActionState.Loading) + viewModelScope.launch(Dispatchers.IO) { + try { + group?.removeMembers(listOf(inboxId)) + flow.value = ActionState.Success("Member removed successfully") + loadGroupInfo() // Refresh + } catch (e: Exception) { + flow.value = ActionState.Error(e.localizedMessage ?: "Failed to remove member") + } + } + return flow + } + + fun promoteToAdmin(inboxId: String): StateFlow { + val flow = MutableStateFlow(ActionState.Loading) + viewModelScope.launch(Dispatchers.IO) { + try { + group?.addAdmin(inboxId) + flow.value = ActionState.Success("Member promoted to admin") + loadGroupInfo() // Refresh + } catch (e: Exception) { + flow.value = ActionState.Error(e.localizedMessage ?: "Failed to promote member") + } + } + return flow + } + + fun demoteFromAdmin(inboxId: String): StateFlow { + val flow = MutableStateFlow(ActionState.Loading) + viewModelScope.launch(Dispatchers.IO) { + try { + group?.removeAdmin(inboxId) + flow.value = ActionState.Success("Admin demoted to member") + loadGroupInfo() // Refresh + } catch (e: Exception) { + flow.value = ActionState.Error(e.localizedMessage ?: "Failed to demote admin") + } + } + return flow + } + + sealed class UiState { + object Loading : UiState() + + data class Success( + val groupId: String, + val createdAt: java.util.Date?, + val members: List, + val currentUserRole: PermissionLevel, + val canManageMembers: Boolean, + ) : UiState() + + data class Error( + val message: String, + ) : UiState() + } + + sealed class ActionState { + object Loading : ActionState() + + data class Success( + val message: String, + ) : ActionState() + + data class Error( + val message: String, + ) : ActionState() + } +} diff --git a/example/src/main/java/org/xmtp/android/example/conversation/MemberAdapter.kt b/example/src/main/java/org/xmtp/android/example/conversation/MemberAdapter.kt new file mode 100644 index 000000000..a81f4a38c --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/conversation/MemberAdapter.kt @@ -0,0 +1,216 @@ +package org.xmtp.android.example.conversation + +import android.graphics.Color +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.PopupMenu +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import org.xmtp.android.example.R +import org.xmtp.android.example.databinding.ListItemMemberBinding +import org.xmtp.android.example.extension.truncatedAddress +import org.xmtp.android.library.libxmtp.Member +import org.xmtp.android.library.libxmtp.PermissionLevel +import kotlin.math.abs + +data class MemberItem( + val member: Member, + val isCurrentUser: Boolean, + val displayAddress: String?, +) + +interface MemberClickListener { + fun onMemberClick(member: MemberItem) + + fun onPromoteToAdmin(member: MemberItem) + + fun onDemoteFromAdmin(member: MemberItem) + + fun onRemoveMember(member: MemberItem) +} + +class MemberAdapter( + private val listener: MemberClickListener, + private val canManageMembers: Boolean, +) : RecyclerView.Adapter() { + private val members = mutableListOf() + + private val avatarColors = + listOf( + Color.parseColor("#FC4F37"), // XMTP Red + Color.parseColor("#5856D6"), // Purple + Color.parseColor("#34C759"), // Green + Color.parseColor("#FF9500"), // Orange + Color.parseColor("#007AFF"), // Blue + Color.parseColor("#AF52DE"), // Magenta + Color.parseColor("#00C7BE"), // Teal + Color.parseColor("#FF2D55"), // Pink + ) + + fun setData(newMembers: List) { + val diffCallback = MemberDiffCallback(members, newMembers) + val diffResult = DiffUtil.calculateDiff(diffCallback) + members.clear() + members.addAll(newMembers) + diffResult.dispatchUpdatesTo(this) + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): MemberViewHolder { + val binding = + ListItemMemberBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return MemberViewHolder(binding) + } + + override fun onBindViewHolder( + holder: MemberViewHolder, + position: Int, + ) { + holder.bind(members[position]) + } + + override fun getItemCount(): Int = members.size + + inner class MemberViewHolder( + private val binding: ListItemMemberBinding, + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: MemberItem) { + val context = binding.root.context + + // Set avatar + val displayText = + item.displayAddress + ?.removePrefix("0x") + ?.take(2) + ?.uppercase() + ?: item.member.inboxId + .take(2) + .uppercase() + binding.memberAvatarText.text = displayText + + val colorIndex = abs(item.member.inboxId.hashCode()) % avatarColors.size + binding.memberAvatarCard.setCardBackgroundColor(avatarColors[colorIndex]) + + // Set address + val addressText = + if (item.isCurrentUser) { + "${item.displayAddress?.truncatedAddress() ?: item.member.inboxId.truncatedAddress()} (You)" + } else { + item.displayAddress?.truncatedAddress() ?: item.member.inboxId.truncatedAddress() + } + binding.memberAddress.text = addressText + + // Set inbox ID + binding.memberInboxId.text = + context.getString( + R.string.inbox_id_display, + item.member.inboxId.take(8) + "...", + ) + + // Set role badge + when (item.member.permissionLevel) { + PermissionLevel.SUPER_ADMIN -> { + binding.memberRole.isVisible = true + binding.memberRole.text = context.getString(R.string.role_super_admin) + binding.memberRole.setChipBackgroundColorResource(R.color.xmtp_primary) + } + PermissionLevel.ADMIN -> { + binding.memberRole.isVisible = true + binding.memberRole.text = context.getString(R.string.role_admin) + binding.memberRole.setChipBackgroundColorResource(R.color.admin_badge) + } + PermissionLevel.MEMBER -> { + binding.memberRole.isVisible = false + } + } + + // Setup menu button + binding.menuButton.isVisible = canManageMembers && !item.isCurrentUser + binding.menuButton.setOnClickListener { view -> + showMemberMenu(view, item) + } + + // Click listener + binding.root.setOnClickListener { + listener.onMemberClick(item) + } + } + + private fun showMemberMenu( + anchor: View, + item: MemberItem, + ) { + val popup = PopupMenu(anchor.context, anchor) + popup.menuInflater.inflate(R.menu.menu_member, popup.menu) + + // Show/hide menu items based on current role + val promoteItem = popup.menu.findItem(R.id.action_promote) + val demoteItem = popup.menu.findItem(R.id.action_demote) + val removeItem = popup.menu.findItem(R.id.action_remove) + + when (item.member.permissionLevel) { + PermissionLevel.SUPER_ADMIN -> { + promoteItem?.isVisible = false + demoteItem?.isVisible = false + removeItem?.isVisible = false + } + PermissionLevel.ADMIN -> { + promoteItem?.isVisible = false + demoteItem?.isVisible = true + removeItem?.isVisible = true + } + PermissionLevel.MEMBER -> { + promoteItem?.isVisible = true + demoteItem?.isVisible = false + removeItem?.isVisible = true + } + } + + popup.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.action_promote -> { + listener.onPromoteToAdmin(item) + true + } + R.id.action_demote -> { + listener.onDemoteFromAdmin(item) + true + } + R.id.action_remove -> { + listener.onRemoveMember(item) + true + } + else -> false + } + } + popup.show() + } + } + + private class MemberDiffCallback( + private val oldList: List, + private val newList: List, + ) : DiffUtil.Callback() { + override fun getOldListSize(): Int = oldList.size + + override fun getNewListSize(): Int = newList.size + + override fun areItemsTheSame( + oldPos: Int, + newPos: Int, + ): Boolean = oldList[oldPos].member.inboxId == newList[newPos].member.inboxId + + override fun areContentsTheSame( + oldPos: Int, + newPos: Int, + ): Boolean = oldList[oldPos] == newList[newPos] + } +} diff --git a/example/src/main/java/org/xmtp/android/example/conversation/NewConversationActivity.kt b/example/src/main/java/org/xmtp/android/example/conversation/NewConversationActivity.kt new file mode 100644 index 000000000..b3ac172c7 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/conversation/NewConversationActivity.kt @@ -0,0 +1,352 @@ +package org.xmtp.android.example.conversation + +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.isVisible +import androidx.core.widget.addTextChangedListener +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.chip.Chip +import kotlinx.coroutines.launch +import org.xmtp.android.example.R +import org.xmtp.android.example.databinding.ActivityNewConversationBinding +import org.xmtp.android.example.extension.truncatedAddress +import java.util.regex.Pattern +import kotlin.math.abs + +class NewConversationActivity : + AppCompatActivity(), + RecentContactClickListener { + private lateinit var binding: ActivityNewConversationBinding + private val viewModel: NewConversationViewModel by viewModels() + + private val groupAddresses: MutableList = mutableListOf() + private var isGroupMode = false + private var recentContactsAdapter: RecentContactsAdapter? = null + + private val avatarColors = + listOf( + Color.parseColor("#FC4F37"), + Color.parseColor("#5856D6"), + Color.parseColor("#34C759"), + Color.parseColor("#FF9500"), + Color.parseColor("#007AFF"), + Color.parseColor("#AF52DE"), + Color.parseColor("#00C7BE"), + Color.parseColor("#FF2D55"), + ) + + companion object { + private const val MIN_GROUP_MEMBERS = 1 // Just 1 other member needed (current user is automatically included) + private val ADDRESS_PATTERN = Pattern.compile("^0x[a-fA-F0-9]{40}$") + + fun intent(context: Context): Intent = Intent(context, NewConversationActivity::class.java) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityNewConversationBinding.inflate(layoutInflater) + setContentView(binding.root) + + setSupportActionBar(binding.toolbar) + binding.toolbar.setNavigationOnClickListener { finish() } + + setupToggleGroup() + setupAddressInput() + setupActionButton() + setupCreateButton() + setupRecentContacts() + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect(::ensureUiState) + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.recentContacts.collect(::updateRecentContacts) + } + } + + updateUiForMode() + viewModel.loadRecentContacts() + } + + private fun setupToggleGroup() { + binding.messageTypeToggle.addOnButtonCheckedListener { _, checkedId, isChecked -> + if (isChecked) { + isGroupMode = checkedId == R.id.groupButton + updateUiForMode() + // Clear input when switching modes + binding.addressInput.text?.clear() + groupAddresses.clear() + binding.membersChipGroup.removeAllViews() + updateMemberCount() + recentContactsAdapter?.setGroupMode(isGroupMode, groupAddresses) + } + } + } + + private fun setupAddressInput() { + binding.addressInput.addTextChangedListener { text -> + val input = text?.toString()?.trim() ?: "" + val isValidAddress = ADDRESS_PATTERN.matcher(input).matches() + + if (isGroupMode) { + // In group mode, show add button for valid addresses not already added + binding.actionButton.isVisible = isValidAddress && !groupAddresses.contains(input.lowercase()) + binding.actionButton.setImageResource(R.drawable.ic_add_24) + binding.actionButton.setColorFilter( + resources.getColor( + if (binding.actionButton.isVisible) R.color.xmtp_primary else R.color.text_tertiary, + null, + ), + ) + } else { + // In DM mode, show clear button when there's text + binding.actionButton.isVisible = input.isNotEmpty() + binding.actionButton.setImageResource(R.drawable.ic_close_24) + binding.actionButton.setColorFilter( + resources.getColor(R.color.text_tertiary, null), + ) + // Enable create button for valid DM address + binding.createButton.isEnabled = isValidAddress + } + + // Update helper text color based on validation + if (input.isNotEmpty() && !isValidAddress) { + binding.helperText.setTextColor( + resources.getColor(R.color.error, null), + ) + } else { + binding.helperText.setTextColor( + resources.getColor(R.color.text_tertiary, null), + ) + } + } + } + + private fun setupActionButton() { + binding.actionButton.setOnClickListener { + if (isGroupMode) { + // Add member to group + val address = + binding.addressInput.text + ?.toString() + ?.trim() ?: "" + if (ADDRESS_PATTERN.matcher(address).matches() && !groupAddresses.contains(address.lowercase())) { + addMember(address) + binding.addressInput.text?.clear() + } + } else { + // Clear DM address input + binding.addressInput.text?.clear() + } + } + } + + private fun setupCreateButton() { + binding.createButton.setOnClickListener { + val address = + binding.addressInput.text + ?.toString() + ?.trim() ?: "" + val groupName = + binding.groupNameInput.text + ?.toString() + ?.trim() ?: "" + + if (isGroupMode) { + if (groupAddresses.size >= MIN_GROUP_MEMBERS) { + viewModel.createGroup(groupAddresses, groupName) + } + } else { + if (ADDRESS_PATTERN.matcher(address).matches()) { + viewModel.createConversation(address) + } + } + } + } + + private fun setupRecentContacts() { + val adapter = RecentContactsAdapter(this, avatarColors) + recentContactsAdapter = adapter + binding.recentContactsList.layoutManager = LinearLayoutManager(this) + binding.recentContactsList.adapter = adapter + // Initialize adapter with current mode + adapter.setGroupMode(isGroupMode, groupAddresses) + } + + private fun updateUiForMode() { + // Update adapter when mode changes + recentContactsAdapter?.setGroupMode(isGroupMode, groupAddresses) + if (isGroupMode) { + binding.groupNameCard.isVisible = true + binding.helperText.text = getString(R.string.minimum_members_hint) + binding.createButton.text = getString(R.string.create_group) + binding.membersChipGroup.isVisible = groupAddresses.isNotEmpty() + binding.memberCount.isVisible = groupAddresses.isNotEmpty() + updateMemberCount() + } else { + binding.groupNameCard.isVisible = false + binding.helperText.text = getString(R.string.address_helper_text) + binding.createButton.text = getString(R.string.start_conversation) + binding.membersChipGroup.isVisible = false + binding.memberCount.isVisible = false + // Re-evaluate create button state for DM mode + val input = + binding.addressInput.text + ?.toString() + ?.trim() ?: "" + binding.createButton.isEnabled = ADDRESS_PATTERN.matcher(input).matches() + } + } + + private fun addMember(address: String) { + val normalizedAddress = address.lowercase() + groupAddresses.add(normalizedAddress) + + val chip = + Chip(this).apply { + text = address.truncatedAddress() + isCloseIconVisible = true + setChipBackgroundColorResource(R.color.surface_variant) + setTextColor(resources.getColor(R.color.text_primary, null)) + setCloseIconTintResource(R.color.text_tertiary) + tag = normalizedAddress + setOnCloseIconClickListener { + removeMember(normalizedAddress) + } + } + + binding.membersChipGroup.addView(chip) + binding.membersChipGroup.isVisible = true + updateMemberCount() + recentContactsAdapter?.setGroupMode(isGroupMode, groupAddresses) + } + + private fun removeMember(address: String) { + groupAddresses.remove(address) + + for (i in 0 until binding.membersChipGroup.childCount) { + val chip = binding.membersChipGroup.getChildAt(i) as? Chip + if (chip?.tag == address) { + binding.membersChipGroup.removeView(chip) + break + } + } + + binding.membersChipGroup.isVisible = groupAddresses.isNotEmpty() + updateMemberCount() + recentContactsAdapter?.setGroupMode(isGroupMode, groupAddresses) + } + + private fun updateMemberCount() { + val count = groupAddresses.size + binding.memberCount.isVisible = count > 0 + binding.memberCount.text = getString(R.string.member_count, count) + if (isGroupMode) { + binding.createButton.isEnabled = count >= MIN_GROUP_MEMBERS + } + } + + private fun updateRecentContacts(contacts: List) { + binding.recentContactsHeader.isVisible = contacts.isNotEmpty() + binding.recentContactsList.isVisible = contacts.isNotEmpty() + recentContactsAdapter?.setData(contacts) + } + + private fun ensureUiState(uiState: NewConversationViewModel.UiState) { + when (uiState) { + is NewConversationViewModel.UiState.Error -> { + binding.addressInput.isEnabled = true + binding.groupNameInput.isEnabled = true + binding.actionButton.isEnabled = true + binding.messageTypeToggle.isEnabled = true + if (isGroupMode) { + binding.createButton.isEnabled = groupAddresses.size >= MIN_GROUP_MEMBERS + binding.createButton.text = getString(R.string.create_group) + } else { + val input = + binding.addressInput.text + ?.toString() + ?.trim() ?: "" + binding.createButton.isEnabled = ADDRESS_PATTERN.matcher(input).matches() + binding.createButton.text = getString(R.string.start_conversation) + } + binding.progress.visibility = View.GONE + showError(uiState.message) + } + + NewConversationViewModel.UiState.Loading -> { + binding.addressInput.isEnabled = false + binding.groupNameInput.isEnabled = false + binding.actionButton.isEnabled = false + binding.createButton.isEnabled = false + binding.createButton.text = "" + binding.messageTypeToggle.isEnabled = false + binding.progress.visibility = View.VISIBLE + } + + is NewConversationViewModel.UiState.Success -> { + startActivity( + ConversationDetailActivity.intent( + this, + topic = uiState.conversation.topic, + peerAddress = uiState.conversation.id, + ), + ) + finish() + } + + NewConversationViewModel.UiState.Unknown -> Unit + } + } + + private fun showError(message: String) { + val error = message.ifBlank { getString(R.string.error) } + Toast.makeText(this, error, Toast.LENGTH_SHORT).show() + } + + override fun onContactClick(contact: RecentContact) { + if (isGroupMode) { + val address = contact.address.lowercase() + if (groupAddresses.contains(address)) { + removeMember(address) + } else { + addMember(contact.address) + } + } else { + // In DM mode, start conversation directly + viewModel.createConversation(contact.address) + } + } + + override fun onAddClick(contact: RecentContact) { + val address = contact.address.lowercase() + if (!groupAddresses.contains(address)) { + addMember(contact.address) + } + } +} + +data class RecentContact( + val address: String, + val inboxId: String?, + val lastActivityTime: Long, +) + +interface RecentContactClickListener { + fun onContactClick(contact: RecentContact) + + fun onAddClick(contact: RecentContact) +} diff --git a/example/src/main/java/org/xmtp/android/example/conversation/NewConversationBottomSheet.kt b/example/src/main/java/org/xmtp/android/example/conversation/NewConversationBottomSheet.kt index f88926a11..bfb9ccda8 100644 --- a/example/src/main/java/org/xmtp/android/example/conversation/NewConversationBottomSheet.kt +++ b/example/src/main/java/org/xmtp/android/example/conversation/NewConversationBottomSheet.kt @@ -5,6 +5,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast +import androidx.core.view.isVisible import androidx.core.widget.addTextChangedListener import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle @@ -24,7 +25,7 @@ class NewConversationBottomSheet : BottomSheetDialogFragment() { companion object { const val TAG = "NewConversationBottomSheet" - private val ADDRESS_PATTERN = Pattern.compile("^0x[a-fA-F0-9]{40}\$") + private val ADDRESS_PATTERN = Pattern.compile("^0x[a-fA-F0-9]{40}$") fun newInstance(): NewConversationBottomSheet = NewConversationBottomSheet() } @@ -50,12 +51,39 @@ class NewConversationBottomSheet : BottomSheetDialogFragment() { } } - binding.addressInput.addTextChangedListener { - if (viewModel.uiState.value is NewConversationViewModel.UiState.Loading) return@addTextChangedListener - val input = binding.addressInput.text.trim() - val matcher = ADDRESS_PATTERN.matcher(input) - if (matcher.matches()) { - viewModel.createConversation(input.toString()) + binding.addressInput.addTextChangedListener { text -> + val input = text?.toString()?.trim() ?: "" + val isValidAddress = ADDRESS_PATTERN.matcher(input).matches() + + // Show/hide clear button + binding.clearButton.isVisible = input.isNotEmpty() + + // Enable/disable create button + binding.createButton.isEnabled = isValidAddress + + // Update helper text color based on validation + if (input.isNotEmpty() && !isValidAddress) { + binding.helperText.setTextColor( + resources.getColor(R.color.error, null), + ) + } else { + binding.helperText.setTextColor( + resources.getColor(R.color.text_tertiary, null), + ) + } + } + + binding.clearButton.setOnClickListener { + binding.addressInput.text?.clear() + } + + binding.createButton.setOnClickListener { + val address = + binding.addressInput.text + ?.toString() + ?.trim() ?: "" + if (ADDRESS_PATTERN.matcher(address).matches()) { + viewModel.createConversation(address) } } } @@ -69,11 +97,15 @@ class NewConversationBottomSheet : BottomSheetDialogFragment() { when (uiState) { is NewConversationViewModel.UiState.Error -> { binding.addressInput.isEnabled = true + binding.createButton.isEnabled = true + binding.createButton.text = getString(R.string.start_conversation) binding.progress.visibility = View.GONE showError(uiState.message) } NewConversationViewModel.UiState.Loading -> { binding.addressInput.isEnabled = false + binding.createButton.isEnabled = false + binding.createButton.text = "" binding.progress.visibility = View.VISIBLE } is NewConversationViewModel.UiState.Success -> { diff --git a/example/src/main/java/org/xmtp/android/example/conversation/NewConversationViewModel.kt b/example/src/main/java/org/xmtp/android/example/conversation/NewConversationViewModel.kt index 607e4374a..0bef97492 100644 --- a/example/src/main/java/org/xmtp/android/example/conversation/NewConversationViewModel.kt +++ b/example/src/main/java/org/xmtp/android/example/conversation/NewConversationViewModel.kt @@ -16,6 +16,9 @@ class NewConversationViewModel : ViewModel() { private val _uiState = MutableStateFlow(UiState.Unknown) val uiState: StateFlow = _uiState + private val _recentContacts = MutableStateFlow>(emptyList()) + val recentContacts: StateFlow> = _recentContacts + @UiThread fun createConversation(address: String) { _uiState.value = UiState.Loading @@ -36,18 +39,23 @@ class NewConversationViewModel : ViewModel() { } @UiThread - fun createGroup(addresses: List) { + fun createGroup( + addresses: List, + groupName: String = "", + ) { _uiState.value = UiState.Loading viewModelScope.launch(Dispatchers.IO) { try { val group = ClientManager.client.conversations.newGroupWithIdentities( - addresses.map { - PublicIdentity( - IdentityKind.ETHEREUM, - it, - ) - }, + identities = + addresses.map { + PublicIdentity( + IdentityKind.ETHEREUM, + it, + ) + }, + groupName = groupName, ) _uiState.value = UiState.Success(Conversation.Group(group)) } catch (e: Exception) { @@ -56,6 +64,72 @@ class NewConversationViewModel : ViewModel() { } } + @UiThread + fun loadRecentContacts() { + viewModelScope.launch(Dispatchers.IO) { + try { + val currentInboxId = ClientManager.client.inboxId + val conversations = ClientManager.client.conversations.list() + + // Get unique peer addresses from DM conversations + val contactsMap = mutableMapOf() + + for (conversation in conversations) { + when (conversation) { + is Conversation.Dm -> { + val members = conversation.dm.members() + val peerMember = members.find { it.inboxId != currentInboxId } + peerMember?.let { member -> + val address = member.identities.firstOrNull()?.identifier + if (address != null && !contactsMap.containsKey(address.lowercase())) { + contactsMap[address.lowercase()] = + RecentContact( + address = address, + inboxId = member.inboxId, + lastActivityTime = conversation.lastActivityNs, + ) + } + } + } + is Conversation.Group -> { + // Get all members from groups except current user + val members = conversation.group.members() + for (member in members) { + if (member.inboxId != currentInboxId) { + val address = member.identities.firstOrNull()?.identifier + if (address != null) { + val existingContact = contactsMap[address.lowercase()] + if (existingContact == null || + conversation.lastActivityNs > existingContact.lastActivityTime + ) { + contactsMap[address.lowercase()] = + RecentContact( + address = address, + inboxId = member.inboxId, + lastActivityTime = conversation.lastActivityNs, + ) + } + } + } + } + } + } + } + + // Sort by most recent activity + val sortedContacts = + contactsMap.values + .sortedByDescending { it.lastActivityTime } + .take(20) // Limit to 20 recent contacts + + _recentContacts.value = sortedContacts + } catch (e: Exception) { + // Silently fail - just show empty recent contacts + _recentContacts.value = emptyList() + } + } + } + sealed class UiState { object Unknown : UiState() diff --git a/example/src/main/java/org/xmtp/android/example/conversation/NewGroupBottomSheet.kt b/example/src/main/java/org/xmtp/android/example/conversation/NewGroupBottomSheet.kt index c892f418f..304dd8031 100644 --- a/example/src/main/java/org/xmtp/android/example/conversation/NewGroupBottomSheet.kt +++ b/example/src/main/java/org/xmtp/android/example/conversation/NewGroupBottomSheet.kt @@ -3,18 +3,20 @@ package org.xmtp.android.example.conversation import android.os.Bundle import android.view.LayoutInflater import android.view.View -import android.view.View.VISIBLE import android.view.ViewGroup import android.widget.Toast +import androidx.core.view.isVisible import androidx.core.widget.addTextChangedListener import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.chip.Chip import kotlinx.coroutines.launch import org.xmtp.android.example.R import org.xmtp.android.example.databinding.BottomSheetNewGroupBinding +import org.xmtp.android.example.extension.truncatedAddress import java.util.regex.Pattern class NewGroupBottomSheet : BottomSheetDialogFragment() { @@ -25,8 +27,9 @@ class NewGroupBottomSheet : BottomSheetDialogFragment() { companion object { const val TAG = "NewGroupBottomSheet" + private const val MIN_MEMBERS = 2 - private val ADDRESS_PATTERN = Pattern.compile("^0x[a-fA-F0-9]{40}\$") + private val ADDRESS_PATTERN = Pattern.compile("^0x[a-fA-F0-9]{40}$") fun newInstance(): NewGroupBottomSheet = NewGroupBottomSheet() } @@ -52,25 +55,51 @@ class NewGroupBottomSheet : BottomSheetDialogFragment() { } } - binding.addressInput1.addTextChangedListener { - if (viewModel.uiState.value is NewConversationViewModel.UiState.Loading) return@addTextChangedListener - val input = binding.addressInput1.text.trim() - val matcher = ADDRESS_PATTERN.matcher(input) - if (matcher.matches()) { - addresses.add(input.toString()) - binding.addressInput2.visibility = VISIBLE + binding.addressInput.addTextChangedListener { text -> + val input = text?.toString()?.trim() ?: "" + val isValidAddress = ADDRESS_PATTERN.matcher(input).matches() + + // Enable add button only for valid addresses not already added + binding.addButton.isEnabled = isValidAddress && !addresses.contains(input) + + // Update add button tint based on enabled state + binding.addButton.setColorFilter( + resources.getColor( + if (binding.addButton.isEnabled) R.color.xmtp_primary else R.color.text_tertiary, + null, + ), + ) + + // Update helper text color based on validation + if (input.isNotEmpty() && !isValidAddress) { + binding.helperText.setTextColor( + resources.getColor(R.color.error, null), + ) + } else { + binding.helperText.setTextColor( + resources.getColor(R.color.text_tertiary, null), + ) + } + } + + binding.addButton.setOnClickListener { + val address = + binding.addressInput.text + ?.toString() + ?.trim() ?: "" + if (ADDRESS_PATTERN.matcher(address).matches() && !addresses.contains(address)) { + addMember(address) + binding.addressInput.text?.clear() } } - binding.addressInput2.addTextChangedListener { - if (viewModel.uiState.value is NewConversationViewModel.UiState.Loading) return@addTextChangedListener - val input = binding.addressInput2.text.trim() - val matcher = ADDRESS_PATTERN.matcher(input) - if (matcher.matches()) { - addresses.add(input.toString()) + binding.createButton.setOnClickListener { + if (addresses.size >= MIN_MEMBERS) { viewModel.createGroup(addresses) } } + + updateMemberCount() } override fun onDestroyView() { @@ -78,19 +107,68 @@ class NewGroupBottomSheet : BottomSheetDialogFragment() { _binding = null } + private fun addMember(address: String) { + addresses.add(address) + + // Create a chip for the member + val chip = + Chip(requireContext()).apply { + text = address.truncatedAddress() + isCloseIconVisible = true + setChipBackgroundColorResource(R.color.surface_variant) + setTextColor(resources.getColor(R.color.text_primary, null)) + setCloseIconTintResource(R.color.text_tertiary) + tag = address + setOnCloseIconClickListener { + removeMember(address) + } + } + + binding.membersChipGroup.addView(chip) + binding.membersChipGroup.isVisible = true + updateMemberCount() + } + + private fun removeMember(address: String) { + addresses.remove(address) + + // Find and remove the chip with this address + for (i in 0 until binding.membersChipGroup.childCount) { + val chip = binding.membersChipGroup.getChildAt(i) as? Chip + if (chip?.tag == address) { + binding.membersChipGroup.removeView(chip) + break + } + } + + binding.membersChipGroup.isVisible = addresses.isNotEmpty() + updateMemberCount() + } + + private fun updateMemberCount() { + val count = addresses.size + binding.memberCount.isVisible = count > 0 + binding.memberCount.text = resources.getString(R.string.member_count, count) + binding.createButton.isEnabled = count >= MIN_MEMBERS + } + private fun ensureUiState(uiState: NewConversationViewModel.UiState) { when (uiState) { is NewConversationViewModel.UiState.Error -> { - binding.addressInput1.isEnabled = true - binding.addressInput2.isEnabled = true + binding.addressInput.isEnabled = true + binding.addButton.isEnabled = true + binding.createButton.isEnabled = addresses.size >= MIN_MEMBERS + binding.createButton.text = getString(R.string.create_group) binding.progress.visibility = View.GONE showError(uiState.message) } NewConversationViewModel.UiState.Loading -> { - binding.addressInput1.isEnabled = false - binding.addressInput2.isEnabled = false - binding.progress.visibility = VISIBLE + binding.addressInput.isEnabled = false + binding.addButton.isEnabled = false + binding.createButton.isEnabled = false + binding.createButton.text = "" + binding.progress.visibility = View.VISIBLE } is NewConversationViewModel.UiState.Success -> { diff --git a/example/src/main/java/org/xmtp/android/example/conversation/NewMessageBottomSheet.kt b/example/src/main/java/org/xmtp/android/example/conversation/NewMessageBottomSheet.kt new file mode 100644 index 000000000..08c3da85d --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/conversation/NewMessageBottomSheet.kt @@ -0,0 +1,280 @@ +package org.xmtp.android.example.conversation + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.view.isVisible +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.chip.Chip +import kotlinx.coroutines.launch +import org.xmtp.android.example.R +import org.xmtp.android.example.databinding.BottomSheetNewMessageBinding +import org.xmtp.android.example.extension.truncatedAddress +import java.util.regex.Pattern + +class NewMessageBottomSheet : BottomSheetDialogFragment() { + private val viewModel: NewConversationViewModel by viewModels() + private var _binding: BottomSheetNewMessageBinding? = null + private val binding get() = _binding!! + + private val groupAddresses: MutableList = mutableListOf() + private var isGroupMode = false + + companion object { + const val TAG = "NewMessageBottomSheet" + private const val MIN_GROUP_MEMBERS = 2 + + private val ADDRESS_PATTERN = Pattern.compile("^0x[a-fA-F0-9]{40}$") + + fun newInstance(): NewMessageBottomSheet = NewMessageBottomSheet() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = BottomSheetNewMessageBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect(::ensureUiState) + } + } + + setupToggleGroup() + setupAddressInput() + setupActionButton() + setupCreateButton() + updateUiForMode() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun setupToggleGroup() { + binding.messageTypeToggle.addOnButtonCheckedListener { _, checkedId, isChecked -> + if (isChecked) { + isGroupMode = checkedId == R.id.groupButton + updateUiForMode() + // Clear input when switching modes + binding.addressInput.text?.clear() + groupAddresses.clear() + binding.membersChipGroup.removeAllViews() + updateMemberCount() + } + } + } + + private fun setupAddressInput() { + binding.addressInput.addTextChangedListener { text -> + val input = text?.toString()?.trim() ?: "" + val isValidAddress = ADDRESS_PATTERN.matcher(input).matches() + + if (isGroupMode) { + // In group mode, show add button for valid addresses not already added + binding.actionButton.isVisible = isValidAddress && !groupAddresses.contains(input) + binding.actionButton.setImageResource(R.drawable.ic_add_24) + binding.actionButton.setColorFilter( + resources.getColor( + if (binding.actionButton.isVisible) R.color.xmtp_primary else R.color.text_tertiary, + null, + ), + ) + } else { + // In DM mode, show clear button when there's text + binding.actionButton.isVisible = input.isNotEmpty() + binding.actionButton.setImageResource(R.drawable.ic_close_24) + binding.actionButton.setColorFilter( + resources.getColor(R.color.text_tertiary, null), + ) + // Enable create button for valid DM address + binding.createButton.isEnabled = isValidAddress + } + + // Update helper text color based on validation + if (input.isNotEmpty() && !isValidAddress) { + binding.helperText.setTextColor( + resources.getColor(R.color.error, null), + ) + } else { + binding.helperText.setTextColor( + resources.getColor(R.color.text_tertiary, null), + ) + } + } + } + + private fun setupActionButton() { + binding.actionButton.setOnClickListener { + if (isGroupMode) { + // Add member to group + val address = + binding.addressInput.text + ?.toString() + ?.trim() ?: "" + if (ADDRESS_PATTERN.matcher(address).matches() && !groupAddresses.contains(address)) { + addMember(address) + binding.addressInput.text?.clear() + } + } else { + // Clear DM address input + binding.addressInput.text?.clear() + } + } + } + + private fun setupCreateButton() { + binding.createButton.setOnClickListener { + val address = + binding.addressInput.text + ?.toString() + ?.trim() ?: "" + + if (isGroupMode) { + if (groupAddresses.size >= MIN_GROUP_MEMBERS) { + viewModel.createGroup(groupAddresses) + } + } else { + if (ADDRESS_PATTERN.matcher(address).matches()) { + viewModel.createConversation(address) + } + } + } + } + + private fun updateUiForMode() { + if (isGroupMode) { + binding.headerSubtitle.text = getString(R.string.new_group_subtitle) + binding.helperText.text = getString(R.string.minimum_members_hint) + binding.createButton.text = getString(R.string.create_group) + binding.membersChipGroup.isVisible = groupAddresses.isNotEmpty() + binding.memberCount.isVisible = groupAddresses.isNotEmpty() + updateMemberCount() + } else { + binding.headerSubtitle.text = getString(R.string.new_conversation_subtitle) + binding.helperText.text = getString(R.string.address_helper_text) + binding.createButton.text = getString(R.string.start_conversation) + binding.membersChipGroup.isVisible = false + binding.memberCount.isVisible = false + // Re-evaluate create button state for DM mode + val input = + binding.addressInput.text + ?.toString() + ?.trim() ?: "" + binding.createButton.isEnabled = ADDRESS_PATTERN.matcher(input).matches() + } + } + + private fun addMember(address: String) { + groupAddresses.add(address) + + val chip = + Chip(requireContext()).apply { + text = address.truncatedAddress() + isCloseIconVisible = true + setChipBackgroundColorResource(R.color.surface_variant) + setTextColor(resources.getColor(R.color.text_primary, null)) + setCloseIconTintResource(R.color.text_tertiary) + tag = address + setOnCloseIconClickListener { + removeMember(address) + } + } + + binding.membersChipGroup.addView(chip) + binding.membersChipGroup.isVisible = true + updateMemberCount() + } + + private fun removeMember(address: String) { + groupAddresses.remove(address) + + for (i in 0 until binding.membersChipGroup.childCount) { + val chip = binding.membersChipGroup.getChildAt(i) as? Chip + if (chip?.tag == address) { + binding.membersChipGroup.removeView(chip) + break + } + } + + binding.membersChipGroup.isVisible = groupAddresses.isNotEmpty() + updateMemberCount() + } + + private fun updateMemberCount() { + val count = groupAddresses.size + binding.memberCount.isVisible = count > 0 + binding.memberCount.text = resources.getString(R.string.member_count, count) + if (isGroupMode) { + binding.createButton.isEnabled = count >= MIN_GROUP_MEMBERS + } + } + + private fun ensureUiState(uiState: NewConversationViewModel.UiState) { + when (uiState) { + is NewConversationViewModel.UiState.Error -> { + binding.addressInput.isEnabled = true + binding.actionButton.isEnabled = true + binding.messageTypeToggle.isEnabled = true + if (isGroupMode) { + binding.createButton.isEnabled = groupAddresses.size >= MIN_GROUP_MEMBERS + binding.createButton.text = getString(R.string.create_group) + } else { + val input = + binding.addressInput.text + ?.toString() + ?.trim() ?: "" + binding.createButton.isEnabled = ADDRESS_PATTERN.matcher(input).matches() + binding.createButton.text = getString(R.string.start_conversation) + } + binding.progress.visibility = View.GONE + showError(uiState.message) + } + + NewConversationViewModel.UiState.Loading -> { + binding.addressInput.isEnabled = false + binding.actionButton.isEnabled = false + binding.createButton.isEnabled = false + binding.createButton.text = "" + binding.messageTypeToggle.isEnabled = false + binding.progress.visibility = View.VISIBLE + } + + is NewConversationViewModel.UiState.Success -> { + startActivity( + ConversationDetailActivity.intent( + requireContext(), + topic = uiState.conversation.topic, + peerAddress = uiState.conversation.id, + ), + ) + dismiss() + } + + NewConversationViewModel.UiState.Unknown -> Unit + } + } + + private fun showError(message: String) { + val error = message.ifBlank { resources.getString(R.string.error) } + Toast.makeText(requireContext(), error, Toast.LENGTH_SHORT).show() + } +} diff --git a/example/src/main/java/org/xmtp/android/example/conversation/RecentContactsAdapter.kt b/example/src/main/java/org/xmtp/android/example/conversation/RecentContactsAdapter.kt new file mode 100644 index 000000000..0480007c7 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/conversation/RecentContactsAdapter.kt @@ -0,0 +1,177 @@ +package org.xmtp.android.example.conversation + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import org.xmtp.android.example.R +import org.xmtp.android.example.databinding.ListItemRecentContactBinding +import org.xmtp.android.example.extension.truncatedAddress +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import kotlin.math.abs + +class RecentContactsAdapter( + private val clickListener: RecentContactClickListener, + private val avatarColors: List, +) : RecyclerView.Adapter() { + private var contacts: List = emptyList() + private var isGroupMode: Boolean = false + private var selectedAddresses: Set = emptySet() + + fun setData(newContacts: List) { + val diffCallback = RecentContactDiffCallback(contacts, newContacts, selectedAddresses, selectedAddresses) + val diffResult = DiffUtil.calculateDiff(diffCallback) + contacts = newContacts + diffResult.dispatchUpdatesTo(this) + } + + fun setGroupMode( + groupMode: Boolean, + selectedList: List, + ) { + val oldSelectedAddresses = selectedAddresses + isGroupMode = groupMode + selectedAddresses = selectedList.map { it.lowercase() }.toSet() + + // Only update items that changed selection state + if (oldSelectedAddresses != selectedAddresses || isGroupMode != groupMode) { + val diffCallback = RecentContactDiffCallback(contacts, contacts, oldSelectedAddresses, selectedAddresses) + val diffResult = DiffUtil.calculateDiff(diffCallback) + diffResult.dispatchUpdatesTo(this) + } + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): ViewHolder { + val binding = + ListItemRecentContactBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return ViewHolder(binding, clickListener, avatarColors) + } + + override fun onBindViewHolder( + holder: ViewHolder, + position: Int, + ) { + holder.bind(contacts[position], isGroupMode, selectedAddresses.contains(contacts[position].address.lowercase())) + } + + override fun getItemCount(): Int = contacts.size + + class ViewHolder( + private val binding: ListItemRecentContactBinding, + private val clickListener: RecentContactClickListener, + private val avatarColors: List, + ) : RecyclerView.ViewHolder(binding.root) { + private var currentContact: RecentContact? = null + + init { + binding.root.setOnClickListener { + currentContact?.let { clickListener.onContactClick(it) } + } + binding.addButton.setOnClickListener { + currentContact?.let { clickListener.onAddClick(it) } + } + } + + fun bind( + contact: RecentContact, + isGroupMode: Boolean, + isSelected: Boolean, + ) { + currentContact = contact + + // Set avatar + val avatarText = + contact.address + .removePrefix("0x") + .take(2) + .uppercase() + binding.avatarText.text = avatarText + + val colorIndex = abs(contact.address.hashCode()) % avatarColors.size + binding.avatarCard.setCardBackgroundColor(avatarColors[colorIndex]) + + // Set address + binding.contactAddress.text = contact.address.truncatedAddress() + + // Set last activity time + binding.contactSubtitle.text = formatLastActivity(contact.lastActivityTime) + + // Show/hide elements based on mode and selection + if (isGroupMode) { + binding.addButton.isVisible = !isSelected + binding.selectedCheck.isVisible = isSelected + } else { + binding.addButton.isVisible = false + binding.selectedCheck.isVisible = false + } + } + + private fun formatLastActivity(timestampNs: Long): String { + val timestampMs = timestampNs / 1_000_000 + val messageDate = Date(timestampMs) + val now = Calendar.getInstance() + val messageCalendar = Calendar.getInstance().apply { time = messageDate } + + val timeAgo = + when { + now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) && + now.get(Calendar.DAY_OF_YEAR) == messageCalendar.get(Calendar.DAY_OF_YEAR) -> { + binding.root.context.getString(R.string.last_messaged, "today") + } + now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) && + now.get(Calendar.DAY_OF_YEAR) - messageCalendar.get(Calendar.DAY_OF_YEAR) == 1 -> { + binding.root.context.getString(R.string.last_messaged, "yesterday") + } + now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) && + now.get(Calendar.DAY_OF_YEAR) - messageCalendar.get(Calendar.DAY_OF_YEAR) < 7 -> { + val daysDiff = now.get(Calendar.DAY_OF_YEAR) - messageCalendar.get(Calendar.DAY_OF_YEAR) + binding.root.context.getString(R.string.last_messaged, "$daysDiff days ago") + } + else -> { + val formattedDate = SimpleDateFormat("MMM d", Locale.getDefault()).format(messageDate) + binding.root.context.getString(R.string.last_messaged, formattedDate) + } + } + return timeAgo + } + } + + private class RecentContactDiffCallback( + private val oldList: List, + private val newList: List, + private val oldSelected: Set, + private val newSelected: Set, + ) : DiffUtil.Callback() { + override fun getOldListSize(): Int = oldList.size + + override fun getNewListSize(): Int = newList.size + + override fun areItemsTheSame( + oldItemPosition: Int, + newItemPosition: Int, + ): Boolean = oldList[oldItemPosition].address == newList[newItemPosition].address + + override fun areContentsTheSame( + oldItemPosition: Int, + newItemPosition: Int, + ): Boolean { + val oldItem = oldList[oldItemPosition] + val newItem = newList[newItemPosition] + val oldAddress = oldItem.address.lowercase() + val newAddress = newItem.address.lowercase() + return oldItem == newItem && + oldSelected.contains(oldAddress) == newSelected.contains(newAddress) + } + } +} diff --git a/example/src/main/java/org/xmtp/android/example/conversation/UserProfileActivity.kt b/example/src/main/java/org/xmtp/android/example/conversation/UserProfileActivity.kt new file mode 100644 index 000000000..6f7bb8c04 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/conversation/UserProfileActivity.kt @@ -0,0 +1,249 @@ +package org.xmtp.android.example.conversation + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.xmtp.android.example.ClientManager +import org.xmtp.android.example.R +import org.xmtp.android.example.databinding.ActivityUserProfileBinding +import org.xmtp.android.example.extension.truncatedAddress +import org.xmtp.android.library.libxmtp.IdentityKind +import org.xmtp.android.library.libxmtp.InboxState +import org.xmtp.android.library.libxmtp.PublicIdentity +import kotlin.math.abs + +class UserProfileActivity : AppCompatActivity() { + private lateinit var binding: ActivityUserProfileBinding + + private val avatarColors = + listOf( + Color.parseColor("#FC4F37"), + Color.parseColor("#5856D6"), + Color.parseColor("#34C759"), + Color.parseColor("#FF9500"), + Color.parseColor("#007AFF"), + Color.parseColor("#AF52DE"), + Color.parseColor("#00C7BE"), + Color.parseColor("#FF2D55"), + ) + + companion object { + private const val EXTRA_WALLET_ADDRESS = "EXTRA_WALLET_ADDRESS" + private const val EXTRA_INBOX_ID = "EXTRA_INBOX_ID" + + fun intent( + context: Context, + walletAddress: String, + inboxId: String? = null, + ): Intent = + Intent(context, UserProfileActivity::class.java).apply { + putExtra(EXTRA_WALLET_ADDRESS, walletAddress) + putExtra(EXTRA_INBOX_ID, inboxId) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = ActivityUserProfileBinding.inflate(layoutInflater) + setContentView(binding.root) + setSupportActionBar(binding.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + val walletAddress = + intent.getStringExtra(EXTRA_WALLET_ADDRESS) ?: run { + finish() + return + } + val inboxId = intent.getStringExtra(EXTRA_INBOX_ID) + + setupUi(walletAddress, inboxId) + setupClickListeners(walletAddress, inboxId) + + if (inboxId != null) { + loadInboxState(inboxId) + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = + when (item.itemId) { + android.R.id.home -> { + finish() + true + } + else -> super.onOptionsItemSelected(item) + } + + private fun setupUi( + walletAddress: String, + inboxId: String?, + ) { + // Set avatar + val avatarText = + walletAddress + .removePrefix("0x") + .take(2) + .uppercase() + binding.userAvatarText.text = avatarText + + val colorIndex = abs((inboxId ?: walletAddress).hashCode()) % avatarColors.size + binding.userAvatarCard.setCardBackgroundColor(avatarColors[colorIndex]) + + // Set wallet address + binding.walletAddress.text = walletAddress.truncatedAddress() + binding.fullWalletAddress.text = walletAddress + + // Set inbox ID + binding.inboxId.text = inboxId ?: getString(R.string.unknown) + } + + private fun setupClickListeners( + walletAddress: String, + inboxId: String?, + ) { + binding.copyAddressButton.setOnClickListener { + copyToClipboard("Wallet Address", walletAddress) + } + + binding.copyInboxIdButton.setOnClickListener { + inboxId?.let { id -> + copyToClipboard("Inbox ID", id) + } + } + + binding.sendMessageButton.setOnClickListener { + startConversation(walletAddress) + } + } + + private fun loadInboxState(inboxId: String) { + binding.progress.visibility = View.VISIBLE + + lifecycleScope.launch { + try { + val inboxStates = + withContext(Dispatchers.IO) { + ClientManager.client.inboxStatesForInboxIds( + refreshFromNetwork = true, + inboxIds = listOf(inboxId), + ) + } + + val inboxState = inboxStates.firstOrNull() + if (inboxState != null) { + displayInboxState(inboxState) + } + + binding.progress.visibility = View.GONE + } catch (e: Exception) { + binding.progress.visibility = View.GONE + // Silently fail - we still have basic info + } + } + } + + private fun displayInboxState(inboxState: InboxState) { + val identities = inboxState.identities + if (identities.size > 1) { + binding.identitiesCard.isVisible = true + binding.identitiesList.layoutManager = LinearLayoutManager(this) + binding.identitiesList.adapter = IdentityAdapter(identities) + } + } + + private fun copyToClipboard( + label: String, + text: String, + ) { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(label, text) + clipboard.setPrimaryClip(clip) + Toast.makeText(this, getString(R.string.copied_to_clipboard, label), Toast.LENGTH_SHORT).show() + } + + private fun startConversation(walletAddress: String) { + binding.progress.visibility = View.VISIBLE + + lifecycleScope.launch { + try { + val conversation = + withContext(Dispatchers.IO) { + val publicIdentity = PublicIdentity(IdentityKind.ETHEREUM, walletAddress) + ClientManager.client.conversations.findOrCreateDmWithIdentity(publicIdentity) + } + + binding.progress.visibility = View.GONE + + startActivity( + ConversationDetailActivity.intent( + this@UserProfileActivity, + topic = conversation.topic, + peerAddress = conversation.id, + ), + ) + finish() + } catch (e: Exception) { + binding.progress.visibility = View.GONE + Toast + .makeText( + this@UserProfileActivity, + e.localizedMessage ?: getString(R.string.error), + Toast.LENGTH_SHORT, + ).show() + } + } + } +} + +// Simple adapter for linked identities +class IdentityAdapter( + private val identities: List, +) : androidx.recyclerview.widget.RecyclerView.Adapter() { + override fun onCreateViewHolder( + parent: android.view.ViewGroup, + viewType: Int, + ): IdentityViewHolder { + val textView = + android.widget.TextView(parent.context).apply { + layoutParams = + android.view.ViewGroup.LayoutParams( + android.view.ViewGroup.LayoutParams.MATCH_PARENT, + android.view.ViewGroup.LayoutParams.WRAP_CONTENT, + ) + setPadding(0, 8, 0, 8) + setTextColor(parent.context.getColor(R.color.text_secondary)) + textSize = 14f + } + return IdentityViewHolder(textView) + } + + override fun onBindViewHolder( + holder: IdentityViewHolder, + position: Int, + ) { + holder.bind(identities[position]) + } + + override fun getItemCount(): Int = identities.size + + class IdentityViewHolder( + private val textView: android.widget.TextView, + ) : androidx.recyclerview.widget.RecyclerView.ViewHolder(textView) { + fun bind(identity: org.xmtp.android.library.libxmtp.PublicIdentity) { + textView.text = identity.identifier + } + } +} diff --git a/example/src/main/java/org/xmtp/android/example/message/EmojiPickerAdapter.kt b/example/src/main/java/org/xmtp/android/example/message/EmojiPickerAdapter.kt new file mode 100644 index 000000000..72568af56 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/message/EmojiPickerAdapter.kt @@ -0,0 +1,399 @@ +package org.xmtp.android.example.message + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import org.xmtp.android.example.databinding.ListItemEmojiBinding + +class EmojiPickerAdapter( + private val onEmojiClick: (String) -> Unit, +) : RecyclerView.Adapter() { + private val emojis = + listOf( + // Smileys & Emotion + "\uD83D\uDE00", + "\uD83D\uDE03", + "\uD83D\uDE04", + "\uD83D\uDE01", + "\uD83D\uDE06", + "\uD83D\uDE05", + "\uD83D\uDE02", + "\uD83E\uDD23", + "\uD83D\uDE0A", + "\uD83D\uDE07", + "\uD83D\uDE42", + "\uD83D\uDE43", + "\uD83D\uDE09", + "\uD83D\uDE0C", + "\uD83D\uDE0D", + "\uD83E\uDD70", + "\uD83D\uDE18", + "\uD83D\uDE17", + "\uD83D\uDE19", + "\uD83D\uDE1A", + "\uD83D\uDE0B", + "\uD83D\uDE1B", + "\uD83D\uDE1C", + "\uD83E\uDD2A", + "\uD83D\uDE1D", + "\uD83E\uDD11", + "\uD83E\uDD17", + "\uD83E\uDD2D", + "\uD83E\uDD2B", + "\uD83E\uDD14", + "\uD83E\uDD10", + "\uD83E\uDD28", + "\uD83D\uDE10", + "\uD83D\uDE11", + "\uD83D\uDE36", + "\uD83D\uDE0F", + "\uD83D\uDE12", + "\uD83D\uDE44", + "\uD83D\uDE2C", + "\uD83E\uDD25", + "\uD83D\uDE0C", + "\uD83D\uDE14", + "\uD83D\uDE2A", + "\uD83E\uDD24", + "\uD83D\uDE34", + "\uD83D\uDE37", + "\uD83E\uDD12", + "\uD83E\uDD15", + "\uD83E\uDD22", + "\uD83E\uDD2E", + "\uD83E\uDD27", + "\uD83E\uDD75", + "\uD83E\uDD76", + "\uD83E\uDD74", + "\uD83D\uDE35", + "\uD83E\uDD2F", + "\uD83E\uDD20", + "\uD83E\uDD73", + "\uD83D\uDE0E", + "\uD83E\uDD13", + "\uD83E\uDDD0", + "\uD83D\uDE15", + "\uD83D\uDE1F", + "\uD83D\uDE41", + "\u2639\uFE0F", + "\uD83D\uDE2E", + "\uD83D\uDE2F", + "\uD83D\uDE32", + "\uD83D\uDE33", + "\uD83E\uDD7A", + "\uD83D\uDE26", + "\uD83D\uDE27", + "\uD83D\uDE28", + "\uD83D\uDE30", + "\uD83D\uDE25", + "\uD83D\uDE22", + "\uD83D\uDE2D", + "\uD83D\uDE31", + "\uD83D\uDE16", + "\uD83D\uDE23", + "\uD83D\uDE1E", + "\uD83D\uDE13", + "\uD83D\uDE29", + "\uD83D\uDE2B", + "\uD83E\uDD71", + "\uD83D\uDE24", + "\uD83D\uDE21", + "\uD83D\uDE20", + "\uD83E\uDD2C", + "\uD83D\uDE08", + "\uD83D\uDC7F", + "\uD83D\uDC80", + "\u2620\uFE0F", + "\uD83D\uDCA9", + "\uD83E\uDD21", + "\uD83D\uDC79", + "\uD83D\uDC7A", + "\uD83D\uDC7B", + "\uD83D\uDC7D", + "\uD83D\uDC7E", + "\uD83E\uDD16", + "\uD83D\uDE3A", + "\uD83D\uDE38", + "\uD83D\uDE39", + "\uD83D\uDE3B", + "\uD83D\uDE3C", + "\uD83D\uDE3D", + "\uD83D\uDE40", + "\uD83D\uDE3F", + "\uD83D\uDE3E", + // Gestures + "\uD83D\uDC4D", + "\uD83D\uDC4E", + "\uD83D\uDC4A", + "\u270A", + "\uD83E\uDD1B", + "\uD83E\uDD1C", + "\uD83D\uDC4F", + "\uD83D\uDE4C", + "\uD83D\uDC50", + "\uD83E\uDD32", + "\uD83E\uDD1D", + "\uD83D\uDE4F", + "\u270D\uFE0F", + "\uD83D\uDC85", + "\uD83E\uDD33", + "\uD83D\uDCAA", + "\uD83E\uDDBE", + "\uD83E\uDDBF", + "\uD83E\uDDB5", + "\uD83E\uDDB6", + "\uD83D\uDC42", + "\uD83E\uDDBB", + "\uD83D\uDC43", + "\uD83E\uDDE0", + "\uD83D\uDC40", + "\uD83D\uDC41\uFE0F", + "\uD83D\uDC45", + "\uD83D\uDC44", + "\uD83D\uDC8B", + // Hearts + "\u2764\uFE0F", + "\uD83E\uDDE1", + "\uD83D\uDC9B", + "\uD83D\uDC9A", + "\uD83D\uDC99", + "\uD83D\uDC9C", + "\uD83E\uDD0E", + "\uD83D\uDDA4", + "\uD83E\uDD0D", + "\uD83D\uDC94", + "\u2763\uFE0F", + "\uD83D\uDC95", + "\uD83D\uDC9E", + "\uD83D\uDC93", + "\uD83D\uDC97", + "\uD83D\uDC96", + "\uD83D\uDC98", + "\uD83D\uDC9D", + "\uD83D\uDC9F", + "\u2665\uFE0F", + "\uD83D\uDCAF", + "\uD83D\uDCA2", + "\uD83D\uDCA5", + "\uD83D\uDCAB", + "\uD83D\uDCA6", + "\uD83D\uDCA8", + "\uD83D\uDD73\uFE0F", + "\uD83D\uDCA3", + "\uD83D\uDCAC", + // Common objects + "\uD83D\uDD25", + "\u2B50", + "\uD83C\uDF1F", + "\u2728", + "\uD83C\uDF88", + "\uD83C\uDF89", + "\uD83C\uDF8A", + "\uD83C\uDF81", + "\uD83C\uDF80", + "\uD83C\uDF8E", + "\uD83C\uDF8F", + "\uD83C\uDF90", + "\uD83C\uDF91", + "\uD83E\uDDE7", + "\u2709\uFE0F", + "\uD83D\uDCE9", + "\uD83D\uDCE8", + "\uD83D\uDCE7", + "\uD83D\uDC8C", + "\uD83D\uDCDD", + "\u270F\uFE0F", + "\uD83D\uDCDA", + "\uD83D\uDCD6", + "\uD83D\uDCF7", + "\uD83C\uDFA5", + "\uD83C\uDFB5", + "\uD83C\uDFB6", + "\uD83C\uDFB8", + "\uD83C\uDFB9", + "\uD83C\uDFA4", + "\uD83C\uDFA7", + // Food & Drink + "\uD83C\uDF54", + "\uD83C\uDF55", + "\uD83C\uDF5F", + "\uD83C\uDF2D", + "\uD83C\uDF2E", + "\uD83C\uDF2F", + "\uD83C\uDF73", + "\uD83E\uDD5A", + "\uD83E\uDD53", + "\uD83C\uDF66", + "\uD83C\uDF70", + "\uD83C\uDF82", + "\u2615", + "\uD83C\uDF7A", + "\uD83C\uDF77", + "\uD83C\uDF78", + "\uD83C\uDF79", + "\uD83E\uDD42", + // Animals + "\uD83D\uDC36", + "\uD83D\uDC31", + "\uD83D\uDC2D", + "\uD83D\uDC39", + "\uD83D\uDC30", + "\uD83E\uDD8A", + "\uD83D\uDC3B", + "\uD83D\uDC3C", + "\uD83D\uDC28", + "\uD83D\uDC2F", + "\uD83E\uDD81", + "\uD83D\uDC2E", + "\uD83D\uDC37", + "\uD83D\uDC38", + "\uD83D\uDC35", + "\uD83D\uDE48", + "\uD83D\uDE49", + "\uD83D\uDE4A", + "\uD83D\uDC12", + "\uD83D\uDC14", + "\uD83D\uDC27", + "\uD83D\uDC26", + "\uD83E\uDD86", + "\uD83E\uDD85", + "\uD83E\uDD89", + "\uD83E\uDD87", + "\uD83D\uDC3A", + "\uD83D\uDC17", + "\uD83D\uDC34", + "\uD83E\uDD84", + // Weather & Nature + "\u2600\uFE0F", + "\uD83C\uDF24\uFE0F", + "\u26C5", + "\uD83C\uDF25\uFE0F", + "\uD83C\uDF26\uFE0F", + "\uD83C\uDF27\uFE0F", + "\u26C8\uFE0F", + "\uD83C\uDF29\uFE0F", + "\uD83C\uDF2A\uFE0F", + "\uD83C\uDF2B\uFE0F", + "\uD83C\uDF1E", + "\uD83C\uDF1D", + "\uD83C\uDF1B", + "\uD83C\uDF1C", + "\uD83C\uDF1A", + "\uD83C\uDF08", + "\u2601\uFE0F", + "\u2744\uFE0F", + "\u26A1", + "\uD83D\uDD25", + "\uD83D\uDCA7", + "\uD83C\uDF0A", + "\uD83C\uDF31", + "\uD83C\uDF32", + "\uD83C\uDF33", + "\uD83C\uDF34", + "\uD83C\uDF35", + "\uD83C\uDF3B", + "\uD83C\uDF3C", + "\uD83C\uDF39", + "\uD83C\uDF3A", + "\uD83C\uDF37", + "\uD83C\uDF38", + "\uD83C\uDF3E", + // Sports & Activities + "\u26BD", + "\uD83C\uDFC0", + "\uD83C\uDFC8", + "\u26BE", + "\uD83E\uDD4E", + "\uD83C\uDFBE", + "\uD83C\uDFD0", + "\uD83C\uDFC9", + "\uD83E\uDD4F", + "\uD83C\uDFB1", + "\uD83C\uDFD3", + "\uD83C\uDFF8", + "\uD83C\uDFD2", + "\uD83C\uDFD1", + "\uD83C\uDFF3\uFE0F", + "\u26F3", + "\uD83C\uDFCE\uFE0F", + "\uD83C\uDFCD\uFE0F", + "\uD83E\uDD47", + "\uD83E\uDD48", + "\uD83E\uDD49", + "\uD83C\uDFC6", + "\uD83C\uDF96\uFE0F", + // Travel + "\u2708\uFE0F", + "\uD83D\uDE80", + "\uD83D\uDEF8", + "\uD83D\uDE97", + "\uD83D\uDE95", + "\uD83D\uDE8C", + "\uD83D\uDE8E", + "\uD83D\uDE91", + "\uD83D\uDE92", + "\uD83D\uDE93", + "\uD83D\uDE94", + "\uD83D\uDE96", + "\uD83D\uDE99", + "\uD83D\uDE9A", + "\uD83D\uDE9B", + "\uD83D\uDEB2", + "\uD83D\uDEF4", + "\uD83D\uDEB4", + // Symbols + "\u2714\uFE0F", + "\u2716\uFE0F", + "\u2795", + "\u2796", + "\u2797", + "\u2716\uFE0F", + "\u274C", + "\u274E", + "\u2049\uFE0F", + "\u2753", + "\u2754", + "\u2755", + "\u203C\uFE0F", + "\u2757", + "\u26A0\uFE0F", + "\uD83D\uDEAB", + "\uD83D\uDD1E", + "\u267B\uFE0F", + "\u2705", + "\uD83D\uDC4C", + ) + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): EmojiViewHolder { + val binding = + ListItemEmojiBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return EmojiViewHolder(binding) + } + + override fun onBindViewHolder( + holder: EmojiViewHolder, + position: Int, + ) { + holder.bind(emojis[position]) + } + + override fun getItemCount(): Int = emojis.size + + inner class EmojiViewHolder( + private val binding: ListItemEmojiBinding, + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(emoji: String) { + binding.emojiText.text = emoji + binding.root.setOnClickListener { + onEmojiClick(emoji) + } + } + } +} diff --git a/example/src/main/java/org/xmtp/android/example/message/MessageAdapter.kt b/example/src/main/java/org/xmtp/android/example/message/MessageAdapter.kt index dfef5857c..3036f30e1 100644 --- a/example/src/main/java/org/xmtp/android/example/message/MessageAdapter.kt +++ b/example/src/main/java/org/xmtp/android/example/message/MessageAdapter.kt @@ -2,26 +2,102 @@ package org.xmtp.android.example.message import android.view.LayoutInflater import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import org.xmtp.android.example.conversation.ConversationDetailViewModel -import org.xmtp.android.example.databinding.ListItemMessageBinding +import org.xmtp.android.example.databinding.ListItemMessageReceivedBinding +import org.xmtp.android.example.databinding.ListItemMessageSentBinding +import org.xmtp.android.example.databinding.ListItemMessageSystemBinding +import org.xmtp.android.library.codecs.DeletedMessage +import org.xmtp.android.library.libxmtp.DecodedMessageV2 -class MessageAdapter : RecyclerView.Adapter() { - init { - setHasStableIds(true) - } +interface MessageClickListener { + fun onMessageLongClick(message: DecodedMessageV2) + + fun onReplyClick(referenceMessageId: String) + fun onReactionClick( + messageId: String, + emoji: String, + ) +} + +class MessageAdapter( + private val clickListener: MessageClickListener? = null, +) : RecyclerView.Adapter() { private val listItems = mutableListOf() fun setData(newItems: List) { + val diffCallback = MessageDiffCallback(listItems.toList(), newItems) + val diffResult = DiffUtil.calculateDiff(diffCallback) listItems.clear() listItems.addAll(newItems) - notifyDataSetChanged() + diffResult.dispatchUpdatesTo(this) } fun addItem(item: ConversationDetailViewModel.MessageListItem) { listItems.add(0, item) - notifyDataSetChanged() + notifyItemInserted(0) + } + + private class MessageDiffCallback( + private val oldList: List, + private val newList: List, + ) : DiffUtil.Callback() { + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areItemsTheSame( + oldItemPosition: Int, + newItemPosition: Int, + ): Boolean = oldList[oldItemPosition].id == newList[newItemPosition].id + + override fun areContentsTheSame( + oldItemPosition: Int, + newItemPosition: Int, + ): Boolean { + val oldItem = oldList[oldItemPosition] + val newItem = newList[newItemPosition] + + // Different item types = different content + if (oldItem.itemType != newItem.itemType) return false + + // Compare based on item type + return when { + oldItem is ConversationDetailViewModel.MessageListItem.SentMessage && + newItem is ConversationDetailViewModel.MessageListItem.SentMessage -> { + val oldContent = oldItem.message.content() + val newContent = newItem.message.content() + val oldIsDeleted = oldContent is DeletedMessage + val newIsDeleted = newContent is DeletedMessage + // Also compare reaction counts to detect reaction changes + val oldReactionCount = oldItem.message.reactionCount + val newReactionCount = newItem.message.reactionCount + if (oldIsDeleted != newIsDeleted) return false + if (oldReactionCount != newReactionCount) return false + oldContent == newContent + } + oldItem is ConversationDetailViewModel.MessageListItem.ReceivedMessage && + newItem is ConversationDetailViewModel.MessageListItem.ReceivedMessage -> { + val oldContent = oldItem.message.content() + val newContent = newItem.message.content() + val oldIsDeleted = oldContent is DeletedMessage + val newIsDeleted = newContent is DeletedMessage + // Also compare reaction counts to detect reaction changes + val oldReactionCount = oldItem.message.reactionCount + val newReactionCount = newItem.message.reactionCount + if (oldIsDeleted != newIsDeleted) return false + if (oldReactionCount != newReactionCount) return false + oldContent == newContent + } + oldItem is ConversationDetailViewModel.MessageListItem.SystemMessage && + newItem is ConversationDetailViewModel.MessageListItem.SystemMessage -> { + oldItem.text == newItem.text + } + else -> oldItem == newItem + } + } } override fun onCreateViewHolder( @@ -30,9 +106,17 @@ class MessageAdapter : RecyclerView.Adapter() { ): RecyclerView.ViewHolder { val inflater = LayoutInflater.from(parent.context) return when (viewType) { - ConversationDetailViewModel.MessageListItem.ITEM_TYPE_MESSAGE -> { - val binding = ListItemMessageBinding.inflate(inflater, parent, false) - MessageViewHolder(binding) + ConversationDetailViewModel.MessageListItem.ITEM_TYPE_SENT -> { + val binding = ListItemMessageSentBinding.inflate(inflater, parent, false) + SentMessageViewHolder(binding, clickListener) + } + ConversationDetailViewModel.MessageListItem.ITEM_TYPE_RECEIVED -> { + val binding = ListItemMessageReceivedBinding.inflate(inflater, parent, false) + ReceivedMessageViewHolder(binding, clickListener) + } + ConversationDetailViewModel.MessageListItem.ITEM_TYPE_SYSTEM -> { + val binding = ListItemMessageSystemBinding.inflate(inflater, parent, false) + SystemMessageViewHolder(binding) } else -> throw IllegalArgumentException("Unsupported view type $viewType") } @@ -44,8 +128,14 @@ class MessageAdapter : RecyclerView.Adapter() { ) { val item = listItems[position] when (holder) { - is MessageViewHolder -> { - holder.bind(item as ConversationDetailViewModel.MessageListItem.Message) + is SentMessageViewHolder -> { + holder.bind(item as ConversationDetailViewModel.MessageListItem.SentMessage) + } + is ReceivedMessageViewHolder -> { + holder.bind(item as ConversationDetailViewModel.MessageListItem.ReceivedMessage) + } + is SystemMessageViewHolder -> { + holder.bind(item as ConversationDetailViewModel.MessageListItem.SystemMessage) } else -> throw IllegalArgumentException("Unsupported view holder") } @@ -54,6 +144,4 @@ class MessageAdapter : RecyclerView.Adapter() { override fun getItemViewType(position: Int) = listItems[position].itemType override fun getItemCount() = listItems.size - - override fun getItemId(position: Int) = listItems[position].id.hashCode().toLong() } diff --git a/example/src/main/java/org/xmtp/android/example/message/MessageViewHolder.kt b/example/src/main/java/org/xmtp/android/example/message/MessageViewHolder.kt deleted file mode 100644 index 4f2bee4da..000000000 --- a/example/src/main/java/org/xmtp/android/example/message/MessageViewHolder.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.xmtp.android.example.message - -import android.annotation.SuppressLint -import android.graphics.Color -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID -import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.UNSET -import androidx.recyclerview.widget.RecyclerView -import org.xmtp.android.example.ClientManager -import org.xmtp.android.example.R -import org.xmtp.android.example.conversation.ConversationDetailViewModel -import org.xmtp.android.example.databinding.ListItemMessageBinding -import org.xmtp.android.example.extension.margins -import org.xmtp.proto.mls.message.contents.TranscriptMessages.GroupUpdated -import java.text.SimpleDateFormat -import java.util.Locale - -class MessageViewHolder( - private val binding: ListItemMessageBinding, -) : RecyclerView.ViewHolder(binding.root) { - private val marginLarge = binding.root.resources.getDimensionPixelSize(R.dimen.message_margin) - private val marginSmall = binding.root.resources.getDimensionPixelSize(R.dimen.padding) - private val backgroundMe = Color.LTGRAY - private val backgroundPeer = - binding.root.resources.getColor(R.color.teal_700, binding.root.context.theme) - - @SuppressLint("SetTextI18n") - fun bind(item: ConversationDetailViewModel.MessageListItem.Message) { - val isFromMe = - ClientManager.client.inboxId == item.message.senderInboxId - val params = binding.messageContainer.layoutParams as ConstraintLayout.LayoutParams - if (isFromMe) { - params.rightToRight = PARENT_ID - params.leftToLeft = UNSET - binding.messageRow.margins(left = marginLarge, right = marginSmall) - binding.messageContainer.setCardBackgroundColor(backgroundMe) - binding.messageBody.setTextColor(Color.BLACK) - } else { - params.leftToLeft = PARENT_ID - params.rightToRight = UNSET - binding.messageRow.margins(right = marginLarge, left = marginSmall) - binding.messageContainer.setCardBackgroundColor(backgroundPeer) - binding.messageBody.setTextColor(Color.WHITE) - } - binding.messageContainer.layoutParams = params - if (item.message.content() is String) { - binding.messageBody.text = item.message.body - val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) - binding.messageDate.text = sdf.format(item.message.sentAt) - } else if (item.message.content() is GroupUpdated) { - val changes = item.message.content() as? GroupUpdated - binding.messageBody.text = - "Membership Changed ${ - changes?.addedInboxesList?.mapNotNull { it.inboxId } - }" - } - } -} diff --git a/example/src/main/java/org/xmtp/android/example/message/ReceivedMessageViewHolder.kt b/example/src/main/java/org/xmtp/android/example/message/ReceivedMessageViewHolder.kt new file mode 100644 index 000000000..cfc90cbfe --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/message/ReceivedMessageViewHolder.kt @@ -0,0 +1,173 @@ +package org.xmtp.android.example.message + +import android.graphics.BitmapFactory +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import org.xmtp.android.example.conversation.ConversationDetailViewModel +import org.xmtp.android.example.databinding.ListItemMessageReceivedBinding +import org.xmtp.android.library.codecs.Attachment +import org.xmtp.android.library.codecs.DeletedMessage +import org.xmtp.android.library.codecs.Reaction +import org.xmtp.android.library.codecs.ReactionAction +import org.xmtp.android.library.libxmtp.Reply +import java.text.SimpleDateFormat +import java.util.Locale + +class ReceivedMessageViewHolder( + private val binding: ListItemMessageReceivedBinding, + private val clickListener: MessageClickListener? = null, +) : RecyclerView.ViewHolder(binding.root) { + private val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault()) + + fun bind( + item: ConversationDetailViewModel.MessageListItem.ReceivedMessage, + showSenderName: Boolean = false, + ) { + binding.messageContainer.setOnLongClickListener { + clickListener?.onMessageLongClick(item.message) + true + } + + val content = item.message.content() + + // Reset attachment visibility + binding.attachmentContainer.visibility = View.GONE + binding.fileAttachmentContainer.visibility = View.GONE + + when (content) { + is String -> { + binding.messageBody.text = content + binding.messageBody.visibility = View.VISIBLE + } + is Reaction -> { + binding.messageBody.text = "${content.content} (reaction)" + binding.messageBody.visibility = View.VISIBLE + } + is Attachment -> { + val isImage = content.mimeType.startsWith("image/") + if (isImage) { + // Display image attachment + binding.attachmentContainer.visibility = View.VISIBLE + binding.attachmentLoading.visibility = View.GONE + try { + val bytes = content.data.toByteArray() + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + binding.attachmentImage.setImageBitmap(bitmap) + } catch (e: Exception) { + binding.attachmentImage.setImageResource(android.R.drawable.ic_menu_gallery) + } + // Hide text body for image-only messages + binding.messageBody.visibility = View.GONE + } else { + // Display file attachment + binding.fileAttachmentContainer.visibility = View.VISIBLE + binding.fileName.text = content.filename + binding.fileSize.text = formatFileSize(content.data.size()) + binding.messageBody.visibility = View.GONE + } + } + is Reply -> { + val replyContent = content.content + val replyText = if (replyContent is String) replyContent else "Reply" + binding.messageBody.text = replyText + binding.messageBody.visibility = View.VISIBLE + + // Show reply container with original message info + binding.replyContainer.visibility = View.VISIBLE + + // Get original message info + val originalMessage = content.inReplyTo + if (originalMessage != null) { + val originalSender = originalMessage.senderInboxId.take(8) + "..." + val originalContent = originalMessage.content() + val originalText = + when (originalContent) { + is String -> originalContent + is DeletedMessage -> "🗑️ This message was deleted" + else -> originalMessage.fallbackText ?: "Message" + } + binding.replyAuthor.text = originalSender + binding.replyText.text = originalText + + // Disable click if message was deleted + if (originalContent is DeletedMessage) { + binding.replyContainer.setOnClickListener(null) + } else { + // Set click listener to jump to original message + binding.replyContainer.setOnClickListener { + clickListener?.onReplyClick(content.referenceId) + } + } + } else { + binding.replyAuthor.text = "Reply" + binding.replyText.text = "Original message" + binding.replyContainer.setOnClickListener(null) + } + } + else -> { + binding.messageBody.text = item.message.fallbackText ?: "Unknown content" + binding.messageBody.visibility = View.VISIBLE + } + } + + // Set time + binding.messageTime.text = timeFormat.format(item.message.sentAt) + + // Show sender name for group conversations + if (showSenderName) { + binding.senderName.visibility = View.VISIBLE + val senderId = item.message.senderInboxId + binding.senderName.text = senderId.take(8) + "..." + } else { + binding.senderName.visibility = View.GONE + } + + // Show reactions if present + if (item.message.hasReactions) { + val reactions = item.message.reactions + // Sort reactions by timestamp to ensure correct chronological processing + val sortedReactions = reactions.sortedBy { it.sentAtNs } + // Aggregate reactions: track adds and removes per sender+emoji + val activeReactions = mutableMapOf>() // emoji -> set of senderInboxIds + for (reactionMsg in sortedReactions) { + val reaction = reactionMsg.content() ?: continue + val emoji = reaction.content + val sender = reactionMsg.senderInboxId + when (reaction.action) { + ReactionAction.Added -> { + activeReactions.getOrPut(emoji) { mutableSetOf() }.add(sender) + } + ReactionAction.Removed -> { + activeReactions[emoji]?.remove(sender) + } + else -> {} + } + } + // Remove emojis with no active reactions + activeReactions.entries.removeAll { it.value.isEmpty() } + + if (activeReactions.isNotEmpty()) { + val totalCount = activeReactions.values.sumOf { it.size } + val displayEmojis = activeReactions.keys.take(3).joinToString("") + binding.messageReactions.visibility = View.VISIBLE + binding.messageReactions.text = if (totalCount > 1) "$displayEmojis $totalCount" else displayEmojis + } else { + binding.messageReactions.visibility = View.GONE + } + } else { + binding.messageReactions.visibility = View.GONE + } + + // Hide reply container by default unless it's a Reply + if (content !is Reply) { + binding.replyContainer.visibility = View.GONE + } + } + + private fun formatFileSize(bytes: Int): String = + when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "${bytes / 1024} KB" + else -> String.format(Locale.getDefault(), "%.1f MB", bytes / (1024.0 * 1024.0)) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/message/SentMessageViewHolder.kt b/example/src/main/java/org/xmtp/android/example/message/SentMessageViewHolder.kt new file mode 100644 index 000000000..1e1c2adc4 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/message/SentMessageViewHolder.kt @@ -0,0 +1,161 @@ +package org.xmtp.android.example.message + +import android.graphics.BitmapFactory +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import org.xmtp.android.example.conversation.ConversationDetailViewModel +import org.xmtp.android.example.databinding.ListItemMessageSentBinding +import org.xmtp.android.library.codecs.Attachment +import org.xmtp.android.library.codecs.DeletedMessage +import org.xmtp.android.library.codecs.Reaction +import org.xmtp.android.library.codecs.ReactionAction +import org.xmtp.android.library.libxmtp.Reply +import java.text.SimpleDateFormat +import java.util.Locale + +class SentMessageViewHolder( + private val binding: ListItemMessageSentBinding, + private val clickListener: MessageClickListener? = null, +) : RecyclerView.ViewHolder(binding.root) { + private val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault()) + + fun bind(item: ConversationDetailViewModel.MessageListItem.SentMessage) { + binding.messageContainer.setOnLongClickListener { + clickListener?.onMessageLongClick(item.message) + true + } + + val content = item.message.content() + + // Reset attachment visibility + binding.attachmentContainer.visibility = View.GONE + binding.fileAttachmentContainer.visibility = View.GONE + + when (content) { + is String -> { + binding.messageBody.text = content + binding.messageBody.visibility = View.VISIBLE + } + is Reaction -> { + binding.messageBody.text = "${content.content} (reaction)" + binding.messageBody.visibility = View.VISIBLE + } + is Attachment -> { + val isImage = content.mimeType.startsWith("image/") + if (isImage) { + // Display image attachment + binding.attachmentContainer.visibility = View.VISIBLE + binding.attachmentLoading.visibility = View.GONE + try { + val bytes = content.data.toByteArray() + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + binding.attachmentImage.setImageBitmap(bitmap) + } catch (e: Exception) { + binding.attachmentImage.setImageResource(android.R.drawable.ic_menu_gallery) + } + // Hide text body for image-only messages + binding.messageBody.visibility = View.GONE + } else { + // Display file attachment + binding.fileAttachmentContainer.visibility = View.VISIBLE + binding.fileName.text = content.filename + binding.fileSize.text = formatFileSize(content.data.size()) + binding.messageBody.visibility = View.GONE + } + } + is Reply -> { + val replyContent = content.content + val replyText = if (replyContent is String) replyContent else "Reply" + binding.messageBody.text = replyText + binding.messageBody.visibility = View.VISIBLE + + // Show reply container with original message info + binding.replyContainer.visibility = View.VISIBLE + + // Get original message info + val originalMessage = content.inReplyTo + if (originalMessage != null) { + val originalSender = originalMessage.senderInboxId.take(8) + "..." + val originalContent = originalMessage.content() + val originalText = + when (originalContent) { + is String -> originalContent + is DeletedMessage -> "🗑️ This message was deleted" + else -> originalMessage.fallbackText ?: "Message" + } + binding.replyAuthor.text = originalSender + binding.replyText.text = originalText + + // Disable click if message was deleted + if (originalContent is DeletedMessage) { + binding.replyContainer.setOnClickListener(null) + } else { + // Set click listener to jump to original message + binding.replyContainer.setOnClickListener { + clickListener?.onReplyClick(content.referenceId) + } + } + } else { + binding.replyAuthor.text = "Reply" + binding.replyText.text = "Original message" + binding.replyContainer.setOnClickListener(null) + } + } + else -> { + binding.messageBody.text = item.message.fallbackText ?: "Unknown content" + binding.messageBody.visibility = View.VISIBLE + } + } + + // Set time + binding.messageTime.text = timeFormat.format(item.message.sentAt) + + // Show reactions if present + if (item.message.hasReactions) { + val reactions = item.message.reactions + // Sort reactions by timestamp to ensure correct chronological processing + val sortedReactions = reactions.sortedBy { it.sentAtNs } + // Aggregate reactions: track adds and removes per sender+emoji + val activeReactions = mutableMapOf>() // emoji -> set of senderInboxIds + for (reactionMsg in sortedReactions) { + val reaction = reactionMsg.content() ?: continue + val emoji = reaction.content + val sender = reactionMsg.senderInboxId + when (reaction.action) { + ReactionAction.Added -> { + activeReactions.getOrPut(emoji) { mutableSetOf() }.add(sender) + } + ReactionAction.Removed -> { + activeReactions[emoji]?.remove(sender) + } + else -> {} + } + } + // Remove emojis with no active reactions + activeReactions.entries.removeAll { it.value.isEmpty() } + + if (activeReactions.isNotEmpty()) { + val totalCount = activeReactions.values.sumOf { it.size } + val displayEmojis = activeReactions.keys.take(3).joinToString("") + binding.messageReactions.visibility = View.VISIBLE + binding.messageReactions.text = if (totalCount > 1) "$displayEmojis $totalCount" else displayEmojis + } else { + binding.messageReactions.visibility = View.GONE + } + } else { + binding.messageReactions.visibility = View.GONE + } + + // Hide reply container by default unless it's a Reply + if (content !is Reply) { + binding.replyContainer.visibility = View.GONE + } + } + + private fun formatFileSize(bytes: Int): String = + when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "${bytes / 1024} KB" + else -> String.format(Locale.getDefault(), "%.1f MB", bytes / (1024.0 * 1024.0)) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/message/SystemMessageViewHolder.kt b/example/src/main/java/org/xmtp/android/example/message/SystemMessageViewHolder.kt new file mode 100644 index 000000000..df3926433 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/message/SystemMessageViewHolder.kt @@ -0,0 +1,22 @@ +package org.xmtp.android.example.message + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import org.xmtp.android.example.conversation.ConversationDetailViewModel +import org.xmtp.android.example.databinding.ListItemMessageSystemBinding +import java.text.SimpleDateFormat +import java.util.Locale + +class SystemMessageViewHolder( + private val binding: ListItemMessageSystemBinding, +) : RecyclerView.ViewHolder(binding.root) { + private val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault()) + + fun bind(item: ConversationDetailViewModel.MessageListItem.SystemMessage) { + binding.systemMessageText.text = item.text + + // Show time for system messages + binding.systemMessageTime.visibility = View.VISIBLE + binding.systemMessageTime.text = timeFormat.format(item.message.sentAt) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/utils/KeyUtil.kt b/example/src/main/java/org/xmtp/android/example/utils/KeyUtil.kt index 770b29b2b..11a0f95e5 100644 --- a/example/src/main/java/org/xmtp/android/example/utils/KeyUtil.kt +++ b/example/src/main/java/org/xmtp/android/example/utils/KeyUtil.kt @@ -2,20 +2,19 @@ package org.xmtp.android.example.utils import android.accounts.AccountManager import android.content.Context -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties import android.util.Base64.NO_WRAP import android.util.Base64.decode import android.util.Base64.encodeToString import org.xmtp.android.example.R -import java.security.KeyStore -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey class KeyUtil( val context: Context, ) { private val PREFS_NAME = "EncryptionPref" + private val PRIVATE_KEY_PREFS = "PrivateKeyPref" + private val SETTINGS_PREFS = "SettingsPref" + private val KEY_ENVIRONMENT = "xmtp_environment" + private val KEY_HIDE_DELETED_MESSAGES = "hide_deleted_messages" fun loadKeys(): String? { val accountManager = AccountManager.get(context) @@ -48,4 +47,63 @@ class KeyUtil( null } } + + // Store the wallet private key for signing + fun storePrivateKey( + address: String, + privateKeyBytes: ByteArray, + ) { + val alias = "xmtp-wallet-${address.lowercase()}" + val prefs = context.getSharedPreferences(PRIVATE_KEY_PREFS, Context.MODE_PRIVATE) + prefs.edit().putString(alias, encodeToString(privateKeyBytes, NO_WRAP)).apply() + } + + // Retrieve the wallet private key for signing + fun retrievePrivateKey(address: String): ByteArray? { + val alias = "xmtp-wallet-${address.lowercase()}" + val prefs = context.getSharedPreferences(PRIVATE_KEY_PREFS, Context.MODE_PRIVATE) + val keyString = prefs.getString(alias, null) + return if (keyString != null) { + decode(keyString, NO_WRAP) + } else { + null + } + } + + // Clear the wallet private key + fun clearPrivateKey(address: String) { + val alias = "xmtp-wallet-${address.lowercase()}" + val prefs = context.getSharedPreferences(PRIVATE_KEY_PREFS, Context.MODE_PRIVATE) + prefs.edit().remove(alias).apply() + } + + // Store the selected environment + fun storeEnvironment(environment: String) { + val prefs = context.getSharedPreferences(SETTINGS_PREFS, Context.MODE_PRIVATE) + prefs.edit().putString(KEY_ENVIRONMENT, environment).apply() + } + + // Retrieve the selected environment + fun retrieveEnvironment(): String? { + val prefs = context.getSharedPreferences(SETTINGS_PREFS, Context.MODE_PRIVATE) + return prefs.getString(KEY_ENVIRONMENT, null) + } + + // Clear the environment setting + fun clearEnvironment() { + val prefs = context.getSharedPreferences(SETTINGS_PREFS, Context.MODE_PRIVATE) + prefs.edit().remove(KEY_ENVIRONMENT).apply() + } + + // Store hide deleted messages setting + fun setHideDeletedMessages(hide: Boolean) { + val prefs = context.getSharedPreferences(SETTINGS_PREFS, Context.MODE_PRIVATE) + prefs.edit().putBoolean(KEY_HIDE_DELETED_MESSAGES, hide).apply() + } + + // Retrieve hide deleted messages setting + fun getHideDeletedMessages(): Boolean { + val prefs = context.getSharedPreferences(SETTINGS_PREFS, Context.MODE_PRIVATE) + return prefs.getBoolean(KEY_HIDE_DELETED_MESSAGES, false) + } } diff --git a/example/src/main/java/org/xmtp/android/example/wallet/WalletInfoBottomSheet.kt b/example/src/main/java/org/xmtp/android/example/wallet/WalletInfoBottomSheet.kt new file mode 100644 index 000000000..bca24269c --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/wallet/WalletInfoBottomSheet.kt @@ -0,0 +1,122 @@ +package org.xmtp.android.example.wallet + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.TextView +import android.widget.Toast +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.button.MaterialButton +import com.google.android.material.switchmaterial.SwitchMaterial +import org.xmtp.android.example.ClientManager +import org.xmtp.android.example.R + +class WalletInfoBottomSheet : BottomSheetDialogFragment() { + interface WalletInfoListener { + fun onLogsToggled(enabled: Boolean) + + fun onDisconnectClicked() + + fun isLogsEnabled(): Boolean + } + + private var listener: WalletInfoListener? = null + + companion object { + const val TAG = "WalletInfoBottomSheet" + + fun newInstance(): WalletInfoBottomSheet = WalletInfoBottomSheet() + } + + override fun onAttach(context: Context) { + super.onAttach(context) + listener = context as? WalletInfoListener + } + + override fun onDetach() { + super.onDetach() + listener = null + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? = inflater.inflate(R.layout.bottom_sheet_wallet_info, container, false) + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + val client = ClientManager.client + + // Populate wallet info + view.findViewById(R.id.walletAddressValue).text = + client.publicIdentity.identifier + view.findViewById(R.id.inboxIdValue).text = + client.inboxId + view.findViewById(R.id.installationIdValue).text = + client.installationId + view.findViewById(R.id.environmentValue).text = + client.environment.name + view.findViewById(R.id.libxmtpVersionValue).text = + client.libXMTPVersion + + // Setup copy button click listeners + view.findViewById(R.id.copyWalletAddress).setOnClickListener { + copyToClipboard( + getString(R.string.wallet_address_label), + client.publicIdentity.identifier, + ) + } + + view.findViewById(R.id.copyInboxId).setOnClickListener { + copyToClipboard(getString(R.string.inbox_id_label), client.inboxId) + } + + view.findViewById(R.id.copyInstallationId).setOnClickListener { + copyToClipboard(getString(R.string.installation_id_label), client.installationId) + } + + view.findViewById(R.id.copyEnvironment).setOnClickListener { + copyToClipboard(getString(R.string.environment_label), client.environment.name) + } + + view.findViewById(R.id.copyLibxmtpVersion).setOnClickListener { + copyToClipboard(getString(R.string.libxmtp_version_label), client.libXMTPVersion) + } + + // Setup logs toggle + val logsSwitch = view.findViewById(R.id.logsSwitch) + logsSwitch.isChecked = listener?.isLogsEnabled() ?: false + logsSwitch.setOnCheckedChangeListener { _, isChecked -> + listener?.onLogsToggled(isChecked) + } + + // Setup disconnect button + view.findViewById(R.id.disconnectButton).setOnClickListener { + dismiss() + listener?.onDisconnectClicked() + } + } + + private fun copyToClipboard( + label: String, + value: String, + ) { + val clipboard = + requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(label, value) + clipboard.setPrimaryClip(clip) + Toast + .makeText(requireContext(), getString(R.string.copied_to_clipboard, label), Toast.LENGTH_SHORT) + .show() + } +} diff --git a/example/src/main/res/color/switch_thumb_color.xml b/example/src/main/res/color/switch_thumb_color.xml new file mode 100644 index 000000000..c1db29c52 --- /dev/null +++ b/example/src/main/res/color/switch_thumb_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/example/src/main/res/color/switch_track_color.xml b/example/src/main/res/color/switch_track_color.xml new file mode 100644 index 000000000..9ab59648f --- /dev/null +++ b/example/src/main/res/color/switch_track_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/example/src/main/res/drawable/bottom_sheet_handle.xml b/example/src/main/res/drawable/bottom_sheet_handle.xml new file mode 100644 index 000000000..a6b72dbc2 --- /dev/null +++ b/example/src/main/res/drawable/bottom_sheet_handle.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/example/src/main/res/drawable/drawer_avatar_background.xml b/example/src/main/res/drawable/drawer_avatar_background.xml new file mode 100644 index 000000000..bc4f0561d --- /dev/null +++ b/example/src/main/res/drawable/drawer_avatar_background.xml @@ -0,0 +1,5 @@ + + + + diff --git a/example/src/main/res/drawable/file_attachment_background.xml b/example/src/main/res/drawable/file_attachment_background.xml new file mode 100644 index 000000000..930de6330 --- /dev/null +++ b/example/src/main/res/drawable/file_attachment_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/example/src/main/res/drawable/file_attachment_background_received.xml b/example/src/main/res/drawable/file_attachment_background_received.xml new file mode 100644 index 000000000..678d124ac --- /dev/null +++ b/example/src/main/res/drawable/file_attachment_background_received.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/example/src/main/res/drawable/ic_account_circle_24.xml b/example/src/main/res/drawable/ic_account_circle_24.xml new file mode 100644 index 000000000..3cbd3c447 --- /dev/null +++ b/example/src/main/res/drawable/ic_account_circle_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_arrow_back_24.xml b/example/src/main/res/drawable/ic_arrow_back_24.xml new file mode 100644 index 000000000..bab545a70 --- /dev/null +++ b/example/src/main/res/drawable/ic_arrow_back_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/example/src/main/res/drawable/ic_attach_file_24.xml b/example/src/main/res/drawable/ic_attach_file_24.xml new file mode 100644 index 000000000..b2943e262 --- /dev/null +++ b/example/src/main/res/drawable/ic_attach_file_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_bug_report_24.xml b/example/src/main/res/drawable/ic_bug_report_24.xml new file mode 100644 index 000000000..5e360b2e3 --- /dev/null +++ b/example/src/main/res/drawable/ic_bug_report_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_camera_24.xml b/example/src/main/res/drawable/ic_camera_24.xml new file mode 100644 index 000000000..0b57fb0aa --- /dev/null +++ b/example/src/main/res/drawable/ic_camera_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/example/src/main/res/drawable/ic_check_24.xml b/example/src/main/res/drawable/ic_check_24.xml new file mode 100644 index 000000000..0432fa69b --- /dev/null +++ b/example/src/main/res/drawable/ic_check_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/example/src/main/res/drawable/ic_check_double.xml b/example/src/main/res/drawable/ic_check_double.xml new file mode 100644 index 000000000..442a05083 --- /dev/null +++ b/example/src/main/res/drawable/ic_check_double.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/example/src/main/res/drawable/ic_close_24.xml b/example/src/main/res/drawable/ic_close_24.xml new file mode 100644 index 000000000..d53ab5e47 --- /dev/null +++ b/example/src/main/res/drawable/ic_close_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_content_copy_24.xml b/example/src/main/res/drawable/ic_content_copy_24.xml new file mode 100644 index 000000000..8bf74a77d --- /dev/null +++ b/example/src/main/res/drawable/ic_content_copy_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/example/src/main/res/drawable/ic_copy_24.xml b/example/src/main/res/drawable/ic_copy_24.xml new file mode 100644 index 000000000..e03f61805 --- /dev/null +++ b/example/src/main/res/drawable/ic_copy_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_delete_24.xml b/example/src/main/res/drawable/ic_delete_24.xml new file mode 100644 index 000000000..3ce7304db --- /dev/null +++ b/example/src/main/res/drawable/ic_delete_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_emoji_24.xml b/example/src/main/res/drawable/ic_emoji_24.xml new file mode 100644 index 000000000..c636d5bcd --- /dev/null +++ b/example/src/main/res/drawable/ic_emoji_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_gif_24.xml b/example/src/main/res/drawable/ic_gif_24.xml new file mode 100644 index 000000000..9882f69ae --- /dev/null +++ b/example/src/main/res/drawable/ic_gif_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/example/src/main/res/drawable/ic_group_24.xml b/example/src/main/res/drawable/ic_group_24.xml new file mode 100644 index 000000000..3436691a2 --- /dev/null +++ b/example/src/main/res/drawable/ic_group_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/example/src/main/res/drawable/ic_image_24.xml b/example/src/main/res/drawable/ic_image_24.xml new file mode 100644 index 000000000..8f7314354 --- /dev/null +++ b/example/src/main/res/drawable/ic_image_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/example/src/main/res/drawable/ic_info_24.xml b/example/src/main/res/drawable/ic_info_24.xml new file mode 100644 index 000000000..2096c119b --- /dev/null +++ b/example/src/main/res/drawable/ic_info_24.xml @@ -0,0 +1,11 @@ + + + + diff --git a/example/src/main/res/drawable/ic_keyboard_24.xml b/example/src/main/res/drawable/ic_keyboard_24.xml new file mode 100644 index 000000000..45da6b6b1 --- /dev/null +++ b/example/src/main/res/drawable/ic_keyboard_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_logout_24.xml b/example/src/main/res/drawable/ic_logout_24.xml new file mode 100644 index 000000000..86971585a --- /dev/null +++ b/example/src/main/res/drawable/ic_logout_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_menu_24.xml b/example/src/main/res/drawable/ic_menu_24.xml new file mode 100644 index 000000000..9dc1fca8a --- /dev/null +++ b/example/src/main/res/drawable/ic_menu_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_mic_24.xml b/example/src/main/res/drawable/ic_mic_24.xml new file mode 100644 index 000000000..953071839 --- /dev/null +++ b/example/src/main/res/drawable/ic_mic_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_more_vert_24.xml b/example/src/main/res/drawable/ic_more_vert_24.xml new file mode 100644 index 000000000..3b2167ece --- /dev/null +++ b/example/src/main/res/drawable/ic_more_vert_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_reply_24.xml b/example/src/main/res/drawable/ic_reply_24.xml new file mode 100644 index 000000000..5514cbf4e --- /dev/null +++ b/example/src/main/res/drawable/ic_reply_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_visibility_off_24.xml b/example/src/main/res/drawable/ic_visibility_off_24.xml new file mode 100644 index 000000000..a91f4ec69 --- /dev/null +++ b/example/src/main/res/drawable/ic_visibility_off_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/message_bubble_received.xml b/example/src/main/res/drawable/message_bubble_received.xml new file mode 100644 index 000000000..08d766949 --- /dev/null +++ b/example/src/main/res/drawable/message_bubble_received.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/drawable/message_bubble_sent.xml b/example/src/main/res/drawable/message_bubble_sent.xml new file mode 100644 index 000000000..7b127a3f7 --- /dev/null +++ b/example/src/main/res/drawable/message_bubble_sent.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/drawable/reaction_button_background.xml b/example/src/main/res/drawable/reaction_button_background.xml new file mode 100644 index 000000000..bd49c6748 --- /dev/null +++ b/example/src/main/res/drawable/reaction_button_background.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/example/src/main/res/drawable/reaction_button_selected_background.xml b/example/src/main/res/drawable/reaction_button_selected_background.xml new file mode 100644 index 000000000..9be313eb5 --- /dev/null +++ b/example/src/main/res/drawable/reaction_button_selected_background.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/example/src/main/res/drawable/reply_background.xml b/example/src/main/res/drawable/reply_background.xml new file mode 100644 index 000000000..ccba3eced --- /dev/null +++ b/example/src/main/res/drawable/reply_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/example/src/main/res/drawable/reply_background_received.xml b/example/src/main/res/drawable/reply_background_received.xml new file mode 100644 index 000000000..53bef7468 --- /dev/null +++ b/example/src/main/res/drawable/reply_background_received.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/drawable/reply_background_sent.xml b/example/src/main/res/drawable/reply_background_sent.xml new file mode 100644 index 000000000..04bf82d2a --- /dev/null +++ b/example/src/main/res/drawable/reply_background_sent.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/drawable/system_message_background.xml b/example/src/main/res/drawable/system_message_background.xml new file mode 100644 index 000000000..effd73d9e --- /dev/null +++ b/example/src/main/res/drawable/system_message_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/example/src/main/res/layout/activity_conversation_detail.xml b/example/src/main/res/layout/activity_conversation_detail.xml index 2c2eb9fff..7d855fd0d 100644 --- a/example/src/main/res/layout/activity_conversation_detail.xml +++ b/example/src/main/res/layout/activity_conversation_detail.xml @@ -1,71 +1,308 @@ + android:layout_height="match_parent" + android:background="@color/chat_background"> - + + app:layout_constraintTop_toTopOf="parent"> - + + + + + + + + + + + + + + + + + + + + + + + - + android:orientation="vertical"> - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + app:layout_constraintTop_toBottomOf="@id/headerBar"> + android:layout_height="match_parent" + android:clipToPadding="false" + android:paddingVertical="8dp" /> @@ -78,6 +315,6 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/toolbar" /> + app:layout_constraintTop_toBottomOf="@id/headerBar" /> diff --git a/example/src/main/res/layout/activity_group_management.xml b/example/src/main/res/layout/activity_group_management.xml new file mode 100644 index 000000000..ef49e0d38 --- /dev/null +++ b/example/src/main/res/layout/activity_group_management.xml @@ -0,0 +1,255 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/layout/activity_main.xml b/example/src/main/res/layout/activity_main.xml index da5be9a7e..4f2e762bc 100644 --- a/example/src/main/res/layout/activity_main.xml +++ b/example/src/main/res/layout/activity_main.xml @@ -1,82 +1,84 @@ - + android:layout_height="match_parent" + android:fitsSystemWindows="true"> - + + - + - + - + - + - + - + - + + + + - + diff --git a/example/src/main/res/layout/activity_new_conversation.xml b/example/src/main/res/layout/activity_new_conversation.xml new file mode 100644 index 000000000..fdce3efc7 --- /dev/null +++ b/example/src/main/res/layout/activity_new_conversation.xml @@ -0,0 +1,301 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/layout/activity_user_profile.xml b/example/src/main/res/layout/activity_user_profile.xml new file mode 100644 index 000000000..dec46c43f --- /dev/null +++ b/example/src/main/res/layout/activity_user_profile.xml @@ -0,0 +1,253 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/layout/bottom_sheet_new_conversation.xml b/example/src/main/res/layout/bottom_sheet_new_conversation.xml index 9c1b0b504..787c2cf5f 100644 --- a/example/src/main/res/layout/bottom_sheet_new_conversation.xml +++ b/example/src/main/res/layout/bottom_sheet_new_conversation.xml @@ -1,40 +1,161 @@ - + android:layout_height="wrap_content" + android:background="@color/surface"> - + + + + + + - - - - + + + + + + android:padding="4dp"> + + - + - + + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/layout/bottom_sheet_new_group.xml b/example/src/main/res/layout/bottom_sheet_new_group.xml index 3e9cfed93..7e250a4b4 100644 --- a/example/src/main/res/layout/bottom_sheet_new_group.xml +++ b/example/src/main/res/layout/bottom_sheet_new_group.xml @@ -1,56 +1,190 @@ - + android:layout_height="wrap_content" + android:background="@color/surface"> - + + + + - - - - - - + + + + + + + + + + + android:padding="4dp"> + + - + - + + + + + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/layout/bottom_sheet_new_message.xml b/example/src/main/res/layout/bottom_sheet_new_message.xml new file mode 100644 index 000000000..5b6803d27 --- /dev/null +++ b/example/src/main/res/layout/bottom_sheet_new_message.xml @@ -0,0 +1,223 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/layout/bottom_sheet_wallet_info.xml b/example/src/main/res/layout/bottom_sheet_wallet_info.xml new file mode 100644 index 000000000..4c74fa733 --- /dev/null +++ b/example/src/main/res/layout/bottom_sheet_wallet_info.xml @@ -0,0 +1,341 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/layout/dialog_attachment_picker.xml b/example/src/main/res/layout/dialog_attachment_picker.xml new file mode 100644 index 000000000..575d489aa --- /dev/null +++ b/example/src/main/res/layout/dialog_attachment_picker.xml @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/layout/dialog_message_options.xml b/example/src/main/res/layout/dialog_message_options.xml new file mode 100644 index 000000000..4ac6cb71e --- /dev/null +++ b/example/src/main/res/layout/dialog_message_options.xml @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/layout/fragment_connect_wallet.xml b/example/src/main/res/layout/fragment_connect_wallet.xml index 8d93ca824..d693d3c30 100644 --- a/example/src/main/res/layout/fragment_connect_wallet.xml +++ b/example/src/main/res/layout/fragment_connect_wallet.xml @@ -17,6 +17,50 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + + + + + + + + android:background="?attr/selectableItemBackground" + android:paddingHorizontal="16dp" + android:paddingVertical="12dp"> + + + + + + + + + + + + app:layout_constraintTop_toTopOf="@id/peerAddress" + app:layout_constraintBottom_toBottomOf="@id/peerAddress" + tools:text="12:34" /> + + app:layout_constraintStart_toEndOf="@id/avatarCard" + app:layout_constraintTop_toBottomOf="@id/peerAddress" + tools:text="Last message preview..." /> diff --git a/example/src/main/res/layout/list_item_conversation_footer.xml b/example/src/main/res/layout/list_item_conversation_footer.xml deleted file mode 100644 index 90a625375..000000000 --- a/example/src/main/res/layout/list_item_conversation_footer.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/example/src/main/res/layout/list_item_emoji.xml b/example/src/main/res/layout/list_item_emoji.xml new file mode 100644 index 000000000..53564b9dd --- /dev/null +++ b/example/src/main/res/layout/list_item_emoji.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/example/src/main/res/layout/list_item_member.xml b/example/src/main/res/layout/list_item_member.xml new file mode 100644 index 000000000..5ec669ca4 --- /dev/null +++ b/example/src/main/res/layout/list_item_member.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/layout/list_item_message_received.xml b/example/src/main/res/layout/list_item_message_received.xml new file mode 100644 index 000000000..b8f9276bc --- /dev/null +++ b/example/src/main/res/layout/list_item_message_received.xml @@ -0,0 +1,216 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/layout/list_item_message_sent.xml b/example/src/main/res/layout/list_item_message_sent.xml new file mode 100644 index 000000000..f5ec9deb0 --- /dev/null +++ b/example/src/main/res/layout/list_item_message_sent.xml @@ -0,0 +1,214 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/layout/list_item_message_system.xml b/example/src/main/res/layout/list_item_message_system.xml new file mode 100644 index 000000000..96784a282 --- /dev/null +++ b/example/src/main/res/layout/list_item_message_system.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + diff --git a/example/src/main/res/layout/list_item_recent_contact.xml b/example/src/main/res/layout/list_item_recent_contact.xml new file mode 100644 index 000000000..3eda3f3a6 --- /dev/null +++ b/example/src/main/res/layout/list_item_recent_contact.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/layout/nav_drawer_header.xml b/example/src/main/res/layout/nav_drawer_header.xml new file mode 100644 index 000000000..027b7755e --- /dev/null +++ b/example/src/main/res/layout/nav_drawer_header.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + diff --git a/example/src/main/res/menu/menu_conversation_detail.xml b/example/src/main/res/menu/menu_conversation_detail.xml new file mode 100644 index 000000000..f55261859 --- /dev/null +++ b/example/src/main/res/menu/menu_conversation_detail.xml @@ -0,0 +1,9 @@ + + + + diff --git a/example/src/main/res/menu/menu_drawer.xml b/example/src/main/res/menu/menu_drawer.xml new file mode 100644 index 000000000..f68990e7e --- /dev/null +++ b/example/src/main/res/menu/menu_drawer.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/menu/menu_member.xml b/example/src/main/res/menu/menu_member.xml new file mode 100644 index 000000000..82b8c272a --- /dev/null +++ b/example/src/main/res/menu/menu_member.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/example/src/main/res/values/colors.xml b/example/src/main/res/values/colors.xml index f8c6127d3..4c7738c0a 100644 --- a/example/src/main/res/values/colors.xml +++ b/example/src/main/res/values/colors.xml @@ -1,5 +1,49 @@ + + #FC4F37 + #E0422D + #FF7A68 + + + #FFFFFF + #F5F5F5 + #FAFAFA + + + #1A1A1A + #666666 + #999999 + #FFFFFF + + + #34C759 + #FF9500 + #FF3B30 + #5856D6 + + + #E0E0E0 + + + #EFFDDE + #1A1A1A + #6FB87C + #FFFFFF + #1A1A1A + #8E8E93 + #E5DDD5 + + + #4CAF50 + #4A635B + #FC4F37 + #666666 + + + #FC4F37 + + #FFBB86FC #FF6200EE #FF3700B3 @@ -7,4 +51,4 @@ #FF018786 #FF000000 #FFFFFFFF - \ No newline at end of file + diff --git a/example/src/main/res/values/strings.xml b/example/src/main/res/values/strings.xml index dbb35948f..e8ce879f5 100644 --- a/example/src/main/res/values/strings.xml +++ b/example/src/main/res/values/strings.xml @@ -7,20 +7,124 @@ Generate wallet Connect wallet No wallet apps installed + Select Environment + Production + Dev + + Dev + Production + + Log Level + + Off + Error + Warn + Info + Debug + Trace + Disconnect wallet Copy address + + Wallet Information + Wallet Address + Inbox ID + Installation ID + Environment + LibXMTP Version + Copy Inbox ID + Copy Installation ID + Copy Environment + Copy LibXMTP Version + %1$s copied + Signed in as %1$s on %2$s - New message + New Message + New Message + Start a new conversation + Start a conversation with someone Enter Ethereum address + 0x… + Enter a valid Ethereum address (0x followed by 40 characters) + Start Conversation Create conversation No messages yet You: %1$s XMTP Direct Message + Direct Message + Group Chat + Info + Loading… + + + New Group + Add members to create a group chat + Add member + Add + %1$d member(s) added + Create Group + Add another member + Add at least 1 member to create a group + Invalid Ethereum address + Group name (optional) + Recent Contacts + Clear + Last messaged %1$s + + + Group Info + Group Details + Created + Members + %1$d members + Your Role + Members + Leave Group + Are you sure you want to leave this group? + Leave + Super Admin + Admin + Member + Inbox: %1$s + Promote to Admin + Demote from Admin + Remove Member + Promote + Demote + Remove + Promote %1$s to admin? + Demote %1$s from admin? + Remove %1$s from the group? + + + Profile + User Details + Linked Identities + Send Message + Unknown - Type a message… + Message + Reply + Delete + Replying to %1$s + Emoji + Attach file + Voice message + + + Send Attachment + Camera + Gallery + File + GIF + Sending attachment… + File is too large (max 10MB) + Failed to send attachment + Loading… + Download View Logs @@ -28,5 +132,20 @@ No log files found Activate Persistent Logs Deactivate Persistent Logs - + + + Settings + Persistent Logs + Enable debug logging to file + + + New Conversation + New Group + Settings + Logs + Account + Open navigation drawer + Close navigation drawer + Hide Deleted Messages + diff --git a/example/src/main/res/values/themes.xml b/example/src/main/res/values/themes.xml index 88ae2efc5..52065ef66 100644 --- a/example/src/main/res/values/themes.xml +++ b/example/src/main/res/values/themes.xml @@ -1,5 +1,68 @@ - + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/xml/file_paths.xml b/example/src/main/res/xml/file_paths.xml index a0f87037d..b8353e1a2 100644 --- a/example/src/main/res/xml/file_paths.xml +++ b/example/src/main/res/xml/file_paths.xml @@ -1,4 +1,5 @@ + \ No newline at end of file From 908aa57f42697eaf4336d7d764f505bd82aa27ef Mon Sep 17 00:00:00 2001 From: mchenani Date: Tue, 20 Jan 2026 20:02:02 +0100 Subject: [PATCH 4/8] add edit message and convert designs to compose --- build.gradle | 1 + example/build.gradle | 28 ++ example/src/main/AndroidManifest.xml | 4 + .../org/xmtp/android/example/ClientManager.kt | 16 +- .../org/xmtp/android/example/MainActivity.kt | 19 +- .../org/xmtp/android/example/MainViewModel.kt | 40 +- .../conversation/AttachmentPreviewActivity.kt | 241 +++++++++ .../ConversationDetailActivity.kt | 458 ++++++++++++++++-- .../ConversationDetailViewModel.kt | 145 ++++-- .../conversation/ConversationViewHolder.kt | 19 +- .../example/extension/FlowExtension.kt | 2 +- .../message/ReceivedMessageViewHolder.kt | 76 ++- .../example/message/SearchResultAdapter.kt | 149 ++++++ .../example/message/SentMessageViewHolder.kt | 76 ++- .../android/example/ui/components/Avatar.kt | 102 ++++ .../example/ui/components/ConversationRow.kt | 198 ++++++++ .../example/ui/components/MessageBubble.kt | 334 +++++++++++++ .../example/ui/components/MessageComposer.kt | 296 +++++++++++ .../example/ui/components/SearchBar.kt | 370 ++++++++++++++ .../example/ui/navigation/AppNavigation.kt | 187 +++++++ .../android/example/ui/screens/ChatScreen.kt | 386 +++++++++++++++ .../android/example/ui/screens/HomeScreen.kt | 225 +++++++++ .../example/ui/screens/ProfileScreen.kt | 242 +++++++++ .../example/ui/screens/SettingsScreen.kt | 313 ++++++++++++ .../xmtp/android/example/ui/theme/Color.kt | 39 ++ .../xmtp/android/example/ui/theme/Theme.kt | 76 +++ .../org/xmtp/android/example/ui/theme/Type.kt | 115 +++++ .../org/xmtp/android/example/utils/KeyUtil.kt | 151 ++++-- example/src/main/res/drawable/ic_audio_24.xml | 10 + example/src/main/res/drawable/ic_edit_24.xml | 10 + example/src/main/res/drawable/ic_pdf_24.xml | 10 + .../src/main/res/drawable/ic_search_24.xml | 10 + example/src/main/res/drawable/ic_video_24.xml | 10 + .../main/res/drawable/thumbnail_border.xml | 8 + .../layout/activity_attachment_preview.xml | 166 +++++++ .../layout/activity_conversation_detail.xml | 249 +++++++++- .../res/layout/dialog_message_options.xml | 40 +- .../main/res/layout/item_search_result.xml | 77 +++ .../res/layout/list_item_message_received.xml | 64 ++- .../res/layout/list_item_message_sent.xml | 92 +++- example/src/main/res/values/dimens.xml | 1 + example/src/main/res/values/strings.xml | 11 + example/src/main/res/values/themes.xml | 7 + .../xmtp/android/library/DeleteMessageTest.kt | 5 +- .../xmtp/android/library/EditMessageTest.kt | 269 ++++++++++ 45 files changed, 5100 insertions(+), 247 deletions(-) create mode 100644 example/src/main/java/org/xmtp/android/example/conversation/AttachmentPreviewActivity.kt create mode 100644 example/src/main/java/org/xmtp/android/example/message/SearchResultAdapter.kt create mode 100644 example/src/main/java/org/xmtp/android/example/ui/components/Avatar.kt create mode 100644 example/src/main/java/org/xmtp/android/example/ui/components/ConversationRow.kt create mode 100644 example/src/main/java/org/xmtp/android/example/ui/components/MessageBubble.kt create mode 100644 example/src/main/java/org/xmtp/android/example/ui/components/MessageComposer.kt create mode 100644 example/src/main/java/org/xmtp/android/example/ui/components/SearchBar.kt create mode 100644 example/src/main/java/org/xmtp/android/example/ui/navigation/AppNavigation.kt create mode 100644 example/src/main/java/org/xmtp/android/example/ui/screens/ChatScreen.kt create mode 100644 example/src/main/java/org/xmtp/android/example/ui/screens/HomeScreen.kt create mode 100644 example/src/main/java/org/xmtp/android/example/ui/screens/ProfileScreen.kt create mode 100644 example/src/main/java/org/xmtp/android/example/ui/screens/SettingsScreen.kt create mode 100644 example/src/main/java/org/xmtp/android/example/ui/theme/Color.kt create mode 100644 example/src/main/java/org/xmtp/android/example/ui/theme/Theme.kt create mode 100644 example/src/main/java/org/xmtp/android/example/ui/theme/Type.kt create mode 100644 example/src/main/res/drawable/ic_audio_24.xml create mode 100644 example/src/main/res/drawable/ic_edit_24.xml create mode 100644 example/src/main/res/drawable/ic_pdf_24.xml create mode 100644 example/src/main/res/drawable/ic_search_24.xml create mode 100644 example/src/main/res/drawable/ic_video_24.xml create mode 100644 example/src/main/res/drawable/thumbnail_border.xml create mode 100644 example/src/main/res/layout/activity_attachment_preview.xml create mode 100644 example/src/main/res/layout/item_search_result.xml create mode 100644 library/src/androidTest/java/org/xmtp/android/library/EditMessageTest.kt diff --git a/build.gradle b/build.gradle index ee4b95d2f..9b8bce262 100644 --- a/build.gradle +++ b/build.gradle @@ -12,6 +12,7 @@ plugins { id 'com.android.application' version '8.9.1' apply false id 'com.android.library' version '8.9.1' apply false id 'org.jetbrains.kotlin.android' version '2.0.0' apply false + id 'org.jetbrains.kotlin.plugin.compose' version '2.0.0' apply false id 'io.github.gradle-nexus.publish-plugin' version "1.2.0" id "org.jetbrains.dokka" version "1.8.10" id 'com.diffplug.spotless' version '8.0.0' apply false diff --git a/example/build.gradle b/example/build.gradle index 35f4b8a02..22f62d66d 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -8,6 +8,7 @@ buildscript { plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.plugin.compose' // id 'com.google.gms.google-services' } @@ -45,9 +46,14 @@ android { buildFeatures { viewBinding true buildConfig true + compose true } } +composeCompiler { + reportsDestination = layout.buildDirectory.dir("compose_compiler") +} + dependencies { implementation project(':library') implementation 'androidx.core:core-ktx:1.12.0' @@ -64,6 +70,28 @@ dependencies { implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'org.web3j:crypto:5.0.0' + // Security - Encrypted SharedPreferences + implementation 'androidx.security:security-crypto:1.1.0-alpha06' + + // Glide for GIF support + implementation 'com.github.bumptech.glide:glide:4.16.0' + + // Jetpack Compose + def composeBom = platform('androidx.compose:compose-bom:2024.02.00') + implementation composeBom + androidTestImplementation composeBom + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.ui:ui-graphics' + implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.material:material-icons-extended' + implementation 'androidx.activity:activity-compose:1.8.2' + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0' + implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.7.0' + implementation 'androidx.navigation:navigation-compose:2.7.6' + debugImplementation 'androidx.compose.ui:ui-tooling' + debugImplementation 'androidx.compose.ui:ui-test-manifest' + // WalletConnect V2: core library + WalletConnectModal implementation(platform("com.walletconnect:android-bom:1.19.1")) implementation("com.walletconnect:android-core") diff --git a/example/src/main/AndroidManifest.xml b/example/src/main/AndroidManifest.xml index ba5a8329c..bded0e098 100644 --- a/example/src/main/AndroidManifest.xml +++ b/example/src/main/AndroidManifest.xml @@ -72,6 +72,10 @@ + = - runBlocking { - when (conversation) { - is Conversation.Group -> { - val groupName = conversation.group.name() - val displayName = if (groupName.isNotBlank()) groupName else conversation.id - Pair(displayName, null) - } - is Conversation.Dm -> { - val peerInboxId = conversation.dm.peerInboxId - val members = conversation.dm.members() - val peerMember = members.find { it.inboxId == peerInboxId } - val peerAddress = peerMember?.identities?.firstOrNull()?.identifier - val displayName = peerAddress ?: conversation.id - Pair(displayName, peerAddress) - } + private suspend fun fetchMostRecentMessage(conversation: Conversation): DecodedMessage? = + conversation.lastMessage() + + private suspend fun getConversationDisplayInfo(conversation: Conversation): Pair = + when (conversation) { + is Conversation.Group -> { + val groupName = conversation.group.name() + val displayName = if (groupName.isNotBlank()) groupName else conversation.id + Pair(displayName, null) + } + is Conversation.Dm -> { + val peerInboxId = conversation.dm.peerInboxId + val members = conversation.dm.members() + val peerMember = members.find { it.inboxId == peerInboxId } + val peerAddress = peerMember?.identities?.firstOrNull()?.identifier + val displayName = peerAddress ?: conversation.id + Pair(displayName, peerAddress) } } diff --git a/example/src/main/java/org/xmtp/android/example/conversation/AttachmentPreviewActivity.kt b/example/src/main/java/org/xmtp/android/example/conversation/AttachmentPreviewActivity.kt new file mode 100644 index 000000000..ebb67daae --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/conversation/AttachmentPreviewActivity.kt @@ -0,0 +1,241 @@ +package org.xmtp.android.example.conversation + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Bundle +import android.text.format.Formatter +import android.view.LayoutInflater +import android.view.View +import android.widget.ImageView +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.widget.addTextChangedListener +import androidx.viewpager2.widget.ViewPager2 +import org.xmtp.android.example.R +import org.xmtp.android.example.databinding.ActivityAttachmentPreviewBinding + +/** + * Telegram-style attachment preview activity. + * Shows selected attachments with caption input before sending. + */ +class AttachmentPreviewActivity : AppCompatActivity() { + + private lateinit var binding: ActivityAttachmentPreviewBinding + + private val attachmentUris = mutableListOf() + private val captions = mutableMapOf() + private var currentIndex = 0 + + companion object { + private const val EXTRA_ATTACHMENT_URIS = "attachment_uris" + const val RESULT_ATTACHMENTS = "result_attachments" + const val RESULT_CAPTIONS = "result_captions" + + fun intent(context: Context, uris: List): Intent { + return Intent(context, AttachmentPreviewActivity::class.java).apply { + putParcelableArrayListExtra(EXTRA_ATTACHMENT_URIS, ArrayList(uris)) + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityAttachmentPreviewBinding.inflate(layoutInflater) + setContentView(binding.root) + + // Get attachment URIs from intent + val uris = intent.getParcelableArrayListExtra(EXTRA_ATTACHMENT_URIS) + if (uris.isNullOrEmpty()) { + finish() + return + } + attachmentUris.addAll(uris) + + setupUI() + displayAttachment(0) + } + + private fun setupUI() { + // Close button + binding.closeButton.setOnClickListener { + setResult(Activity.RESULT_CANCELED) + finish() + } + + // Send button + binding.sendButton.setOnClickListener { + // Save current caption before sending + saveCaptionForCurrentIndex() + + val resultIntent = Intent().apply { + putParcelableArrayListExtra(RESULT_ATTACHMENTS, ArrayList(attachmentUris)) + putStringArrayListExtra(RESULT_CAPTIONS, ArrayList( + attachmentUris.indices.map { captions[it] ?: "" } + )) + } + setResult(Activity.RESULT_OK, resultIntent) + finish() + } + + // Caption text change listener + binding.captionEditText.addTextChangedListener { text -> + captions[currentIndex] = text?.toString() ?: "" + } + + // Setup for multiple attachments + if (attachmentUris.size > 1) { + setupMultipleAttachments() + } else { + binding.thumbnailContainer.visibility = View.GONE + binding.pageIndicator.visibility = View.GONE + binding.attachmentViewPager.visibility = View.GONE + } + + updateTitle() + } + + private fun setupMultipleAttachments() { + binding.thumbnailContainer.visibility = View.VISIBLE + binding.pageIndicator.visibility = View.VISIBLE + + // Create thumbnail strip + val thumbnailStrip = binding.thumbnailStrip + thumbnailStrip.removeAllViews() + + attachmentUris.forEachIndexed { index, uri -> + val thumbnailView = createThumbnailView(uri, index) + thumbnailStrip.addView(thumbnailView) + } + + updateThumbnailSelection(0) + } + + private fun createThumbnailView(uri: Uri, index: Int): View { + val thumbnailSize = resources.getDimensionPixelSize(R.dimen.thumbnail_size) + val imageView = ImageView(this).apply { + layoutParams = android.widget.LinearLayout.LayoutParams(thumbnailSize, thumbnailSize).apply { + marginEnd = 8 + } + scaleType = ImageView.ScaleType.CENTER_CROP + setBackgroundResource(R.drawable.thumbnail_border) + setPadding(4, 4, 4, 4) + + // Load thumbnail + try { + contentResolver.openInputStream(uri)?.use { inputStream -> + val bitmap = BitmapFactory.decodeStream(inputStream) + setImageBitmap(bitmap) + } + } catch (e: Exception) { + setImageResource(R.drawable.ic_attach_file_24) + } + + setOnClickListener { + saveCaptionForCurrentIndex() + displayAttachment(index) + updateThumbnailSelection(index) + } + } + imageView.tag = index + return imageView + } + + private fun updateThumbnailSelection(selectedIndex: Int) { + for (i in 0 until binding.thumbnailStrip.childCount) { + val child = binding.thumbnailStrip.getChildAt(i) + child.alpha = if (i == selectedIndex) 1.0f else 0.5f + child.scaleX = if (i == selectedIndex) 1.1f else 1.0f + child.scaleY = if (i == selectedIndex) 1.1f else 1.0f + } + } + + private fun saveCaptionForCurrentIndex() { + captions[currentIndex] = binding.captionEditText.text?.toString() ?: "" + } + + private fun displayAttachment(index: Int) { + currentIndex = index + val uri = attachmentUris[index] + + // Load caption for this attachment + binding.captionEditText.setText(captions[index] ?: "") + + // Get mime type + val mimeType = contentResolver.getType(uri) ?: "application/octet-stream" + + if (mimeType.startsWith("image/")) { + // Show image + binding.singleImageView.visibility = View.VISIBLE + binding.filePreviewContainer.visibility = View.GONE + + try { + contentResolver.openInputStream(uri)?.use { inputStream -> + val bitmap = BitmapFactory.decodeStream(inputStream) + binding.singleImageView.setImageBitmap(bitmap) + } + } catch (e: Exception) { + Toast.makeText(this, "Failed to load image", Toast.LENGTH_SHORT).show() + } + } else { + // Show file preview + binding.singleImageView.visibility = View.GONE + binding.filePreviewContainer.visibility = View.VISIBLE + + // Get file info + val filename = getFilename(uri) + val fileSize = getFileSize(uri) + + binding.fileName.text = filename + binding.fileSize.text = Formatter.formatFileSize(this, fileSize) + + // Set appropriate icon based on mime type + val iconRes = when { + mimeType.startsWith("video/") -> R.drawable.ic_video_24 + mimeType.startsWith("audio/") -> R.drawable.ic_audio_24 + mimeType == "application/pdf" -> R.drawable.ic_pdf_24 + else -> R.drawable.ic_attach_file_24 + } + binding.fileIcon.setImageResource(iconRes) + } + + updateTitle() + } + + private fun updateTitle() { + if (attachmentUris.size > 1) { + binding.titleText.text = getString(R.string.attachment_count, currentIndex + 1, attachmentUris.size) + binding.pageIndicator.text = getString(R.string.attachment_count, currentIndex + 1, attachmentUris.size) + } else { + binding.titleText.text = getString(R.string.preview) + } + } + + private fun getFilename(uri: Uri): String { + var filename = "attachment" + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0) { + filename = cursor.getString(nameIndex) ?: "attachment" + } + } + } + return filename + } + + private fun getFileSize(uri: Uri): Long { + var size = 0L + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE) + if (sizeIndex >= 0) { + size = cursor.getLong(sizeIndex) + } + } + } + return size + } +} diff --git a/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailActivity.kt b/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailActivity.kt index 0718d462e..a37a16d93 100644 --- a/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailActivity.kt +++ b/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailActivity.kt @@ -32,10 +32,13 @@ import org.xmtp.android.example.extension.truncatedAddress import org.xmtp.android.example.message.EmojiPickerAdapter import org.xmtp.android.example.message.MessageAdapter import org.xmtp.android.example.message.MessageClickListener +import org.xmtp.android.example.message.SearchResultAdapter +import org.xmtp.android.example.message.SearchResultItem import org.xmtp.android.library.Conversation import org.xmtp.android.library.codecs.Reaction import org.xmtp.android.library.codecs.ReactionAction import org.xmtp.android.library.libxmtp.DecodedMessageV2 +import org.xmtp.android.library.libxmtp.Reply import java.io.File import kotlin.math.abs @@ -45,18 +48,35 @@ class ConversationDetailActivity : private lateinit var binding: ActivityConversationDetailBinding private lateinit var adapter: MessageAdapter private lateinit var emojiAdapter: EmojiPickerAdapter + private lateinit var searchResultAdapter: SearchResultAdapter private var isEmojiPickerVisible = false + private var isSearchVisible = false + private var shouldScrollAfterFetch = false private val viewModel: ConversationDetailViewModel by viewModels() // Attachment handling private var cameraImageUri: Uri? = null + private val attachmentPreviewLauncher = + registerForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + if (result.resultCode == RESULT_OK) { + val uris = result.data?.getParcelableArrayListExtra(AttachmentPreviewActivity.RESULT_ATTACHMENTS) + if (!uris.isNullOrEmpty()) { + sendAttachments(uris) + } + } + } + private val galleryLauncher = registerForActivityResult( - ActivityResultContracts.GetContent(), - ) { uri: Uri? -> - uri?.let { handleSelectedAttachment(it) } + ActivityResultContracts.GetMultipleContents(), + ) { uris: List -> + if (uris.isNotEmpty()) { + showAttachmentPreview(uris) + } } private val cameraLauncher = @@ -64,15 +84,17 @@ class ConversationDetailActivity : ActivityResultContracts.TakePicture(), ) { success: Boolean -> if (success) { - cameraImageUri?.let { handleSelectedAttachment(it) } + cameraImageUri?.let { showAttachmentPreview(listOf(it)) } } } private val fileLauncher = registerForActivityResult( - ActivityResultContracts.OpenDocument(), - ) { uri: Uri? -> - uri?.let { handleSelectedAttachment(it) } + ActivityResultContracts.OpenMultipleDocuments(), + ) { uris: List -> + if (uris.isNotEmpty()) { + showAttachmentPreview(uris) + } } private val peerAddress @@ -119,6 +141,10 @@ class ConversationDetailActivity : super.onCreate(savedInstanceState) viewModel.setConversationTopic(intent.extras?.getString(EXTRA_CONVERSATION_TOPIC)) + // Load and apply user preferences for hide deleted messages + val keyUtil = org.xmtp.android.example.utils.KeyUtil(this) + viewModel.setHideDeletedMessages(keyUtil.getHideDeletedMessages()) + binding = ActivityConversationDetailBinding.inflate(layoutInflater) setContentView(binding.root) @@ -152,6 +178,10 @@ class ConversationDetailActivity : binding.sendButton.setOnClickListener { val text = binding.messageEditText.text.toString() if (text.isNotBlank()) { + // Hide emoji picker when sending + if (isEmojiPickerVisible) { + hideEmojiPicker() + } val flow = viewModel.sendMessage(text) lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -186,6 +216,12 @@ class ConversationDetailActivity : // Setup reply preview setupReplyPreview() + + // Setup edit preview + setupEditPreview() + + // Setup search + setupSearch() } private fun setupEmojiPicker() { @@ -242,6 +278,132 @@ class ConversationDetailActivity : binding.emojiButton.setImageResource(R.drawable.ic_emoji_24) } + private fun setupSearch() { + // Setup search result adapter + searchResultAdapter = SearchResultAdapter { messageId -> + // Close search and scroll to the message + hideSearch() + scrollToMessage(messageId) + } + binding.searchResultsList.layoutManager = LinearLayoutManager(this) + binding.searchResultsList.adapter = searchResultAdapter + + // Search button click + binding.searchButton.setOnClickListener { + showSearch() + } + + // Cancel button click + binding.cancelSearchButton.setOnClickListener { + hideSearch() + } + + // Clear search text button + binding.clearSearchButton.setOnClickListener { + binding.searchEditText.text?.clear() + } + + // Search text changes + binding.searchEditText.addTextChangedListener { text -> + val query = text?.toString() ?: "" + binding.clearSearchButton.visibility = if (query.isNotEmpty()) View.VISIBLE else View.GONE + performSearch(query) + } + } + + private fun showSearch() { + isSearchVisible = true + binding.searchBarCard.visibility = View.VISIBLE + binding.messageComposerCard.visibility = View.GONE + binding.refresh.visibility = View.GONE + binding.searchEditText.requestFocus() + val imm = getSystemService(INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager + imm.showSoftInput(binding.searchEditText, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT) + } + + private fun hideSearch() { + isSearchVisible = false + binding.searchBarCard.visibility = View.GONE + binding.searchResultsList.visibility = View.GONE + binding.noResultsView.visibility = View.GONE + binding.messageComposerCard.visibility = View.VISIBLE + binding.refresh.visibility = View.VISIBLE + binding.searchEditText.text?.clear() + val imm = getSystemService(INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager + imm.hideSoftInputFromWindow(binding.searchEditText.windowToken, 0) + } + + private fun performSearch(query: String) { + if (query.isEmpty()) { + binding.searchResultsList.visibility = View.GONE + binding.noResultsView.visibility = View.GONE + return + } + + val currentState = viewModel.uiState.value + val items = when (currentState) { + is ConversationDetailViewModel.UiState.Success -> currentState.listItems + is ConversationDetailViewModel.UiState.Loading -> currentState.listItems + else -> null + } + + items?.let { list -> + val lowercaseQuery = query.lowercase() + val results = list + .mapNotNull { item -> + // Get the message from the sealed class + val message = when (item) { + is ConversationDetailViewModel.MessageListItem.SentMessage -> item.message + is ConversationDetailViewModel.MessageListItem.ReceivedMessage -> item.message + is ConversationDetailViewModel.MessageListItem.SystemMessage -> null // Skip system messages + } ?: return@mapNotNull null + + // Get the content as text, handling Reply messages specially + val content = message.content() + val textContent = when (content) { + is String -> { + // Skip fallback text patterns like "Replied with '...' to an earlier message" + if (content.startsWith("Replied with")) return@mapNotNull null + content + } + is Reply -> { + // For replies, extract the actual reply text (not "Replied with...") + when (val replyContent = content.content) { + is String -> replyContent + else -> return@mapNotNull null + } + } + else -> return@mapNotNull null + } + + // Skip empty or deleted messages + if (textContent.isEmpty()) return@mapNotNull null + + // Check if it matches the search query + if (!textContent.lowercase().contains(lowercaseQuery)) return@mapNotNull null + + SearchResultItem( + id = message.id, + senderInboxId = message.senderInboxId, + content = textContent, + sentAtNs = message.sentAtNs, + isDeleted = false + ) + } + + searchResultAdapter.setSearchQuery(query) + searchResultAdapter.submitList(results) + + if (results.isEmpty()) { + binding.searchResultsList.visibility = View.GONE + binding.noResultsView.visibility = View.VISIBLE + } else { + binding.searchResultsList.visibility = View.VISIBLE + binding.noResultsView.visibility = View.GONE + } + } + } + private fun setupReplyPreview() { // Close button clears the reply binding.replyPreviewClose.setOnClickListener { @@ -256,9 +418,37 @@ class ConversationDetailActivity : binding.replyPreviewContainer.visibility = View.VISIBLE binding.replyPreviewAuthor.text = message.senderInboxId.take(8) + "..." val content = message.content() + + // Reset image visibility + binding.replyPreviewImageContainer.visibility = View.GONE + + // Extract the actual text content, handling Reply messages specially val messageText = when (content) { is String -> content + is Reply -> { + // For replies, show the reply content (not "Replied with...") + when (val replyContent = content.content) { + is String -> replyContent + else -> "Message" + } + } + is org.xmtp.android.library.codecs.Attachment -> { + // Show image thumbnail for attachments + if (content.mimeType.startsWith("image/")) { + try { + val bytes = content.data.toByteArray() + val bitmap = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + binding.replyPreviewImage.setImageBitmap(bitmap) + binding.replyPreviewImageContainer.visibility = View.VISIBLE + } catch (e: Exception) { + // Ignore image decode errors + } + "Photo" + } else { + "Attachment" + } + } else -> message.fallbackText ?: "Message" } binding.replyPreviewText.text = messageText @@ -271,6 +461,73 @@ class ConversationDetailActivity : } } + private fun setupEditPreview() { + // Close button clears the edit + binding.editPreviewClose.setOnClickListener { + viewModel.clearEdit() + binding.messageEditText.text.clear() + } + + // Observe edit state + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.editingMessage.collect { message -> + if (message != null) { + binding.editPreviewContainer.visibility = View.VISIBLE + val content = message.content() + + // Reset image visibility + binding.editPreviewImageContainer.visibility = View.GONE + + // Extract the actual text content, handling Reply messages specially + val messageText = + when (content) { + is String -> content + is Reply -> { + // For replies, get the reply content (which is the actual text) + when (val replyContent = content.content) { + is String -> replyContent + else -> message.fallbackText ?: "Message" + } + } + is org.xmtp.android.library.codecs.Attachment -> { + // Show image thumbnail for attachments being edited + if (content.mimeType.startsWith("image/")) { + try { + val bytes = content.data.toByteArray() + val bitmap = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + binding.editPreviewImage.setImageBitmap(bitmap) + binding.editPreviewImageContainer.visibility = View.VISIBLE + } catch (e: Exception) { + // Ignore image decode errors + } + "Photo" + } else { + "Attachment" + } + } + else -> message.fallbackText ?: "Message" + } + binding.editPreviewText.text = messageText + // Pre-fill the text field with the message content + binding.messageEditText.setText(messageText) + binding.messageEditText.setSelection(messageText.length) + binding.messageEditText.requestFocus() + } else { + binding.editPreviewContainer.visibility = View.GONE + } + } + } + } + } + + private fun startEditingMessage(message: DecodedMessageV2) { + // Clear any reply first + viewModel.clearReply() + // Set the message to edit + viewModel.setEditingMessage(message) + } + private fun setupInitialHeader() { // Set initial values based on peerAddress val displayAddress = peerAddress ?: "" @@ -405,6 +662,13 @@ class ConversationDetailActivity : is ConversationDetailViewModel.UiState.Success -> { binding.refresh.isRefreshing = false adapter.setData(uiState.listItems) + // Scroll to bottom after data is loaded if flag is set + if (shouldScrollAfterFetch) { + shouldScrollAfterFetch = false + binding.list.post { + binding.list.scrollToPosition(0) + } + } } is ConversationDetailViewModel.UiState.Error -> { @@ -429,6 +693,7 @@ class ConversationDetailActivity : binding.messageEditText.text.clear() binding.messageEditText.isEnabled = true binding.sendButton.isEnabled = true + shouldScrollAfterFetch = true viewModel.fetchMessages() } } @@ -438,6 +703,10 @@ class ConversationDetailActivity : when (result) { is ConversationDetailViewModel.StreamedMessageResult.NewMessage -> { adapter.addItem(result.item) + // Auto-scroll to show new message (position 0 since layout is reversed) + binding.list.post { + binding.list.smoothScrollToPosition(0) + } } is ConversationDetailViewModel.StreamedMessageResult.RefreshNeeded -> { // A delete message was received, refresh the list to show updated state @@ -460,6 +729,9 @@ class ConversationDetailActivity : val isFromMe = ClientManager.client.inboxId == message.senderInboxId // Allow delete if it's my message OR if I'm a super admin in a group val canDelete = isFromMe || (conversationType == Conversation.Type.GROUP && isSuperAdmin) + // Only allow edit for own text and reply messages (not attachments) + val content = message.content() + val canEdit = isFromMe && (content is String || content is Reply) // Find user's existing active reaction on this message // We need to aggregate Added/Removed to find the current state @@ -523,8 +795,8 @@ class ConversationDetailActivity : } // Different emoji clicked when user has existing reaction - remove old, add new myExistingReaction != null -> { - removeReaction(message.id, myExistingReaction) - sendReaction(message.id, emoji) + // Use sequential operation to avoid race condition + replaceReaction(message.id, myExistingReaction, emoji) } // No existing reaction - add new else -> { @@ -540,6 +812,21 @@ class ConversationDetailActivity : viewModel.setReplyToMessage(message) } + // Setup edit button + val editButton = dialogView.findViewById(R.id.editButton) + val dividerEdit = dialogView.findViewById(R.id.dividerEdit) + if (canEdit) { + editButton.visibility = View.VISIBLE + dividerEdit.visibility = View.VISIBLE + editButton.setOnClickListener { + dialog.dismiss() + startEditingMessage(message) + } + } else { + editButton.visibility = View.GONE + dividerEdit.visibility = View.GONE + } + val deleteButton = dialogView.findViewById(R.id.deleteButton) val divider = dialogView.findViewById(R.id.divider) if (canDelete) { @@ -604,6 +891,41 @@ class ConversationDetailActivity : } } + /** + * Replace an existing reaction with a new one. + * Removes the old reaction first and waits for completion before adding the new one. + * This prevents race conditions when changing reactions. + */ + private fun replaceReaction( + messageId: String, + oldEmoji: String, + newEmoji: String, + ) { + lifecycleScope.launch { + // First remove the old reaction and wait for completion + val removeResult = viewModel.sendReaction(messageId, oldEmoji, isRemoving = true) + when (removeResult) { + is ConversationDetailViewModel.ReactionState.Error -> { + showError(removeResult.message) + return@launch + } + ConversationDetailViewModel.ReactionState.Success -> { + // Only after successful removal, add the new reaction + when (val addResult = viewModel.sendReaction(messageId, newEmoji, isRemoving = false)) { + is ConversationDetailViewModel.ReactionState.Error -> { + showError(addResult.message) + } + ConversationDetailViewModel.ReactionState.Success -> { + viewModel.fetchMessages() + } + else -> {} + } + } + else -> {} + } + } + } + private fun scrollToMessage(messageId: String) { // Find the position of the message with the given ID val currentState = viewModel.uiState.value @@ -732,68 +1054,98 @@ class ConversationDetailActivity : galleryLauncher.launch("image/gif") } - private fun handleSelectedAttachment(uri: Uri) { + private fun showAttachmentPreview(uris: List) { + val intent = AttachmentPreviewActivity.intent(this, uris) + attachmentPreviewLauncher.launch(intent) + } + + private fun sendAttachments(uris: List) { lifecycleScope.launch { - try { - val (filename, mimeType, data) = - withContext(Dispatchers.IO) { - readAttachmentFromUri(uri) - } + val attachmentCount = uris.size + var successCount = 0 + var errorCount = 0 + + Toast + .makeText( + this@ConversationDetailActivity, + if (attachmentCount > 1) "Sending $attachmentCount attachments..." else getString(R.string.sending_attachment), + Toast.LENGTH_SHORT, + ).show() + + for ((index, uri) in uris.withIndex()) { + try { + val (filename, mimeType, data) = + withContext(Dispatchers.IO) { + readAttachmentFromUri(uri) + } - // Check file size (max 10MB for inline attachments) - val maxSize = 10 * 1024 * 1024 // 10MB - if (data.size > maxSize) { - Toast - .makeText( + // Check file size (max 10MB for inline attachments) + val maxSize = 10 * 1024 * 1024 // 10MB + if (data.size > maxSize) { + errorCount++ + Toast.makeText( this@ConversationDetailActivity, - R.string.attachment_too_large, - Toast.LENGTH_SHORT, + getString(R.string.attachment_too_large), + Toast.LENGTH_SHORT ).show() - return@launch - } - - // Send attachment - Toast - .makeText( - this@ConversationDetailActivity, - R.string.sending_attachment, - Toast.LENGTH_SHORT, - ).show() - - when (val result = viewModel.sendAttachment(filename, mimeType, data)) { - is ConversationDetailViewModel.SendAttachmentState.Success -> { - viewModel.fetchMessages() + continue } - is ConversationDetailViewModel.SendAttachmentState.Error -> { - showError(result.message) + + when (val result = viewModel.sendAttachment(filename, mimeType, data)) { + is ConversationDetailViewModel.SendAttachmentState.Success -> { + successCount++ + } + is ConversationDetailViewModel.SendAttachmentState.Error -> { + errorCount++ + } + else -> {} } - else -> {} + } catch (e: Exception) { + errorCount++ } - } catch (e: Exception) { + } + + // Show result toast + if (errorCount > 0) { Toast .makeText( this@ConversationDetailActivity, - R.string.attachment_error, + if (successCount > 0) "Sent $successCount attachments, $errorCount failed" else getString(R.string.attachment_error), Toast.LENGTH_SHORT, ).show() } + + if (successCount > 0) { + viewModel.fetchMessages() + } } } private fun readAttachmentFromUri(uri: Uri): Triple { val contentResolver: ContentResolver = contentResolver - // Get filename + // Get filename and size var filename = "attachment" + var fileSize: Long = 0 contentResolver.query(uri, null, null, null, null)?.use { cursor -> if (cursor.moveToFirst()) { val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) if (nameIndex >= 0) { filename = cursor.getString(nameIndex) ?: "attachment" } + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (sizeIndex >= 0) { + fileSize = cursor.getLong(sizeIndex) + } } } + // Check file size limit (10MB max to prevent OOM) + val maxSize = 10 * 1024 * 1024L // 10MB + if (fileSize > maxSize) { + throw IllegalArgumentException("File too large. Maximum size is 10MB.") + } + // Get MIME type val mimeType = contentResolver.getType(uri) @@ -802,10 +1154,24 @@ class ConversationDetailActivity : ) ?: "application/octet-stream" - // Read data - val data = - contentResolver.openInputStream(uri)?.use { it.readBytes() } - ?: throw IllegalStateException("Could not read attachment") + // Validate MIME type (basic security check) + val allowedTypes = setOf( + "image/", "video/", "audio/", "text/", "application/pdf", + "application/msword", "application/vnd.", "application/json" + ) + val isAllowed = allowedTypes.any { mimeType.startsWith(it) || mimeType == it } + if (!isAllowed && !mimeType.startsWith("application/")) { + throw IllegalArgumentException("File type not supported: $mimeType") + } + + // Read data with size check + val data = contentResolver.openInputStream(uri)?.use { inputStream -> + val bytes = inputStream.readBytes() + if (bytes.size > maxSize) { + throw IllegalArgumentException("File too large. Maximum size is 10MB.") + } + bytes + } ?: throw IllegalStateException("Could not read attachment") return Triple(filename, mimeType, data) } diff --git a/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailViewModel.kt b/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailViewModel.kt index 2b4ad86ce..e270e7e21 100644 --- a/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailViewModel.kt +++ b/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailViewModel.kt @@ -16,7 +16,6 @@ import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.xmtp.android.example.ClientManager import org.xmtp.android.example.extension.flowWhileShared @@ -28,13 +27,16 @@ import org.xmtp.android.library.codecs.ContentTypeAttachment import org.xmtp.android.library.codecs.ContentTypeReaction import org.xmtp.android.library.codecs.ContentTypeReply import org.xmtp.android.library.codecs.ContentTypeText +import org.xmtp.android.library.codecs.TextCodec import org.xmtp.android.library.codecs.DeletedBy import org.xmtp.android.library.codecs.DeletedMessage import org.xmtp.android.library.codecs.Reaction import org.xmtp.android.library.codecs.ReactionAction import org.xmtp.android.library.codecs.ReactionSchema import org.xmtp.android.library.codecs.Reply +import org.xmtp.android.library.codecs.ReplyCodec import org.xmtp.android.library.libxmtp.DecodedMessageV2 +import org.xmtp.android.library.libxmtp.Reply as EnrichedReply import org.xmtp.proto.mls.message.contents.TranscriptMessages.GroupUpdated class ConversationDetailViewModel( @@ -58,6 +60,16 @@ class ConversationDetailViewModel( private val _replyToMessage = MutableStateFlow(null) val replyToMessage: StateFlow = _replyToMessage + private val _editingMessage = MutableStateFlow(null) + val editingMessage: StateFlow = _editingMessage + + // Instance-level setting - set from Activity based on user preferences + private val _hideDeletedMessages = MutableStateFlow(false) + + fun setHideDeletedMessages(hide: Boolean) { + _hideDeletedMessages.value = hide + } + private var conversation: Conversation? = null fun setReplyToMessage(message: DecodedMessageV2?) { @@ -68,6 +80,14 @@ class ConversationDetailViewModel( _replyToMessage.value = null } + fun setEditingMessage(message: DecodedMessageV2?) { + _editingMessage.value = message + } + + fun clearEdit() { + _editingMessage.value = null + } + @UiThread fun fetchMessages() { when (val uiState = uiState.value) { @@ -80,18 +100,18 @@ class ConversationDetailViewModel( if (conversation == null) { conversation = ClientManager.client.conversations.findConversationByTopic(conversationTopic!!) } - conversation?.let { + conversation?.let { conv -> // Sync conversation to get latest messages (including deletions) - when (it) { - is Conversation.Group -> it.group.sync() - is Conversation.Dm -> it.dm.sync() + val isDm = when (conv) { + is Conversation.Group -> { conv.group.sync(); false } + is Conversation.Dm -> { conv.dm.sync(); true } } - listItems.addAll( - it.enrichedMessages().mapNotNull { message -> + val shouldHideDeleted = _hideDeletedMessages.value + listItems.addAll(conv.enrichedMessages().mapNotNull { message -> message?.let { msg -> - val item = classifyMessage(msg) + val item = classifyMessage(msg, isDm) ?: return@mapNotNull null // Filter out deleted messages if hideDeletedMessages is enabled - if (hideDeletedMessages && item is MessageListItem.SystemMessage) { + if (shouldHideDeleted && item is MessageListItem.SystemMessage) { val content = msg.content() if (content is DeletedMessage) { return@mapNotNull null @@ -99,8 +119,7 @@ class ConversationDetailViewModel( } item } - }, - ) + }) } _uiState.value = UiState.Success(listItems) } catch (e: Exception) { @@ -112,13 +131,12 @@ class ConversationDetailViewModel( @OptIn(ExperimentalCoroutinesApi::class) val streamMessages: StateFlow = stateFlow(viewModelScope, null) { subscriptionCount -> + // Ensure conversation is initialized if (conversation == null) { - conversation = - runBlocking { - ClientManager.client.conversations.findConversationByTopic(conversationTopic!!) - } + conversation = ClientManager.client.conversations.findConversationByTopic(conversationTopic!!) } if (conversation != null) { + val isDm = conversation is Conversation.Dm conversation!! .streamMessages() .flowWhileShared( @@ -127,21 +145,24 @@ class ConversationDetailViewModel( ).flowOn(Dispatchers.IO) .distinctUntilChanged() .mapLatest { message -> - // Check if this is a delete or reaction message - if so, signal a refresh is needed + // Check if this is a delete, reaction, or edit message - if so, signal a refresh is needed val contentTypeId = message.encodedContent.type val isDeleteMessage = contentTypeId?.typeId == "deleteMessage" val isReactionMessage = contentTypeId?.typeId == "reaction" + val isEditMessage = contentTypeId?.typeId == "editMessage" - if (isDeleteMessage || isReactionMessage) { + if (isDeleteMessage || isReactionMessage || isEditMessage) { // Return a signal to refresh the message list - // Reactions and deletes modify existing messages, so we need a full refresh + // Reactions, deletes, and edits modify existing messages, so we need a full refresh StreamedMessageResult.RefreshNeeded } else { // Convert streamed DecodedMessage to DecodedMessageV2 using findEnrichedMessage val enrichedMessage = ClientManager.client.conversations.findEnrichedMessage(message.id) enrichedMessage?.let { - StreamedMessageResult.NewMessage(classifyMessage(it)) + classifyMessage(it, isDm)?.let { item -> + StreamedMessageResult.NewMessage(item) + } } } }.catch { _ -> @@ -157,16 +178,35 @@ class ConversationDetailViewModel( val item: MessageListItem, ) : StreamedMessageResult() - object RefreshNeeded : StreamedMessageResult() + data object RefreshNeeded : StreamedMessageResult() } @UiThread fun sendMessage(body: String): StateFlow { val flow = MutableStateFlow(SendMessageState.Loading) val replyTo = _replyToMessage.value + val editMessage = _editingMessage.value viewModelScope.launch(Dispatchers.IO) { try { - if (replyTo != null) { + if (editMessage != null) { + // Send as edit message using native editMessage API + // Check if the original message was a Reply - if so, preserve the Reply structure + val originalContent = editMessage.content() + val editedContent = if (originalContent is EnrichedReply) { + // Preserve the Reply structure with the new content + val updatedReply = Reply( + reference = originalContent.referenceId, + content = body, + contentType = ContentTypeText, + ) + ReplyCodec().encode(updatedReply) + } else { + // Regular text message + TextCodec().encode(body) + } + conversation?.editMessage(editMessage.id, editedContent.toByteArray()) + _editingMessage.value = null + } else if (replyTo != null) { // Send as reply using Reply codec val replyContent = Reply( @@ -274,9 +314,9 @@ class ConversationDetailViewModel( } sealed class SendMessageState { - object Loading : SendMessageState() + data object Loading : SendMessageState() - object Success : SendMessageState() + data object Success : SendMessageState() data class Error( val message: String, @@ -284,9 +324,9 @@ class ConversationDetailViewModel( } sealed class DeleteMessageState { - object Loading : DeleteMessageState() + data object Loading : DeleteMessageState() - object Success : DeleteMessageState() + data object Success : DeleteMessageState() data class Error( val message: String, @@ -294,9 +334,9 @@ class ConversationDetailViewModel( } sealed class ReactionState { - object Loading : ReactionState() + data object Loading : ReactionState() - object Success : ReactionState() + data object Success : ReactionState() data class Error( val message: String, @@ -304,9 +344,9 @@ class ConversationDetailViewModel( } sealed class SendAttachmentState { - object Loading : SendAttachmentState() + data object Loading : SendAttachmentState() - object Success : SendAttachmentState() + data object Success : SendAttachmentState() data class Error( val message: String, @@ -341,15 +381,22 @@ class ConversationDetailViewModel( } companion object { - // Flag to hide deleted messages entirely (set from Activity) - var hideDeletedMessages: Boolean = false + // Protocol message types that should not be displayed in the UI + private val HIDDEN_CONTENT_TYPES = setOf("editMessage", "deleteMessage", "reaction") + + fun classifyMessage(message: DecodedMessageV2, isDm: Boolean = false): MessageListItem? { + // Filter out protocol messages that modify other messages + val contentTypeId = message.contentTypeId.typeId + if (contentTypeId in HIDDEN_CONTENT_TYPES) { + return null + } - fun classifyMessage(message: DecodedMessageV2): MessageListItem { val content = message.content() val isFromMe = ClientManager.client.inboxId == message.senderInboxId - // Check for system messages (deleted, group updates) + // Check for system/protocol messages return when (content) { + // Placeholder for deleted message content (shown in UI) is DeletedMessage -> { val deletedByText = when (content.deletedBy) { @@ -363,17 +410,27 @@ class ConversationDetailViewModel( ) } is GroupUpdated -> { - val addedText = - content.addedInboxesList - ?.mapNotNull { it.inboxId } - ?.takeIf { it.isNotEmpty() } - ?.let { "Added: ${it.joinToString(", ") { id -> id.take(8) + "..." }}" } - val removedText = - content.removedInboxesList - ?.mapNotNull { it.inboxId } - ?.takeIf { it.isNotEmpty() } - ?.let { "Removed: ${it.joinToString(", ") { id -> id.take(8) + "..." }}" } - val text = listOfNotNull(addedText, removedText).joinToString("\n").ifEmpty { "Group updated" } + // For DMs, show "Conversation started by [initiator]" instead of member changes + val text = if (isDm) { + val initiatorId = content.initiatedByInboxId + if (initiatorId.isNotEmpty()) { + "Conversation started by ${initiatorId.take(8)}..." + } else { + "Conversation started" + } + } else { + val addedText = + content.addedInboxesList + ?.mapNotNull { it.inboxId } + ?.takeIf { it.isNotEmpty() } + ?.let { "Added: ${it.joinToString(", ") { id -> id.take(8) + "..." }}" } + val removedText = + content.removedInboxesList + ?.mapNotNull { it.inboxId } + ?.takeIf { it.isNotEmpty() } + ?.let { "Removed: ${it.joinToString(", ") { id -> id.take(8) + "..." }}" } + listOfNotNull(addedText, removedText).joinToString("\n").ifEmpty { "Group updated" } + } MessageListItem.SystemMessage(message.id, message, text) } else -> { diff --git a/example/src/main/java/org/xmtp/android/example/conversation/ConversationViewHolder.kt b/example/src/main/java/org/xmtp/android/example/conversation/ConversationViewHolder.kt index d304d8399..bed1ec7ef 100644 --- a/example/src/main/java/org/xmtp/android/example/conversation/ConversationViewHolder.kt +++ b/example/src/main/java/org/xmtp/android/example/conversation/ConversationViewHolder.kt @@ -9,7 +9,9 @@ import org.xmtp.android.example.R import org.xmtp.android.example.databinding.ListItemConversationBinding import org.xmtp.android.example.extension.truncatedAddress import org.xmtp.android.library.Conversation +import org.xmtp.android.library.codecs.Attachment import org.xmtp.android.library.codecs.DeletedMessage +import org.xmtp.android.library.libxmtp.Reply import org.xmtp.proto.mls.message.contents.TranscriptMessages.GroupUpdated import java.text.SimpleDateFormat import java.util.Calendar @@ -90,6 +92,21 @@ class ConversationViewHolder( val messageBody: String = when (val content = item.mostRecentMessage?.content()) { is String -> content + is Reply -> { + // Extract the actual reply text instead of "Replied with..." + when (val replyContent = content.content) { + is String -> replyContent + else -> "Message" + } + } + is Attachment -> { + // Show appropriate label for attachments + if (content.mimeType.startsWith("image/")) { + if (content.mimeType == "image/gif") "GIF" else "Photo" + } else { + "Attachment" + } + } is GroupUpdated -> { val added = content.addedInboxesList?.size ?: 0 val removed = content.removedInboxesList?.size ?: 0 @@ -101,7 +118,7 @@ class ConversationViewHolder( } } is DeletedMessage -> "Message deleted" - else -> item.mostRecentMessage?.body ?: "" + else -> "Message" } val isMe = item.mostRecentMessage?.senderInboxId == ClientManager.client.inboxId diff --git a/example/src/main/java/org/xmtp/android/example/extension/FlowExtension.kt b/example/src/main/java/org/xmtp/android/example/extension/FlowExtension.kt index 921f65f96..6324ad794 100644 --- a/example/src/main/java/org/xmtp/android/example/extension/FlowExtension.kt +++ b/example/src/main/java/org/xmtp/android/example/extension/FlowExtension.kt @@ -34,7 +34,7 @@ fun Flow.flowWhileShared( fun stateFlow( scope: CoroutineScope, initialValue: T, - producer: (subscriptionCount: StateFlow) -> Flow, + producer: suspend (subscriptionCount: StateFlow) -> Flow, ): StateFlow { val state = MutableStateFlow(initialValue) scope.launch(Dispatchers.IO) { diff --git a/example/src/main/java/org/xmtp/android/example/message/ReceivedMessageViewHolder.kt b/example/src/main/java/org/xmtp/android/example/message/ReceivedMessageViewHolder.kt index cfc90cbfe..54774e96f 100644 --- a/example/src/main/java/org/xmtp/android/example/message/ReceivedMessageViewHolder.kt +++ b/example/src/main/java/org/xmtp/android/example/message/ReceivedMessageViewHolder.kt @@ -3,6 +3,8 @@ package org.xmtp.android.example.message import android.graphics.BitmapFactory import android.view.View import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy import org.xmtp.android.example.conversation.ConversationDetailViewModel import org.xmtp.android.example.databinding.ListItemMessageReceivedBinding import org.xmtp.android.library.codecs.Attachment @@ -45,18 +47,30 @@ class ReceivedMessageViewHolder( } is Attachment -> { val isImage = content.mimeType.startsWith("image/") + val isGif = content.mimeType == "image/gif" if (isImage) { - // Display image attachment + // Display image/GIF attachment using Glide binding.attachmentContainer.visibility = View.VISIBLE binding.attachmentLoading.visibility = View.GONE try { val bytes = content.data.toByteArray() - val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) - binding.attachmentImage.setImageBitmap(bitmap) + if (isGif) { + // Use Glide for GIF playback + Glide.with(binding.attachmentImage.context) + .asGif() + .load(bytes) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(binding.attachmentImage) + } else { + // Use Glide for regular images too for consistency + Glide.with(binding.attachmentImage.context) + .load(bytes) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(binding.attachmentImage) + } } catch (e: Exception) { binding.attachmentImage.setImageResource(android.R.drawable.ic_menu_gallery) } - // Hide text body for image-only messages binding.messageBody.visibility = View.GONE } else { // Display file attachment @@ -75,6 +89,9 @@ class ReceivedMessageViewHolder( // Show reply container with original message info binding.replyContainer.visibility = View.VISIBLE + // Reset reply image visibility + binding.replyImageContainer.visibility = View.GONE + // Get original message info val originalMessage = content.inReplyTo if (originalMessage != null) { @@ -83,8 +100,45 @@ class ReceivedMessageViewHolder( val originalText = when (originalContent) { is String -> originalContent - is DeletedMessage -> "🗑️ This message was deleted" - else -> originalMessage.fallbackText ?: "Message" + is DeletedMessage -> "Deleted message" + is Attachment -> { + // Check if it's an image and show thumbnail + if (originalContent.mimeType.startsWith("image/")) { + try { + val bytes = originalContent.data.toByteArray() + val isGif = originalContent.mimeType == "image/gif" + if (isGif) { + Glide.with(binding.replyImage.context) + .asGif() + .load(bytes) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(binding.replyImage) + } else { + Glide.with(binding.replyImage.context) + .load(bytes) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(binding.replyImage) + } + binding.replyImageContainer.visibility = View.VISIBLE + } catch (e: Exception) { + // Ignore image decode errors + } + if (originalContent.mimeType == "image/gif") "GIF" else "Photo" + } else { + "Attachment" + } + } + is Reply -> { + // For replies, show the actual reply content + when (val nestedReplyContent = originalContent.content) { + is String -> nestedReplyContent + else -> "Message" + } + } + else -> { + // For unknown content types, just show "Message" to avoid "Replied with..." fallback text + "Message" + } } binding.replyAuthor.text = originalSender binding.replyText.text = originalText @@ -143,12 +197,12 @@ class ReceivedMessageViewHolder( else -> {} } } - // Remove emojis with no active reactions - activeReactions.entries.removeAll { it.value.isEmpty() } + // Filter out emojis with no active reactions (use filter instead of mutable removeAll) + val filteredReactions = activeReactions.filterValues { it.isNotEmpty() } - if (activeReactions.isNotEmpty()) { - val totalCount = activeReactions.values.sumOf { it.size } - val displayEmojis = activeReactions.keys.take(3).joinToString("") + if (filteredReactions.isNotEmpty()) { + val totalCount = filteredReactions.values.sumOf { it.size } + val displayEmojis = filteredReactions.keys.take(3).joinToString("") binding.messageReactions.visibility = View.VISIBLE binding.messageReactions.text = if (totalCount > 1) "$displayEmojis $totalCount" else displayEmojis } else { diff --git a/example/src/main/java/org/xmtp/android/example/message/SearchResultAdapter.kt b/example/src/main/java/org/xmtp/android/example/message/SearchResultAdapter.kt new file mode 100644 index 000000000..3f64009c7 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/message/SearchResultAdapter.kt @@ -0,0 +1,149 @@ +package org.xmtp.android.example.message + +import android.graphics.Color +import android.text.SpannableString +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.card.MaterialCardView +import org.xmtp.android.example.R +import org.xmtp.android.example.extension.truncatedAddress +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.math.abs + +data class SearchResultItem( + val id: String, + val senderInboxId: String, + val content: String, + val sentAtNs: Long, + val isDeleted: Boolean = false +) + +class SearchResultAdapter( + private val onResultClick: (String) -> Unit +) : ListAdapter(DiffCallback()) { + + private var searchQuery: String = "" + + fun setSearchQuery(query: String) { + searchQuery = query + } + + private val avatarColors = listOf( + Color.parseColor("#FC4F37"), + Color.parseColor("#5856D6"), + Color.parseColor("#34C759"), + Color.parseColor("#FF9500"), + Color.parseColor("#007AFF"), + Color.parseColor("#AF52DE"), + Color.parseColor("#00C7BE"), + Color.parseColor("#FF2D55") + ) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_search_result, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val avatarCard: MaterialCardView = itemView.findViewById(R.id.avatarCard) + private val avatarText: TextView = itemView.findViewById(R.id.avatarText) + private val senderName: TextView = itemView.findViewById(R.id.senderName) + private val timestamp: TextView = itemView.findViewById(R.id.timestamp) + private val messageContent: TextView = itemView.findViewById(R.id.messageContent) + + fun bind(item: SearchResultItem) { + // Set avatar + val displayAddress = item.senderInboxId + avatarText.text = displayAddress.removePrefix("0x").take(2).uppercase() + val colorIndex = abs(displayAddress.hashCode()) % avatarColors.size + avatarCard.setCardBackgroundColor(avatarColors[colorIndex]) + + // Set sender name + senderName.text = displayAddress.truncatedAddress() + + // Set timestamp + timestamp.text = formatTimestamp(item.sentAtNs) + + // Set content with highlighted search term + messageContent.text = highlightSearchTerm(item.content, searchQuery) + + // Click handler + itemView.setOnClickListener { + onResultClick(item.id) + } + } + + private fun formatTimestamp(sentAtNs: Long): String { + val date = Date(sentAtNs / 1_000_000) // Convert nanoseconds to milliseconds + val now = System.currentTimeMillis() + val diff = now - date.time + + return when { + diff < 60_000 -> "Just now" + diff < 3600_000 -> "${diff / 60_000}m ago" + diff < 86400_000 -> SimpleDateFormat("h:mm a", Locale.getDefault()).format(date) + diff < 604800_000 -> SimpleDateFormat("EEE", Locale.getDefault()).format(date) + else -> SimpleDateFormat("MMM d", Locale.getDefault()).format(date) + } + } + + private fun highlightSearchTerm(text: String, query: String): SpannableString { + val spannableString = SpannableString(text) + if (query.isEmpty()) return spannableString + + val lowerText = text.lowercase() + val lowerQuery = query.lowercase() + var startIndex = 0 + + while (true) { + val index = lowerText.indexOf(lowerQuery, startIndex) + if (index == -1) break + + // Bold the matched text + spannableString.setSpan( + StyleSpan(android.graphics.Typeface.BOLD), + index, + index + query.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + + // Color the matched text + spannableString.setSpan( + ForegroundColorSpan(Color.parseColor("#007AFF")), + index, + index + query.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + + startIndex = index + query.length + } + + return spannableString + } + } + + class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: SearchResultItem, newItem: SearchResultItem): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: SearchResultItem, newItem: SearchResultItem): Boolean { + return oldItem == newItem + } + } +} diff --git a/example/src/main/java/org/xmtp/android/example/message/SentMessageViewHolder.kt b/example/src/main/java/org/xmtp/android/example/message/SentMessageViewHolder.kt index 1e1c2adc4..9130a908c 100644 --- a/example/src/main/java/org/xmtp/android/example/message/SentMessageViewHolder.kt +++ b/example/src/main/java/org/xmtp/android/example/message/SentMessageViewHolder.kt @@ -3,6 +3,8 @@ package org.xmtp.android.example.message import android.graphics.BitmapFactory import android.view.View import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy import org.xmtp.android.example.conversation.ConversationDetailViewModel import org.xmtp.android.example.databinding.ListItemMessageSentBinding import org.xmtp.android.library.codecs.Attachment @@ -42,18 +44,30 @@ class SentMessageViewHolder( } is Attachment -> { val isImage = content.mimeType.startsWith("image/") + val isGif = content.mimeType == "image/gif" if (isImage) { - // Display image attachment + // Display image/GIF attachment using Glide binding.attachmentContainer.visibility = View.VISIBLE binding.attachmentLoading.visibility = View.GONE try { val bytes = content.data.toByteArray() - val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) - binding.attachmentImage.setImageBitmap(bitmap) + if (isGif) { + // Use Glide for GIF playback + Glide.with(binding.attachmentImage.context) + .asGif() + .load(bytes) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(binding.attachmentImage) + } else { + // Use Glide for regular images too for consistency + Glide.with(binding.attachmentImage.context) + .load(bytes) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(binding.attachmentImage) + } } catch (e: Exception) { binding.attachmentImage.setImageResource(android.R.drawable.ic_menu_gallery) } - // Hide text body for image-only messages binding.messageBody.visibility = View.GONE } else { // Display file attachment @@ -72,6 +86,9 @@ class SentMessageViewHolder( // Show reply container with original message info binding.replyContainer.visibility = View.VISIBLE + // Reset reply image visibility + binding.replyImageContainer.visibility = View.GONE + // Get original message info val originalMessage = content.inReplyTo if (originalMessage != null) { @@ -80,8 +97,45 @@ class SentMessageViewHolder( val originalText = when (originalContent) { is String -> originalContent - is DeletedMessage -> "🗑️ This message was deleted" - else -> originalMessage.fallbackText ?: "Message" + is DeletedMessage -> "Deleted message" + is Attachment -> { + // Check if it's an image and show thumbnail + if (originalContent.mimeType.startsWith("image/")) { + try { + val bytes = originalContent.data.toByteArray() + val isGif = originalContent.mimeType == "image/gif" + if (isGif) { + Glide.with(binding.replyImage.context) + .asGif() + .load(bytes) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(binding.replyImage) + } else { + Glide.with(binding.replyImage.context) + .load(bytes) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(binding.replyImage) + } + binding.replyImageContainer.visibility = View.VISIBLE + } catch (e: Exception) { + // Ignore image decode errors + } + if (originalContent.mimeType == "image/gif") "GIF" else "Photo" + } else { + "Attachment" + } + } + is Reply -> { + // For replies, show the actual reply content + when (val nestedReplyContent = originalContent.content) { + is String -> nestedReplyContent + else -> "Message" + } + } + else -> { + // For unknown content types, just show "Message" to avoid "Replied with..." fallback text + "Message" + } } binding.replyAuthor.text = originalSender binding.replyText.text = originalText @@ -131,12 +185,12 @@ class SentMessageViewHolder( else -> {} } } - // Remove emojis with no active reactions - activeReactions.entries.removeAll { it.value.isEmpty() } + // Filter out emojis with no active reactions (use filter instead of mutable removeAll) + val filteredReactions = activeReactions.filterValues { it.isNotEmpty() } - if (activeReactions.isNotEmpty()) { - val totalCount = activeReactions.values.sumOf { it.size } - val displayEmojis = activeReactions.keys.take(3).joinToString("") + if (filteredReactions.isNotEmpty()) { + val totalCount = filteredReactions.values.sumOf { it.size } + val displayEmojis = filteredReactions.keys.take(3).joinToString("") binding.messageReactions.visibility = View.VISIBLE binding.messageReactions.text = if (totalCount > 1) "$displayEmojis $totalCount" else displayEmojis } else { diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/Avatar.kt b/example/src/main/java/org/xmtp/android/example/ui/components/Avatar.kt new file mode 100644 index 000000000..eb80cdea9 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/components/Avatar.kt @@ -0,0 +1,102 @@ +package org.xmtp.android.example.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.xmtp.android.example.ui.theme.AvatarColors +import org.xmtp.android.example.ui.theme.XMTPTheme +import kotlin.math.abs + +@Composable +fun Avatar( + name: String, + modifier: Modifier = Modifier, + size: Dp = 48.dp, + showOnlineIndicator: Boolean = false +) { + val initials = getInitials(name) + val backgroundColor = getAvatarColor(name) + + Box( + modifier = modifier.size(size), + contentAlignment = Alignment.Center + ) { + // Main avatar circle + Box( + modifier = Modifier + .size(size) + .clip(CircleShape) + .background(backgroundColor), + contentAlignment = Alignment.Center + ) { + Text( + text = initials, + color = Color.White, + fontSize = (size.value * 0.35f).sp, + fontWeight = FontWeight.SemiBold + ) + } + + // Online indicator + if (showOnlineIndicator) { + Box( + modifier = Modifier + .size(size * 0.25f) + .align(Alignment.BottomEnd) + .clip(CircleShape) + .background(Color.White) + ) { + Box( + modifier = Modifier + .size(size * 0.2f) + .align(Alignment.Center) + .clip(CircleShape) + .background(Color(0xFF34C759)) + ) + } + } + } +} + +private fun getInitials(name: String): String { + val cleaned = name.removePrefix("0x").trim() + return if (cleaned.length >= 2) { + cleaned.take(2).uppercase() + } else { + cleaned.uppercase() + } +} + +private fun getAvatarColor(name: String): Color { + val hash = abs(name.hashCode()) + return AvatarColors[hash % AvatarColors.size] +} + +@Preview +@Composable +private fun AvatarPreview() { + XMTPTheme { + Avatar(name = "0x1234567890abcdef") + } +} + +@Preview +@Composable +private fun AvatarWithOnlinePreview() { + XMTPTheme { + Avatar(name = "John Doe", showOnlineIndicator = true) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/ConversationRow.kt b/example/src/main/java/org/xmtp/android/example/ui/components/ConversationRow.kt new file mode 100644 index 000000000..090be03c7 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/components/ConversationRow.kt @@ -0,0 +1,198 @@ +package org.xmtp.android.example.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Group +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.ui.theme.XMTPTheme + +@Composable +fun ConversationRow( + name: String, + lastMessage: String, + timestamp: String, + modifier: Modifier = Modifier, + isGroup: Boolean = false, + memberCount: Int? = null, + unreadCount: Int = 0, + onClick: () -> Unit = {} +) { + Surface( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick), + color = MaterialTheme.colorScheme.surface + ) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Avatar + Box( + modifier = Modifier.size(52.dp), + contentAlignment = Alignment.Center + ) { + Avatar(name = name, size = 52.dp) + + // Group/DM indicator badge + Box( + modifier = Modifier + .size(20.dp) + .align(Alignment.BottomEnd) + .clip(CircleShape) + .background(if (isGroup) Color(0xFF34C759) else Color(0xFF007AFF)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (isGroup) Icons.Default.Group else Icons.Default.Person, + contentDescription = if (isGroup) "Group" else "Direct Message", + modifier = Modifier.size(12.dp), + tint = Color.White + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + // Content + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = name, + style = MaterialTheme.typography.titleMedium, + fontWeight = if (unreadCount > 0) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = timestamp, + style = MaterialTheme.typography.labelSmall, + color = if (unreadCount > 0) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + if (isGroup && memberCount != null) { + Text( + text = "$memberCount members", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Text( + text = lastMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = if (unreadCount > 0) FontWeight.Medium else FontWeight.Normal + ) + } + + if (unreadCount > 0) { + Spacer(modifier = Modifier.width(8.dp)) + + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primary + ) { + Text( + text = if (unreadCount > 99) "99+" else unreadCount.toString(), + style = MaterialTheme.typography.labelSmall, + color = Color.White, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + } + } + } + } +} + +@Preview +@Composable +private fun DMConversationPreview() { + XMTPTheme { + ConversationRow( + name = "0x1234...5678", + lastMessage = "Hey, how are you doing?", + timestamp = "10:30 AM", + isGroup = false + ) + } +} + +@Preview +@Composable +private fun GroupConversationPreview() { + XMTPTheme { + ConversationRow( + name = "XMTP Dev Team", + lastMessage = "Alice: Let's ship this feature!", + timestamp = "Yesterday", + isGroup = true, + memberCount = 5 + ) + } +} + +@Preview +@Composable +private fun UnreadConversationPreview() { + XMTPTheme { + ConversationRow( + name = "Bob", + lastMessage = "Check out this new update!", + timestamp = "2:45 PM", + isGroup = false, + unreadCount = 3 + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/MessageBubble.kt b/example/src/main/java/org/xmtp/android/example/ui/components/MessageBubble.kt new file mode 100644 index 000000000..f6837d3d3 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/components/MessageBubble.kt @@ -0,0 +1,334 @@ +package org.xmtp.android.example.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.xmtp.android.example.ui.theme.SentMessageBackground +import org.xmtp.android.example.ui.theme.XMTPTheme + +enum class DeliveryStatus { + SENDING, + SENT, + FAILED +} + +@Composable +fun MessageBubble( + message: String, + timestamp: String, + isMe: Boolean, + modifier: Modifier = Modifier, + senderName: String? = null, + senderColor: Color? = null, + deliveryStatus: DeliveryStatus = DeliveryStatus.SENT, + reactions: List>? = null, + replyPreview: String? = null, + replyAuthor: String? = null, + isDeleted: Boolean = false, + onLongClick: () -> Unit = {}, + onReplyClick: (() -> Unit)? = null +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp), + horizontalArrangement = if (isMe) Arrangement.End else Arrangement.Start + ) { + if (isMe) { + Spacer(modifier = Modifier.weight(1f, fill = false).widthIn(min = 60.dp)) + } + + Column( + horizontalAlignment = if (isMe) Alignment.End else Alignment.Start + ) { + // Sender name for group messages + if (senderName != null && !isMe) { + Text( + text = senderName, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = senderColor ?: MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 12.dp, bottom = 2.dp) + ) + } + + // Message bubble + Surface( + shape = RoundedCornerShape(18.dp), + color = if (isMe) SentMessageBackground else MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .widthIn(max = 280.dp) + .clickable(onClick = onLongClick) + ) { + // Use IntrinsicSize.Max to ensure all children share the maximum width + // This makes the reply bubble at least as wide as the preview + Column( + modifier = Modifier + .width(IntrinsicSize.Max) + .padding( + horizontal = 12.dp, + vertical = 8.dp + ) + ) { + // Reply preview + if (replyPreview != null) { + Surface( + shape = RoundedCornerShape(8.dp), + color = if (isMe) Color.White.copy(alpha = 0.15f) + else MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp) + .clickable { onReplyClick?.invoke() } + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .width(2.dp) + .size(2.dp, 24.dp) + .background( + if (isMe) Color.White.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) + ) + ) + Spacer(modifier = Modifier.width(6.dp)) + Column { + if (replyAuthor != null) { + Text( + text = replyAuthor, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = if (isMe) Color.White.copy(alpha = 0.8f) + else MaterialTheme.colorScheme.primary + ) + } + Text( + text = replyPreview, + style = MaterialTheme.typography.bodySmall, + color = if (isMe) Color.White.copy(alpha = 0.7f) + else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2 + ) + } + } + } + } + + // Message content + Text( + text = if (isDeleted) "This message was deleted" else message, + style = MaterialTheme.typography.bodyLarge, + color = if (isMe) Color.White else MaterialTheme.colorScheme.onSurface, + fontStyle = if (isDeleted) FontStyle.Italic else FontStyle.Normal, + modifier = if (replyPreview != null) Modifier.fillMaxWidth() else Modifier + ) + + // Timestamp and delivery status + Row( + modifier = Modifier + .align(Alignment.End) + .padding(top = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = timestamp, + style = MaterialTheme.typography.labelSmall, + color = if (isMe) Color.White.copy(alpha = 0.7f) + else MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 11.sp + ) + + if (isMe) { + Spacer(modifier = Modifier.width(4.dp)) + when (deliveryStatus) { + DeliveryStatus.SENDING -> Icon( + imageVector = Icons.Default.Schedule, + contentDescription = "Sending", + modifier = Modifier.size(12.dp), + tint = Color.White.copy(alpha = 0.7f) + ) + DeliveryStatus.SENT -> Icon( + imageVector = Icons.Default.Check, + contentDescription = "Sent", + modifier = Modifier.size(12.dp), + tint = Color.White.copy(alpha = 0.7f) + ) + DeliveryStatus.FAILED -> Icon( + imageVector = Icons.Default.Error, + contentDescription = "Failed", + modifier = Modifier.size(12.dp), + tint = Color.Red + ) + } + } + } + } + } + + // Reactions + if (!reactions.isNullOrEmpty()) { + Row( + modifier = Modifier.padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + reactions.take(5).forEach { (emoji, count) -> + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = emoji, fontSize = 14.sp) + if (count > 1) { + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = count.toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + } + + if (!isMe) { + Spacer(modifier = Modifier.weight(1f, fill = false).widthIn(min = 60.dp)) + } + } +} + +@Composable +fun SystemMessage( + text: String, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) + ) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) + } + } +} + +@Preview +@Composable +private fun SentMessagePreview() { + XMTPTheme { + MessageBubble( + message = "Hello! How are you doing today?", + timestamp = "10:30 AM", + isMe = true + ) + } +} + +@Preview +@Composable +private fun ReceivedMessagePreview() { + XMTPTheme { + MessageBubble( + message = "I'm doing great, thanks for asking!", + timestamp = "10:31 AM", + isMe = false, + senderName = "0x1234...5678", + senderColor = Color(0xFF5856D6) + ) + } +} + +@Preview +@Composable +private fun MessageWithReactionsPreview() { + XMTPTheme { + MessageBubble( + message = "This is awesome!", + timestamp = "10:32 AM", + isMe = true, + reactions = listOf("👍" to 3, "❤️" to 2) + ) + } +} + +@Preview +@Composable +private fun ReplyMessagePreview() { + XMTPTheme { + MessageBubble( + message = "Yes, I agree with you!", + timestamp = "10:33 AM", + isMe = false, + replyPreview = "This is the original message that was very long...", + replyAuthor = "0x1234...5678" + ) + } +} + +@Preview +@Composable +private fun DeletedMessagePreview() { + XMTPTheme { + MessageBubble( + message = "", + timestamp = "10:34 AM", + isMe = true, + isDeleted = true + ) + } +} + +@Preview +@Composable +private fun SystemMessagePreview() { + XMTPTheme { + SystemMessage(text = "Alice joined the group") + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/MessageComposer.kt b/example/src/main/java/org/xmtp/android/example/ui/components/MessageComposer.kt new file mode 100644 index 000000000..57ff2db8b --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/components/MessageComposer.kt @@ -0,0 +1,296 @@ +package org.xmtp.android.example.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.EmojiEmotions +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.xmtp.android.example.ui.theme.XMTPTheme + +@Composable +fun MessageComposer( + value: String, + onValueChange: (String) -> Unit, + onSendClick: () -> Unit, + modifier: Modifier = Modifier, + isSending: Boolean = false, + replyTo: String? = null, + replyAuthor: String? = null, + onClearReply: () -> Unit = {}, + onAttachmentClick: () -> Unit = {}, + onEmojiClick: () -> Unit = {} +) { + val canSend = value.isNotBlank() && !isSending + + Surface( + modifier = modifier + .fillMaxWidth() + .imePadding(), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp + ) { + Column { + // Reply preview + AnimatedVisibility( + visible = replyTo != null, + enter = slideInVertically { it }, + exit = slideOutVertically { it } + ) { + if (replyTo != null) { + ReplyPreview( + message = replyTo, + author = replyAuthor, + onDismiss = onClearReply + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.Bottom + ) { + // Attachment button + IconButton( + onClick = onAttachmentClick, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Add attachment", + tint = MaterialTheme.colorScheme.primary + ) + } + + // Text input field + Surface( + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceVariant + ) { + Row( + modifier = Modifier.padding(horizontal = 4.dp), + verticalAlignment = Alignment.Bottom + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .weight(1f) + .heightIn(min = 40.dp, max = 120.dp) + .padding(horizontal = 12.dp, vertical = 10.dp), + textStyle = TextStyle( + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + decorationBox = { innerTextField -> + Box { + if (value.isEmpty()) { + Text( + text = "Message", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp + ) + } + innerTextField() + } + }, + enabled = !isSending + ) + + // Emoji button + IconButton( + onClick = onEmojiClick, + modifier = Modifier + .size(36.dp) + .padding(bottom = 2.dp) + ) { + Icon( + imageVector = Icons.Default.EmojiEmotions, + contentDescription = "Emoji", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp) + ) + } + } + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Send button + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background( + if (canSend) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surfaceVariant + ) + .clickable(enabled = canSend) { onSendClick() }, + contentAlignment = Alignment.Center + ) { + if (isSending) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = Color.White, + strokeWidth = 2.dp + ) + } else { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = "Send", + tint = Color.White, + modifier = Modifier.size(20.dp) + ) + } + } + } + } + } +} + +@Composable +private fun ReplyPreview( + message: String, + author: String?, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Left accent bar + Box( + modifier = Modifier + .width(2.dp) + .height(32.dp) + .background(MaterialTheme.colorScheme.primary) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Replying to ${author ?: "message"}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + } + + IconButton( + onClick = onDismiss, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Cancel reply", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp) + ) + } + } +} + +@Preview +@Composable +private fun MessageComposerPreview() { + XMTPTheme { + var text by remember { mutableStateOf("") } + MessageComposer( + value = text, + onValueChange = { text = it }, + onSendClick = {} + ) + } +} + +@Preview +@Composable +private fun MessageComposerWithTextPreview() { + XMTPTheme { + MessageComposer( + value = "Hello, this is a test message!", + onValueChange = {}, + onSendClick = {} + ) + } +} + +@Preview +@Composable +private fun MessageComposerSendingPreview() { + XMTPTheme { + MessageComposer( + value = "Sending message...", + onValueChange = {}, + onSendClick = {}, + isSending = true + ) + } +} + +@Preview +@Composable +private fun MessageComposerWithReplyPreview() { + XMTPTheme { + MessageComposer( + value = "", + onValueChange = {}, + onSendClick = {}, + replyTo = "This is the original message content", + replyAuthor = "0x1234...5678" + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/SearchBar.kt b/example/src/main/java/org/xmtp/android/example/ui/components/SearchBar.kt new file mode 100644 index 000000000..00a0eac69 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/components/SearchBar.kt @@ -0,0 +1,370 @@ +package org.xmtp.android.example.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.xmtp.android.example.ui.theme.XMTPTheme + +data class SearchResult( + val id: String, + val senderName: String, + val content: String, + val timestamp: String, + val isDeleted: Boolean = false +) + +@Composable +fun MessageSearchBar( + searchText: String, + onSearchTextChange: (String) -> Unit, + isSearching: Boolean, + onSearchToggle: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + val focusRequester = remember { FocusRequester() } + + AnimatedVisibility( + visible = isSearching, + enter = fadeIn(), + exit = fadeOut() + ) { + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Search input field + Surface( + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceVariant + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.width(8.dp)) + + BasicTextField( + value = searchText, + onValueChange = onSearchTextChange, + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester), + textStyle = TextStyle( + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + singleLine = true, + decorationBox = { innerTextField -> + Box { + if (searchText.isEmpty()) { + Text( + text = "Search messages", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp + ) + } + innerTextField() + } + } + ) + + if (searchText.isNotEmpty()) { + IconButton( + onClick = { onSearchTextChange("") }, + modifier = Modifier.size(20.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Clear", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + Spacer(modifier = Modifier.width(8.dp)) + + TextButton(onClick = { + onSearchTextChange("") + onSearchToggle(false) + }) { + Text("Cancel") + } + } + } + } +} + +@Composable +fun SearchResultsList( + searchText: String, + results: List, + onResultClick: (String) -> Unit, + modifier: Modifier = Modifier +) { + if (searchText.isEmpty()) { + return + } + + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + if (results.isEmpty()) { + // Empty state + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "No results found", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Try searching for different keywords", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + items( + items = results, + key = { it.id } + ) { result -> + SearchResultRow( + result = result, + searchText = searchText, + onClick = { onResultClick(result.id) } + ) + HorizontalDivider( + modifier = Modifier.padding(start = 72.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + } + } + } + } +} + +@Composable +private fun SearchResultRow( + result: SearchResult, + searchText: String, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.Top + ) { + // Avatar + Avatar(name = result.senderName, size = 44.dp) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + // Sender and timestamp row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = result.senderName, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + Text( + text = result.timestamp, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Highlighted content + Text( + text = highlightSearchTerm(result.content, searchText), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun highlightSearchTerm(text: String, searchTerm: String) = buildAnnotatedString { + if (searchTerm.isEmpty()) { + append(text) + return@buildAnnotatedString + } + + val lowercaseText = text.lowercase() + val lowercaseSearch = searchTerm.lowercase() + var currentIndex = 0 + + while (currentIndex < text.length) { + val matchIndex = lowercaseText.indexOf(lowercaseSearch, currentIndex) + if (matchIndex == -1) { + append(text.substring(currentIndex)) + break + } + + // Add text before match + if (matchIndex > currentIndex) { + append(text.substring(currentIndex, matchIndex)) + } + + // Add highlighted match + withStyle( + style = SpanStyle( + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + ) { + append(text.substring(matchIndex, matchIndex + searchTerm.length)) + } + + currentIndex = matchIndex + searchTerm.length + } +} + +@Preview +@Composable +private fun SearchBarPreview() { + XMTPTheme { + var searchText by remember { mutableStateOf("") } + MessageSearchBar( + searchText = searchText, + onSearchTextChange = { searchText = it }, + isSearching = true, + onSearchToggle = {} + ) + } +} + +@Preview +@Composable +private fun SearchResultsPreview() { + XMTPTheme { + SearchResultsList( + searchText = "hello", + results = listOf( + SearchResult( + id = "1", + senderName = "0x1234...5678", + content = "Hello! How are you doing today?", + timestamp = "10:30 AM" + ), + SearchResult( + id = "2", + senderName = "Alice", + content = "Just wanted to say hello and check in", + timestamp = "Yesterday" + ) + ), + onResultClick = {} + ) + } +} + +@Preview +@Composable +private fun EmptySearchResultsPreview() { + XMTPTheme { + SearchResultsList( + searchText = "xyz", + results = emptyList(), + onResultClick = {} + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/navigation/AppNavigation.kt b/example/src/main/java/org/xmtp/android/example/ui/navigation/AppNavigation.kt new file mode 100644 index 000000000..ab0471087 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/navigation/AppNavigation.kt @@ -0,0 +1,187 @@ +package org.xmtp.android.example.ui.navigation + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Chat +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.outlined.Chat +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import org.xmtp.android.example.ui.screens.ChatScreen +import org.xmtp.android.example.ui.screens.ConversationItem +import org.xmtp.android.example.ui.screens.HomeScreen +import org.xmtp.android.example.ui.screens.MessageItem +import org.xmtp.android.example.ui.screens.ProfileScreen +import org.xmtp.android.example.ui.screens.SettingsScreen + +sealed class Screen(val route: String) { + object Home : Screen("home") + object Profile : Screen("profile") + object Settings : Screen("settings") + object Chat : Screen("chat/{conversationId}") { + fun createRoute(conversationId: String) = "chat/$conversationId" + } +} + +data class BottomNavItem( + val screen: Screen, + val title: String, + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector +) + +val bottomNavItems = listOf( + BottomNavItem( + screen = Screen.Home, + title = "Chats", + selectedIcon = Icons.Filled.Chat, + unselectedIcon = Icons.Outlined.Chat + ), + BottomNavItem( + screen = Screen.Profile, + title = "Profile", + selectedIcon = Icons.Filled.Person, + unselectedIcon = Icons.Outlined.Person + ), + BottomNavItem( + screen = Screen.Settings, + title = "Settings", + selectedIcon = Icons.Filled.Settings, + unselectedIcon = Icons.Outlined.Settings + ) +) + +@Composable +fun AppNavigation( + navController: NavHostController = rememberNavController(), + conversations: List = emptyList(), + messages: Map> = emptyMap(), + walletAddress: String = "", + inboxId: String = "", + installationId: String = "", + hideDeletedMessages: Boolean = false, + onSendMessage: (String, String) -> Unit = { _, _ -> }, + onNewConversation: () -> Unit = {}, + onLogout: () -> Unit = {}, + onHideDeletedMessagesChange: (Boolean) -> Unit = {} +) { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + + // Check if we should show bottom nav + val showBottomNav = currentDestination?.route in listOf( + Screen.Home.route, + Screen.Profile.route, + Screen.Settings.route + ) + + Scaffold( + bottomBar = { + if (showBottomNav) { + NavigationBar( + containerColor = MaterialTheme.colorScheme.surface + ) { + bottomNavItems.forEach { item -> + val selected = currentDestination?.hierarchy?.any { + it.route == item.screen.route + } == true + + NavigationBarItem( + icon = { + Icon( + imageVector = if (selected) item.selectedIcon else item.unselectedIcon, + contentDescription = item.title + ) + }, + label = { Text(item.title) }, + selected = selected, + onClick = { + navController.navigate(item.screen.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + ) + } + } + } + } + ) { paddingValues -> + NavHost( + navController = navController, + startDestination = Screen.Home.route, + modifier = Modifier.padding(paddingValues) + ) { + composable(Screen.Home.route) { + HomeScreen( + conversations = conversations, + onConversationClick = { conversationId -> + navController.navigate(Screen.Chat.createRoute(conversationId)) + }, + onNewConversationClick = onNewConversation + ) + } + + composable(Screen.Profile.route) { + ProfileScreen( + walletAddress = walletAddress, + inboxId = inboxId, + installationId = installationId, + onLogout = onLogout + ) + } + + composable(Screen.Settings.route) { + SettingsScreen( + hideDeletedMessages = hideDeletedMessages, + onHideDeletedMessagesChange = onHideDeletedMessagesChange + ) + } + + composable( + route = Screen.Chat.route, + arguments = listOf( + navArgument("conversationId") { type = NavType.StringType } + ) + ) { backStackEntry -> + val conversationId = backStackEntry.arguments?.getString("conversationId") ?: "" + val conversation = conversations.find { it.id == conversationId } + val conversationMessages = messages[conversationId] ?: emptyList() + + ChatScreen( + conversationName = conversation?.name ?: "Conversation", + messages = conversationMessages, + isGroup = conversation?.isGroup ?: false, + memberCount = conversation?.memberCount, + onBackClick = { navController.popBackStack() }, + onSendMessage = { message -> + onSendMessage(conversationId, message) + } + ) + } + } + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/screens/ChatScreen.kt b/example/src/main/java/org/xmtp/android/example/ui/screens/ChatScreen.kt new file mode 100644 index 000000000..7c2b4757a --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/screens/ChatScreen.kt @@ -0,0 +1,386 @@ +package org.xmtp.android.example.ui.screens + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.ui.components.DeliveryStatus +import org.xmtp.android.example.ui.components.MessageBubble +import org.xmtp.android.example.ui.components.MessageComposer +import org.xmtp.android.example.ui.components.MessageSearchBar +import org.xmtp.android.example.ui.components.SearchResult +import org.xmtp.android.example.ui.components.SearchResultsList +import org.xmtp.android.example.ui.components.SystemMessage +import org.xmtp.android.example.ui.theme.XMTPTheme + +data class MessageItem( + val id: String, + val content: String, + val timestamp: String, + val isMe: Boolean, + val senderName: String? = null, + val senderColor: Color? = null, + val deliveryStatus: DeliveryStatus = DeliveryStatus.SENT, + val reactions: List>? = null, + val replyPreview: String? = null, + val replyAuthor: String? = null, + val isDeleted: Boolean = false, + val isSystemMessage: Boolean = false +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatScreen( + conversationName: String, + messages: List, + isLoading: Boolean = false, + isGroup: Boolean = false, + memberCount: Int? = null, + replyingTo: MessageItem? = null, + modifier: Modifier = Modifier, + onBackClick: () -> Unit = {}, + onInfoClick: () -> Unit = {}, + onSendMessage: (String) -> Unit = {}, + onAttachmentClick: () -> Unit = {}, + onEmojiClick: () -> Unit = {}, + onCancelReply: () -> Unit = {}, + onMessageLongClick: (String) -> Unit = {}, + onReplyClick: (String) -> Unit = {}, + onScrollToMessage: (String) -> Unit = {} +) { + val listState = rememberLazyListState() + var messageText by rememberSaveable { mutableStateOf("") } + var isSearching by rememberSaveable { mutableStateOf(false) } + var searchText by rememberSaveable { mutableStateOf("") } + + // Filter messages for search results (exclude deleted and system messages) + val searchResults by remember(searchText, messages) { + derivedStateOf { + if (searchText.isEmpty()) { + emptyList() + } else { + val lowercaseSearch = searchText.lowercase() + messages + .filter { msg -> + !msg.isSystemMessage && + !msg.isDeleted && + msg.content.isNotEmpty() && + msg.content.lowercase().contains(lowercaseSearch) + } + .map { msg -> + SearchResult( + id = msg.id, + senderName = msg.senderName ?: "You", + content = msg.content, + timestamp = msg.timestamp, + isDeleted = msg.isDeleted + ) + } + } + } + } + + // Scroll to bottom when new messages arrive + LaunchedEffect(messages.size) { + if (messages.isNotEmpty() && !isSearching) { + listState.animateScrollToItem(messages.size - 1) + } + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + Column { + TopAppBar( + title = { + Column { + Text( + text = conversationName, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (isGroup && memberCount != null) { + Text( + text = "$memberCount members", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + actions = { + IconButton(onClick = { isSearching = !isSearching }) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search" + ) + } + if (isGroup) { + IconButton(onClick = onInfoClick) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = "Group Info" + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + + // Search bar + MessageSearchBar( + searchText = searchText, + onSearchTextChange = { searchText = it }, + isSearching = isSearching, + onSearchToggle = { isSearching = it } + ) + } + }, + bottomBar = { + AnimatedVisibility( + visible = !isSearching, + enter = slideInVertically { it }, + exit = slideOutVertically { it } + ) { + MessageComposer( + value = messageText, + onValueChange = { messageText = it }, + onSendClick = { + if (messageText.isNotBlank()) { + onSendMessage(messageText) + messageText = "" + } + }, + replyTo = replyingTo?.content, + replyAuthor = replyingTo?.senderName, + onClearReply = onCancelReply, + onAttachmentClick = onAttachmentClick, + onEmojiClick = onEmojiClick + ) + } + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } else if (isSearching && searchText.isNotEmpty()) { + // Show search results + SearchResultsList( + searchText = searchText, + results = searchResults, + onResultClick = { messageId -> + // Scroll to message and close search + onScrollToMessage(messageId) + isSearching = false + searchText = "" + + // Find the index of the message and scroll to it + val index = messages.indexOfFirst { it.id == messageId } + if (index >= 0) { + // Using launched effect won't work here, so we handle it via callback + } + } + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + contentPadding = PaddingValues(vertical = 8.dp), + reverseLayout = false + ) { + items( + items = messages, + key = { it.id } + ) { message -> + if (message.isSystemMessage) { + SystemMessage(text = message.content) + } else { + MessageBubble( + message = message.content, + timestamp = message.timestamp, + isMe = message.isMe, + senderName = message.senderName, + senderColor = message.senderColor, + deliveryStatus = message.deliveryStatus, + reactions = message.reactions, + replyPreview = message.replyPreview, + replyAuthor = message.replyAuthor, + isDeleted = message.isDeleted, + onLongClick = { onMessageLongClick(message.id) }, + onReplyClick = { onReplyClick(message.id) } + ) + } + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ChatScreenPreview() { + XMTPTheme { + ChatScreen( + conversationName = "0x1234...5678", + messages = listOf( + MessageItem( + id = "1", + content = "Hey! How are you?", + timestamp = "10:30 AM", + isMe = false, + senderName = "0x1234...5678" + ), + MessageItem( + id = "2", + content = "I'm doing great! Just shipped a new feature.", + timestamp = "10:31 AM", + isMe = true + ), + MessageItem( + id = "3", + content = "That's awesome! Can you show me?", + timestamp = "10:32 AM", + isMe = false, + senderName = "0x1234...5678" + ), + MessageItem( + id = "4", + content = "Sure, let me send you a screenshot!", + timestamp = "10:33 AM", + isMe = true, + reactions = listOf("👍" to 1, "🔥" to 2) + ) + ) + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun GroupChatScreenPreview() { + XMTPTheme { + ChatScreen( + conversationName = "XMTP Dev Team", + isGroup = true, + memberCount = 5, + messages = listOf( + MessageItem( + id = "sys1", + content = "Alice joined the group", + timestamp = "", + isMe = false, + isSystemMessage = true + ), + MessageItem( + id = "1", + content = "Welcome to the team!", + timestamp = "10:30 AM", + isMe = false, + senderName = "Bob", + senderColor = Color(0xFF5856D6) + ), + MessageItem( + id = "2", + content = "Thanks! Excited to be here.", + timestamp = "10:31 AM", + isMe = true + ), + MessageItem( + id = "3", + content = "Let's ship some features!", + timestamp = "10:32 AM", + isMe = false, + senderName = "Charlie", + senderColor = Color(0xFFFF9500) + ) + ) + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ChatWithReplyPreview() { + XMTPTheme { + ChatScreen( + conversationName = "Alice", + messages = listOf( + MessageItem( + id = "1", + content = "Hey, did you see the new update?", + timestamp = "10:30 AM", + isMe = false + ), + MessageItem( + id = "2", + content = "Yes! It looks amazing!", + timestamp = "10:31 AM", + isMe = true, + replyPreview = "Hey, did you see the new update?", + replyAuthor = "Alice" + ) + ), + replyingTo = MessageItem( + id = "1", + content = "Hey, did you see the new update?", + timestamp = "10:30 AM", + isMe = false + ) + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/screens/HomeScreen.kt b/example/src/main/java/org/xmtp/android/example/ui/screens/HomeScreen.kt new file mode 100644 index 000000000..3a1e0691a --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/screens/HomeScreen.kt @@ -0,0 +1,225 @@ +package org.xmtp.android.example.ui.screens + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.ui.components.ConversationRow +import org.xmtp.android.example.ui.theme.XMTPTheme + +data class ConversationItem( + val id: String, + val name: String, + val lastMessage: String, + val timestamp: String, + val isGroup: Boolean = false, + val memberCount: Int? = null, + val unreadCount: Int = 0 +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + conversations: List, + isLoading: Boolean = false, + modifier: Modifier = Modifier, + onConversationClick: (String) -> Unit = {}, + onNewConversationClick: () -> Unit = {}, + onSearchClick: () -> Unit = {} +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { + Text( + text = "Messages", + style = MaterialTheme.typography.headlineMedium + ) + }, + actions = { + IconButton(onClick = onSearchClick) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = onNewConversationClick, + containerColor = MaterialTheme.colorScheme.primary + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "New Conversation" + ) + } + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + when { + isLoading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + conversations.isEmpty() -> { + EmptyConversationsView( + modifier = Modifier.align(Alignment.Center) + ) + } + else -> { + ConversationsList( + conversations = conversations, + onConversationClick = onConversationClick + ) + } + } + } + } +} + +@Composable +private fun ConversationsList( + conversations: List, + onConversationClick: (String) -> Unit +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp) + ) { + items( + items = conversations, + key = { it.id } + ) { conversation -> + ConversationRow( + name = conversation.name, + lastMessage = conversation.lastMessage, + timestamp = conversation.timestamp, + isGroup = conversation.isGroup, + memberCount = conversation.memberCount, + unreadCount = conversation.unreadCount, + onClick = { onConversationClick(conversation.id) } + ) + Divider( + modifier = Modifier.padding(start = 80.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + } + } +} + +@Composable +private fun EmptyConversationsView( + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "No Conversations Yet", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "Start a new conversation using the + button", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp) + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun HomeScreenPreview() { + XMTPTheme { + HomeScreen( + conversations = listOf( + ConversationItem( + id = "1", + name = "0x1234...5678", + lastMessage = "Hey, how are you?", + timestamp = "10:30 AM", + isGroup = false + ), + ConversationItem( + id = "2", + name = "XMTP Dev Team", + lastMessage = "Alice: Let's ship this!", + timestamp = "Yesterday", + isGroup = true, + memberCount = 5, + unreadCount = 3 + ), + ConversationItem( + id = "3", + name = "Bob", + lastMessage = "Check out this update!", + timestamp = "2:45 PM", + isGroup = false, + unreadCount = 1 + ) + ) + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun EmptyHomeScreenPreview() { + XMTPTheme { + HomeScreen(conversations = emptyList()) + } +} + +@Preview(showBackground = true) +@Composable +private fun LoadingHomeScreenPreview() { + XMTPTheme { + HomeScreen( + conversations = emptyList(), + isLoading = true + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/screens/ProfileScreen.kt b/example/src/main/java/org/xmtp/android/example/ui/screens/ProfileScreen.kt new file mode 100644 index 000000000..2eef0a74e --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/screens/ProfileScreen.kt @@ -0,0 +1,242 @@ +package org.xmtp.android.example.ui.screens + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Logout +import androidx.compose.material.icons.filled.QrCode +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.ui.components.Avatar +import org.xmtp.android.example.ui.theme.XMTPTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfileScreen( + walletAddress: String, + inboxId: String, + installationId: String, + modifier: Modifier = Modifier, + onLogout: () -> Unit = {}, + onShowQR: () -> Unit = {} +) { + val context = LocalContext.current + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { + Text( + text = "Profile", + style = MaterialTheme.typography.headlineMedium + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Avatar + Avatar( + name = walletAddress, + size = 100.dp + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Wallet Address + Text( + text = abbreviateAddress(walletAddress), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Info Cards + InfoCard( + title = "Wallet Address", + value = walletAddress, + onCopy = { copyToClipboard(context, "Wallet Address", walletAddress) } + ) + + Spacer(modifier = Modifier.height(12.dp)) + + InfoCard( + title = "Inbox ID", + value = inboxId, + onCopy = { copyToClipboard(context, "Inbox ID", inboxId) } + ) + + Spacer(modifier = Modifier.height(12.dp)) + + InfoCard( + title = "Installation ID", + value = installationId, + onCopy = { copyToClipboard(context, "Installation ID", installationId) } + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // QR Code Button + Button( + onClick = onShowQR, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + ) { + Icon( + imageVector = Icons.Default.QrCode, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Text( + text = "Show QR Code", + modifier = Modifier.padding(start = 8.dp) + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + // Logout Button + Button( + onClick = onLogout, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Icon( + imageVector = Icons.Default.Logout, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Text( + text = "Logout", + modifier = Modifier.padding(start = 8.dp) + ) + } + } + } +} + +@Composable +private fun InfoCard( + title: String, + value: String, + onCopy: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp) + ) + } + IconButton(onClick = onCopy) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy", + tint = MaterialTheme.colorScheme.primary + ) + } + } + } +} + +private fun abbreviateAddress(address: String): String { + return if (address.length > 12) { + "${address.take(6)}...${address.takeLast(4)}" + } else { + address + } +} + +private fun copyToClipboard(context: Context, label: String, text: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(label, text) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "$label copied to clipboard", Toast.LENGTH_SHORT).show() +} + +@Preview(showBackground = true) +@Composable +private fun ProfileScreenPreview() { + XMTPTheme { + ProfileScreen( + walletAddress = "0x1234567890123456789012345678901234567890", + inboxId = "abcdef1234567890abcdef1234567890", + installationId = "installation-id-12345" + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/screens/SettingsScreen.kt b/example/src/main/java/org/xmtp/android/example/ui/screens/SettingsScreen.kt new file mode 100644 index 000000000..973454ce2 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/screens/SettingsScreen.kt @@ -0,0 +1,313 @@ +package org.xmtp.android.example.ui.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.ui.theme.XMTPTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + hideDeletedMessages: Boolean = false, + notificationsEnabled: Boolean = true, + darkModeEnabled: Boolean = false, + modifier: Modifier = Modifier, + onHideDeletedMessagesChange: (Boolean) -> Unit = {}, + onNotificationsChange: (Boolean) -> Unit = {}, + onDarkModeChange: (Boolean) -> Unit = {}, + onPrivacyClick: () -> Unit = {}, + onStorageClick: () -> Unit = {}, + onViewLogsClick: () -> Unit = {}, + onAboutClick: () -> Unit = {}, + onClearDataClick: () -> Unit = {} +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { + Text( + text = "Settings", + style = MaterialTheme.typography.headlineMedium + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + // Appearance Section + SectionTitle(text = "Appearance") + + SettingsCard { + SettingsToggleRow( + icon = Icons.Default.DarkMode, + title = "Dark Mode", + subtitle = "Use dark theme", + isChecked = darkModeEnabled, + onCheckedChange = onDarkModeChange + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Messages Section + SectionTitle(text = "Messages") + + SettingsCard { + SettingsToggleRow( + icon = Icons.Default.DeleteForever, + title = "Hide Deleted Messages", + subtitle = "Don't show deleted messages in conversations", + isChecked = hideDeletedMessages, + onCheckedChange = onHideDeletedMessagesChange + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Notifications Section + SectionTitle(text = "Notifications") + + SettingsCard { + SettingsToggleRow( + icon = Icons.Default.Notifications, + title = "Push Notifications", + subtitle = "Receive notifications for new messages", + isChecked = notificationsEnabled, + onCheckedChange = onNotificationsChange + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // General Section + SectionTitle(text = "General") + + SettingsCard { + SettingsNavigationRow( + icon = Icons.Default.Security, + title = "Privacy & Security", + onClick = onPrivacyClick + ) + SettingsNavigationRow( + icon = Icons.Default.Storage, + title = "Storage", + onClick = onStorageClick + ) + SettingsNavigationRow( + icon = Icons.Default.BugReport, + title = "View Logs", + onClick = onViewLogsClick + ) + SettingsNavigationRow( + icon = Icons.Default.Info, + title = "About", + onClick = onAboutClick + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Danger Zone + SectionTitle(text = "Danger Zone", color = MaterialTheme.colorScheme.error) + + SettingsCard( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) + ) { + SettingsNavigationRow( + icon = Icons.Default.DeleteForever, + title = "Clear All Data", + iconTint = MaterialTheme.colorScheme.error, + onClick = onClearDataClick + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Version info + Text( + text = "XMTP Example App v1.0.0", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + } + } +} + +@Composable +private fun SectionTitle( + text: String, + color: Color = MaterialTheme.colorScheme.primary +) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + color = color, + modifier = Modifier.padding(bottom = 8.dp, start = 4.dp) + ) +} + +@Composable +private fun SettingsCard( + containerColor: Color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + content: @Composable () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = containerColor) + ) { + Column { + content() + } + } +} + +@Composable +private fun SettingsToggleRow( + icon: ImageVector, + title: String, + subtitle: String, + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!isChecked) } + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Switch( + checked = isChecked, + onCheckedChange = onCheckedChange + ) + } +} + +@Composable +private fun SettingsNavigationRow( + icon: ImageVector, + title: String, + iconTint: Color = MaterialTheme.colorScheme.primary, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = title, + style = MaterialTheme.typography.bodyLarge + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun SettingsScreenPreview() { + XMTPTheme { + SettingsScreen() + } +} + +@Preview(showBackground = true) +@Composable +private fun SettingsScreenDarkPreview() { + XMTPTheme(darkTheme = true) { + SettingsScreen(darkModeEnabled = true) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/theme/Color.kt b/example/src/main/java/org/xmtp/android/example/ui/theme/Color.kt new file mode 100644 index 000000000..9d0a6b6a4 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/theme/Color.kt @@ -0,0 +1,39 @@ +package org.xmtp.android.example.ui.theme + +import androidx.compose.ui.graphics.Color + +// Primary colors +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) + +// XMTP Brand Colors +val XMTPBlue = Color(0xFF0052FF) +val XMTPBlueLight = Color(0xFF4D8AFF) +val XMTPBlueDark = Color(0xFF0041CC) + +// Message bubble colors +val SentMessageBackground = Color(0xFF0052FF) +val ReceivedMessageBackground = Color(0xFFE8E8ED) +val ReceivedMessageBackgroundDark = Color(0xFF2C2C2E) + +// Status colors +val OnlineGreen = Color(0xFF34C759) +val WarningOrange = Color(0xFFFF9500) +val ErrorRed = Color(0xFFFF3B30) + +// Avatar colors for consistent sender identification +val AvatarColors = listOf( + Color(0xFFFC4F37), + Color(0xFF5856D6), + Color(0xFF34C759), + Color(0xFFFF9500), + Color(0xFF007AFF), + Color(0xFFAF52DE), + Color(0xFF00C7BE), + Color(0xFFFF2D55) +) diff --git a/example/src/main/java/org/xmtp/android/example/ui/theme/Theme.kt b/example/src/main/java/org/xmtp/android/example/ui/theme/Theme.kt new file mode 100644 index 000000000..d114ff2dd --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/theme/Theme.kt @@ -0,0 +1,76 @@ +package org.xmtp.android.example.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = XMTPBlue, + onPrimary = Color.White, + primaryContainer = XMTPBlueDark, + onPrimaryContainer = Color.White, + secondary = PurpleGrey80, + onSecondary = Color.Black, + secondaryContainer = PurpleGrey40, + onSecondaryContainer = Color.White, + tertiary = Pink80, + onTertiary = Color.Black, + background = Color(0xFF121212), + onBackground = Color.White, + surface = Color(0xFF1E1E1E), + onSurface = Color.White, + surfaceVariant = Color(0xFF2C2C2E), + onSurfaceVariant = Color(0xFFCAC4D0), + outline = Color(0xFF938F99), + outlineVariant = Color(0xFF49454F), +) + +private val LightColorScheme = lightColorScheme( + primary = XMTPBlue, + onPrimary = Color.White, + primaryContainer = XMTPBlueLight, + onPrimaryContainer = Color.White, + secondary = PurpleGrey40, + onSecondary = Color.White, + secondaryContainer = PurpleGrey80, + onSecondaryContainer = Color.Black, + tertiary = Pink40, + onTertiary = Color.White, + background = Color(0xFFF8F8F8), + onBackground = Color.Black, + surface = Color.White, + onSurface = Color.Black, + surfaceVariant = Color(0xFFE8E8ED), + onSurfaceVariant = Color(0xFF49454F), + outline = Color(0xFF79747E), + outlineVariant = Color(0xFFCAC4D0), +) + +@Composable +fun XMTPTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/theme/Type.kt b/example/src/main/java/org/xmtp/android/example/ui/theme/Type.kt new file mode 100644 index 000000000..98c7ec650 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/theme/Type.kt @@ -0,0 +1,115 @@ +package org.xmtp.android.example.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + displayLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp + ), + displayMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp + ), + displaySmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp + ), + headlineLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp + ), + headlineMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp + ), + headlineSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + titleMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp + ), + titleSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp + ), + bodySmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp + ), + labelLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + labelMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) +) diff --git a/example/src/main/java/org/xmtp/android/example/utils/KeyUtil.kt b/example/src/main/java/org/xmtp/android/example/utils/KeyUtil.kt index 11a0f95e5..118616636 100644 --- a/example/src/main/java/org/xmtp/android/example/utils/KeyUtil.kt +++ b/example/src/main/java/org/xmtp/android/example/utils/KeyUtil.kt @@ -2,19 +2,54 @@ package org.xmtp.android.example.utils import android.accounts.AccountManager import android.content.Context +import android.content.SharedPreferences import android.util.Base64.NO_WRAP import android.util.Base64.decode import android.util.Base64.encodeToString +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey import org.xmtp.android.example.R +import timber.log.Timber class KeyUtil( - val context: Context, + private val context: Context, ) { - private val PREFS_NAME = "EncryptionPref" - private val PRIVATE_KEY_PREFS = "PrivateKeyPref" - private val SETTINGS_PREFS = "SettingsPref" - private val KEY_ENVIRONMENT = "xmtp_environment" - private val KEY_HIDE_DELETED_MESSAGES = "hide_deleted_messages" + private companion object { + const val ENCRYPTED_PREFS_NAME = "EncryptedKeyPref" + const val SETTINGS_PREFS = "SettingsPref" + const val KEY_ENVIRONMENT = "xmtp_environment" + const val KEY_HIDE_DELETED_MESSAGES = "hide_deleted_messages" + + // Key prefixes + const val PREFIX_DB_KEY = "xmtp-dev-" + const val PREFIX_WALLET_KEY = "xmtp-wallet-" + } + + private val masterKey: MasterKey by lazy { + MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + } + + private val encryptedPrefs: SharedPreferences by lazy { + try { + EncryptedSharedPreferences.create( + context, + ENCRYPTED_PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } catch (e: Exception) { + Timber.e(e, "Failed to create EncryptedSharedPreferences, falling back to regular prefs") + // Fallback to regular SharedPreferences if encryption fails (e.g., on some devices) + context.getSharedPreferences(ENCRYPTED_PREFS_NAME, Context.MODE_PRIVATE) + } + } + + private val settingsPrefs: SharedPreferences by lazy { + context.getSharedPreferences(SETTINGS_PREFS, Context.MODE_PRIVATE) + } fun loadKeys(): String? { val accountManager = AccountManager.get(context) @@ -28,82 +63,96 @@ class KeyUtil( address: String, dbEncryptionKey: ByteArray?, ) { - val alias = "xmtp-dev-${address.lowercase()}" - - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - val editor = prefs.edit() - editor.putString(alias, encodeToString(dbEncryptionKey, NO_WRAP)) - editor.apply() + val alias = "$PREFIX_DB_KEY${address.lowercase()}" + encryptedPrefs.edit() + .putString(alias, encodeToString(dbEncryptionKey, NO_WRAP)) + .apply() } fun retrieveKey(address: String): ByteArray? { - val alias = "xmtp-dev-${address.lowercase()}" - - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - val keyString = prefs.getString(alias, null) - return if (keyString != null) { - decode(keyString, NO_WRAP) - } else { - null + val alias = "$PREFIX_DB_KEY${address.lowercase()}" + val keyString = encryptedPrefs.getString(alias, null) + return keyString?.let { + try { + decode(it, NO_WRAP) + } catch (e: Exception) { + Timber.e(e, "Failed to decode key") + null + } } } - // Store the wallet private key for signing + /** + * Store the wallet private key for signing. + * Uses EncryptedSharedPreferences backed by Android Keystore. + */ fun storePrivateKey( address: String, privateKeyBytes: ByteArray, ) { - val alias = "xmtp-wallet-${address.lowercase()}" - val prefs = context.getSharedPreferences(PRIVATE_KEY_PREFS, Context.MODE_PRIVATE) - prefs.edit().putString(alias, encodeToString(privateKeyBytes, NO_WRAP)).apply() + val alias = "$PREFIX_WALLET_KEY${address.lowercase()}" + encryptedPrefs.edit() + .putString(alias, encodeToString(privateKeyBytes, NO_WRAP)) + .apply() } - // Retrieve the wallet private key for signing + /** + * Retrieve the wallet private key for signing. + * Uses EncryptedSharedPreferences backed by Android Keystore. + */ fun retrievePrivateKey(address: String): ByteArray? { - val alias = "xmtp-wallet-${address.lowercase()}" - val prefs = context.getSharedPreferences(PRIVATE_KEY_PREFS, Context.MODE_PRIVATE) - val keyString = prefs.getString(alias, null) - return if (keyString != null) { - decode(keyString, NO_WRAP) - } else { - null + val alias = "$PREFIX_WALLET_KEY${address.lowercase()}" + val keyString = encryptedPrefs.getString(alias, null) + return keyString?.let { + try { + decode(it, NO_WRAP) + } catch (e: Exception) { + Timber.e(e, "Failed to decode private key") + null + } } } - // Clear the wallet private key + /** + * Clear the wallet private key. + */ fun clearPrivateKey(address: String) { - val alias = "xmtp-wallet-${address.lowercase()}" - val prefs = context.getSharedPreferences(PRIVATE_KEY_PREFS, Context.MODE_PRIVATE) - prefs.edit().remove(alias).apply() + val alias = "$PREFIX_WALLET_KEY${address.lowercase()}" + encryptedPrefs.edit().remove(alias).apply() } - // Store the selected environment + /** + * Store the selected environment. + */ fun storeEnvironment(environment: String) { - val prefs = context.getSharedPreferences(SETTINGS_PREFS, Context.MODE_PRIVATE) - prefs.edit().putString(KEY_ENVIRONMENT, environment).apply() + settingsPrefs.edit().putString(KEY_ENVIRONMENT, environment).apply() } - // Retrieve the selected environment + /** + * Retrieve the selected environment. + */ fun retrieveEnvironment(): String? { - val prefs = context.getSharedPreferences(SETTINGS_PREFS, Context.MODE_PRIVATE) - return prefs.getString(KEY_ENVIRONMENT, null) + return settingsPrefs.getString(KEY_ENVIRONMENT, null) } - // Clear the environment setting + /** + * Clear the environment setting. + */ fun clearEnvironment() { - val prefs = context.getSharedPreferences(SETTINGS_PREFS, Context.MODE_PRIVATE) - prefs.edit().remove(KEY_ENVIRONMENT).apply() + settingsPrefs.edit().remove(KEY_ENVIRONMENT).apply() } - // Store hide deleted messages setting + /** + * Store hide deleted messages setting. + */ fun setHideDeletedMessages(hide: Boolean) { - val prefs = context.getSharedPreferences(SETTINGS_PREFS, Context.MODE_PRIVATE) - prefs.edit().putBoolean(KEY_HIDE_DELETED_MESSAGES, hide).apply() + settingsPrefs.edit().putBoolean(KEY_HIDE_DELETED_MESSAGES, hide).apply() } - // Retrieve hide deleted messages setting + /** + * Retrieve hide deleted messages setting. + */ fun getHideDeletedMessages(): Boolean { - val prefs = context.getSharedPreferences(SETTINGS_PREFS, Context.MODE_PRIVATE) - return prefs.getBoolean(KEY_HIDE_DELETED_MESSAGES, false) + return settingsPrefs.getBoolean(KEY_HIDE_DELETED_MESSAGES, false) } } diff --git a/example/src/main/res/drawable/ic_audio_24.xml b/example/src/main/res/drawable/ic_audio_24.xml new file mode 100644 index 000000000..f92cc4cdd --- /dev/null +++ b/example/src/main/res/drawable/ic_audio_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_edit_24.xml b/example/src/main/res/drawable/ic_edit_24.xml new file mode 100644 index 000000000..2844bafeb --- /dev/null +++ b/example/src/main/res/drawable/ic_edit_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/example/src/main/res/drawable/ic_pdf_24.xml b/example/src/main/res/drawable/ic_pdf_24.xml new file mode 100644 index 000000000..e0940f5f7 --- /dev/null +++ b/example/src/main/res/drawable/ic_pdf_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_search_24.xml b/example/src/main/res/drawable/ic_search_24.xml new file mode 100644 index 000000000..d9a217a17 --- /dev/null +++ b/example/src/main/res/drawable/ic_search_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_video_24.xml b/example/src/main/res/drawable/ic_video_24.xml new file mode 100644 index 000000000..57d3e17a6 --- /dev/null +++ b/example/src/main/res/drawable/ic_video_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/thumbnail_border.xml b/example/src/main/res/drawable/thumbnail_border.xml new file mode 100644 index 000000000..2e66ebaa2 --- /dev/null +++ b/example/src/main/res/drawable/thumbnail_border.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/example/src/main/res/layout/activity_attachment_preview.xml b/example/src/main/res/layout/activity_attachment_preview.xml new file mode 100644 index 000000000..e1d6587c0 --- /dev/null +++ b/example/src/main/res/layout/activity_attachment_preview.xml @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/layout/activity_conversation_detail.xml b/example/src/main/res/layout/activity_conversation_detail.xml index 7d855fd0d..62a11ea43 100644 --- a/example/src/main/res/layout/activity_conversation_detail.xml +++ b/example/src/main/res/layout/activity_conversation_detail.xml @@ -30,6 +30,19 @@ app:layout_constraintTop_toTopOf="parent" app:tint="@color/white" /> + + + @@ -108,6 +121,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/layout/list_item_message_received.xml b/example/src/main/res/layout/list_item_message_received.xml index b8f9276bc..d3c8e41a7 100644 --- a/example/src/main/res/layout/list_item_message_received.xml +++ b/example/src/main/res/layout/list_item_message_received.xml @@ -51,7 +51,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/reply_background_received" - android:orientation="vertical" + android:orientation="horizontal" android:paddingStart="12dp" android:paddingEnd="10dp" android:paddingVertical="6dp" @@ -62,27 +62,53 @@ android:visibility="gone" tools:visibility="visible"> - + + - + + + + android:orientation="vertical"> + + + + + diff --git a/example/src/main/res/layout/list_item_message_sent.xml b/example/src/main/res/layout/list_item_message_sent.xml index f5ec9deb0..ce6d4cea4 100644 --- a/example/src/main/res/layout/list_item_message_sent.xml +++ b/example/src/main/res/layout/list_item_message_sent.xml @@ -23,10 +23,9 @@ app:layout_constraintWidth_max="wrap" app:layout_constraintWidth_percent="0.85"> - - + + - + + + + android:orientation="vertical"> + + + + + @@ -82,6 +111,8 @@ app:cardElevation="0dp" app:strokeWidth="0dp" android:visibility="gone" + app:layout_constraintTop_toBottomOf="@id/replyContainer" + app:layout_constraintStart_toStartOf="parent" tools:visibility="visible"> + android:layout_marginTop="3dp" + app:layout_constraintTop_toBottomOf="@id/messageBody" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintBottom_toBottomOf="parent"> - + diff --git a/example/src/main/res/values/dimens.xml b/example/src/main/res/values/dimens.xml index f5260bbc1..ede167765 100644 --- a/example/src/main/res/values/dimens.xml +++ b/example/src/main/res/values/dimens.xml @@ -2,4 +2,5 @@ 16dp 48dp + 60dp diff --git a/example/src/main/res/values/strings.xml b/example/src/main/res/values/strings.xml index e8ce879f5..6f6fa72a0 100644 --- a/example/src/main/res/values/strings.xml +++ b/example/src/main/res/values/strings.xml @@ -108,11 +108,18 @@ Message Reply + Edit Delete + Edit Message + Editing message Replying to %1$s Emoji Attach file Voice message + Search + Search messages + No results found + Cancel Send Attachment @@ -125,6 +132,10 @@ Failed to send attachment Loading… Download + Preview + Add a caption… + Send + %1$d of %2$d View Logs diff --git a/example/src/main/res/values/themes.xml b/example/src/main/res/values/themes.xml index 52065ef66..cfb70da3b 100644 --- a/example/src/main/res/values/themes.xml +++ b/example/src/main/res/values/themes.xml @@ -48,6 +48,13 @@ @color/white + + +