From cffc38400833634d0e7359c034a2f5f70a6d7ffc Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Tue, 19 Nov 2024 15:17:17 -0500 Subject: [PATCH] fix: Android V3 Build (#1217) Fix Android Build Update Conversation List to have hooks --- .../src/main/java/com/converse/Preferences.kt | 35 +++ .../com/converse/PushNotificationsService.kt | 82 ++---- .../app/src/main/java/com/converse/Spam.kt | 74 +++-- .../src/main/java/com/converse/xmtp/Client.kt | 27 +- .../java/com/converse/xmtp/Conversations.kt | 103 +------ .../main/java/com/converse/xmtp/Messages.kt | 276 ++++++------------ components/V3DMListItem.tsx | 230 ++++++++++++--- components/V3GroupConversationListItem.tsx | 72 +---- data/store/chatStore.ts | 4 +- .../hooks/useMessageIsUnread.ts | 39 +++ .../conversation-list/hooks/useMessageText.ts | 30 ++ .../hooks/useToggleReadStatus.ts | 36 +++ 12 files changed, 541 insertions(+), 467 deletions(-) create mode 100644 android/app/src/main/java/com/converse/Preferences.kt create mode 100644 features/conversation-list/hooks/useMessageIsUnread.ts create mode 100644 features/conversation-list/hooks/useMessageText.ts create mode 100644 features/conversation-list/hooks/useToggleReadStatus.ts diff --git a/android/app/src/main/java/com/converse/Preferences.kt b/android/app/src/main/java/com/converse/Preferences.kt new file mode 100644 index 000000000..7d8461fa6 --- /dev/null +++ b/android/app/src/main/java/com/converse/Preferences.kt @@ -0,0 +1,35 @@ +import org.xmtp.android.library.ConsentList +import org.xmtp.android.library.ConsentState +import org.xmtp.android.library.Conversation + +suspend fun isConversationBlocked(conversation: Conversation, consentList: ConsentList): Boolean { + return isConversationIdBlocked(conversation.id, consentList) +} + +suspend fun isConversationAllowed(conversation: Conversation, consentList: ConsentList): Boolean { + return isConversationIdAllowed(conversation.id, consentList) +} + +suspend fun isConversationIdBlocked(conversationId: String, consentList: ConsentList): Boolean { + return consentList.conversationState(conversationId) == ConsentState.DENIED +} + +suspend fun isConversationIdAllowed(conversationId: String, consentList: ConsentList): Boolean { + return consentList.conversationState(conversationId) == ConsentState.ALLOWED +} + +suspend fun isAddressAllowed(address: String, consentList: ConsentList): Boolean { + return consentList.addressState(address) == ConsentState.ALLOWED +} + +suspend fun isAddressBlocked(address: String, consentList: ConsentList): Boolean { + return consentList.addressState(address) == ConsentState.DENIED +} + +suspend fun isInboxIdAllowed(inboxId: String, consentList: ConsentList): Boolean { + return consentList.inboxIdState(inboxId) == ConsentState.ALLOWED +} + +suspend fun isInboxIdBlocked(inboxId: String, consentList: ConsentList): Boolean { + return consentList.inboxIdState(inboxId) == ConsentState.DENIED +} diff --git a/android/app/src/main/java/com/converse/PushNotificationsService.kt b/android/app/src/main/java/com/converse/PushNotificationsService.kt index ea52a5c37..ac6bb1641 100644 --- a/android/app/src/main/java/com/converse/PushNotificationsService.kt +++ b/android/app/src/main/java/com/converse/PushNotificationsService.kt @@ -7,17 +7,9 @@ import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import com.beust.klaxon.Klaxon -import com.bumptech.glide.Glide -import com.bumptech.glide.load.engine.DiskCacheStrategy import com.converse.xmtp.NotificationDataResult import com.converse.xmtp.getGroup -import com.converse.xmtp.getNewConversationFromEnvelope -import com.converse.xmtp.getNewGroup import com.converse.xmtp.getXmtpClient -import com.converse.xmtp.handleGroupMessage -import com.converse.xmtp.handleGroupWelcome -import com.converse.xmtp.handleNewConversationFirstMessage -import com.converse.xmtp.handleOngoingConversationMessage import com.converse.xmtp.initCodecs import com.facebook.react.bridge.ReactApplicationContext import com.google.crypto.tink.subtle.Base64 @@ -32,18 +24,11 @@ import expo.modules.kotlin.ModulesProvider import expo.modules.kotlin.modules.Module import expo.modules.notifications.notifications.JSONNotificationContentBuilder import expo.modules.notifications.notifications.model.Notification -import expo.modules.notifications.notifications.model.NotificationAction import expo.modules.notifications.notifications.model.NotificationContent import expo.modules.notifications.notifications.model.NotificationRequest -import expo.modules.notifications.notifications.model.NotificationResponse import expo.modules.notifications.notifications.model.triggers.FirebaseNotificationTrigger import expo.modules.notifications.notifications.presentation.builders.CategoryAwareNotificationBuilder import expo.modules.notifications.notifications.presentation.builders.ExpoNotificationBuilder -import expo.modules.notifications.service.NotificationsService.Companion.EVENT_TYPE_KEY -import expo.modules.notifications.service.NotificationsService.Companion.NOTIFICATION_ACTION_KEY -import expo.modules.notifications.service.NotificationsService.Companion.NOTIFICATION_EVENT_ACTION -import expo.modules.notifications.service.NotificationsService.Companion.NOTIFICATION_KEY -import expo.modules.notifications.service.NotificationsService.Companion.findDesignatedBroadcastReceiver import expo.modules.notifications.service.delegates.SharedPreferencesNotificationCategoriesStore import expo.modules.securestore.AuthenticationHelper import expo.modules.securestore.SecureStoreModule @@ -51,7 +36,6 @@ import expo.modules.securestore.encryptors.AESEncryptor import expo.modules.securestore.encryptors.HybridAESEncryptor import kotlinx.coroutines.* import org.json.JSONObject -import org.xmtp.android.library.messages.EnvelopeBuilder import java.lang.ref.WeakReference import java.security.KeyStore import java.util.* @@ -60,6 +44,12 @@ import kotlin.reflect.jvm.isAccessible import kotlin.reflect.jvm.javaField import androidx.lifecycle.Lifecycle import androidx.lifecycle.ProcessLifecycleOwner +import com.converse.xmtp.getNewConversation +import com.converse.xmtp.handleV3Message +import com.converse.xmtp.handleV3Welcome +import com.google.protobuf.kotlin.toByteString +import org.xmtp.proto.message.api.v1.MessageApiOuterClass +import org.xmtp.proto.message.api.v1.MessageApiOuterClass.Envelope class PushNotificationsService : FirebaseMessagingService() { companion object { @@ -110,11 +100,11 @@ class PushNotificationsService : FirebaseMessagingService() { Log.d(TAG, "INSTANTIATED XMTP CLIENT FOR ${notificationData.contentTopic}") val encryptedMessageData = Base64.decode(notificationData.message, Base64.NO_WRAP) - val envelope = EnvelopeBuilder.buildFromString( - notificationData.contentTopic, - Date(notificationData.timestampNs.toLong() / 1000000), - encryptedMessageData - ) + val envelope = Envelope.newBuilder().apply { + timestampNs = notificationData.timestampNs.toLong() / 1_000_000 + message = encryptedMessageData.toByteString()// Convert ByteString to byte array + contentTopic = notificationData.contentTopic + }.build() var shouldShowNotification = false var result = NotificationDataResult() @@ -130,38 +120,13 @@ class PushNotificationsService : FirebaseMessagingService() { ) return@launch } - if (isInviteTopic(notificationData.contentTopic)) { - Log.d(TAG, "Handling a new conversation notification") - val conversation = - getNewConversationFromEnvelope(applicationContext, xmtpClient, envelope) - if (conversation != null) { - result = handleNewConversationFirstMessage( + if (isV3WelcomeTopic(notificationData.contentTopic)) { + val convo = getNewConversation(xmtpClient, notificationData.contentTopic) + if (convo != null) { + result = handleV3Welcome( applicationContext, xmtpClient, - conversation, - remoteMessage - ) - if (result != NotificationDataResult()) { - shouldShowNotification = result.shouldShowNotification - } - - // Replace invite-topic with the topic in the notification content - val newNotificationData = NotificationData( - notificationData.message, - notificationData.timestampNs, - conversation.topic, - notificationData.account, - ) - val newNotificationDataJson = Klaxon().toJsonString(newNotificationData) - remoteMessage.data["body"] = newNotificationDataJson - } - } else if (isV3WelcomeTopic(notificationData.contentTopic)) { - val group = getNewGroup(xmtpClient, notificationData.contentTopic) - if (group != null) { - result = handleGroupWelcome( - applicationContext, - xmtpClient, - group, + convo, remoteMessage ) if (result != NotificationDataResult()) { @@ -170,22 +135,11 @@ class PushNotificationsService : FirebaseMessagingService() { } } else if (isV3MessageTopic(notificationData.contentTopic)) { Log.d(TAG, "Handling an ongoing group message notification") - result = handleGroupMessage( - applicationContext, - xmtpClient, - envelope, - remoteMessage - ) - if (result != NotificationDataResult()) { - shouldShowNotification = result.shouldShowNotification - } - } else { - Log.d(TAG, "Handling an ongoing conversation message notification") - result = handleOngoingConversationMessage( + result = handleV3Message( applicationContext, xmtpClient, envelope, - remoteMessage + remoteMessage, ) if (result != NotificationDataResult()) { shouldShowNotification = result.shouldShowNotification diff --git a/android/app/src/main/java/com/converse/Spam.kt b/android/app/src/main/java/com/converse/Spam.kt index b194243fa..2f0f90059 100644 --- a/android/app/src/main/java/com/converse/Spam.kt +++ b/android/app/src/main/java/com/converse/Spam.kt @@ -17,6 +17,7 @@ import java.util.HashMap import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import org.web3j.crypto.Keys +import org.xmtp.android.library.Dm val restrictedWords = listOf( "Coinbase", @@ -53,7 +54,7 @@ fun getMessageSpamScore(message: String?, contentType: String): Int { return spamScore } -fun computeSpamScore(address: String, message: String?, contentType: String, apiURI: String?, appContext: Context): Double { +fun computeDmSpamScore(address: String, message: String?, contentType: String, apiURI: String?, appContext: Context): Double { var senderScore = runBlocking { getSenderSpamScore(appContext, address, apiURI); } @@ -63,27 +64,28 @@ fun computeSpamScore(address: String, message: String?, contentType: String, api suspend fun computeSpamScoreGroupMessage(client: Client, group: Group, decodedMessage: DecodedMessage, apiURI: String?): Int { val senderSpamScore = 0 try { - client.contacts.refreshConsentList() - val groupDenied = client.contacts.isGroupDenied(group.id) - if (groupDenied) { + client.syncConsent() + val consentList = client.preferences.consentList + val groupBlocked = isConversationIdBlocked(group.id, consentList) + if (groupBlocked) { // Network consent will override other checks return 1 } val senderInboxId = decodedMessage.senderAddress - val senderDenied = client.contacts.isInboxDenied(senderInboxId) - if (senderDenied) { + val senderBlocked = isInboxIdBlocked(senderInboxId, consentList) + if (senderBlocked) { // Network consent will override other checks return 1 } - val senderAllowed = client.contacts.isInboxAllowed(senderInboxId) + val senderAllowed = isInboxIdAllowed(senderInboxId, consentList) if (senderAllowed) { // Network consent will override other checks return -1 } - val groupAllowed = client.contacts.isGroupAllowed(group.id) + val groupAllowed = isConversationIdAllowed(group.id, consentList) if (groupAllowed) { // Network consent will override other checks return -1 @@ -92,12 +94,12 @@ suspend fun computeSpamScoreGroupMessage(client: Client, group: Group, decodedMe val senderAddresses = group.members().find { it.inboxId == senderInboxId }?.addresses if (senderAddresses != null) { for (address in senderAddresses) { - if (client.contacts.isDenied(Keys.toChecksumAddress(address))) { + if (isAddressBlocked(Keys.toChecksumAddress(address), consentList)) { return 1 } } for (address in senderAddresses) { - if (client.contacts.isAllowed(Keys.toChecksumAddress(address))) { + if (isAddressAllowed(Keys.toChecksumAddress(address), consentList)) { return -1 } } @@ -121,22 +123,60 @@ suspend fun computeSpamScoreGroupMessage(client: Client, group: Group, decodedMe return senderSpamScore + messageSpamScore } +suspend fun computeSpamScoreDmWelcome(appContext: Context, client: Client, dm: Dm, apiURI: String?): Double { + try { + val consentList = client.preferences.consentList + // Probably an unlikely case until consent proofs for groups exist + val groupAllowed = isConversationIdAllowed(dm.id, consentList) + if (groupAllowed) { + return -1.0 + } + + val peerInboxId = dm.peerInboxId + val peerAllowed = isInboxIdAllowed(peerInboxId, consentList) + if (peerAllowed) { + return -1.0 + } + + val peerDenied = isInboxIdBlocked(peerInboxId, consentList) + if (peerDenied) { + return 1.0 + } + val members = dm.members() + for (member in members) { + if (member.inboxId == peerInboxId) { + val firstAddress = member.addresses.first() + val senderSpamScore = getSenderSpamScore( + appContext = appContext, + address = Keys.toChecksumAddress(firstAddress), + apiURI = apiURI + ) + return senderSpamScore + } + } + return 0.0 + } catch (e: Exception) { + return 0.0 + } +} + + suspend fun computeSpamScoreGroupWelcome(appContext: Context, client: Client, group: Group, apiURI: String?): Double { try { - client.contacts.refreshConsentList() + val consentList = client.preferences.consentList // Probably an unlikely case until consent proofs for groups exist - val groupAllowed = client.contacts.isGroupAllowed(groupId = group.id) + val groupAllowed = isConversationIdAllowed(group.id, consentList) if (groupAllowed) { return -1.0 } val inviterInboxId = group.addedByInboxId() - val inviterAllowed = client.contacts.isInboxAllowed(inboxId = inviterInboxId) + val inviterAllowed = isInboxIdAllowed(inviterInboxId, consentList) if (inviterAllowed) { return -1.0 } - val inviterDenied = client.contacts.isInboxDenied(inboxId = inviterInboxId) + val inviterDenied = isInboxIdBlocked(inviterInboxId, consentList) if (inviterDenied) { return 1.0 } @@ -146,14 +186,14 @@ suspend fun computeSpamScoreGroupWelcome(appContext: Context, client: Client, gr if (member.inboxId == inviterInboxId) { member.addresses?.forEach { address -> val ethereumAddress = Keys.toChecksumAddress(address) - if (client.contacts.isDenied(ethereumAddress)) { + if (isAddressBlocked(ethereumAddress, consentList)) { return 1.0 } } member.addresses?.forEach { address -> val ethereumAddress = Keys.toChecksumAddress(address) - if (client.contacts.isAllowed(ethereumAddress)) { + if (isAddressAllowed(ethereumAddress, consentList)) { return -1.0 } } @@ -168,8 +208,6 @@ suspend fun computeSpamScoreGroupWelcome(appContext: Context, client: Client, gr } } } - - } catch (e: Exception) { return 0.0 } diff --git a/android/app/src/main/java/com/converse/xmtp/Client.kt b/android/app/src/main/java/com/converse/xmtp/Client.kt index 36b8de51f..c6b3b7500 100644 --- a/android/app/src/main/java/com/converse/xmtp/Client.kt +++ b/android/app/src/main/java/com/converse/xmtp/Client.kt @@ -12,7 +12,6 @@ import org.xmtp.android.library.codecs.AttachmentCodec import org.xmtp.android.library.codecs.ReactionCodec import org.xmtp.android.library.codecs.RemoteAttachmentCodec import org.xmtp.android.library.codecs.ReplyCodec -import org.xmtp.android.library.messages.PrivateKeyBundleV1Builder fun initCodecs() { Client.register(codec = AttachmentCodec()) @@ -20,22 +19,6 @@ fun initCodecs() { Client.register(codec = ReactionCodec()) Client.register(codec = ReplyCodec()) } - -fun getXmtpKeyForAccount(appContext: Context, account: String): String? { - val legacyKey = getKeychainValue("XMTP_BASE64_KEY") - if (legacyKey != null && legacyKey.isNotEmpty()) { - Log.d("XmtpClient", "Legacy Key Found: ${legacyKey} ${legacyKey.length}") - return legacyKey - } - - val accountKey = getKeychainValue("XMTP_KEY_${account}") - if (accountKey != null && accountKey.isNotEmpty()) { - Log.d("XmtpClient", "Found key for account: ${account}") - return accountKey - } - return null -} - fun getDbEncryptionKey(): ByteArray? { val key = getKeychainValue("LIBXMTP_DB_ENCRYPTION_KEY") if (key != null) { @@ -46,9 +29,6 @@ fun getDbEncryptionKey(): ByteArray? { } suspend fun getXmtpClient(appContext: Context, account: String): Client? { - val keyString = getXmtpKeyForAccount(appContext, account) ?: return null - val keyByteArray = Base64.decode(keyString) - val keys = PrivateKeyBundleV1Builder.buildFromBundle(keyByteArray) val mmkv = getMmkv(appContext) var xmtpEnvString = mmkv?.decodeString("xmtp-env") // TODO => stop using async storage @@ -60,8 +40,11 @@ suspend fun getXmtpClient(appContext: Context, account: String): Client? { val dbDirectory = "/data/data/${appContext.packageName}/databases" val dbEncryptionKey = getDbEncryptionKey() + if (dbEncryptionKey == null) { + throw Error("Missing dbEncryptionKey") + } - val options = ClientOptions(api = ClientOptions.Api(env = xmtpEnv, isSecure = true), enableV3 = true, dbEncryptionKey = dbEncryptionKey, dbDirectory = dbDirectory, appContext = appContext) + val options = ClientOptions(api = ClientOptions.Api(env = xmtpEnv, isSecure = true), dbEncryptionKey = dbEncryptionKey, dbDirectory = dbDirectory, appContext = appContext) - return Client().buildFrom(bundle = keys, options = options) + return Client().build(address = account, options = options) } \ No newline at end of file diff --git a/android/app/src/main/java/com/converse/xmtp/Conversations.kt b/android/app/src/main/java/com/converse/xmtp/Conversations.kt index 4fb846c6e..1b7942aa9 100644 --- a/android/app/src/main/java/com/converse/xmtp/Conversations.kt +++ b/android/app/src/main/java/com/converse/xmtp/Conversations.kt @@ -8,15 +8,10 @@ import com.android.volley.toolbox.JsonObjectRequest import com.android.volley.toolbox.Volley import com.beust.klaxon.Klaxon import com.converse.* -import com.google.crypto.tink.subtle.Base64 - import org.json.JSONObject import org.xmtp.android.library.Client import org.xmtp.android.library.Conversation import org.xmtp.android.library.Group -import org.xmtp.android.library.messages.Envelope -import org.xmtp.proto.keystore.api.v1.Keystore.TopicMap.TopicData -import java.security.MessageDigest import java.util.HashMap fun subscribeToTopic(appContext: Context, apiURI: String, account: String, pushToken: String, topic: String, hmacKeys: String?) { @@ -57,97 +52,31 @@ fun saveConversationToStorage(appContext: Context, account: String, topic: Strin mmkv?.putString("saved-notifications-conversations", newSavedConversationsString) } -suspend fun getNewConversationFromEnvelope(appContext: Context, xmtpClient: Client, envelope: Envelope): Conversation? { +suspend fun getNewConversation(xmtpClient: Client, contentTopic: String): Conversation? { return try { - if (isInviteTopic(envelope.contentTopic)) { - val conversation = xmtpClient.conversations.fromInvite(envelope) - when (conversation) { - is Conversation.V1 -> { - // Nothing to do - } - is Conversation.V2 -> { - persistNewConversation(appContext, xmtpClient.address, conversation) - } - else -> { - // Nothing to do (group) - } - } + if (isV3WelcomeTopic(contentTopic)) { + // Welcome envelopes are too large to send in a push, so a bit of a hack to get the latest group + xmtpClient.conversations.sync() + val conversation = xmtpClient.findConversationByTopic(contentTopic) + + conversation?.sync() conversation } else { null } - } catch (e: Exception) { - Log.e("PushNotificationsService", "Could not decode new conversation envelope", e) + } catch (error: Exception) { + sentryTrackError(error, mapOf("message" to "Could not sync new group")) null } } -fun getPersistedConversation(appContext: Context, xmtpClient: Client, topic: String): Conversation? { - try { - val secureMmkv = getSecureMmkvForAccount(appContext, xmtpClient.address) - secureMmkv?.let { mmkv -> - val jsonData = mmkv.decodeString("XMTP_TOPICS_DATA") - jsonData?.let {data -> - val json = JSONObject(data); - val topData = json.optString(topic, null); - - topData?.let { topicData -> - val persistedTopicData = TopicData.parseFrom(Base64.decode(topicData, NO_WRAP)) - Log.d("PushNotificationsService", "Got saved conversation from topic data") - return xmtpClient.conversations.importTopicData(persistedTopicData) - } - } - } - - - // TODO => remove this a bit later - // During migration time, data is still in keychain, not in mmkv - val topicBytes = topic.toByteArray(Charsets.UTF_8) - val digest = MessageDigest.getInstance("SHA-256").digest(topicBytes) - val encodedTopic = digest.joinToString("") { "%02x".format(it) } - val persistedTopicData = getKeychainValue("XMTP_TOPIC_DATA_${xmtpClient.address}_$encodedTopic") - if (persistedTopicData !== null) { - val data = TopicData.parseFrom(Base64.decode(persistedTopicData, NO_WRAP)) - Log.d("PushNotificationsService", "Got saved conversation from topic data") - return xmtpClient.conversations.importTopicData(data) - } - } catch (e: Exception) { - Log.d("PushNotificationsService", "Could not retrieve conversation: $e") - } - return null -} - -fun persistNewConversation(appContext:Context, account: String, conversation: Conversation) { - try { - val secureMmkv = getSecureMmkvForAccount(appContext, account) - secureMmkv?.let { mmkv -> - val jsonData = mmkv.decodeString("XMTP_TOPICS_DATA") - jsonData?.let {data -> - val json = JSONObject(data); - val conversationTopicData = Base64.encodeToString(conversation.toTopicData().toByteArray(), NO_WRAP); - json.put(conversation.topic, conversationTopicData); - val jsonString = json.toString() - secureMmkv.putString("XMTP_TOPICS_DATA", jsonString) - } - } - } catch (e: Exception) { - Log.d("PushNotificationsService", "Could not persist conversation: $e") - } -} - -suspend fun getNewGroup(xmtpClient: Client, contentTopic: String): Group? { +suspend fun getConversation(xmtpClient: Client, conversationTopic: String): Conversation? { return try { - if (isV3WelcomeTopic(contentTopic)) { - // Welcome envelopes are too large to send in a push, so a bit of a hack to get the latest group - xmtpClient.conversations.syncGroups() - val groups = xmtpClient.conversations.listGroups() - val group = groups.maxByOrNull { it.createdAt } - - group?.sync() - group - } else { - null - } + // Welcome envelopes are too large to send in a push, so a bit of a hack to get the latest group + xmtpClient.conversations.sync() + val conversation = xmtpClient.findConversationByTopic(conversationTopic) + conversation?.sync() + conversation } catch (error: Exception) { sentryTrackError(error, mapOf("message" to "Could not sync new group")) null @@ -157,7 +86,7 @@ suspend fun getNewGroup(xmtpClient: Client, contentTopic: String): Group? { suspend fun getGroup(xmtpClient: Client, groupId: String): Group? { return try { // Welcome envelopes are too large to send in a push, so a bit of a hack to get the latest group - xmtpClient.conversations.syncGroups() + xmtpClient.conversations.sync() val group = xmtpClient.findGroup(groupId) group?.sync() group diff --git a/android/app/src/main/java/com/converse/xmtp/Messages.kt b/android/app/src/main/java/com/converse/xmtp/Messages.kt index df796cedd..3f6987f9d 100644 --- a/android/app/src/main/java/com/converse/xmtp/Messages.kt +++ b/android/app/src/main/java/com/converse/xmtp/Messages.kt @@ -2,40 +2,28 @@ package com.converse.xmtp import android.content.Context import android.util.Log -import com.android.volley.Request -import com.android.volley.toolbox.JsonObjectRequest -import com.android.volley.toolbox.Volley import com.beust.klaxon.Klaxon import com.converse.* import com.converse.PushNotificationsService.Companion.TAG -import android.util.Base64 -import android.util.Base64.NO_WRAP -import androidx.core.app.Person import com.google.firebase.messaging.RemoteMessage -import computeSpamScore +import computeSpamScoreDmWelcome import computeSpamScoreGroupMessage import computeSpamScoreGroupWelcome -import expo.modules.notifications.service.delegates.encodedInBase64 -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.suspendCancellableCoroutine +import isConversationAllowed +import isConversationBlocked + import org.json.JSONObject import org.xmtp.android.library.Client -import org.xmtp.android.library.ConsentState import org.xmtp.android.library.Conversation import org.xmtp.android.library.DecodedMessage -import org.xmtp.android.library.Group import org.xmtp.android.library.codecs.Reaction import org.xmtp.android.library.codecs.RemoteAttachment import org.xmtp.android.library.codecs.Reply -import org.xmtp.android.library.messages.Envelope -import org.xmtp.proto.keystore.api.v1.Keystore import org.xmtp.proto.message.api.v1.MessageApiOuterClass + import org.xmtp.proto.message.contents.Content -import java.util.HashMap -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException + data class NotificationDataResult( val title: String = "", @@ -55,148 +43,60 @@ data class DecodedMessageResult( val id: String? = null ) -suspend fun handleNewConversationFirstMessage( +suspend fun handleV3Message( appContext: Context, xmtpClient: Client, - conversation: Conversation, - remoteMessage: RemoteMessage + envelope: MessageApiOuterClass.Envelope, + remoteMessage: RemoteMessage, ): NotificationDataResult { + val conversation = xmtpClient.findConversationByTopic(envelope.contentTopic) + if (conversation == null) { + Log.d("PushNotificationsService", "No conversation found for ${envelope.contentTopic}") + return NotificationDataResult() + } + sentryTrackMessage( + "[NotificationExtension] Found conversation", + mapOf() + ) - var shouldShowNotification = false - var attempts = 0 - var messageId: String? = null - var body = "" - - while (attempts < 5) { - try { - val messages = conversation.messages(limit = 1, direction = MessageApiOuterClass.SortDirection.SORT_DIRECTION_ASCENDING) - if (messages.isNotEmpty()) { - val message = messages[0] - messageId = message.id - var conversationContext: ConversationContext? = null - val contentType = getContentTypeString(message.encodedContent.type) - - var messageContent: String? = null - if (contentType.startsWith("xmtp.org/text:")) { - messageContent = message.encodedContent.content.toStringUtf8() - } - - val mmkv = getMmkv(appContext) - var apiURI = mmkv?.decodeString("api-uri") - if (apiURI == null) { - apiURI = getAsyncStorage("api-uri") - } - - val spamScore = computeSpamScore( - address = conversation.peerAddress, - message = messageContent, - contentType = contentType, - appContext = appContext, - apiURI = apiURI - ) - - when (conversation) { - is Conversation.V1 -> { - // Nothing to do - } - is Conversation.V2 -> { - val conversationV2 = conversation.conversationV2 - - conversationContext = ConversationContext( - conversationV2.context.conversationId, - conversationV2.context.metadataMap - ) - - // Save conversation and its spamScore to mmkv - saveConversationToStorage( - appContext, - xmtpClient.address, - conversationV2.topic, - conversationV2.peerAddress, - conversationV2.createdAt.time, - conversationContext, - spamScore, - ) - } - else -> { - // Nothing to do (group) - } - } + conversation.sync() - val decodedMessageResult = handleMessageByContentType( - appContext, - message, - xmtpClient, - ) - - if (decodedMessageResult.senderAddress == xmtpClient.address || decodedMessageResult.forceIgnore) { - // Drop the message - Log.d(TAG, "Not showing a notification") - } else if (decodedMessageResult.content != null) { - shouldShowNotification = true - body = decodedMessageResult.content - } + sentryTrackMessage( + "[NotificationExtension] Done syncing group", + mapOf() + ) - if (spamScore >= 1) { - Log.d(TAG, "Not showing a notification because considered spam") - shouldShowNotification = false - } else { - val pushToken = getKeychainValue("PUSH_TOKEN") - - if (apiURI != null && pushToken !== null) { - Log.d(TAG, "Subscribing to new topic at api: $apiURI") - xmtpClient.conversations.importTopicData(conversation.toTopicData()) - val request = Keystore.GetConversationHmacKeysRequest.newBuilder().addTopics(conversation.topic).build() - val hmacKeys = xmtpClient.conversations.getHmacKeys(request) - var conversationHmacKeys = hmacKeys.hmacKeysMap[conversation.topic]?.let { - Base64.encodeToString(it.toByteArray(), NO_WRAP) - } - subscribeToTopic(appContext, apiURI, xmtpClient.address, pushToken, conversation.topic, conversationHmacKeys) - shouldShowNotification = true - } - } - break - } else { - Log.d(TAG, "No message found in conversation, for now.") - } - } catch (e: Exception) { - Log.e(TAG, "Error fetching messages: $e") - break + val decodedMessage = conversation.processMessage(envelope.message.toByteArray()).decode() + when (conversation) { + is Conversation.Group -> { + // Handle the Group case + return handleGroupMessage(appContext, conversation, decodedMessage, xmtpClient, remoteMessage) + } + is Conversation.Dm -> { + // Handle the Dm case + return handleDmMessage(appContext, conversation, decodedMessage, xmtpClient, remoteMessage) } - - // Wait for 4 seconds before the next attempt - delay(4000) - - attempts++ } - - return NotificationDataResult( - title = shortAddress(conversation.peerAddress), - body = body, - remoteMessage = remoteMessage, - messageId = messageId, - shouldShowNotification = shouldShowNotification - ) } -suspend fun handleOngoingConversationMessage( +suspend fun handleDmMessage( appContext: Context, + conversation: Conversation.Dm, + decodedMessage: DecodedMessage, xmtpClient: Client, - envelope: Envelope, - remoteMessage: RemoteMessage, + remoteMessage: RemoteMessage ): NotificationDataResult { - val conversation = getPersistedConversation(appContext, xmtpClient, envelope.contentTopic) - ?: run { - Log.d("PushNotificationsService", "No conversation found for ${envelope.contentTopic}") - return NotificationDataResult() - } - - val message = conversation.decode(envelope) +// For now, use the conversation member linked address as "senderAddress" + val dm = conversation.dm + // @todo => make inboxId a first class citizen + dm.members().firstOrNull { it.inboxId == decodedMessage.senderAddress }?.addresses?.get(0)?.let { senderAddress -> + decodedMessage.senderAddress = senderAddress + } var conversationTitle = "" val decodedMessageResult = handleMessageByContentType( appContext, - message, + decodedMessage, xmtpClient, ) @@ -234,35 +134,17 @@ suspend fun handleOngoingConversationMessage( suspend fun handleGroupMessage( appContext: Context, + convoGroup: Conversation.Group, + decodedMessage: DecodedMessage, xmtpClient: Client, - envelope: Envelope, - remoteMessage: RemoteMessage, + remoteMessage: RemoteMessage ): NotificationDataResult { - val group = xmtpClient.findGroup(getV3IdFromTopic(envelope.contentTopic)) - if (group == null) { - Log.d("PushNotificationsService", "No group found for ${envelope.contentTopic}") - return NotificationDataResult() - } - sentryTrackMessage( - "[NotificationExtension] Found group", - mapOf() - ) - - group.sync() - - sentryTrackMessage( - "[NotificationExtension] Done syncing group", - mapOf() - ) - - val decodedMessage = group.processMessage(envelope.message.toByteArray()).decode() - - // For now, use the group member linked address as "senderAddress" +// For now, use the conversation member linked address as "senderAddress" + val group = convoGroup.group // @todo => make inboxId a first class citizen group.members().firstOrNull { it.inboxId == decodedMessage.senderAddress }?.addresses?.get(0)?.let { senderAddress -> decodedMessage.senderAddress = senderAddress } - val decodedMessageResult = handleMessageByContentType( appContext, decodedMessage, @@ -471,10 +353,10 @@ fun getJsonReaction(decodedMessage: DecodedMessage): String { } -suspend fun handleGroupWelcome( +suspend fun handleV3Welcome( appContext: Context, xmtpClient: Client, - group: Group, + conversation: Conversation, remoteMessage: RemoteMessage ): NotificationDataResult { var shouldShowNotification = false @@ -484,11 +366,30 @@ suspend fun handleGroupWelcome( if (apiURI == null) { apiURI = getAsyncStorage("api-uri") } - val spamScore = computeSpamScoreGroupWelcome(appContext, xmtpClient, group, apiURI) + xmtpClient.syncConsent() + val consentList = xmtpClient.preferences.consentList + var spamScore = 1.0 + when (conversation) { + is Conversation.Group -> { + // Handle the Group case + spamScore = + computeSpamScoreGroupWelcome(appContext, xmtpClient, conversation.group, apiURI) + } + + is Conversation.Dm -> { + // Handle the Dm case + spamScore = computeSpamScoreDmWelcome( + appContext, + xmtpClient, + conversation.dm, + apiURI + ) + } + } if (spamScore < 0) { // Message is going to main inbox // consent list loaded in computeSpamScoreGroupWelcome - val groupAllowed = xmtpClient.contacts.isGroupAllowed(groupId = group.id) - val groupDenied = xmtpClient.contacts.isGroupDenied(groupId = group.id) + val groupAllowed = isConversationAllowed(conversation, consentList) + val groupDenied = isConversationBlocked(conversation, consentList) // If group is already consented (either way) then don't show a notification for welcome as this will likely be a second+ installation if (!groupAllowed && !groupDenied) { shouldShowNotification = true @@ -504,14 +405,31 @@ suspend fun handleGroupWelcome( } catch (e: Exception) { } - group.sync() - return NotificationDataResult( - title = group.name, - body = "You have been added to a new group", - remoteMessage = remoteMessage, - messageId = "welcome-${group.topic}", - shouldShowNotification = shouldShowNotification - ) + conversation.sync() + when (conversation) { + is Conversation.Group -> { + // Handle the Group case + return NotificationDataResult( + title = conversation.group.name, + body = "You have been added to a new group", + remoteMessage = remoteMessage, + messageId = "welcome-${conversation.topic}", + shouldShowNotification = shouldShowNotification + ) + } + is Conversation.Dm -> { + // TODO: + // Handle the Dm case + return NotificationDataResult( + title = conversation.dm.peerInboxId, + body = "You have a new DM", + remoteMessage = remoteMessage, + messageId = "welcome-${conversation.topic}", + shouldShowNotification = shouldShowNotification + ) + } + } + } fun isGroupMessageFromMe(xmtpClient: Client, messageId: String): Boolean { diff --git a/components/V3DMListItem.tsx b/components/V3DMListItem.tsx index b87c37f33..c87c69924 100644 --- a/components/V3DMListItem.tsx +++ b/components/V3DMListItem.tsx @@ -1,10 +1,8 @@ import { DmWithCodecsType } from "@utils/xmtpRN/client"; import { ConversationListItemDumb } from "./ConversationListItem/ConversationListItemDumb"; import { useCallback, useMemo, useRef, useState } from "react"; -import { getMessageContentType } from "@utils/xmtpRN/contentTypes"; -import logger from "@utils/logger"; import Avatar from "./Avatar"; -import { useCurrentAccount } from "@data/store/accountsStore"; +import { useChatStore, useCurrentAccount } from "@data/store/accountsStore"; import { AvatarSizes } from "@styles/sizes"; import { getMinimalDate } from "@utils/date"; import { useColorScheme } from "react-native"; @@ -15,6 +13,19 @@ import { usePreferredInboxName } from "@hooks/usePreferredInboxName"; import { usePreferredInboxAvatar } from "@hooks/usePreferredInboxAvatar"; import { navigate } from "@utils/navigation"; import { Swipeable } from "react-native-gesture-handler"; +import { saveTopicsData } from "@utils/api"; +import { useSelect } from "@data/store/storeHelpers"; +import { Haptics } from "@utils/haptics"; +import { runOnJS } from "react-native-reanimated"; +import { translate } from "@i18n/index"; +import { actionSheetColors } from "@styles/colors"; +import { showActionSheetWithOptions } from "./StateHandlers/ActionSheetStateHandler"; +import { useAppTheme } from "@theme/useAppTheme"; +import { consentToInboxIdsOnProtocolByAccount } from "@utils/xmtpRN/contacts"; +import { useToggleReadStatus } from "../features/conversation-list/hooks/useToggleReadStatus"; +import { useMessageText } from "../features/conversation-list/hooks/useMessageText"; +import { useRoute } from "@navigation/useNavigation"; +import { useConversationIsUnread } from "../features/conversation-list/hooks/useMessageIsUnread"; type V3DMListItemProps = { conversation: DmWithCodecsType; @@ -34,7 +45,17 @@ const useDisplayInfo = ({ timestamp, isUnread }: UseDisplayInfoProps) => { }; export const V3DMListItem = ({ conversation }: V3DMListItemProps) => { - const currentAccount = useCurrentAccount(); + console.log("conversationMessageDebug111", conversation); + const currentAccount = useCurrentAccount()!; + const { name: routeName } = useRoute(); + const isBlockedChatView = routeName === "Blocked"; + const { theme } = useAppTheme(); + const colorScheme = theme.isDark ? "dark" : "light"; + const topic = conversation.topic; + const ref = useRef(null); + const { topicsData, setTopicsData, setPinnedConversations } = useChatStore( + useSelect(["topicsData", "setTopicsData", "setPinnedConversations"]) + ); const { data: peer } = useDmPeerInboxOnConversationList( currentAccount!, conversation @@ -45,32 +66,138 @@ export const V3DMListItem = ({ conversation }: V3DMListItemProps) => { isUnread: false, }); - const messageText = useMemo(() => { - const lastMessage = conversation?.lastMessage; - if (!lastMessage) return ""; - try { - const content = conversation?.lastMessage?.content(); - const contentType = getMessageContentType(lastMessage.contentTypeId); - if (contentType === "conversationUpdated") { - // TODO: Update this - return "conversation updated"; - } - if (typeof content === "string") { - return content; - } - return conversation?.lastMessage?.fallback; - } catch (e) { - logger.error("Error getting message text", { - error: e, - contentTypeId: lastMessage?.contentTypeId, - }); - return conversation?.lastMessage?.fallback; - } - }, [conversation?.lastMessage]); - + const messageText = useMessageText(conversation.lastMessage); const prefferedName = usePreferredInboxName(peer); const avatarUri = usePreferredInboxAvatar(peer); + const timestamp = conversation?.lastMessage?.sentNs ?? 0; + + const isUnread = useConversationIsUnread({ + topicsData, + topic, + lastMessage: conversation.lastMessage, + conversation, + timestamp, + }); + + const toggleReadStatus = useToggleReadStatus({ + setTopicsData, + topic, + isUnread, + currentAccount, + }); + + const closeContextMenu = useCallback((openConversationOnClose = false) => { + setIsContextMenuVisible(false); + if (openConversationOnClose) { + // openConversation(); + } + }, []); + + const handleDelete = useCallback(() => { + const options = [ + translate("delete"), + translate("delete_and_block"), + translate("cancel"), + ]; + const title = `${translate("delete_chat_with")} ${prefferedName}?`; + const actions = [ + () => { + saveTopicsData(currentAccount, { + [topic]: { + status: "deleted", + timestamp: new Date().getTime(), + }, + }), + setTopicsData({ + [topic]: { + status: "deleted", + timestamp: new Date().getTime(), + }, + }); + }, + async () => { + saveTopicsData(currentAccount, { + [topic]: { status: "deleted" }, + }); + setTopicsData({ + [topic]: { + status: "deleted", + timestamp: new Date().getTime(), + }, + }); + await conversation.updateConsent("denied"); + const peerInboxId = await conversation.peerInboxId(); + await consentToInboxIdsOnProtocolByAccount({ + account: currentAccount, + inboxIds: [peerInboxId], + consent: "deny", + }); + }, + ]; + + showActionSheetWithOptions( + { + options, + cancelButtonIndex: options.length - 1, + destructiveButtonIndex: [0, 1], + title, + ...actionSheetColors(colorScheme), + }, + async (selectedIndex?: number) => { + if (selectedIndex !== undefined && selectedIndex < actions.length) { + actions[selectedIndex](); + } + } + ); + }, [ + colorScheme, + conversation, + currentAccount, + prefferedName, + setTopicsData, + topic, + ]); + + const contextMenuItems = useMemo( + () => [ + { + title: translate("pin"), + action: () => { + setPinnedConversations([topic]); + closeContextMenu(); + }, + id: "pin", + }, + { + title: isUnread + ? translate("mark_as_read") + : translate("mark_as_unread"), + action: () => { + toggleReadStatus(); + closeContextMenu(); + }, + id: "markAsUnread", + }, + { + title: translate("delete"), + action: () => { + handleDelete(); + closeContextMenu(); + }, + id: "delete", + }, + ], + [ + topic, + setPinnedConversations, + handleDelete, + closeContextMenu, + isUnread, + toggleReadStatus, + ] + ); + const avatarComponent = useMemo(() => { return ( { setIsContextMenuVisible(false)} - items={[]} - conversationTopic={conversation.topic} + items={contextMenuItems} + conversationTopic={topic} /> ), - [isContextMenuVisible, conversation.topic] + [isContextMenuVisible, topic, contextMenuItems] ); - const ref = useRef(null); + const onPress = useCallback(() => { navigate("Conversation", { - topic: conversation.topic, + topic: topic, }); - }, [conversation.topic]); + }, [topic]); + + const onLeftSwipe = useCallback(() => { + toggleReadStatus(); + }, [toggleReadStatus]); + + const triggerHapticFeedback = useCallback(() => { + return Haptics.mediumImpactAsync(); + }, []); + + const showContextMenu = useCallback(() => { + setIsContextMenuVisible(true); + }, []); + + const onLongPress = useCallback(() => { + runOnJS(triggerHapticFeedback)(); + runOnJS(showContextMenu)(); + }, [triggerHapticFeedback, showContextMenu]); + + const onWillLeftSwipe = useCallback(() => { + Haptics.successNotificationAsync(); + }, []); + + const onWillRightSwipe = useCallback(() => {}, []); + + const onRightSwipe = useCallback(() => {}, []); return ( { subtitle={`${timeToShow} ⋅ ${messageText}`} isUnread={false} contextMenuComponent={contextMenuComponent} - // rightIsDestructive={isBlockedChatView} + rightIsDestructive={isBlockedChatView} /> ); }; diff --git a/components/V3GroupConversationListItem.tsx b/components/V3GroupConversationListItem.tsx index f3fef9213..744398e20 100644 --- a/components/V3GroupConversationListItem.tsx +++ b/components/V3GroupConversationListItem.tsx @@ -19,8 +19,6 @@ import Avatar from "./Avatar"; import { ConversationContextMenu } from "./ConversationContextMenu"; import { ConversationListItemDumb } from "./ConversationListItem/ConversationListItemDumb"; import { GroupAvatarDumb } from "./GroupAvatar"; -import { getMessageContentType } from "@utils/xmtpRN/contentTypes"; -import logger from "@utils/logger"; import { useGroupConversationListAvatarInfo } from "../features/conversation-list/useGroupConversationListAvatarInfo"; import { IIconName } from "@design-system/Icon/Icon.types"; import { GroupWithCodecsType } from "@utils/xmtpRN/client"; @@ -30,6 +28,9 @@ import { actionSheetColors } from "@styles/colors"; import { consentToInboxIdsOnProtocolByAccount } from "@utils/xmtpRN/contacts"; import type { ConversationTopic } from "@xmtp/react-native-sdk"; import { prefetchConversationMessages } from "@queries/useConversationMessages"; +import { useToggleReadStatus } from "../features/conversation-list/hooks/useToggleReadStatus"; +import { useMessageText } from "../features/conversation-list/hooks/useMessageText"; +import { useConversationIsUnread } from "../features/conversation-list/hooks/useMessageIsUnread"; type V3GroupConversationListItemProps = { group: GroupWithCodecsType; @@ -40,7 +41,6 @@ type UseDataProps = { }; const useData = ({ group }: UseDataProps) => { - // TODO Items const { name: routeName } = useRoute(); const isBlockedChatView = routeName === "Blocked"; const colorScheme = useColorScheme(); @@ -55,50 +55,28 @@ const useData = ({ group }: UseDataProps) => { setIsContextMenuVisible(true); }, []); - const groupExists = !!group; const topic = group?.topic; const timestamp = group?.lastMessage?.sentNs ?? 0; - const isUnread = useMemo(() => { - if (!groupExists) return false; - if (topicsData[topic]?.status === "unread") { - return true; - } - if (group.lastMessage?.senderAddress === group?.client.inboxId) { - return false; - } - const readUntil = topicsData[topic]?.readUntil || 0; - return readUntil < (timestamp ?? 0); - }, [ - groupExists, + const isUnread = useConversationIsUnread({ topicsData, topic, - group.lastMessage?.senderAddress, - group?.client.inboxId, + lastMessage: group.lastMessage, + conversation: group, timestamp, - ]); + }); const { memberData } = useGroupConversationListAvatarInfo( currentAccount, group ); - const toggleReadStatus = useCallback(() => { - const newStatus = isUnread ? "read" : "unread"; - const timestamp = new Date().getTime(); - setTopicsData({ - [topic]: { - status: newStatus, - timestamp, - }, - }); - saveTopicsData(currentAccount, { - [topic]: { - status: newStatus, - timestamp, - }, - }); - }, [setTopicsData, topic, isUnread, currentAccount]); + const toggleReadStatus = useToggleReadStatus({ + setTopicsData, + topic, + isUnread, + currentAccount, + }); const closeContextMenu = useCallback((openConversationOnClose = false) => { setIsContextMenuVisible(false); @@ -256,28 +234,7 @@ const useData = ({ group }: UseDataProps) => { ] ); - const messageText = useMemo(() => { - const lastMessage = group?.lastMessage; - if (!lastMessage) return ""; - try { - const content = group?.lastMessage?.content(); - const contentType = getMessageContentType(lastMessage.contentTypeId); - if (contentType === "groupUpdated") { - // TODO: Update this - return "Group updated"; - } - if (typeof content === "string") { - return content; - } - return group?.lastMessage?.fallback; - } catch (e) { - logger.error("Error getting message text", { - error: e, - contentTypeId: lastMessage?.contentTypeId, - }); - return group?.lastMessage?.fallback; - } - }, [group?.lastMessage]); + const messageText = useMessageText(group.lastMessage); return { group, @@ -316,6 +273,7 @@ const useUserInteractions = ({ isBlockedChatView, }: UseUserInteractionsProps) => { const currentAccount = useCurrentAccount()!; + const onPress = useCallback(() => { prefetchConversationMessages(currentAccount, topic); navigate("Conversation", { diff --git a/data/store/chatStore.ts b/data/store/chatStore.ts index 9c7868540..23a49ff2b 100644 --- a/data/store/chatStore.ts +++ b/data/store/chatStore.ts @@ -97,8 +97,10 @@ export type XmtpMessage = XmtpProtocolMessage & { localMediaURI?: string; }; +export type TopicStatus = "deleted" | "unread" | "read"; + export type TopicData = { - status: "deleted" | "unread" | "read"; + status: TopicStatus; readUntil?: number; timestamp?: number; isPinned?: boolean; diff --git a/features/conversation-list/hooks/useMessageIsUnread.ts b/features/conversation-list/hooks/useMessageIsUnread.ts new file mode 100644 index 000000000..1ede6abfd --- /dev/null +++ b/features/conversation-list/hooks/useMessageIsUnread.ts @@ -0,0 +1,39 @@ +import { useMemo } from "react"; +import { TopicsData } from "@data/store/chatStore"; +import { + ConversationWithCodecsType, + DecodedMessageWithCodecsType, +} from "@utils/xmtpRN/client"; + +type UseConversationIsUnreadProps = { + topicsData: TopicsData; + topic: string; + lastMessage: DecodedMessageWithCodecsType | undefined; + conversation: ConversationWithCodecsType; + timestamp: number; +}; + +export const useConversationIsUnread = ({ + topicsData, + topic, + lastMessage, + conversation, + timestamp, +}: UseConversationIsUnreadProps) => { + return useMemo(() => { + if (topicsData[topic]?.status === "unread") { + return true; + } + if (lastMessage?.senderAddress === conversation?.client.inboxId) { + return false; + } + const readUntil = topicsData[topic]?.readUntil || 0; + return readUntil < (timestamp ?? 0); + }, [ + topicsData, + topic, + lastMessage?.senderAddress, + conversation?.client.inboxId, + timestamp, + ]); +}; diff --git a/features/conversation-list/hooks/useMessageText.ts b/features/conversation-list/hooks/useMessageText.ts new file mode 100644 index 000000000..d7687c583 --- /dev/null +++ b/features/conversation-list/hooks/useMessageText.ts @@ -0,0 +1,30 @@ +import { useMemo } from "react"; +import logger from "@utils/logger"; +import { DecodedMessageWithCodecsType } from "@utils/xmtpRN/client"; +import { getMessageContentType } from "@utils/xmtpRN/contentTypes"; + +export const useMessageText = ( + message: DecodedMessageWithCodecsType | undefined +) => { + return useMemo(() => { + if (!message) return ""; + try { + const content = message?.content(); + const contentType = getMessageContentType(message.contentTypeId); + if (contentType === "conversationUpdated") { + // TODO: Update this + return "conversation updated"; + } + if (typeof content === "string") { + return content; + } + return message?.fallback; + } catch (e) { + logger.error("Error getting message text", { + error: e, + contentTypeId: message.contentTypeId, + }); + return message.fallback; + } + }, [message]); +}; diff --git a/features/conversation-list/hooks/useToggleReadStatus.ts b/features/conversation-list/hooks/useToggleReadStatus.ts new file mode 100644 index 000000000..da0f1e9ab --- /dev/null +++ b/features/conversation-list/hooks/useToggleReadStatus.ts @@ -0,0 +1,36 @@ +import { TopicStatus } from "@data/store/chatStore"; +import { saveTopicsData } from "@utils/api"; +import { useCallback } from "react"; + +type UseToggleReadStatusProps = { + setTopicsData: ( + data: Record + ) => void; + topic: string; + isUnread: boolean; + currentAccount: string; +}; + +export const useToggleReadStatus = ({ + setTopicsData, + topic, + isUnread, + currentAccount, +}: UseToggleReadStatusProps) => { + return useCallback(() => { + const newStatus = isUnread ? "read" : "unread"; + const timestamp = new Date().getTime(); + setTopicsData({ + [topic]: { + status: newStatus, + timestamp, + }, + }); + saveTopicsData(currentAccount, { + [topic]: { + status: newStatus, + timestamp, + }, + }); + }, [setTopicsData, topic, isUnread, currentAccount]); +};