From b52d593c5501b7d714898967687a24b5b56a9ffd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 3 May 2024 12:58:05 -0400 Subject: [PATCH 1/6] feat: add tip chat prototype for referencing Signed-off-by: Brandon McAnsh --- api/build.gradle.kts | 11 +- .../com.getcode.db.AppDatabase/10.json | 378 ++++++++++++++++++ .../main/java/com/getcode/db/AppDatabase.kt | 13 +- .../java/com/getcode/db/ConversationDao.kt | 61 +++ .../com/getcode/db/ConversationMessageDao.kt | 34 ++ .../db/ConversationMessageRemoteKeyDao.kt | 24 ++ .../main/java/com/getcode/db/Converters.kt | 18 + api/src/main/java/com/getcode/model/Chat.kt | 34 +- .../java/com/getcode/model/Conversation.kt | 163 ++++++++ .../main/java/com/getcode/model/KinAmount.kt | 7 + .../main/java/com/getcode/model/PrefBool.kt | 1 - api/src/main/java/com/getcode/model/Rate.kt | 3 + .../getcode/network/ConversationController.kt | 210 ++++++++++ .../com/getcode/network/HistoryController.kt | 3 +- .../com/getcode/network/exchange/Exchange.kt | 7 - .../network/repository/BetaFlagsRepository.kt | 6 +- .../getcode/network/repository/Extensions.kt | 3 + .../source/ConversationPagingSource.kt | 79 ++++ .../ConversationMessageContentSerializer.kt | 24 ++ .../getcode/utils/serializer/KinSerializer.kt | 22 + .../utils/serializer/RateSerializer.kt | 23 ++ app/build.gradle.kts | 12 +- .../main/java/com/getcode/inject/ApiModule.kt | 1 - .../main/java/com/getcode/inject/AppModule.kt | 2 - .../java/com/getcode/inject/DataModule.kt | 23 ++ .../java/com/getcode/manager/AuthManager.kt | 4 - .../getcode/navigation/screens/ChatScreens.kt | 25 +- .../notifications/CodePushMessagingService.kt | 1 - app/src/main/java/com/getcode/theme/Color.kt | 1 + .../com/getcode/ui/components/CodeButton.kt | 13 +- .../getcode/ui/components/chat/ChatNode.kt | 2 - .../getcode/ui/components/chat/MessageNode.kt | 164 ++++---- .../conversation/AnnouncementMessage.kt | 39 ++ .../ui/components/conversation/ChatInput.kt | 114 ++++++ .../components/conversation/DateWithStatus.kt | 118 ++++++ .../components/conversation/MessageBubble.kt | 192 +++++++++ .../utils/HandleMessageChanges.kt | 76 ++++ .../java/com/getcode/ui/utils/LazyList.kt | 44 ++ .../java/com/getcode/util/AndroidResources.kt | 2 - .../main/java/com/getcode/util/Currency.kt | 16 +- .../main/java/com/getcode/util/ErrorUtils.kt | 1 - .../java/com/getcode/util/KinAmountExt.kt | 17 +- .../java/com/getcode/view/BaseViewModel.kt | 2 - .../view/login/PhoneVerifyViewModel.kt | 5 - .../getcode/view/login/SeedInputViewModel.kt | 1 - .../view/main/account/AccountFaqViewModel.kt | 6 - .../main/account/AccountPhoneViewModel.kt | 2 - .../view/main/account/BetaFlagsScreen.kt | 7 - .../view/main/account/BetaFlagsViewModel.kt | 5 - .../AccountWithdrawAddressViewModel.kt | 3 - .../AccountWithdrawAmountViewModel.kt | 2 - .../AccountWithdrawSummaryViewModel.kt | 1 - .../getcode/view/main/balance/BalanceSheet.kt | 4 +- .../com/getcode/view/main/chat/ChatScreen.kt | 5 +- .../getcode/view/main/chat/ChatViewModel.kt | 49 ++- .../conversation/ChatConversationScreen.kt | 196 +++++---- .../conversation/ConversationViewModel.kt | 153 +++++++ .../view/main/currency/CurrencyViewModel.kt | 1 - .../giveKin/BaseAmountCurrencyViewModel.kt | 1 - .../main/requestKin/RequestKinViewModel.kt | 1 - .../view/main/tip/TipConnectViewModel.kt | 5 - .../view/main/tip/TipPaymentViewModel.kt | 3 - .../drawable/ic_message_status_delivered.xml | 12 + .../res/drawable/ic_message_status_read.xml | 12 + .../res/drawable/ic_message_status_sent.xml | 9 + app/src/main/res/values/strings-universal.xml | 2 - app/src/main/res/values/strings.xml | 10 +- buildSrc/src/main/java/Dependencies.kt | 3 + common/.gitignore | 1 + common/build.gradle.kts | 44 ++ .../getcode/util/resources/ResourceHelper.kt | 0 scripts/internal-testing-build.sh | 4 +- settings.gradle | 1 + 73 files changed, 2203 insertions(+), 338 deletions(-) create mode 100644 api/schemas/com.getcode.db.AppDatabase/10.json create mode 100644 api/src/main/java/com/getcode/db/ConversationDao.kt create mode 100644 api/src/main/java/com/getcode/db/ConversationMessageDao.kt create mode 100644 api/src/main/java/com/getcode/db/ConversationMessageRemoteKeyDao.kt create mode 100644 api/src/main/java/com/getcode/model/Conversation.kt create mode 100644 api/src/main/java/com/getcode/network/ConversationController.kt create mode 100644 api/src/main/java/com/getcode/network/source/ConversationPagingSource.kt create mode 100644 api/src/main/java/com/getcode/utils/serializer/ConversationMessageContentSerializer.kt create mode 100644 api/src/main/java/com/getcode/utils/serializer/KinSerializer.kt create mode 100644 api/src/main/java/com/getcode/utils/serializer/RateSerializer.kt create mode 100644 app/src/main/java/com/getcode/inject/DataModule.kt create mode 100644 app/src/main/java/com/getcode/ui/components/conversation/AnnouncementMessage.kt create mode 100644 app/src/main/java/com/getcode/ui/components/conversation/ChatInput.kt create mode 100644 app/src/main/java/com/getcode/ui/components/conversation/DateWithStatus.kt create mode 100644 app/src/main/java/com/getcode/ui/components/conversation/MessageBubble.kt create mode 100644 app/src/main/java/com/getcode/ui/components/conversation/utils/HandleMessageChanges.kt create mode 100644 app/src/main/java/com/getcode/ui/utils/LazyList.kt create mode 100644 app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt create mode 100644 app/src/main/res/drawable/ic_message_status_delivered.xml create mode 100644 app/src/main/res/drawable/ic_message_status_read.xml create mode 100644 app/src/main/res/drawable/ic_message_status_sent.xml create mode 100644 common/.gitignore create mode 100644 common/build.gradle.kts rename {app => common}/src/main/java/com/getcode/util/resources/ResourceHelper.kt (100%) diff --git a/api/build.gradle.kts b/api/build.gradle.kts index 6530a19ef..1f4e6b5bd 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -49,7 +49,7 @@ android { java { toolchain { - languageVersion.set(JavaLanguageVersion.of(17)) + languageVersion.set(JavaLanguageVersion.of(Versions.java)) } } @@ -69,14 +69,10 @@ android { } dependencies { - implementation(project(":model")) - implementation(project(":ed25519")) + implementation(project(":common")) - implementation(Libs.kotlin_stdlib) implementation(Libs.rxjava) - api(Libs.kotlinx_coroutines_core) implementation(Libs.kotlinx_coroutines_core) - api(Libs.kotlinx_coroutines_rx3) implementation(Libs.kotlinx_serialization_json) implementation(Libs.inject) @@ -85,6 +81,7 @@ dependencies { implementation(Libs.androidx_room_runtime) implementation(Libs.androidx_room_ktx) implementation(Libs.androidx_room_rxjava3) + implementation(Libs.androidx_room_paging) implementation(Libs.okhttp) implementation(Libs.mixpanel) @@ -113,8 +110,6 @@ dependencies { androidTestImplementation(Libs.androidx_test_runner) implementation(Libs.hilt) - implementation(Libs.kin_sdk) - implementation(Libs.timber) implementation(Libs.bugsnag) } diff --git a/api/schemas/com.getcode.db.AppDatabase/10.json b/api/schemas/com.getcode.db.AppDatabase/10.json new file mode 100644 index 000000000..e6b9cf2b8 --- /dev/null +++ b/api/schemas/com.getcode.db.AppDatabase/10.json @@ -0,0 +1,378 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "77b82b833853b340858b2d2606bfdfd6", + "entities": [ + { + "tableName": "CurrencyRate", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `rate` REAL NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FaqItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT, `question` TEXT NOT NULL, `answer` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "question", + "columnName": "question", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "answer", + "columnName": "answer", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "GiftCard", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `entropy` TEXT NOT NULL, `amount` INTEGER NOT NULL, `date` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entropy", + "columnName": "entropy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "exchangeData", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fiat` REAL NOT NULL, `currency` TEXT NOT NULL, `synced_at` INTEGER NOT NULL, PRIMARY KEY(`currency`))", + "fields": [ + { + "fieldPath": "fx", + "columnName": "fiat", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "synced", + "columnName": "synced_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "currency" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `messageIdBase58` TEXT NOT NULL, `cursorBase58` TEXT NOT NULL, `tipAmount` TEXT NOT NULL, `createdByUser` INTEGER NOT NULL, `hasRevealedIdentity` INTEGER NOT NULL, `user` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageIdBase58", + "columnName": "messageIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cursorBase58", + "columnName": "cursorBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tipAmount", + "columnName": "tipAmount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdByUser", + "columnName": "createdByUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasRevealedIdentity", + "columnName": "hasRevealedIdentity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `cursorBase58` TEXT NOT NULL, `conversationIdBase58` TEXT NOT NULL, `dateMillis` INTEGER NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cursorBase58", + "columnName": "cursorBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationIdBase58", + "columnName": "conversationIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages_remote_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageIdBase58` TEXT NOT NULL, `prevCursorBase58` TEXT, `nextCursorBase58` TEXT, PRIMARY KEY(`messageIdBase58`))", + "fields": [ + { + "fieldPath": "messageIdBase58", + "columnName": "messageIdBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "prevCursorBase58", + "columnName": "prevCursorBase58", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextCursorBase58", + "columnName": "nextCursorBase58", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageIdBase58" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '77b82b833853b340858b2d2606bfdfd6')" + ] + } +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/db/AppDatabase.kt b/api/src/main/java/com/getcode/db/AppDatabase.kt index 043262a11..b6489b986 100644 --- a/api/src/main/java/com/getcode/db/AppDatabase.kt +++ b/api/src/main/java/com/getcode/db/AppDatabase.kt @@ -30,12 +30,15 @@ import java.io.File PrefDouble::class, GiftCard::class, ExchangeRate::class, + Conversation::class, + ConversationMessage::class, + ConversationMessageRemoteKey::class, ], autoMigrations = [ AutoMigration(from = 7, to = 8, spec = AppDatabase.Migration7To8::class), AutoMigration(from = 8, to = 9, spec = AppDatabase.Migration8To9::class) ], - version = 9 + version = 10 ) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { @@ -46,6 +49,10 @@ abstract class AppDatabase : RoomDatabase() { abstract fun giftCardDao(): GiftCardDao abstract fun exchangeDao(): ExchangeDao + abstract fun conversationDao(): ConversationDao + abstract fun conversationMessageDao(): ConversationMessageDao + abstract fun conversationMessageRemoteKeyDao(): ConversationMessageRemoteKeyDao + @DeleteTable(tableName = "HistoricalTransaction") class Migration7To8 : AutoMigrationSpec @@ -76,6 +83,10 @@ object Database { .fallbackToDestructiveMigration() .build() + instance?.conversationDao()?.clearConversations() + instance?.conversationMessageDao()?.clearMessages() + instance?.conversationMessageRemoteKeyDao()?.clearRemoteKeys() + isInitSubject.onNext(true) } diff --git a/api/src/main/java/com/getcode/db/ConversationDao.kt b/api/src/main/java/com/getcode/db/ConversationDao.kt new file mode 100644 index 000000000..6b09955cb --- /dev/null +++ b/api/src/main/java/com/getcode/db/ConversationDao.kt @@ -0,0 +1,61 @@ +package com.getcode.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.getcode.model.Conversation +import com.getcode.model.ConversationWithMessages +import com.getcode.model.ID +import com.getcode.network.repository.base58 +import kotlinx.coroutines.flow.Flow + +@Dao +interface ConversationDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertConversations(vararg conversation: Conversation) + + @Transaction + @Query("SELECT * FROM conversations WHERE idBase58 = :id") + fun observeConversationWithMessages(id: String): Flow + + fun observeConversationWithMessages(id: ID): Flow { + return observeConversationWithMessages(id.base58) + } + + @Query("SELECT * FROM conversations WHERE idBase58 = :conversationId") + suspend fun findConversation(conversationId: String): Conversation? + + suspend fun findConversation(conversationId: ID): Conversation? { + return findConversation(conversationId.base58) + } + + @Query("SELECT * FROM conversations WHERE messageIdBase58 = :messageId") + suspend fun findConversationForMessage(messageId: String): Conversation? + + suspend fun findConversationForMessage(messageId: ID): Conversation? { + return findConversationForMessage(messageId.base58) + } + + @Query("SELECT * FROM conversations") + suspend fun queryConversations(): List + + @Query("SELECT EXISTS (SELECT * FROM messages WHERE conversationIdBase58 = :conversationId AND content LIKE '%1|%')") + suspend fun hasTipMessage(conversationId: String): Boolean + + suspend fun hasTipMessage(conversationId: ID): Boolean { + return hasTipMessage(conversationId.base58) + } + + @Query("SELECT EXISTS (SELECT * FROM messages WHERE conversationIdBase58 = :conversationId AND content LIKE '%2|%')") + suspend fun hasThanked(conversationId: String): Boolean + + suspend fun hasThanked(conversationId: ID): Boolean { + return hasThanked(conversationId.base58) + } + + @Query("DELETE FROM conversations") + fun clearConversations() +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/db/ConversationMessageDao.kt b/api/src/main/java/com/getcode/db/ConversationMessageDao.kt new file mode 100644 index 000000000..49b0996f1 --- /dev/null +++ b/api/src/main/java/com/getcode/db/ConversationMessageDao.kt @@ -0,0 +1,34 @@ +package com.getcode.db + +import androidx.paging.PagingSource +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.getcode.model.ConversationMessage +import com.getcode.model.ID +import com.getcode.network.repository.base58 + +@Dao +interface ConversationMessageDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertMessages(vararg message: ConversationMessage) + + @Query("SELECT * FROM messages WHERE conversationIdBase58 = :id ORDER BY dateMillis DESC") + fun observeConversationMessages(id: String): PagingSource + + fun observeConversationMessages(id: ID): PagingSource { + return observeConversationMessages(id.base58) + } + + @Query("SELECT * FROM messages WHERE conversationIdBase58 = :conversationId") + suspend fun queryMessages(conversationId: String): List + + suspend fun queryMessages(conversationId: ID): List { + return queryMessages(conversationId.base58) + } + + @Query("DELETE FROM messages") + fun clearMessages() +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/db/ConversationMessageRemoteKeyDao.kt b/api/src/main/java/com/getcode/db/ConversationMessageRemoteKeyDao.kt new file mode 100644 index 000000000..e90932ba9 --- /dev/null +++ b/api/src/main/java/com/getcode/db/ConversationMessageRemoteKeyDao.kt @@ -0,0 +1,24 @@ +package com.getcode.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.getcode.model.ConversationMessageRemoteKey +import com.getcode.model.ID +import com.getcode.network.repository.base58 + +@Dao +interface ConversationMessageRemoteKeyDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertAll(remoteKey: List) + @Query("SELECT * FROM messages_remote_keys WHERE messageIdBase58 = :id") + fun remoteKeysByMessageId(id: String): ConversationMessageRemoteKey? + + fun remoteKeysByMessageId(id: ID): ConversationMessageRemoteKey? { + return remoteKeysByMessageId(id.base58) + } + + @Query("DELETE FROM messages_remote_keys") + fun clearRemoteKeys() +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/db/Converters.kt b/api/src/main/java/com/getcode/db/Converters.kt index bacba641a..32208cbca 100644 --- a/api/src/main/java/com/getcode/db/Converters.kt +++ b/api/src/main/java/com/getcode/db/Converters.kt @@ -1,13 +1,20 @@ package com.getcode.db import androidx.room.TypeConverter +import com.getcode.model.ConversationMessageContent import com.getcode.model.CurrencyCode +import com.getcode.model.ID +import com.getcode.model.KinAmount import com.getcode.model.Rate +import com.getcode.network.repository.base58 import com.getcode.network.repository.decodeBase64 import com.getcode.network.repository.encodeBase64 +import com.getcode.utils.serializer.ConversationMessageContentSerializer +import com.getcode.vendor.Base58 import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule class Converters { @TypeConverter @@ -31,4 +38,15 @@ class Converters { fun ratesToString(value: List) = Json.encodeToString(value) @TypeConverter fun stringToRates(value: String) = Json.decodeFromString>(value) + + @TypeConverter + fun messageContentToString(value: ConversationMessageContent) = value.serialize() + @TypeConverter + fun stringToMessageContent(value: String) = ConversationMessageContent.deserialize(value) + + @TypeConverter + fun kinAmountToString(value: KinAmount) = Json.encodeToString(KinAmount.serializer(), value) + + @TypeConverter + fun stringToKinAmount(value: String) = Json.decodeFromString(KinAmount.serializer(), value) } \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/Chat.kt b/api/src/main/java/com/getcode/model/Chat.kt index 163d11aca..f7ef3b338 100644 --- a/api/src/main/java/com/getcode/model/Chat.kt +++ b/api/src/main/java/com/getcode/model/Chat.kt @@ -68,6 +68,7 @@ sealed interface Pointer { sealed interface Title { val value: String + data class Localized(override val value: String) : Title data class Domain(override val value: String) : Title @@ -85,6 +86,7 @@ sealed interface Title { sealed interface Verb { val increasesBalance: Boolean + data object Unknown : Verb { override val increasesBalance: Boolean = false } @@ -92,34 +94,44 @@ sealed interface Verb { data object Gave : Verb { override val increasesBalance: Boolean = false } + data object Received : Verb { override val increasesBalance: Boolean = true } + data object Withdrew : Verb { override val increasesBalance: Boolean = false } - data object Deposited: Verb { + + data object Deposited : Verb { override val increasesBalance: Boolean = true } + data object Sent : Verb { override val increasesBalance: Boolean = false } + data object Returned : Verb { override val increasesBalance: Boolean = true } + data object Spent : Verb { override val increasesBalance: Boolean = false } + data object Paid : Verb { override val increasesBalance: Boolean = false } + data object Purchased : Verb { override val increasesBalance: Boolean = true } - data object ReceivedTip: Verb { + + data object ReceivedTip : Verb { override val increasesBalance: Boolean = true } - data object SentTip: Verb { + + data object SentTip : Verb { override val increasesBalance: Boolean = false } @@ -128,7 +140,7 @@ sealed interface Verb { return when (proto) { ChatService.ExchangeDataContent.Verb.UNKNOWN -> Unknown ChatService.ExchangeDataContent.Verb.GAVE -> Gave - ChatService.ExchangeDataContent.Verb.RECEIVED ->Received + ChatService.ExchangeDataContent.Verb.RECEIVED -> Received ChatService.ExchangeDataContent.Verb.WITHDREW -> Withdrew ChatService.ExchangeDataContent.Verb.DEPOSITED -> Deposited ChatService.ExchangeDataContent.Verb.SENT -> Sent @@ -181,10 +193,11 @@ data class ChatMessage( sealed interface MessageContent { data class Localized(val value: String) : MessageContent - data class Exchange(val amount: GenericAmount, val verb: Verb) : MessageContent - data class SodiumBox(val data: EncryptedData) : MessageContent - data class Decrypted(val data: String): MessageContent + data class Exchange(val amount: GenericAmount, val verb: Verb, val thanked: Boolean = false) : + MessageContent + data class SodiumBox(val data: EncryptedData) : MessageContent + data class Decrypted(val data: String) : MessageContent companion object { operator fun invoke(proto: Content): MessageContent? { @@ -206,6 +219,7 @@ sealed interface MessageContent { Exchange(GenericAmount.Exact(kinAmount), verb) } + ChatService.ExchangeDataContent.ExchangeDataCase.PARTIAL -> { val partial = proto.exchangeData.partial val currency = CurrencyCode.tryValueOf(partial.currency) ?: return null @@ -217,13 +231,16 @@ sealed interface MessageContent { Exchange(GenericAmount.Partial(fiat), verb) } + ChatService.ExchangeDataContent.ExchangeDataCase.EXCHANGEDATA_NOT_SET -> return null else -> return null } } + Content.TypeCase.NACL_BOX -> { val encryptedContent = proto.naclBox - val peerPublicKey = encryptedContent.peerPublicKey.value.toByteArray().toPublicKey() + val peerPublicKey = + encryptedContent.peerPublicKey.value.toByteArray().toPublicKey() val data = EncryptedData( peerPublicKey = peerPublicKey, @@ -232,6 +249,7 @@ sealed interface MessageContent { ) SodiumBox(data = data) } + Content.TypeCase.TYPE_NOT_SET -> return null else -> return null } diff --git a/api/src/main/java/com/getcode/model/Conversation.kt b/api/src/main/java/com/getcode/model/Conversation.kt new file mode 100644 index 000000000..1348e7e34 --- /dev/null +++ b/api/src/main/java/com/getcode/model/Conversation.kt @@ -0,0 +1,163 @@ +package com.getcode.model + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey +import androidx.room.Relation +import com.getcode.manager.SessionManager +import com.getcode.network.repository.base58 +import com.getcode.utils.serializer.ConversationMessageContentSerializer +import com.getcode.utils.serializer.RateAsStringSerializer +import com.getcode.vendor.Base58 +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule + +@Serializable +@Entity(tableName = "conversations") +data class Conversation( + @PrimaryKey + val idBase58: String, + val messageIdBase58: String, + val cursorBase58: String, + val tipAmount: KinAmount, + val createdByUser: Boolean, // if this conversation was created as a result of the user messaging the tipper., + val hasRevealedIdentity: Boolean, + val user: String?, +) { + @Ignore + val id: ID = Base58.decode(idBase58).toList() + @Ignore + val messageId: ID = Base58.decode(messageIdBase58).toList() + @Ignore + val cursor: Cursor = Base58.decode(cursorBase58).toList() + + override fun toString(): String { + return """ + { + id:${idBase58}, + messageId:${messageIdBase58}, + tipAmount:$tipAmount, + createByUser:$createdByUser, + hasRevealedIdentity:$hasRevealedIdentity, + user:$user, + } + """.trimIndent() + } +} + +@Serializable +@Entity(tableName = "messages") +data class ConversationMessage( + @PrimaryKey + val idBase58: String, + val cursorBase58: String, + val conversationIdBase58: String, + val dateMillis: Long, + @Serializable(with = ConversationMessageContentSerializer::class) + val content: ConversationMessageContent, +) { + @Ignore + val id: ID = Base58.decode(idBase58).toList() + @Ignore + val conversationId: ID = Base58.decode(conversationIdBase58).toList() + @Ignore + val cursor: Cursor = Base58.decode(cursorBase58).toList() +} + +data class ConversationWithMessages( + @Embedded val user: Conversation, + @Relation( + parentColumn = "idBase58", + entityColumn = "conversationIdBase58" + ) + val messages: List, +) + +@Entity(tableName = "messages_remote_keys") +data class ConversationMessageRemoteKey( + @PrimaryKey + val messageIdBase58: String, + val prevCursorBase58: String?, + val nextCursorBase58: String? +) { + @Ignore + val messageId: ID = Base58.decode(messageIdBase58).toList() + @Ignore + val prevCursor: Cursor? = prevCursorBase58?.let { Base58.decode(it).toList() } + @Ignore + val nextCursor: Cursor? = nextCursorBase58?.let { Base58.decode(it).toList() } +} + +sealed interface ConversationMessageContent { + val kind: Int + + @Serializable + data class Text( + val message: String, + val status: MessageStatus, + val from: String, + ) : ConversationMessageContent { + override val kind: Int = 0 + val isFromSelf: Boolean + get() = SessionManager.getOrganizer()?.primaryVault?.let { Base58.encode(it.byteArray) } == from + } + + @Serializable + data object TipMessage : ConversationMessageContent { + override val kind: Int = 1 + } + @Serializable + data object ThanksSent : ConversationMessageContent { + override val kind: Int = 2 + } + @Serializable + data object ThanksReceived : ConversationMessageContent { + override val kind: Int = 3 + } + @Serializable + data object IdentityRevealed : ConversationMessageContent { + override val kind: Int = 4 + } + @Serializable + data object IdentityRevealedToYou : ConversationMessageContent { + override val kind: Int = 5 + } + + fun serialize(): String { + val kind = kind + val payload = when (this) { + IdentityRevealed, + IdentityRevealedToYou, + ThanksReceived, + ThanksSent, + TipMessage -> { + "$kind|${javaClass.simpleName}" + } + is Text -> "$kind|${Json.encodeToString(this)}" + } + + return payload + } + + companion object { + fun deserialize(string: String): ConversationMessageContent { + val (kind, data) = string.split("|") + return when (kind.toInt()) { + 0 -> Json.decodeFromString(data) + 1 -> TipMessage + 2 -> ThanksSent + 3 -> ThanksReceived + 4 -> IdentityRevealed + 5 -> IdentityRevealedToYou + else -> throw IllegalArgumentException() + } + } + } +} + +enum class MessageStatus { + Incoming, Sent, Delivered, Read +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/KinAmount.kt b/api/src/main/java/com/getcode/model/KinAmount.kt index 7dc4fd19b..b6d726dde 100644 --- a/api/src/main/java/com/getcode/model/KinAmount.kt +++ b/api/src/main/java/com/getcode/model/KinAmount.kt @@ -3,10 +3,17 @@ package com.getcode.model import com.codeinc.gen.transaction.v2.TransactionService.ExchangeData import com.getcode.model.Kin.Companion.fromKin import com.getcode.utils.FormatUtils +import com.getcode.utils.serializer.KinQuarksSerializer +import com.getcode.utils.serializer.PublicKeyAsStringSerializer +import com.getcode.utils.serializer.RateAsStringSerializer +import kotlinx.serialization.Serializable +@Serializable data class KinAmount( + @Serializable(with = KinQuarksSerializer::class) val kin: Kin, val fiat: Double, + @Serializable(with = RateAsStringSerializer::class) val rate: Rate ) { fun truncating() = KinAmount( diff --git a/api/src/main/java/com/getcode/model/PrefBool.kt b/api/src/main/java/com/getcode/model/PrefBool.kt index 40e4e29ae..08ff0f7a1 100644 --- a/api/src/main/java/com/getcode/model/PrefBool.kt +++ b/api/src/main/java/com/getcode/model/PrefBool.kt @@ -36,6 +36,5 @@ sealed class PrefsBool(val value: String) { data object ESTABLISH_CODE_RELATIONSHIP : PrefsBool("establish_code_relationship_enabled"), BetaFlag data object CHAT_UNSUB_ENABLED: PrefsBool("chat_unsub_enabled"), BetaFlag data object TIPS_ENABLED : PrefsBool("tips_enabled"), BetaFlag - data object MESSAGE_PAYMENT_NODE_V2: PrefsBool("message_payment_node_v2"), BetaFlag data object TIPS_CHAT_ENABLED: PrefsBool("tips_chat_enabled"), BetaFlag } \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/Rate.kt b/api/src/main/java/com/getcode/model/Rate.kt index fb4b60fac..3be171699 100644 --- a/api/src/main/java/com/getcode/model/Rate.kt +++ b/api/src/main/java/com/getcode/model/Rate.kt @@ -6,6 +6,7 @@ import androidx.room.PrimaryKey import kotlinx.serialization.Serializable +@Serializable data class Rate( val fx: Double, val currency: CurrencyCode @@ -15,6 +16,8 @@ data class Rate( } } +fun Rate?.orOneToOne() = this ?: Rate.oneToOne + @Serializable @Entity(tableName = "exchangeData") data class ExchangeRate( diff --git a/api/src/main/java/com/getcode/network/ConversationController.kt b/api/src/main/java/com/getcode/network/ConversationController.kt new file mode 100644 index 000000000..d1488c907 --- /dev/null +++ b/api/src/main/java/com/getcode/network/ConversationController.kt @@ -0,0 +1,210 @@ +package com.getcode.network + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import com.getcode.db.AppDatabase +import com.getcode.db.Database +import com.getcode.manager.SessionManager +import com.getcode.model.Conversation +import com.getcode.model.ConversationMessage +import com.getcode.model.ConversationMessageContent +import com.getcode.model.ID +import com.getcode.model.MessageStatus +import com.getcode.network.exchange.Exchange +import com.getcode.network.repository.base58 +import com.getcode.network.source.ConversationMockProvider +import com.getcode.vendor.Base58 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import org.kin.sdk.base.tools.toByteArray +import timber.log.Timber +import java.io.IOException +import java.util.UUID +import javax.inject.Inject + +interface ConversationController { + suspend fun getConversationForMessage(messageId: ID): Conversation? + suspend fun getConversation(conversationId: ID): Conversation? + suspend fun createConversation(messageId: ID) + suspend fun hasThanked(messageId: ID): Boolean + suspend fun thankTipper(messageId: ID) + fun sendMessage(conversationId: ID, message: String) + fun conversationPagingData(conversationId: ID): Flow> +} + +class ConversationMockController @Inject constructor( + private val historyController: HistoryController, + private val exchange: Exchange, +) : ConversationController, CoroutineScope by CoroutineScope(Dispatchers.IO) { + + private val pagingConfig = PagingConfig(pageSize = 20) + + private val db: AppDatabase by lazy { Database.requireInstance() } + + private fun conversationPagingSource(conversationId: ID) = + db.conversationMessageDao().observeConversationMessages(conversationId.base58) + + override suspend fun getConversationForMessage(messageId: ID): Conversation? { + return db.conversationDao().findConversationForMessage(messageId) + } + + override suspend fun getConversation(conversationId: ID): Conversation? { + return db.conversationDao().findConversation(conversationId) + } + + @OptIn(ExperimentalPagingApi::class) + override fun conversationPagingData(conversationId: ID) = + Pager( + config = pagingConfig, + initialKey = null, + remoteMediator = ConversationMessagePageKeyedRemoteMediator(db) + ) { conversationPagingSource(conversationId) }.flow + + override suspend fun createConversation(messageId: ID) { + val message = + historyController.chats.value?.find { it.messages.firstOrNull { it.id == messageId } != null } + ?.messages?.find { it.id == messageId } ?: return + + val conversation = ConversationMockProvider.createConversation(exchange, message) ?: return + + db.conversationDao().upsertConversations(conversation) + + val tipMessage = ConversationMockProvider.createMessage( + conversation.id, + ConversationMessageContent.TipMessage + ) + + Timber.d("upserting tip message") + db.conversationMessageDao().upsertMessages(tipMessage) + } + + override suspend fun hasThanked(messageId: ID): Boolean { + val conversation = db.conversationDao().findConversationForMessage(messageId) ?: return false + return db.conversationDao().hasThanked(conversation.id) + } + + override suspend fun thankTipper(messageId: ID) { + val conversation = db.conversationDao().findConversationForMessage(messageId) + if (conversation == null) { + Timber.d("conversation doesn't exist.. creating") + val message = + historyController.chats.value?.find { it.messages.firstOrNull { it.id == messageId } != null } + ?.messages?.find { it.id == messageId } ?: return + + createConversation(message.id) + } + + val message = ConversationMockProvider.thankTipper(messageId) ?: return + db.conversationMessageDao().upsertMessages(message) + } + + override fun sendMessage(conversationId: ID, message: String) { + launch { + val messageId = generateId() + + val tipAddress = SessionManager.getOrganizer()?.primaryVault + ?.let { Base58.encode(it.byteArray) } ?: return@launch + + val content = ConversationMessageContent.Text( + message = message, + status = MessageStatus.Sent, + from = tipAddress + ) + + val m = ConversationMessage( + idBase58 = messageId.base58, + cursorBase58 = messageId.base58, + conversationIdBase58 = conversationId.base58, + dateMillis = System.currentTimeMillis(), + content = content, + ) + + db.conversationMessageDao().upsertMessages(m) + + // delay and mimic delivery + delay(1_000) + db.conversationMessageDao().upsertMessages( + m.copy(content = content.copy(status = MessageStatus.Delivered)) + ) + + // delay and deliver read + delay(2_500) + db.conversationMessageDao().upsertMessages( + m.copy(content = content.copy(status = MessageStatus.Read)) + ) + + // delay and mimic incoming response + delay(2_000) + + val responseId = generateId() + val response = ConversationMessage( + idBase58 = responseId.base58, + cursorBase58 = responseId.base58, + conversationIdBase58 = conversationId.base58, + dateMillis = System.currentTimeMillis(), + content = ConversationMessageContent.Text( + "You're welcome!!", + status = MessageStatus.Incoming, + from = Base58.encode("reply".encodeToByteArray()) + ), + ) + + db.conversationMessageDao().upsertMessages(response) + } + } + + private fun generateId() = UUID.randomUUID().toByteArray().toList() +} + +/** + * Make sure to have the same sort from DB as it is from the backend side, otherwise items get mixed up and prevKey + * and nextKey are no longer valid (the scroll might get stuck or the load might loop one of the pages) + */ +@OptIn(ExperimentalPagingApi::class) +class ConversationMessagePageKeyedRemoteMediator( + private val db: AppDatabase, +) : RemoteMediator() { + override suspend fun load( + loadType: LoadType, + state: PagingState + ): MediatorResult { + return try { + // The network load method takes an optional after= + // parameter. For every page after the first, pass the last user + // ID to let it continue from where it left off. For REFRESH, + // pass null to load the first page. + val cursor = when (loadType) { + LoadType.REFRESH -> null + // In this example, you never need to prepend, since REFRESH + // will always load the first page in the list. Immediately + // return, reporting end of pagination. + LoadType.PREPEND -> + return MediatorResult.Success(endOfPaginationReached = true) + + LoadType.APPEND -> { + val lastItem = state.lastItemOrNull() + ?: return MediatorResult.Success(endOfPaginationReached = true) + + // You must explicitly check if the last item is null when + // appending, since passing null to networkService is only + // valid for initial load. If lastItem is null it means no + // items were loaded after the initial REFRESH and there are + // no more items to load. + + lastItem.cursor + } + } + MediatorResult.Success(endOfPaginationReached = true) + } catch (e: IOException) { + MediatorResult.Error(e) + } + } +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/network/HistoryController.kt b/api/src/main/java/com/getcode/network/HistoryController.kt index eda310224..753f840c8 100644 --- a/api/src/main/java/com/getcode/network/HistoryController.kt +++ b/api/src/main/java/com/getcode/network/HistoryController.kt @@ -51,8 +51,7 @@ class HistoryController @Inject constructor( private val chatFlows = mutableMapOf>>() private val pagingConfig = PagingConfig(pageSize = 20) - - + fun reset() { pagerMap.clear() chatFlows.clear() diff --git a/api/src/main/java/com/getcode/network/exchange/Exchange.kt b/api/src/main/java/com/getcode/network/exchange/Exchange.kt index 0df5ed5df..dc989a1a1 100644 --- a/api/src/main/java/com/getcode/network/exchange/Exchange.kt +++ b/api/src/main/java/com/getcode/network/exchange/Exchange.kt @@ -1,8 +1,6 @@ package com.getcode.network.exchange import android.annotation.SuppressLint -import android.text.format.DateUtils -import com.getcode.db.AppDatabase import com.getcode.db.Database import com.getcode.model.Currency import com.getcode.model.CurrencyCode @@ -14,18 +12,13 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import timber.log.Timber import java.util.Date import javax.inject.Inject -import javax.inject.Singleton import kotlin.coroutines.resume -import kotlin.time.Duration import kotlin.time.Duration.Companion.convert import kotlin.time.Duration.Companion.minutes import kotlin.time.DurationUnit diff --git a/api/src/main/java/com/getcode/network/repository/BetaFlagsRepository.kt b/api/src/main/java/com/getcode/network/repository/BetaFlagsRepository.kt index 1f9c2fe28..9b92f91a4 100644 --- a/api/src/main/java/com/getcode/network/repository/BetaFlagsRepository.kt +++ b/api/src/main/java/com/getcode/network/repository/BetaFlagsRepository.kt @@ -17,7 +17,6 @@ data class BetaOptions( val establishCodeRelationship: Boolean, val chatUnsubEnabled: Boolean, val tipsEnabled: Boolean, - val chatMessageV2Enabled: Boolean, val tipsChatEnabled: Boolean, ) { companion object { @@ -33,7 +32,6 @@ data class BetaOptions( establishCodeRelationship = false, chatUnsubEnabled = false, tipsEnabled = false, - chatMessageV2Enabled = false, tipsChatEnabled = false ) } @@ -64,10 +62,9 @@ class BetaFlagsRepository @Inject constructor( observeBetaFlag(PrefsBool.ESTABLISH_CODE_RELATIONSHIP, default = defaults.establishCodeRelationship), observeBetaFlag(PrefsBool.CHAT_UNSUB_ENABLED, default = defaults.chatUnsubEnabled), observeBetaFlag(PrefsBool.TIPS_ENABLED, default = defaults.tipsEnabled), - observeBetaFlag(PrefsBool.MESSAGE_PAYMENT_NODE_V2, default = defaults.chatMessageV2Enabled), observeBetaFlag(PrefsBool.TIPS_CHAT_ENABLED, default = defaults.tipsChatEnabled), observeBetaFlag(PrefsBool.DISPLAY_ERRORS, default = defaults.displayErrors), - ) { network, buckets, vibez, times, giveRequests, buyKin, relationship, chatUnsub, tips, chatMessageV2, tipsChat, errors -> + ) { network, buckets, vibez, times, giveRequests, buyKin, relationship, chatUnsub, tips, tipsChat, errors -> BetaOptions( showNetworkDropOff = network, canViewBuckets = buckets, @@ -78,7 +75,6 @@ class BetaFlagsRepository @Inject constructor( establishCodeRelationship = relationship, chatUnsubEnabled = chatUnsub, tipsEnabled = tips, - chatMessageV2Enabled = chatMessageV2, tipsChatEnabled = tipsChat, displayErrors = errors ) diff --git a/api/src/main/java/com/getcode/network/repository/Extensions.kt b/api/src/main/java/com/getcode/network/repository/Extensions.kt index e55154d83..c8e65b7e2 100644 --- a/api/src/main/java/com/getcode/network/repository/Extensions.kt +++ b/api/src/main/java/com/getcode/network/repository/Extensions.kt @@ -42,6 +42,9 @@ fun ByteArray.toSignature(): Model.Signature { .build() } +val List.base58: String + get() = Base58.encode(toByteArray()) + val ByteArray.base58: String get() = Base58.encode(this) diff --git a/api/src/main/java/com/getcode/network/source/ConversationPagingSource.kt b/api/src/main/java/com/getcode/network/source/ConversationPagingSource.kt new file mode 100644 index 000000000..ef3d59992 --- /dev/null +++ b/api/src/main/java/com/getcode/network/source/ConversationPagingSource.kt @@ -0,0 +1,79 @@ +package com.getcode.network.source + +import com.getcode.db.AppDatabase +import com.getcode.db.Database +import com.getcode.model.ChatMessage +import com.getcode.model.Conversation +import com.getcode.model.ConversationMessage +import com.getcode.model.ConversationMessageContent +import com.getcode.model.ID +import com.getcode.model.MessageContent +import com.getcode.model.orOneToOne +import com.getcode.network.exchange.Exchange +import com.getcode.network.repository.base58 +import org.kin.sdk.base.tools.toByteArray +import timber.log.Timber +import java.util.UUID + +object ConversationMockProvider { + + val db: AppDatabase + get() = Database.requireInstance() + + suspend fun createConversation(exchange: Exchange, message: ChatMessage): Conversation? { + val ret = db.conversationDao().findConversationForMessage(message.id) + val hasTipMessage = ret?.let { db.conversationDao().hasTipMessage(it.id) } ?: false + if (hasTipMessage) return null + + val tipAmountRaw = message.contents.filterIsInstance() + .map { it.amount } + .firstOrNull() ?: return null + + val rate = exchange.rateFor(tipAmountRaw.currencyCode).orOneToOne() + val tipAmount = tipAmountRaw.amountUsing(rate) + + val id = generateId() + + val conversation = Conversation( + idBase58 = id.base58, + messageIdBase58 = message.id.base58, + cursorBase58 = id.base58, + tipAmount = tipAmount, + createdByUser = true, + hasRevealedIdentity = false, + user = null, + ) + + Timber.d("Created conversation ${id.base58} from ${tipAmount.fiat}") + + return conversation + } + + fun createMessage( + conversationId: ID, + content: ConversationMessageContent + ): ConversationMessage { + val mId = generateId() + + return ConversationMessage( + idBase58 = mId.base58, + cursorBase58 = mId.base58, + conversationIdBase58 = conversationId.base58, + dateMillis = System.currentTimeMillis(), + content = content, + ) + } + + suspend fun thankTipper(messageId: ID): ConversationMessage? { + val conversation = + db.conversationDao().findConversationForMessage(messageId) ?: return null + + if (db.conversationDao().hasThanked(conversation.id)) { + return null + } + + return createMessage(conversation.id, ConversationMessageContent.ThanksSent) + } + + private fun generateId() = UUID.randomUUID().toByteArray().toList() +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/utils/serializer/ConversationMessageContentSerializer.kt b/api/src/main/java/com/getcode/utils/serializer/ConversationMessageContentSerializer.kt new file mode 100644 index 000000000..bf69d4eb7 --- /dev/null +++ b/api/src/main/java/com/getcode/utils/serializer/ConversationMessageContentSerializer.kt @@ -0,0 +1,24 @@ +package com.getcode.utils.serializer + +import com.getcode.model.ConversationMessageContent +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +object ConversationMessageContentSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CMC", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): ConversationMessageContent { + val string = decoder.decodeString() + return ConversationMessageContent.deserialize(string) + } + + override fun serialize(encoder: Encoder, value: ConversationMessageContent) { + val payload = value.serialize() + encoder.encodeString(payload) + } + +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/utils/serializer/KinSerializer.kt b/api/src/main/java/com/getcode/utils/serializer/KinSerializer.kt new file mode 100644 index 000000000..1eed89ebf --- /dev/null +++ b/api/src/main/java/com/getcode/utils/serializer/KinSerializer.kt @@ -0,0 +1,22 @@ +package com.getcode.utils.serializer + +import com.getcode.model.Kin +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +object KinQuarksSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Kin", PrimitiveKind.LONG) + + override fun serialize(encoder: Encoder, value: Kin) { + encoder.encodeLong(value.quarks) + } + + override fun deserialize(decoder: Decoder): Kin { + val quarks = decoder.decodeLong() + return Kin.fromQuarks(quarks) + } +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/utils/serializer/RateSerializer.kt b/api/src/main/java/com/getcode/utils/serializer/RateSerializer.kt new file mode 100644 index 000000000..1b49b08e1 --- /dev/null +++ b/api/src/main/java/com/getcode/utils/serializer/RateSerializer.kt @@ -0,0 +1,23 @@ +package com.getcode.utils.serializer + +import com.getcode.model.Rate +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encodeToString +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json + +object RateAsStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Rate", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Rate) { + encoder.encodeString(Json.encodeToString(value)) + } + + override fun deserialize(decoder: Decoder): Rate { + return Json.decodeFromString(decoder.decodeString()) + } +} \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 494033330..78072dc0a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,6 @@ import com.android.SdkConstants.FN_LOCAL_PROPERTIES import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties +import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension import org.jetbrains.kotlin.cli.common.toBooleanLenient import java.io.FileInputStream import java.io.InputStreamReader @@ -81,6 +82,10 @@ android { "proguard-rules.pro" ) } + + configure { + mappingFileUploadEnabled = tryReadProperty(rootProject.rootDir, "DEBUG_CRASHLYTICS_UPLOAD", "false").toBooleanLenient() ?: false + } } } @@ -110,16 +115,13 @@ android { } } - dependencies { implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) implementation(project(":api")) - implementation(project(":model")) - implementation(project(":ed25519")) + implementation(project(":common")) //standard libraries - implementation(Libs.kotlin_stdlib) implementation(Libs.kotlinx_collections_immutable) implementation(Libs.kotlinx_serialization_json) implementation(Libs.kotlinx_datetime) @@ -178,7 +180,6 @@ dependencies { implementation(Libs.slf4j) implementation(Libs.grpc_android) - implementation(Libs.kin_sdk) implementation(platform(Libs.firebase_bom)) implementation(Libs.firebase_analytics) @@ -205,6 +206,7 @@ dependencies { implementation(Libs.androidx_room_runtime) implementation(Libs.androidx_room_ktx) implementation(Libs.androidx_room_rxjava3) + implementation(Libs.androidx_room_paging) kapt(Libs.androidx_room_compiler) implementation(Libs.markwon_core) diff --git a/app/src/main/java/com/getcode/inject/ApiModule.kt b/app/src/main/java/com/getcode/inject/ApiModule.kt index e7e7a2ad0..f19f2ce2b 100644 --- a/app/src/main/java/com/getcode/inject/ApiModule.kt +++ b/app/src/main/java/com/getcode/inject/ApiModule.kt @@ -6,7 +6,6 @@ import com.getcode.R import com.getcode.analytics.AnalyticsService import com.getcode.model.Currency import com.getcode.network.BalanceController -import com.getcode.network.HistoryController import com.getcode.network.PrivacyMigration import com.getcode.network.api.CurrencyApi import com.getcode.network.api.TransactionApiV2 diff --git a/app/src/main/java/com/getcode/inject/AppModule.kt b/app/src/main/java/com/getcode/inject/AppModule.kt index 649f572db..54c11fa0e 100644 --- a/app/src/main/java/com/getcode/inject/AppModule.kt +++ b/app/src/main/java/com/getcode/inject/AppModule.kt @@ -10,8 +10,6 @@ import android.os.VibratorManager import android.telephony.TelephonyManager import com.getcode.analytics.AnalyticsManager import com.getcode.analytics.AnalyticsService -import com.getcode.network.exchange.CodeExchange -import com.getcode.network.exchange.Exchange import com.getcode.util.AndroidLocale import com.getcode.util.AndroidPermissions import com.getcode.util.AndroidResources diff --git a/app/src/main/java/com/getcode/inject/DataModule.kt b/app/src/main/java/com/getcode/inject/DataModule.kt new file mode 100644 index 000000000..bbf6948f6 --- /dev/null +++ b/app/src/main/java/com/getcode/inject/DataModule.kt @@ -0,0 +1,23 @@ +package com.getcode.inject + +import com.getcode.network.ConversationController +import com.getcode.network.ConversationMockController +import com.getcode.network.HistoryController +import com.getcode.network.exchange.Exchange +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DataModule { + + @Provides + @Singleton + fun providesConversationController( + historyController: HistoryController, + exchange: Exchange + ): ConversationController = ConversationMockController(historyController, exchange) +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/manager/AuthManager.kt b/app/src/main/java/com/getcode/manager/AuthManager.kt index 500ab79f6..a48288fa3 100644 --- a/app/src/main/java/com/getcode/manager/AuthManager.kt +++ b/app/src/main/java/com/getcode/manager/AuthManager.kt @@ -9,7 +9,6 @@ import com.getcode.analytics.AnalyticsService import com.getcode.crypt.MnemonicPhrase import com.getcode.db.Database import com.getcode.db.InMemoryDao -import com.getcode.ed25519.Ed25519 import com.getcode.model.AirdropType import com.getcode.model.PrefsBool import com.getcode.model.PrefsString @@ -40,12 +39,9 @@ import io.reactivex.rxjava3.schedulers.Schedulers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.reactive.asFlow import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine @Singleton class AuthManager @Inject constructor( diff --git a/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt b/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt index 41e7db855..96035244a 100644 --- a/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt +++ b/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt @@ -1,7 +1,6 @@ package com.getcode.navigation.screens import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf @@ -14,9 +13,7 @@ import cafe.adriel.voyager.core.lifecycle.LifecycleEffect import cafe.adriel.voyager.core.screen.ScreenKey import cafe.adriel.voyager.core.screen.uniqueScreenKey import cafe.adriel.voyager.hilt.getViewModel -import com.getcode.LocalBetaFlags import com.getcode.R -import com.getcode.TopLevelViewModel import com.getcode.analytics.AnalyticsManager import com.getcode.analytics.AnalyticsScreenWatcher import com.getcode.model.ID @@ -28,6 +25,7 @@ import com.getcode.view.main.balance.BalanceSheetViewModel import com.getcode.view.main.chat.ChatScreen import com.getcode.view.main.chat.ChatViewModel import com.getcode.view.main.chat.conversation.ChatConversationScreen +import com.getcode.view.main.chat.conversation.ConversationViewModel import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -117,7 +115,7 @@ data class ChatScreen(val chatId: ID) : ChatGraph, ModalContent { LaunchedEffect(vm) { vm.eventFlow - .filterIsInstance() + .filterIsInstance() .map { it.messageId } .onEach { navigator.push(ChatMessageConversationScreen(it)) } .launchIn(this) @@ -136,28 +134,21 @@ data class ChatMessageConversationScreen(val messageId: ID): ChatGraph, ModalCon @Composable override fun Content() { -// val vm = getViewModel() -// val state by vm.stateFlow.collectAsState() val navigator = LocalCodeNavigator.current + val vm = getViewModel() + val state by vm.stateFlow.collectAsState() ModalContainer( - title = { "Anonymous Tipper" }, + title = { state.title }, backButton = { it is ChatMessageConversationScreen }, ) { - ChatConversationScreen(s = "") + val messages = vm.messages.collectAsLazyPagingItems() + ChatConversationScreen(state, messages, vm::dispatchEvent) } -// LaunchedEffect(vm) { -// vm.eventFlow -// .filterIsInstance() -// .map { it.id } -// .onEach { -//// navigator.push() -// }.launchIn(this) -// } LaunchedEffect(messageId) { -// vm.dispatchEvent(ChatViewModel.Event.OnChatIdChanged(chatId)) + vm.dispatchEvent(ConversationViewModel.Event.OnMessageIdChanged(messageId)) } } } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/notifications/CodePushMessagingService.kt b/app/src/main/java/com/getcode/notifications/CodePushMessagingService.kt index f58002c9e..32d90cfca 100644 --- a/app/src/main/java/com/getcode/notifications/CodePushMessagingService.kt +++ b/app/src/main/java/com/getcode/notifications/CodePushMessagingService.kt @@ -27,7 +27,6 @@ import com.google.firebase.Firebase import com.google.firebase.installations.installations import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage -import com.ionspin.kotlin.crypto.LibsodiumInitializer import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/app/src/main/java/com/getcode/theme/Color.kt b/app/src/main/java/com/getcode/theme/Color.kt index cc1c50c4d..e243ec200 100644 --- a/app/src/main/java/com/getcode/theme/Color.kt +++ b/app/src/main/java/com/getcode/theme/Color.kt @@ -13,6 +13,7 @@ val Brand01 = Color(0xFF130F27) val White = Color(0xffffffff) val White50 = Color(0x80FFFFFF) val White10 = Color(0x1AFFFFFF) +val White20 = Color(0x33FFFFFF) val White05 = Color(0x0CFFFFFF) val Black10 = Color(0x19000000) val Black40 = Color(0x66000000) diff --git a/app/src/main/java/com/getcode/ui/components/CodeButton.kt b/app/src/main/java/com/getcode/ui/components/CodeButton.kt index 303fa1aca..31c446e13 100644 --- a/app/src/main/java/com/getcode/ui/components/CodeButton.kt +++ b/app/src/main/java/com/getcode/ui/components/CodeButton.kt @@ -19,7 +19,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.isSpecified @@ -37,7 +36,6 @@ enum class ButtonState { Subtle } -@OptIn(ExperimentalMaterialApi::class) @Composable fun CodeButton( modifier: Modifier = Modifier, @@ -191,7 +189,7 @@ fun getButtonColors( ButtonState.Bordered -> ButtonDefaults.outlinedButtonColors( - backgroundColor = Brand, + backgroundColor = Transparent, disabledContentColor = Color.LightGray, contentColor = textColor.takeOrElse { Color.LightGray } ) @@ -213,13 +211,12 @@ fun getButtonColors( } @Composable -fun getButtonBorder(buttonState: ButtonState, isEnabled: Boolean = true): BorderStroke? { +fun getButtonBorder(buttonState: ButtonState, isEnabled: Boolean = true): BorderStroke { val border = CodeTheme.dimens.border return remember(buttonState, isEnabled) { - if (buttonState == ButtonState.Bordered && isEnabled) { - BorderStroke(border, White50) - } else { - BorderStroke(border, Transparent) + when (buttonState) { + ButtonState.Bordered -> BorderStroke(border, if (isEnabled) White50 else White20) + else -> BorderStroke(border, Transparent) } } } diff --git a/app/src/main/java/com/getcode/ui/components/chat/ChatNode.kt b/app/src/main/java/com/getcode/ui/components/chat/ChatNode.kt index f117b5402..348c7854e 100644 --- a/app/src/main/java/com/getcode/ui/components/chat/ChatNode.kt +++ b/app/src/main/java/com/getcode/ui/components/chat/ChatNode.kt @@ -42,8 +42,6 @@ fun ChatNode( chat: Chat, onClick: () -> Unit, ) { - val context = LocalContext.current - Column( modifier = modifier .clickable { onClick() } diff --git a/app/src/main/java/com/getcode/ui/components/chat/MessageNode.kt b/app/src/main/java/com/getcode/ui/components/chat/MessageNode.kt index 4df1c1480..e958b87a7 100644 --- a/app/src/main/java/com/getcode/ui/components/chat/MessageNode.kt +++ b/app/src/main/java/com/getcode/ui/components/chat/MessageNode.kt @@ -1,19 +1,33 @@ package com.getcode.ui.components.chat +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.Image import androidx.compose.foundation.background 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CornerBasedShape import androidx.compose.foundation.shape.CornerSize +import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ChatBubbleOutline +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.FavoriteBorder +import androidx.compose.material.icons.rounded.HeartBroken import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +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 @@ -29,12 +43,17 @@ import com.getcode.model.KinAmount import com.getcode.model.MessageContent import com.getcode.model.Rate import com.getcode.model.Verb +import com.getcode.model.orOneToOne +import com.getcode.theme.Alert import com.getcode.theme.BrandDark import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme +import com.getcode.theme.extraLarge +import com.getcode.theme.extraSmall import com.getcode.ui.components.ButtonState import com.getcode.ui.components.CodeButton import com.getcode.ui.components.chat.utils.localizedText +import com.getcode.ui.utils.unboundedClickable import com.getcode.util.formatTimeRelatively import com.getcode.utils.FormatUtils import com.getcode.view.main.giveKin.KinValueHint @@ -65,14 +84,13 @@ fun MessageNode( date: Instant, isPreviousSameMessage: Boolean, isNextSameMessage: Boolean, - openTipChat: () -> Unit, + thankUser: () -> Unit, + openMessageChat: () -> Unit, ) { Box( modifier = modifier .padding(vertical = CodeTheme.dimens.grid.x1) ) { - val exchange = LocalExchange.current - Box( modifier = Modifier .fillMaxWidth(0.895f) @@ -90,39 +108,12 @@ fun MessageNode( ) { when (contents) { is MessageContent.Exchange -> { - val rate = exchange.rateFor(contents.amount.currencyCode) - val isV2Enabled = LocalBetaFlags.current.chatMessageV2Enabled - if (rate != null) { - if (isV2Enabled) { - MessagePaymentV2( - verb = contents.verb, - amount = contents.amount.amountUsing(rate), - caption = contents.localizedText, - date = date, - openTipChat = openTipChat - ) - } else { - MessagePayment( - verb = contents.verb, - amount = contents.amount.amountUsing(rate), - ) - } - } else { - if (isV2Enabled) { - MessagePaymentV2( - verb = Verb.Unknown, - amount = KinAmount.newInstance(0, Rate.oneToOne), - caption = contents.localizedText, - date = date, - openTipChat = openTipChat - ) - } else { - MessagePayment( - verb = Verb.Unknown, - amount = KinAmount.newInstance(0, Rate.oneToOne) - ) - } - } + MessagePayment( + contents = contents, + thankUser = thankUser, + date = date, + openMessageChat = openMessageChat + ) } is MessageContent.Localized -> { @@ -155,8 +146,10 @@ fun MessageNode( @Composable private fun MessagePayment( modifier: Modifier = Modifier, - verb: Verb, - amount: KinAmount, + contents: MessageContent.Exchange, + date: Instant, + thankUser: () -> Unit, + openMessageChat: () -> Unit, ) { Column( modifier = modifier @@ -165,7 +158,17 @@ private fun MessagePayment( verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), horizontalAlignment = Alignment.CenterHorizontally, ) { - if (verb == Verb.Returned) { + val exchange = LocalExchange.current + val rate by remember(contents.amount.currencyCode) { + derivedStateOf { + exchange.rateFor(contents.amount.currencyCode).orOneToOne() + } + } + val amount by remember(rate) { + derivedStateOf { contents.amount.amountUsing(rate) } + } + + if (contents.verb == Verb.Returned) { PriceWithFlag( currencyCode = amount.rate.currency, amount = amount, @@ -178,12 +181,12 @@ private fun MessagePayment( } ) Text( - text = verb.localizedText, + text = contents.verb.localizedText, style = CodeTheme.typography.body1.copy(fontWeight = FontWeight.W500) ) } else { Text( - text = verb.localizedText, + text = contents.verb.localizedText, style = CodeTheme.typography.body1.copy(fontWeight = FontWeight.W500) ) PriceWithFlag( @@ -198,53 +201,50 @@ private fun MessagePayment( } ) } - } -} -@Composable -private fun MessagePaymentV2( - modifier: Modifier = Modifier, - verb: Verb, - amount: KinAmount, - caption: String, - date: Instant, - openTipChat: () -> Unit, -) { - val tipChatsEnabled = LocalBetaFlags.current.tipsChatEnabled - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - ) { - CodeTransactionDisplay( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(), - amount = amount, - verb = verb - ) - - Text( - text = caption, - style = CodeTheme.typography.body1.copy(fontWeight = FontWeight.W500) - ) + val tipChatsEnabled = LocalBetaFlags.current.tipsChatEnabled + var thanked by remember(contents.thanked) { + mutableStateOf(contents.thanked) + } - if (tipChatsEnabled && verb is Verb.ReceivedTip) { - CodeButton( - modifier = Modifier.fillMaxWidth(), - buttonState = ButtonState.Filled, - onClick = openTipChat, - text = stringResource(R.string.action_sayThankYou) - ) + val sendThanks = { + thanked = true + thankUser() } - Text( - modifier = Modifier.align(Alignment.End), - text = date.formatTimeRelatively(), - style = CodeTheme.typography.caption, - color = BrandLight, - ) + if (tipChatsEnabled && contents.verb is Verb.ReceivedTip) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + CodeButton( + modifier = Modifier.weight(1f), + enabled = !thanked, + buttonState = if (thanked) ButtonState.Bordered else ButtonState.Filled, + onClick = sendThanks, + shape = CodeTheme.shapes.extraSmall, + text = if (thanked) stringResource(R.string.action_thanked) else stringResource(R.string.action_thank) + ) + CodeButton( + modifier = Modifier.weight(1f), + buttonState = ButtonState.Filled, + onClick = openMessageChat, + shape = CodeTheme.shapes.extraSmall, + text = stringResource(R.string.action_message) + ) + } + Text( + modifier = Modifier.align(Alignment.End), + text = date.formatTimeRelatively(), + style = CodeTheme.typography.caption, + color = BrandLight, + ) + } else { + Spacer(modifier = Modifier.weight(1f)) + } } } + @Composable private fun CodeTransactionDisplay( modifier: Modifier = Modifier, diff --git a/app/src/main/java/com/getcode/ui/components/conversation/AnnouncementMessage.kt b/app/src/main/java/com/getcode/ui/components/conversation/AnnouncementMessage.kt new file mode 100644 index 000000000..391aee458 --- /dev/null +++ b/app/src/main/java/com/getcode/ui/components/conversation/AnnouncementMessage.kt @@ -0,0 +1,39 @@ +package com.getcode.ui.components.conversation + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import com.getcode.theme.BrandDark +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.chat.MessageNodeDefaults + +@Composable +fun AnnouncementMessage( + modifier: Modifier = Modifier, + text: String, +) { + Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Column( + modifier = Modifier + .background( + color = BrandDark, + shape = MessageNodeDefaults.DefaultShape + ) + .padding(CodeTheme.dimens.grid.x2), + verticalArrangement = Arrangement.Center + ) { + Text( + text = text, + style = CodeTheme.typography.body1.copy(fontWeight = FontWeight.W500) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/conversation/ChatInput.kt b/app/src/main/java/com/getcode/ui/components/conversation/ChatInput.kt new file mode 100644 index 000000000..d938250f7 --- /dev/null +++ b/app/src/main/java/com/getcode/ui/components/conversation/ChatInput.kt @@ -0,0 +1,114 @@ +package com.getcode.ui.components.conversation + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.ExperimentalFoundationApi +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.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.shape.CircleShape +import androidx.compose.foundation.text2.input.TextFieldState +import androidx.compose.foundation.text2.input.rememberTextFieldState +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Send +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.getcode.theme.ChatOutgoing +import com.getcode.theme.CodeTheme +import com.getcode.theme.extraLarge +import com.getcode.theme.inputColors +import com.getcode.ui.components.TextInput +import com.getcode.ui.utils.withTopBorder + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ChatInput( + modifier: Modifier = Modifier, + state: TextFieldState = rememberTextFieldState(), + onSend: () -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .withTopBorder() + .padding(CodeTheme.dimens.grid.x2), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + verticalAlignment = Alignment.Bottom + ) { + TextInput( + modifier = Modifier + .weight(1f), + minHeight = 40.dp, + state = state, + shape = CodeTheme.shapes.extraLarge, + contentPadding = PaddingValues(8.dp), + colors = inputColors( + backgroundColor = Color.White, + textColor = CodeTheme.colors.background, + cursorColor = CodeTheme.colors.brand, + ) + ) + AnimatedContent( + targetState = state.text.isNotEmpty(), + label = "show/hide send button", + transitionSpec = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Start + ) togetherWith slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.End + ) + } + ) { show -> + if (show) { + Box( + modifier = Modifier + .align(Alignment.Bottom) + .background(ChatOutgoing, shape = CircleShape) + .clip(CircleShape) + .clickable { onSend() } + .size(ChatInput_Size) + .padding(8.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier + .size(CodeTheme.dimens.staticGrid.x6), + imageVector = Icons.AutoMirrored.Rounded.Send, + tint = Color.White, + contentDescription = "Send message" + ) + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Preview +@Composable +private fun Preview_ChatInput() { + CodeTheme { + ChatInput { + + } + } +} + +private val ChatInput_Size + @Composable get() = CodeTheme.dimens.grid.x8 \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/conversation/DateWithStatus.kt b/app/src/main/java/com/getcode/ui/components/conversation/DateWithStatus.kt new file mode 100644 index 000000000..66b5fffaf --- /dev/null +++ b/app/src/main/java/com/getcode/ui/components/conversation/DateWithStatus.kt @@ -0,0 +1,118 @@ +package com.getcode.ui.components.conversation + +import androidx.compose.foundation.background +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.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import com.getcode.R +import com.getcode.model.MessageStatus +import com.getcode.theme.BrandLight +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.chat.MessageNodeDefaults +import com.getcode.util.formatTimeRelatively +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +object DateWithStatusDefaults { + val DateTextStyle: TextStyle + @Composable get() = CodeTheme.typography.caption + val IconWidth: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x3 + val Spacing: Dp + @Composable get() = CodeTheme.dimens.grid.x1 +} + +@Composable +internal fun DateWithStatus( + modifier: Modifier = Modifier, + date: Instant, + status: MessageStatus +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(DateWithStatusDefaults.Spacing), + ) { + Text( + modifier = Modifier.weight(1f, fill = false), + text = date.formatTimeRelatively(), + style = DateWithStatusDefaults.DateTextStyle, + color = BrandLight, + maxLines = 1 + ) + if (status != MessageStatus.Incoming) { + Icon( + modifier = Modifier + .requiredWidth(width = DateWithStatusDefaults.IconWidth) + .padding(top = CodeTheme.dimens.grid.x1 / 2), + painter = painterResource( + id = when (status) { + MessageStatus.Sent -> R.drawable.ic_message_status_sent + MessageStatus.Delivered -> R.drawable.ic_message_status_delivered + MessageStatus.Read -> R.drawable.ic_message_status_read + else -> -1 + } + ), + tint = Color.Unspecified, + contentDescription = "status" + ) + } + } +} + + + + +@Preview +@Composable +private fun Preview_DateWithStatus() { + CodeTheme { + Column { + Box( + modifier = Modifier + .wrapContentWidth() + .background( + color = Color(0xFF443091), + shape = MessageNodeDefaults.DefaultShape + ) + .padding(CodeTheme.dimens.grid.x2) + ) { + DateWithStatus(date = Clock.System.now(), status = MessageStatus.Sent) + } + Box( + modifier = Modifier + .wrapContentWidth() + .background( + color = Color(0xFF443091), + shape = MessageNodeDefaults.DefaultShape + ) + .padding(CodeTheme.dimens.grid.x2) + ) { + DateWithStatus(date = Clock.System.now(), status = MessageStatus.Delivered) + } + Box( + modifier = Modifier + .wrapContentWidth() + .background( + color = Color(0xFF443091), + shape = MessageNodeDefaults.DefaultShape + ) + .padding(CodeTheme.dimens.grid.x2) + ) { + DateWithStatus(date = Clock.System.now(), status = MessageStatus.Read) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/conversation/MessageBubble.kt b/app/src/main/java/com/getcode/ui/components/conversation/MessageBubble.kt new file mode 100644 index 000000000..f39ea02fb --- /dev/null +++ b/app/src/main/java/com/getcode/ui/components/conversation/MessageBubble.kt @@ -0,0 +1,192 @@ +package com.getcode.ui.components.conversation + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +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.requiredHeight +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import com.getcode.model.ConversationMessageContent +import com.getcode.model.MessageStatus +import com.getcode.theme.BrandDark +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.chat.MessageNodeDefaults +import com.getcode.util.formatDateRelatively +import kotlinx.datetime.Instant + +@Composable +fun MessageBubble( + modifier: Modifier = Modifier, + content: ConversationMessageContent.Text, + date: Instant, +) { + val alignment = if (content.isFromSelf) Alignment.CenterEnd else Alignment.CenterStart + val color = if (content.isFromSelf) Color(0xFF443091) else BrandDark + BoxWithConstraints(modifier = modifier.fillMaxWidth(), contentAlignment = alignment) { + BoxWithConstraints( + modifier = Modifier + .widthIn(max = maxWidth * 0.895f) + .background( + color = color, + shape = MessageNodeDefaults.DefaultShape + ) + .padding(CodeTheme.dimens.grid.x2) + ) contents@{ + val maxWidthPx = with (LocalDensity.current) { maxWidth.roundToPx() } + Column( + modifier = Modifier + .background(color) + // add top padding to accommodate ascents + .padding(top = CodeTheme.dimens.grid.x1), + ) { + MessageContent( + maxWidth = maxWidthPx, + message = content.message, date = date, status = content.status) + } + } + } +} + +@Composable +private fun rememberAlignmentRule( + contentTextStyle: TextStyle, + maxWidth: Int, + message: String, + date: Instant +): State { + val density = LocalDensity.current + val dateTextStyle = DateWithStatusDefaults.DateTextStyle + val iconSizePx = with (density) { DateWithStatusDefaults.IconWidth.roundToPx() } + val spacingPx = with (density) { DateWithStatusDefaults.Spacing.roundToPx() } + val contentPaddingPx = with (density) { CodeTheme.dimens.grid.x2.roundToPx() } + + return remember(maxWidth, message, date) { + mutableStateOf(null) + }.apply { + val textMeasurer = rememberTextMeasurer() + val dateStatusWidth = remember(message, date) { + val result = textMeasurer.measure( + text = date.formatDateRelatively(), + style = dateTextStyle, + maxLines = 1 + ) + result.size.width + spacingPx + iconSizePx + } + + val bufferSize by remember(dateStatusWidth) { + derivedStateOf { + dateStatusWidth + spacingPx + contentPaddingPx * 2 + } + } + + if (value == null) { + Text( + modifier = Modifier.drawWithContent { }, + text = message, + style = contentTextStyle, + onTextLayout = { textLayoutResult -> + val lastLineNum = textLayoutResult.lineCount - 1 + val lineStart = textLayoutResult.getLineStart(lastLineNum) + val lineEnd = + textLayoutResult.getLineEnd(lastLineNum, visibleEnd = true) + val lineContent = message.substring(lineStart, lineEnd) + + val lineContentWidth = + textMeasurer.measure(lineContent, contentTextStyle).size.width + + println("lcw=$lineContentWidth") + println("bw=$bufferSize") + println("result=${lineContentWidth + bufferSize}, max=$maxWidth") + value = when { + lineContentWidth + bufferSize > maxWidth -> AlignmentRule.Column + textLayoutResult.lineCount == 1 -> AlignmentRule.SingleLineEnd + else -> AlignmentRule.ParagraphLastLine + } + } + ) + } + } +} + +@Composable +private fun MessageContent(maxWidth: Int, message: String, date: Instant, status: MessageStatus) { + val contentStyle = CodeTheme.typography.body1.copy(fontWeight = FontWeight.W500) + val alignmentRule by rememberAlignmentRule( + contentTextStyle = contentStyle, + maxWidth = maxWidth, + message = message, + date = date, + ) + + when (alignmentRule) { + AlignmentRule.Column -> { + Column(verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2)) { + Text( + text = message, + style = contentStyle, + ) + DateWithStatus( + modifier = Modifier + .align(Alignment.End), + date = date, + status = status + ) + } + } + AlignmentRule.ParagraphLastLine -> { + Column(verticalArrangement = Arrangement.spacedBy(-(CodeTheme.dimens.grid.x2))) { + Text( + text = message, + style = contentStyle, + ) + DateWithStatus( + modifier = Modifier + .align(Alignment.End), + date = date, + status = status + ) + } + } + AlignmentRule.SingleLineEnd -> { + Row(horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1)) { + Text( + text = message, + style = contentStyle, + ) + DateWithStatus( + modifier = Modifier + .padding(top = CodeTheme.dimens.grid.x1), + date = date, + status = status + ) + } + } + else -> Unit + } +} + +sealed interface AlignmentRule { + data object ParagraphLastLine: AlignmentRule + data object Column: AlignmentRule + data object SingleLineEnd: AlignmentRule +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/conversation/utils/HandleMessageChanges.kt b/app/src/main/java/com/getcode/ui/components/conversation/utils/HandleMessageChanges.kt new file mode 100644 index 000000000..9ebb799a7 --- /dev/null +++ b/app/src/main/java/com/getcode/ui/components/conversation/utils/HandleMessageChanges.kt @@ -0,0 +1,76 @@ +package com.getcode.ui.components.conversation.utils + +import android.os.Build +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.paging.compose.LazyPagingItems +import com.getcode.model.ConversationMessage +import com.getcode.model.ConversationMessageContent +import com.getcode.ui.utils.isScrolledToTheBeginning +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map + +@Composable +internal fun HandleMessageChanges( + listState: LazyListState, + items: LazyPagingItems, +) { + var lastMessageSent by rememberSaveable { + mutableLongStateOf(0L) + } + var lastMessageReceived by rememberSaveable { + mutableLongStateOf(0L) + } + + // handle incoming/outgoing messages - scroll to bottom to reset view in the following circumstances: + // 1) New message is from self (e.g outgoing) + // 2) New message is from participant and we are already at the bottom (to prevent rug pull) + LaunchedEffect(Unit) { + snapshotFlow { items.itemSnapshotList } + .map { it.firstOrNull() } + .filterNotNull() + .distinctUntilChangedBy { it.dateMillis } + .filter { it.content is ConversationMessageContent.Text } + .collect { newMessage -> + val content = newMessage.content as? ConversationMessageContent.Text ?: return@collect + if (content.isFromSelf && newMessage.dateMillis > lastMessageSent) { + listState.handleAndReplayAfter(300) { + scrollToItem(0) + lastMessageSent = newMessage.dateMillis + } + } else { + listState.handleAndReplayAfter(300) { + if (listState.isScrolledToTheBeginning() && newMessage.dateMillis > lastMessageReceived) { + // Android 10 (specifically the S1?) we have to utilize a mimic for IME nested scrolling + // using the [LazyListState#isScrollInProgress] which animateScrollToItem triggers + // thus causing the IME to be dismissed when we trigger the sync. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + listState.scrollToItem(0) + } else { + listState.animateScrollToItem(0) + } + } + lastMessageReceived = newMessage.dateMillis + } + } + } + } +} + +private suspend fun LazyListState.handleAndReplayAfter( + delay: Long, + block: suspend LazyListState.() -> Unit +) { + block() + delay(delay) + block() +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/utils/LazyList.kt b/app/src/main/java/com/getcode/ui/utils/LazyList.kt new file mode 100644 index 000000000..5a8b1168a --- /dev/null +++ b/app/src/main/java/com/getcode/ui/utils/LazyList.kt @@ -0,0 +1,44 @@ +package com.getcode.ui.utils + + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable + + +fun LazyListScope.animatedItem( + key: Any? = null, + contentType: Any? = null, + visible: Boolean, + enter: EnterTransition? = null, + exit: ExitTransition? = null, + content: @Composable LazyItemScope.() -> Unit +) { + item(key = key, contentType = contentType) { + if (enter != null && exit != null) { + AnimatedVisibility( + visible = visible, + enter = enter, + exit = exit, + ) { + content() + } + } else { + AnimatedVisibility( + visible = visible, + ) { + content() + } + } + } +} + +fun LazyListState.isScrolledToTheEnd() = + layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1 + +fun LazyListState.isScrolledToTheBeginning() = + (layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: 0) == 0 \ No newline at end of file diff --git a/app/src/main/java/com/getcode/util/AndroidResources.kt b/app/src/main/java/com/getcode/util/AndroidResources.kt index ddb72511c..6715687b4 100644 --- a/app/src/main/java/com/getcode/util/AndroidResources.kt +++ b/app/src/main/java/com/getcode/util/AndroidResources.kt @@ -10,9 +10,7 @@ import androidx.annotation.RawRes import androidx.annotation.StringRes import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.res.ResourcesCompat -import com.getcode.App import com.getcode.BuildConfig -import com.getcode.R import com.getcode.util.resources.ResourceHelper import com.getcode.util.resources.ResourceType import dagger.hilt.android.qualifiers.ApplicationContext diff --git a/app/src/main/java/com/getcode/util/Currency.kt b/app/src/main/java/com/getcode/util/Currency.kt index 01d4e147e..51dbbd40d 100644 --- a/app/src/main/java/com/getcode/util/Currency.kt +++ b/app/src/main/java/com/getcode/util/Currency.kt @@ -97,20 +97,24 @@ fun Currency.format(resources: ResourceHelper, amount: Double): String { ) } -fun formatAmountString(resources: ResourceHelper, currency: Currency, amount: Double): String { +fun formatAmountString( + resources: ResourceHelper, + currency: Currency, + amount: Double, + kinSuffix: String = "", + suffix: String = resources.getString(R.string.core_ofKin) +): String { val isKin = currency.code == Currency.Kin.code return if (isKin) { - "${FormatUtils.formatWholeRoundDown(amount)} ${ - resources.getString(R.string.core_kin) - }" + "${FormatUtils.formatWholeRoundDown(amount)} ${resources.getString(R.string.core_kin)} $kinSuffix" } else { when { currency.code == currency.symbol -> { - "${FormatUtils.format(amount)} ${resources.getString(R.string.core_ofKin)}" + "${FormatUtils.format(amount)} $suffix" } else -> { - "${currency.symbol}${FormatUtils.format(amount)} ${resources.getString(R.string.core_ofKin)}" + "${currency.symbol}${FormatUtils.format(amount)} $suffix" } } } diff --git a/app/src/main/java/com/getcode/util/ErrorUtils.kt b/app/src/main/java/com/getcode/util/ErrorUtils.kt index 2e01bda71..de0596e28 100644 --- a/app/src/main/java/com/getcode/util/ErrorUtils.kt +++ b/app/src/main/java/com/getcode/util/ErrorUtils.kt @@ -3,7 +3,6 @@ package com.getcode.util import android.content.Context -import com.getcode.App import com.getcode.R import com.getcode.manager.TopBarManager import com.getcode.util.resources.ResourceHelper diff --git a/app/src/main/java/com/getcode/util/KinAmountExt.kt b/app/src/main/java/com/getcode/util/KinAmountExt.kt index db0109b63..16e3886fc 100644 --- a/app/src/main/java/com/getcode/util/KinAmountExt.kt +++ b/app/src/main/java/com/getcode/util/KinAmountExt.kt @@ -3,11 +3,11 @@ package com.getcode.util import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import com.getcode.LocalCurrencyUtils +import com.getcode.R import com.getcode.model.Currency import com.getcode.model.KinAmount import com.getcode.util.resources.ResourceHelper import com.getcode.utils.FormatUtils -import timber.log.Timber fun KinAmount.formattedRaw() = FormatUtils.formatWholeRoundDown(kin.toKin().toDouble()) @@ -25,8 +25,15 @@ fun KinAmount.formatted(): String { return formatted(currency = currency) } -fun KinAmount.formatted(resources: ResourceHelper, currency: Currency) = formatAmountString( - resources, - currency, - fiat +fun KinAmount.formatted( + resources: ResourceHelper, + currency: Currency, + kinSuffix: String = "", + suffix: String = resources.getString(R.string.core_ofKin) +) = formatAmountString( + resources = resources, + currency = currency, + amount = fiat, + kinSuffix = kinSuffix, + suffix = suffix ) \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/BaseViewModel.kt b/app/src/main/java/com/getcode/view/BaseViewModel.kt index 5206c848e..4950b720e 100644 --- a/app/src/main/java/com/getcode/view/BaseViewModel.kt +++ b/app/src/main/java/com/getcode/view/BaseViewModel.kt @@ -2,8 +2,6 @@ package com.getcode.view import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import cafe.adriel.voyager.core.model.ScreenModel -import com.getcode.App import com.getcode.util.resources.ResourceHelper import io.reactivex.rxjava3.disposables.CompositeDisposable import kotlinx.coroutines.Dispatchers diff --git a/app/src/main/java/com/getcode/view/login/PhoneVerifyViewModel.kt b/app/src/main/java/com/getcode/view/login/PhoneVerifyViewModel.kt index 8957103a2..18c47a972 100644 --- a/app/src/main/java/com/getcode/view/login/PhoneVerifyViewModel.kt +++ b/app/src/main/java/com/getcode/view/login/PhoneVerifyViewModel.kt @@ -4,19 +4,14 @@ import android.annotation.SuppressLint import android.app.Activity import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.intl.Locale -import androidx.compose.ui.text.toLowerCase import com.codeinc.gen.phone.v1.PhoneVerificationService -import com.getcode.App import com.getcode.R import com.getcode.manager.TopBarManager import com.getcode.navigation.core.CodeNavigator -import com.getcode.navigation.screens.AccessKeyLoginScreen import com.getcode.navigation.screens.InviteCodeScreen import com.getcode.navigation.screens.LoginPhoneConfirmationScreen import com.getcode.navigation.screens.PhoneConfirmationScreen import com.getcode.network.repository.PhoneRepository -import com.getcode.network.repository.toPhoneNumber import com.getcode.network.repository.urlEncode import com.getcode.util.PhoneUtils import com.getcode.util.resources.ResourceHelper diff --git a/app/src/main/java/com/getcode/view/login/SeedInputViewModel.kt b/app/src/main/java/com/getcode/view/login/SeedInputViewModel.kt index 277857cff..042e3396a 100644 --- a/app/src/main/java/com/getcode/view/login/SeedInputViewModel.kt +++ b/app/src/main/java/com/getcode/view/login/SeedInputViewModel.kt @@ -13,7 +13,6 @@ import com.getcode.manager.TopBarManager import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.screens.HomeScreen import com.getcode.navigation.screens.LoginPhoneVerificationScreen -import com.getcode.navigation.screens.PhoneVerificationScreen import com.getcode.util.resources.ResourceHelper import com.getcode.view.* import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers diff --git a/app/src/main/java/com/getcode/view/main/account/AccountFaqViewModel.kt b/app/src/main/java/com/getcode/view/main/account/AccountFaqViewModel.kt index aa1dbc38b..cdae61fe7 100644 --- a/app/src/main/java/com/getcode/view/main/account/AccountFaqViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/account/AccountFaqViewModel.kt @@ -6,13 +6,7 @@ import com.getcode.model.FaqItem import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel2 import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import timber.log.Timber import javax.inject.Inject @HiltViewModel diff --git a/app/src/main/java/com/getcode/view/main/account/AccountPhoneViewModel.kt b/app/src/main/java/com/getcode/view/main/account/AccountPhoneViewModel.kt index 6612c1a29..958c8ca84 100644 --- a/app/src/main/java/com/getcode/view/main/account/AccountPhoneViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/account/AccountPhoneViewModel.kt @@ -2,10 +2,8 @@ package com.getcode.view.main.account import com.codeinc.gen.user.v1.IdentityService import com.getcode.manager.SessionManager -import com.getcode.model.PrefsBool import com.getcode.network.repository.IdentityRepository import com.getcode.network.repository.PhoneRepository -import com.getcode.network.repository.PrefRepository import com.getcode.util.PhoneUtils import com.getcode.util.resources.ResourceHelper import com.getcode.utils.makeE164 diff --git a/app/src/main/java/com/getcode/view/main/account/BetaFlagsScreen.kt b/app/src/main/java/com/getcode/view/main/account/BetaFlagsScreen.kt index c297b3fd6..e6b117901 100644 --- a/app/src/main/java/com/getcode/view/main/account/BetaFlagsScreen.kt +++ b/app/src/main/java/com/getcode/view/main/account/BetaFlagsScreen.kt @@ -85,12 +85,6 @@ fun BetaFlagsScreen( stringResource(id = R.string.beta_tipcard_description), state.tipsEnabled, ) { viewModel.dispatchEvent(BetaFlagsViewModel.Event.EnableTipCard(it)) }, - BetaFeature( - PrefsBool.MESSAGE_PAYMENT_NODE_V2, - R.string.beta_chatmessage_v2_ui, - stringResource(id = R.string.beta_chatmessage_v2_ui_description), - state.chatMessageV2Enabled, - ) { viewModel.dispatchEvent(BetaFlagsViewModel.Event.EnableChatMessageV2Ui(it)) }, BetaFeature( PrefsBool.TIPS_CHAT_ENABLED, R.string.beta_tipchats, @@ -154,7 +148,6 @@ fun BetaFlagsScreen( private fun BetaFlagsViewModel.State.canMutate(flag: PrefsBool): Boolean { return when (flag) { // PrefsBool.BUY_KIN_ENABLED -> false - PrefsBool.TIPS_CHAT_ENABLED -> chatMessageV2Enabled && tipsEnabled else -> true } } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/main/account/BetaFlagsViewModel.kt b/app/src/main/java/com/getcode/view/main/account/BetaFlagsViewModel.kt index da11be187..2d7ad96a7 100644 --- a/app/src/main/java/com/getcode/view/main/account/BetaFlagsViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/account/BetaFlagsViewModel.kt @@ -32,7 +32,6 @@ class BetaFlagsViewModel @Inject constructor( val establishCodeRelationship: Boolean = false, val chatUnsubEnabled: Boolean = false, val tipsEnabled: Boolean = false, - val chatMessageV2Enabled: Boolean = false, val tipsChatEnabled: Boolean = false, ) @@ -49,7 +48,6 @@ class BetaFlagsViewModel @Inject constructor( data class EnableTipCard(val enabled: Boolean) : Event data class EnableCodeRelationshipEstablish(val enabled: Boolean) : Event data class EnableChatUnsubscribe(val enabled: Boolean) : Event - data class EnableChatMessageV2Ui(val enabled: Boolean) : Event data class EnableTipChats(val enabled: Boolean) : Event } @@ -65,7 +63,6 @@ class BetaFlagsViewModel @Inject constructor( .onEach { event -> when (event) { is Event.EnableBuyKin -> prefRepository.set(PrefsBool.BUY_MODULE_ENABLED, event.enabled) - is Event.EnableChatMessageV2Ui -> prefRepository.set(PrefsBool.MESSAGE_PAYMENT_NODE_V2, event.enabled) is Event.EnableChatUnsubscribe -> prefRepository.set(PrefsBool.CHAT_UNSUB_ENABLED, event.enabled) is Event.EnableCodeRelationshipEstablish -> prefRepository.set(PrefsBool.ESTABLISH_CODE_RELATIONSHIP, event.enabled) is Event.EnableGiveRequests -> prefRepository.set(PrefsBool.GIVE_REQUESTS_ENABLED, event.enabled) @@ -101,7 +98,6 @@ class BetaFlagsViewModel @Inject constructor( establishCodeRelationship = establishCodeRelationship, chatUnsubEnabled = chatUnsubEnabled, tipsEnabled = tipsEnabled, - chatMessageV2Enabled = chatMessageV2Enabled, tipsChatEnabled = tipsChatEnabled, ) } @@ -116,7 +112,6 @@ class BetaFlagsViewModel @Inject constructor( is Event.SetVibrateOnScan, is Event.EnableCodeRelationshipEstablish, is Event.EnableChatUnsubscribe, - is Event.EnableChatMessageV2Ui, is Event.EnableTipChats, is Event.ShowErrors -> { state -> state } } diff --git a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddressViewModel.kt b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddressViewModel.kt index 2aae8be95..0077bc2dc 100644 --- a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddressViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddressViewModel.kt @@ -2,13 +2,11 @@ package com.getcode.view.main.account.withdraw import android.annotation.SuppressLint import android.content.ClipboardManager -import android.content.Context import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.text2.input.TextFieldState import androidx.compose.foundation.text2.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.text2.input.textAsFlow import androidx.lifecycle.viewModelScope -import com.getcode.App import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.screens.WithdrawalArgs import com.getcode.navigation.screens.WithdrawalSummaryScreen @@ -30,7 +28,6 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.kin.sdk.base.tools.Base58 import javax.inject.Inject diff --git a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAmountViewModel.kt b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAmountViewModel.kt index f5017455f..f5bb33fea 100644 --- a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAmountViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAmountViewModel.kt @@ -3,7 +3,6 @@ package com.getcode.view.main.account.withdraw import androidx.lifecycle.viewModelScope import com.getcode.R import com.getcode.manager.TopBarManager -import com.getcode.model.Currency import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.screens.WithdrawalAddressScreen import com.getcode.network.client.Client @@ -13,7 +12,6 @@ import com.getcode.network.repository.BalanceRepository import com.getcode.network.repository.PrefRepository import com.getcode.network.repository.TransactionRepository import com.getcode.util.CurrencyUtils -import com.getcode.util.Kin import com.getcode.util.locale.LocaleHelper import com.getcode.util.resources.ResourceHelper import com.getcode.utils.ErrorUtils diff --git a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawSummaryViewModel.kt b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawSummaryViewModel.kt index 1f6289a42..969fa7bd2 100644 --- a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawSummaryViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawSummaryViewModel.kt @@ -2,7 +2,6 @@ package com.getcode.view.main.account.withdraw import android.annotation.SuppressLint import androidx.lifecycle.viewModelScope -import com.getcode.App import com.getcode.R import com.getcode.solana.keys.PublicKey import com.getcode.manager.BottomBarManager diff --git a/app/src/main/java/com/getcode/view/main/balance/BalanceSheet.kt b/app/src/main/java/com/getcode/view/main/balance/BalanceSheet.kt index 86fec05c4..c362dd3e0 100644 --- a/app/src/main/java/com/getcode/view/main/balance/BalanceSheet.kt +++ b/app/src/main/java/com/getcode/view/main/balance/BalanceSheet.kt @@ -161,8 +161,8 @@ fun BalanceContent( itemsIndexed( state.chats, key = { _, item -> item.id }, - contentType = { _, item -> item }) { index, event -> - ChatNode(chat = event, onClick = { openChat(event.id) }) + contentType = { _, item -> item }) { index, chat -> + ChatNode(chat = chat, onClick = { openChat(chat.id) }) Divider( modifier = Modifier.padding(start = CodeTheme.dimens.inset), color = White10, diff --git a/app/src/main/java/com/getcode/view/main/chat/ChatScreen.kt b/app/src/main/java/com/getcode/view/main/chat/ChatScreen.kt index 62e53387e..79db82140 100644 --- a/app/src/main/java/com/getcode/view/main/chat/ChatScreen.kt +++ b/app/src/main/java/com/getcode/view/main/chat/ChatScreen.kt @@ -12,9 +12,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable -import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.remember -import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape @@ -90,7 +88,8 @@ fun ChatScreen( date = item.date, isPreviousSameMessage = prev == item.chatMessageId, isNextSameMessage = next == item.chatMessageId, - openTipChat = { dispatch(ChatViewModel.Event.OpenTipChat(item.chatMessageId)) } + thankUser = { dispatch(ChatViewModel.Event.ThankUser(item.chatMessageId)) }, + openMessageChat = { dispatch(ChatViewModel.Event.OpenMessageChat(item.chatMessageId)) } ) } diff --git a/app/src/main/java/com/getcode/view/main/chat/ChatViewModel.kt b/app/src/main/java/com/getcode/view/main/chat/ChatViewModel.kt index 078b12a9f..7552a3e06 100644 --- a/app/src/main/java/com/getcode/view/main/chat/ChatViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/chat/ChatViewModel.kt @@ -9,8 +9,11 @@ import com.getcode.model.Chat import com.getcode.model.ID import com.getcode.model.MessageContent import com.getcode.model.Title +import com.getcode.model.Verb +import com.getcode.network.ConversationController import com.getcode.network.HistoryController import com.getcode.network.repository.BetaFlagsRepository +import com.getcode.network.repository.base58 import com.getcode.util.formatDateRelatively import com.getcode.util.toInstantFromMillis import com.getcode.view.BaseViewModel2 @@ -40,7 +43,8 @@ sealed class ChatItem(val key: Any) { val id: String = UUID.randomUUID().toString(), val chatMessageId: ID, val message: MessageContent, - val date: Instant + val date: Instant, + ) : ChatItem(id) data class Date(val date: String) : ChatItem(date) @@ -50,6 +54,7 @@ sealed class ChatItem(val key: Any) { @HiltViewModel class ChatViewModel @Inject constructor( historyController: HistoryController, + conversationController: ConversationController, betaFlags: BetaFlagsRepository, ) : BaseViewModel2( initialState = State( @@ -84,7 +89,9 @@ class ChatViewModel @Inject constructor( data class SetMuted(val muted: Boolean) : Event data class SetSubscribed(val subscribed: Boolean) : Event data class EnableUnsubscribe(val enabled: Boolean): Event - data class OpenTipChat(val messageId: ID): Event + + data class ThankUser(val message: ID): Event + data class OpenMessageChat(val messageId: ID): Event } init { @@ -139,19 +146,24 @@ class ChatViewModel @Inject constructor( } .launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .map { it.message } + .onEach { conversationController.thankTipper(it) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { it.messageId } + .onEach { conversationController.createConversation(it) } + .launchIn(viewModelScope) + betaFlags.observe() .map { it.chatUnsubEnabled } .distinctUntilChanged() .onEach { dispatchEvent(Event.EnableUnsubscribe(it)) }.launchIn(viewModelScope) - -// betaFlags.observe() -// .map { it.chatMessageV2Enabled } -// .distinctUntilChanged() -// .onEach { -// dispatchEvent(Event.EnableMessageV2Ui(it)) -// }.launchIn(viewModelScope) } val chatMessages = stateFlow @@ -159,14 +171,20 @@ class ChatViewModel @Inject constructor( .filterNotNull() .flatMapLatest { historyController.chatFlow(it) } .mapLatest { page -> - page.flatMap { chat -> - chat.contents + page.flatMap { message -> + message.contents .sortedWith(compareBy { it is MessageContent.Localized }) - .map { ChatMessageIndice(it, chat.id, chat.dateMillis.toInstantFromMillis()) } + .map { ChatMessageIndice(it, message.id, message.dateMillis.toInstantFromMillis()) } } } .mapLatest { page -> - page.map { (message, id, date) -> + page.map { (contents, id, date) -> + val message = if (contents is MessageContent.Exchange && contents.verb is Verb.ReceivedTip) { + val tipThanked = conversationController.hasThanked(id) + MessageContent.Exchange(contents.amount, contents.verb, thanked = tipThanked) + } else { + contents + } ChatItem.Message( chatMessageId = id, message = message, @@ -185,7 +203,7 @@ class ChatViewModel @Inject constructor( null } } - }.cachedIn(viewModelScope) + } companion object { val updateStateForEvent: (Event) -> ((State) -> State) = { event -> @@ -204,7 +222,8 @@ class ChatViewModel @Inject constructor( ) } - is Event.OpenTipChat, + is Event.ThankUser, + is Event.OpenMessageChat, Event.OnMuteToggled, Event.OnSubscribeToggled -> { state -> state } diff --git a/app/src/main/java/com/getcode/view/main/chat/conversation/ChatConversationScreen.kt b/app/src/main/java/com/getcode/view/main/chat/conversation/ChatConversationScreen.kt index 9c927d8ef..e58e1c78b 100644 --- a/app/src/main/java/com/getcode/view/main/chat/conversation/ChatConversationScreen.kt +++ b/app/src/main/java/com/getcode/view/main/chat/conversation/ChatConversationScreen.kt @@ -1,131 +1,125 @@ +@file:OptIn(ExperimentalFoundationApi::class) + package com.getcode.view.main.chat.conversation -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi -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.IntrinsicSize +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text2.input.rememberTextFieldState -import androidx.compose.material.Icon -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.Send +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState 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.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.getcode.theme.ChatOutgoing +import androidx.compose.ui.res.stringResource +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.itemKey +import com.getcode.R +import com.getcode.model.ConversationMessage +import com.getcode.model.ConversationMessageContent import com.getcode.theme.CodeTheme -import com.getcode.theme.extraLarge -import com.getcode.theme.inputColors import com.getcode.ui.components.CodeScaffold -import com.getcode.ui.components.Row -import com.getcode.ui.components.TextInput -import com.getcode.ui.utils.withTopBorder +import com.getcode.ui.components.conversation.AnnouncementMessage +import com.getcode.ui.components.conversation.ChatInput +import com.getcode.ui.components.conversation.MessageBubble +import com.getcode.ui.components.conversation.utils.HandleMessageChanges +import com.getcode.util.toInstantFromMillis @Composable fun ChatConversationScreen( - s: String = "" + state: ConversationViewModel.State, + messages: LazyPagingItems, + dispatchEvent: (ConversationViewModel.Event) -> Unit, ) { CodeScaffold( bottomBar = { - ChatInput { - + Column( + modifier = Modifier + .imePadding() + ) { + ChatInput( + state = state.textFieldState, + onSend = { dispatchEvent(ConversationViewModel.Event.SendMessage) }) } } ) { padding -> + val lazyListState = rememberLazyListState() + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + state = lazyListState, + reverseLayout = true, + contentPadding = PaddingValues( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.inset, + ), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3, Alignment.Top), + ) { + items( + count = messages.itemCount, + key = messages.itemKey { item -> item.id }, + ) { index -> + val message = messages[index] + when (val content = message?.content) { + ConversationMessageContent.IdentityRevealed -> { + AnnouncementMessage( + text = stringResource( + id = R.string.title_chat_announcement_identityRevealed, + state.user.orEmpty() + ) + ) + } - } -} + ConversationMessageContent.IdentityRevealedToYou -> { + AnnouncementMessage( + text = stringResource( + id = R.string.title_chat_announcement_identityRevealedToYou, + state.user.orEmpty() + ) + ) + } -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun ChatInput( - modifier: Modifier = Modifier, - onSend: (String) -> Unit, -) { - val state = rememberTextFieldState() + is ConversationMessageContent.Text -> { + MessageBubble( + content = content, + date = message.dateMillis.toInstantFromMillis() + ) + } - Row( - modifier = modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .withTopBorder() - .padding(CodeTheme.dimens.grid.x2) - .imePadding(), - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - verticalAlignment = Alignment.Bottom - ) { - TextInput( - modifier = Modifier - .weight(1f), - minHeight = 40.dp, - state = state, - shape = CodeTheme.shapes.extraLarge, - contentPadding = PaddingValues(8.dp), - colors = inputColors( - backgroundColor = Color.White, - textColor = CodeTheme.colors.background, - cursorColor = CodeTheme.colors.brand, - ) - ) - AnimatedContent( - targetState = state.text.isNotEmpty(), - label = "show/hide send button", - transitionSpec = { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Start - ) togetherWith slideOutOfContainer( - AnimatedContentTransitionScope.SlideDirection.End - ) - } - ) { show -> - if (show) { - Box( - modifier = Modifier - .align(Alignment.Bottom) - .background(ChatOutgoing, shape = CircleShape) - .clip(CircleShape) - .clickable { onSend(state.text.toString()) } - .size(ChatInput_Size) - .padding(8.dp), - contentAlignment = Alignment.Center, - ) { - Icon( - modifier = Modifier - .size(CodeTheme.dimens.staticGrid.x6), - imageVector = Icons.AutoMirrored.Rounded.Send, - tint = Color.White, - contentDescription = "Send message" - ) - } - } - } - } -} + ConversationMessageContent.ThanksSent -> { + AnnouncementMessage( + text = stringResource(id = R.string.title_chat_announcement_thanksSent) + ) + } -@Preview -@Composable -private fun Preview_ChatInput() { - CodeTheme { - ChatInput { + ConversationMessageContent.TipMessage -> { + AnnouncementMessage( + text = stringResource( + id = R.string.title_chat_announcement_tipHeader, + state.tipAmountFormatted.orEmpty() + ) + ) + } + + ConversationMessageContent.ThanksReceived -> { + AnnouncementMessage( + text = stringResource( + id = R.string.title_chat_announcement_thanksReceived, + state.user.orEmpty() + ) + ) + } + else -> Unit + } + } } + + HandleMessageChanges(listState = lazyListState, items = messages) } } -private val ChatInput_Size - @Composable get() = CodeTheme.dimens.grid.x8 \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt b/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt new file mode 100644 index 000000000..818ecb4e6 --- /dev/null +++ b/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt @@ -0,0 +1,153 @@ +@file:OptIn(ExperimentalFoundationApi::class) + +package com.getcode.view.main.chat.conversation + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.text2.input.TextFieldState +import androidx.compose.foundation.text2.input.clearText +import androidx.lifecycle.viewModelScope +import androidx.paging.cachedIn +import com.getcode.model.Conversation +import com.getcode.model.ID +import com.getcode.model.KinAmount +import com.getcode.network.ConversationController +import com.getcode.util.CurrencyUtils +import com.getcode.util.formatted +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel2 +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class ConversationViewModel @Inject constructor( + conversationController: ConversationController, + currencyUtils: CurrencyUtils, + resources: ResourceHelper, +) : BaseViewModel2( + initialState = State.Default, + updateStateForEvent = updateStateForEvent +) { + + data class State( + val messageId: ID?, + val conversationId: ID?, + val title: String, + val tipAmount: KinAmount?, + val tipAmountFormatted: String?, + val textFieldState: TextFieldState, + val identityRevealed: Boolean, + val user: String?, + ) { + companion object { + val Default = State( + messageId = null, + conversationId = null, + title = "Anonymous Tipper", + tipAmount = null, + tipAmountFormatted = null, + textFieldState = TextFieldState(), + identityRevealed = false, + user = null, + ) + } + } + + sealed interface Event { + data class OnMessageIdChanged(val id: ID?) : Event + data class OnConversationChanged(val conversation: Conversation) : Event + data class OnUserRevealed(val user: String): Event + data class OnTitleChanged(val title: String): Event + data class OnTipAmountFormatted(val amount: String): Event + data object SendMessage : Event + data object RevealIdentity : Event + } + + init { + stateFlow + .map { it.messageId } + .filterNotNull() + .mapNotNull { conversationController.getConversationForMessage(it) } + .distinctUntilChangedBy { it.id } + .onEach { dispatchEvent(Dispatchers.Main, Event.OnConversationChanged(it)) } + .launchIn(viewModelScope) + + stateFlow + .map { it.tipAmount } + .filterNotNull() + .distinctUntilChanged() + .mapNotNull { + val currency = currencyUtils.getCurrency(it.rate.currency.name) ?: return@mapNotNull null + val title = it.formatted(currency = currency, resources = resources, suffix = "Tipper") + val formatted = it.formatted(currency = currency, resources = resources) + title to formatted + } + .onEach { (title, formattedAmount) -> + dispatchEvent(Event.OnTitleChanged(title)) + dispatchEvent(Event.OnTipAmountFormatted(formattedAmount)) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { stateFlow.value } + .onEach { + val textFieldState = it.textFieldState + val text = textFieldState.text.toString() + Timber.d("sending message of $text") + textFieldState.clearText() + conversationController.sendMessage(it.conversationId!!, text) + }.launchIn(viewModelScope) + } + + val messages = stateFlow + .map { it.conversationId } + .filterNotNull() + .flatMapLatest { conversationController.conversationPagingData(it) } + .cachedIn(viewModelScope) + + + internal companion object { + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + Timber.d("event=${event}") + when (event) { + is Event.OnConversationChanged -> { state -> + state.copy( + conversationId = event.conversation.id, + tipAmount = event.conversation.tipAmount + ) + } + + is Event.OnTitleChanged -> { state -> + state.copy( + title = event.title + ) + } + + is Event.OnTipAmountFormatted -> { state -> + state.copy(tipAmountFormatted = event.amount) + } + + Event.RevealIdentity, + is Event.SendMessage -> { state -> state } + + is Event.OnMessageIdChanged -> { state -> + state.copy(messageId = event.id) + } + + is Event.OnUserRevealed -> { state -> + state.copy(user = event.user) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/main/currency/CurrencyViewModel.kt b/app/src/main/java/com/getcode/view/main/currency/CurrencyViewModel.kt index c7d6d202f..e9aa8140d 100644 --- a/app/src/main/java/com/getcode/view/main/currency/CurrencyViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/currency/CurrencyViewModel.kt @@ -26,7 +26,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart -import timber.log.Timber import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) diff --git a/app/src/main/java/com/getcode/view/main/giveKin/BaseAmountCurrencyViewModel.kt b/app/src/main/java/com/getcode/view/main/giveKin/BaseAmountCurrencyViewModel.kt index 25534d747..f0b7c9098 100644 --- a/app/src/main/java/com/getcode/view/main/giveKin/BaseAmountCurrencyViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/giveKin/BaseAmountCurrencyViewModel.kt @@ -28,7 +28,6 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import timber.log.Timber import kotlin.math.min sealed class CurrencyListItem { diff --git a/app/src/main/java/com/getcode/view/main/requestKin/RequestKinViewModel.kt b/app/src/main/java/com/getcode/view/main/requestKin/RequestKinViewModel.kt index 7489d040a..ace91154d 100644 --- a/app/src/main/java/com/getcode/view/main/requestKin/RequestKinViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/requestKin/RequestKinViewModel.kt @@ -3,7 +3,6 @@ package com.getcode.view.main.requestKin import androidx.lifecycle.viewModelScope import com.getcode.model.CurrencyCode import com.getcode.model.KinAmount -import com.getcode.model.Rate import com.getcode.network.client.Client import com.getcode.network.client.receiveIfNeeded import com.getcode.network.exchange.Exchange diff --git a/app/src/main/java/com/getcode/view/main/tip/TipConnectViewModel.kt b/app/src/main/java/com/getcode/view/main/tip/TipConnectViewModel.kt index 91a7d1066..29e17e1c2 100644 --- a/app/src/main/java/com/getcode/view/main/tip/TipConnectViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/tip/TipConnectViewModel.kt @@ -4,20 +4,15 @@ import android.content.Intent import android.net.Uri import androidx.lifecycle.viewModelScope import com.getcode.R -import com.getcode.ed25519.Ed25519 import com.getcode.manager.SessionManager -import com.getcode.network.TipController import com.getcode.network.repository.base58 import com.getcode.network.repository.urlEncode import com.getcode.util.resources.ResourceHelper import com.getcode.utils.bytes -import com.getcode.utils.nonce import com.getcode.vendor.Base58 import com.getcode.view.BaseViewModel2 import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach diff --git a/app/src/main/java/com/getcode/view/main/tip/TipPaymentViewModel.kt b/app/src/main/java/com/getcode/view/main/tip/TipPaymentViewModel.kt index 8196262c5..86136dc7c 100644 --- a/app/src/main/java/com/getcode/view/main/tip/TipPaymentViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/tip/TipPaymentViewModel.kt @@ -8,16 +8,13 @@ import com.getcode.model.Kin import com.getcode.model.KinAmount import com.getcode.model.Rate import com.getcode.model.SendLimit -import com.getcode.model.min import com.getcode.network.client.Client import com.getcode.network.client.receiveIfNeeded import com.getcode.network.exchange.Exchange import com.getcode.network.repository.BalanceRepository import com.getcode.network.repository.PrefRepository import com.getcode.network.repository.TransactionRepository -import com.getcode.network.repository.replaceParam import com.getcode.util.CurrencyUtils -import com.getcode.util.formatted import com.getcode.util.formattedRaw import com.getcode.util.locale.LocaleHelper import com.getcode.util.resources.ResourceHelper diff --git a/app/src/main/res/drawable/ic_message_status_delivered.xml b/app/src/main/res/drawable/ic_message_status_delivered.xml new file mode 100644 index 000000000..ee4abc312 --- /dev/null +++ b/app/src/main/res/drawable/ic_message_status_delivered.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_message_status_read.xml b/app/src/main/res/drawable/ic_message_status_read.xml new file mode 100644 index 000000000..fc0dd1a56 --- /dev/null +++ b/app/src/main/res/drawable/ic_message_status_read.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_message_status_sent.xml b/app/src/main/res/drawable/ic_message_status_sent.xml new file mode 100644 index 000000000..7c8dbd5f6 --- /dev/null +++ b/app/src/main/res/drawable/ic_message_status_sent.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings-universal.xml b/app/src/main/res/values/strings-universal.xml index 417007f6c..1d729e2fa 100644 --- a/app/src/main/res/values/strings-universal.xml +++ b/app/src/main/res/values/strings-universal.xml @@ -16,7 +16,6 @@ Vibrate on Scan Show Connectivity Status Tip Card - Chat Message V2 UI Tip Chats Log Scan Processing Times Show Errors @@ -29,7 +28,6 @@ If enabled, a relationship account will be established with getcode.com if it doesn\'t yet exist. If enabled, an option to unsubscribe from a chat will appear for supported chats. If enabled, you\'ll gain the ability to share a tip card. - If enabled, chat messages will render in the new UI for payment messages. If enabled, you\'ll gain the ability to chat with tippers. %1$s %2$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2c6cf039c..b7548942c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -26,5 +26,13 @@ Success Tips linked to %1$s account: %2$s - Say Thank You + 🙏 Thank + 🙏 Thanked + Message + + You revealed your identity to %1$s + %1$s revealed their identity to you + This person tipped you %1$s + 🙏 You thanked them + 🙏 %1$s thanked you for your tip diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index ec8f1c8ea..41f435554 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -18,6 +18,7 @@ object Versions { const val android_gradle_build_tools = "8.2.2" const val google_services = "4.3.15" + const val androidx_annotation = "1.7.1" const val androidx_camerax = "1.3.2" const val androidx_core = "1.12.0" const val androidx_constraint_layout = "2.1.3" @@ -123,6 +124,7 @@ object Libs { const val androidx_camerax_view= "androidx.camera:camera-view:${Versions.androidx_camerax}" + const val androidx_annotation = "androidx.annotation:annotation:${Versions.androidx_annotation}" const val androidx_core = "androidx.core:core-ktx:${Versions.androidx_core}" const val androidx_constraint_layout = "androidx.constraintlayout:constraintlayout:${Versions.androidx_constraint_layout}" @@ -139,6 +141,7 @@ object Libs { const val androidx_room_rxjava3 = "androidx.room:room-rxjava3:${Versions.androidx_room}" const val androidx_room_compiler = "androidx.room:room-compiler:${Versions.androidx_room}" const val androidx_room_ktx = "androidx.room:room-ktx:${Versions.androidx_room}" + const val androidx_room_paging = "androidx.room:room-paging:${Versions.androidx_room}" const val sqlcipher = "net.zetetic:android-database-sqlcipher:${Versions.sqlcipher}" const val coil3 = "io.coil-kt.coil3:coil-compose:${Versions.compose_coil}" diff --git a/common/.gitignore b/common/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/common/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/common/build.gradle.kts b/common/build.gradle.kts new file mode 100644 index 000000000..200009ec8 --- /dev/null +++ b/common/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id(Plugins.android_library) + id(Plugins.kotlin_android) + id(Plugins.kotlin_serialization) +} + +android { + namespace = "${Android.namespace}.common" + compileSdk = Android.compileSdkVersion + defaultConfig { + minSdk = Android.minSdkVersion + targetSdk = Android.targetSdkVersion + buildToolsVersion = Android.buildToolsVersion + testInstrumentationRunner = Android.testInstrumentationRunner + } + + kotlinOptions { + jvmTarget = Versions.java + freeCompilerArgs += listOf( + "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-Xuse-experimental=kotlinx.coroutines.FlowPreview", + "-opt-in=kotlin.ExperimentalUnsignedTypes", + "-opt-in=kotlin.RequiresOptIn" + ) + } + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(Versions.java)) + } + } +} + +dependencies { + api(project(":model")) + api(project(":ed25519")) + + api(Libs.androidx_annotation) + api(Libs.kotlin_stdlib) + api(Libs.kotlinx_coroutines_core) + api(Libs.kotlinx_coroutines_rx3) + + api(Libs.kin_sdk) +} diff --git a/app/src/main/java/com/getcode/util/resources/ResourceHelper.kt b/common/src/main/java/com/getcode/util/resources/ResourceHelper.kt similarity index 100% rename from app/src/main/java/com/getcode/util/resources/ResourceHelper.kt rename to common/src/main/java/com/getcode/util/resources/ResourceHelper.kt diff --git a/scripts/internal-testing-build.sh b/scripts/internal-testing-build.sh index dd491017b..f370a0455 100755 --- a/scripts/internal-testing-build.sh +++ b/scripts/internal-testing-build.sh @@ -4,6 +4,7 @@ date="$(date '+%m.%d.%y')" export NOTIFY_ERRORS=true export DEBUG_MINIFY=true +export DEBUG_CRASHLYTICS_UPLOAD=true ./gradlew assembleDebug @@ -11,4 +12,5 @@ outputDir="$(pwd)/app/build/outputs/apk/debug" mv "${outputDir}/app-debug.apk" "${outputDir}/app-${date}-debug.apk" unset NOTIFY_ERRORS -unset DEBUG_MINIFY \ No newline at end of file +unset DEBUG_MINIFY +unset DEBUG_CRASHLYTICS_UPLOAD \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 5bcc13608..9225aa31e 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,4 +2,5 @@ include ':model' include ':api' include ':app' include ':ed25519' +include ':common' rootProject.name = "Code" \ No newline at end of file From 90b4ead7b9d398b1f111108cce4d05d8c3895674 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 4 May 2024 01:15:28 -0400 Subject: [PATCH 2/6] feat(tipchat): add identity reveal header open up ModalContainer and SheetTitle to support slotting Signed-off-by: Brandon McAnsh --- .../com.getcode.db.AppDatabase/10.json | 18 +++- .../java/com/getcode/db/ConversationDao.kt | 21 +++++ .../java/com/getcode/model/Conversation.kt | 2 + .../java/com/getcode/model/TipMetadata.kt | 7 ++ .../java/com/getcode/model/TwitterUser.kt | 6 +- .../getcode/network/ConversationController.kt | 10 ++ .../source/ConversationPagingSource.kt | 15 +++ .../getcode/navigation/screens/ChatScreens.kt | 63 ++++++++++++- .../getcode/navigation/screens/MainScreens.kt | 1 - .../com/getcode/navigation/screens/Modals.kt | 14 +-- .../com/getcode/ui/components/SheetTitle.kt | 31 +++--- .../conversation/ChatConversationScreen.kt | 94 ++++++++++++++++++- .../conversation/ConversationViewModel.kt | 66 +++++++++++-- .../getcode/view/main/invites/InvitesSheet.kt | 3 +- 14 files changed, 305 insertions(+), 46 deletions(-) diff --git a/api/schemas/com.getcode.db.AppDatabase/10.json b/api/schemas/com.getcode.db.AppDatabase/10.json index e6b9cf2b8..e97511fb5 100644 --- a/api/schemas/com.getcode.db.AppDatabase/10.json +++ b/api/schemas/com.getcode.db.AppDatabase/10.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 10, - "identityHash": "77b82b833853b340858b2d2606bfdfd6", + "identityHash": "d5660e6875cc3237f2282f65ccd11b66", "entities": [ { "tableName": "CurrencyRate", @@ -238,7 +238,7 @@ }, { "tableName": "conversations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `messageIdBase58` TEXT NOT NULL, `cursorBase58` TEXT NOT NULL, `tipAmount` TEXT NOT NULL, `createdByUser` INTEGER NOT NULL, `hasRevealedIdentity` INTEGER NOT NULL, `user` TEXT, PRIMARY KEY(`idBase58`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `messageIdBase58` TEXT NOT NULL, `cursorBase58` TEXT NOT NULL, `tipAmount` TEXT NOT NULL, `createdByUser` INTEGER NOT NULL, `hasRevealedIdentity` INTEGER NOT NULL, `user` TEXT, `userImage` TEXT, `lastActivity` INTEGER, PRIMARY KEY(`idBase58`))", "fields": [ { "fieldPath": "idBase58", @@ -281,6 +281,18 @@ "columnName": "user", "affinity": "TEXT", "notNull": false + }, + { + "fieldPath": "userImage", + "columnName": "userImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": false } ], "primaryKey": { @@ -372,7 +384,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '77b82b833853b340858b2d2606bfdfd6')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd5660e6875cc3237f2282f65ccd11b66')" ] } } \ No newline at end of file diff --git a/api/src/main/java/com/getcode/db/ConversationDao.kt b/api/src/main/java/com/getcode/db/ConversationDao.kt index 6b09955cb..a6a71dac9 100644 --- a/api/src/main/java/com/getcode/db/ConversationDao.kt +++ b/api/src/main/java/com/getcode/db/ConversationDao.kt @@ -25,6 +25,20 @@ interface ConversationDao { return observeConversationWithMessages(id.base58) } + @Query("SELECT * FROM conversations WHERE idBase58 = :conversationId") + fun observeConversation(conversationId: String): Flow + + fun observeConversation(conversationId: ID): Flow { + return observeConversation(conversationId.base58) + } + + @Query("SELECT * FROM conversations WHERE messageIdBase58 = :messageId") + fun observeConversationForMessage(messageId: String): Flow + + fun observeConversationForMessage(messageId: ID): Flow { + return observeConversationForMessage(messageId.base58) + } + @Query("SELECT * FROM conversations WHERE idBase58 = :conversationId") suspend fun findConversation(conversationId: String): Conversation? @@ -56,6 +70,13 @@ interface ConversationDao { return hasThanked(conversationId.base58) } + @Query("SELECT EXISTS (SELECT * FROM messages WHERE conversationIdBase58 = :conversationId AND content LIKE '%4|%')") + suspend fun hasRevealedIdentity(conversationId: String): Boolean + + suspend fun hasRevealedIdentity(conversationId: ID): Boolean { + return hasRevealedIdentity(conversationId.base58) + } + @Query("DELETE FROM conversations") fun clearConversations() } \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/Conversation.kt b/api/src/main/java/com/getcode/model/Conversation.kt index 1348e7e34..3a66e6d7d 100644 --- a/api/src/main/java/com/getcode/model/Conversation.kt +++ b/api/src/main/java/com/getcode/model/Conversation.kt @@ -26,6 +26,8 @@ data class Conversation( val createdByUser: Boolean, // if this conversation was created as a result of the user messaging the tipper., val hasRevealedIdentity: Boolean, val user: String?, + val userImage: String?, + val lastActivity: Long?, ) { @Ignore val id: ID = Base58.decode(idBase58).toList() diff --git a/api/src/main/java/com/getcode/model/TipMetadata.kt b/api/src/main/java/com/getcode/model/TipMetadata.kt index 8baff365e..b3fff821f 100644 --- a/api/src/main/java/com/getcode/model/TipMetadata.kt +++ b/api/src/main/java/com/getcode/model/TipMetadata.kt @@ -1,9 +1,16 @@ package com.getcode.model import com.getcode.solana.keys.PublicKey +import com.getcode.utils.serializer.PublicKeyAsStringSerializer +import kotlinx.serialization.Serializable +@Serializable sealed interface TipMetadata { val platform: String val username: String + @Serializable(with = PublicKeyAsStringSerializer::class) val tipAddress: PublicKey + val imageUrl: String? + + val imageUrlSanitized: String? } \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/TwitterUser.kt b/api/src/main/java/com/getcode/model/TwitterUser.kt index cf88d0894..ee558709a 100644 --- a/api/src/main/java/com/getcode/model/TwitterUser.kt +++ b/api/src/main/java/com/getcode/model/TwitterUser.kt @@ -18,17 +18,17 @@ data class TwitterUser( override val username: String, @Serializable(with = PublicKeyAsStringSerializer::class) override val tipAddress: PublicKey, + override val imageUrl: String?, val displayName: String, - val imageUrl: String, val followerCount: Int, val verificationStatus: VerificationStatus ): TipMetadata { override val platform: String = "X" - val imageUrlSanitized: String + override val imageUrlSanitized: String? get() { - val url = imageUrl + val url = imageUrl ?: return null val extension = MimeTypeMap.getFileExtensionFromUrl(url) val urlWithoutExtension = url.removeSuffix(extension) val urlWithoutType = urlWithoutExtension.substringBeforeLast("_") diff --git a/api/src/main/java/com/getcode/network/ConversationController.kt b/api/src/main/java/com/getcode/network/ConversationController.kt index d1488c907..3eca248de 100644 --- a/api/src/main/java/com/getcode/network/ConversationController.kt +++ b/api/src/main/java/com/getcode/network/ConversationController.kt @@ -31,11 +31,13 @@ import java.util.UUID import javax.inject.Inject interface ConversationController { + fun observeConversationForMessage(messageId: ID): Flow suspend fun getConversationForMessage(messageId: ID): Conversation? suspend fun getConversation(conversationId: ID): Conversation? suspend fun createConversation(messageId: ID) suspend fun hasThanked(messageId: ID): Boolean suspend fun thankTipper(messageId: ID) + suspend fun revealIdentity(messageId: ID) fun sendMessage(conversationId: ID, message: String) fun conversationPagingData(conversationId: ID): Flow> } @@ -52,6 +54,9 @@ class ConversationMockController @Inject constructor( private fun conversationPagingSource(conversationId: ID) = db.conversationMessageDao().observeConversationMessages(conversationId.base58) + override fun observeConversationForMessage(messageId: ID): Flow { + return db.conversationDao().observeConversationForMessage(messageId) + } override suspend fun getConversationForMessage(messageId: ID): Conversation? { return db.conversationDao().findConversationForMessage(messageId) } @@ -106,6 +111,11 @@ class ConversationMockController @Inject constructor( db.conversationMessageDao().upsertMessages(message) } + override suspend fun revealIdentity(messageId: ID) { + val message = ConversationMockProvider.revealIdentity(messageId) ?: return + db.conversationMessageDao().upsertMessages(message) + } + override fun sendMessage(conversationId: ID, message: String) { launch { val messageId = generateId() diff --git a/api/src/main/java/com/getcode/network/source/ConversationPagingSource.kt b/api/src/main/java/com/getcode/network/source/ConversationPagingSource.kt index ef3d59992..67c407706 100644 --- a/api/src/main/java/com/getcode/network/source/ConversationPagingSource.kt +++ b/api/src/main/java/com/getcode/network/source/ConversationPagingSource.kt @@ -42,6 +42,8 @@ object ConversationMockProvider { createdByUser = true, hasRevealedIdentity = false, user = null, + userImage = null, + lastActivity = null, ) Timber.d("Created conversation ${id.base58} from ${tipAmount.fiat}") @@ -75,5 +77,18 @@ object ConversationMockProvider { return createMessage(conversation.id, ConversationMessageContent.ThanksSent) } + suspend fun revealIdentity(messageId: ID): ConversationMessage? { + val conversation = + db.conversationDao().findConversationForMessage(messageId) ?: return null + + if (db.conversationDao().hasRevealedIdentity(conversation.id)) { + return null + } + + db.conversationDao().upsertConversations(conversation.copy(hasRevealedIdentity = true)) + + return createMessage(conversation.id, ConversationMessageContent.IdentityRevealed) + } + private fun generateId() = UUID.randomUUID().toByteArray().toList() } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt b/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt index 96035244a..7d4e13a5b 100644 --- a/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt +++ b/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt @@ -1,25 +1,44 @@ package com.getcode.navigation.screens +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.paging.compose.collectAsLazyPagingItems import cafe.adriel.voyager.core.lifecycle.LifecycleEffect import cafe.adriel.voyager.core.screen.ScreenKey import cafe.adriel.voyager.core.screen.uniqueScreenKey import cafe.adriel.voyager.hilt.getViewModel +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import coil3.request.error import com.getcode.R import com.getcode.analytics.AnalyticsManager import com.getcode.analytics.AnalyticsScreenWatcher import com.getcode.model.ID import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.theme.BrandLight +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.SheetTitleText import com.getcode.ui.utils.getActivityScopedViewModel import com.getcode.ui.components.chat.localized +import com.getcode.util.formatDateRelatively import com.getcode.view.main.balance.BalanceScreeen import com.getcode.view.main.balance.BalanceSheetViewModel import com.getcode.view.main.chat.ChatScreen @@ -55,7 +74,7 @@ data object BalanceModal : ChatGraph, ModalRoot { ModalContainer( navigator = navigator, onLogoClicked = {}, - title = { null }, + titleString = { null }, backButton = { isViewingBuckets }, onBackClicked = isViewingBuckets.takeIf { it }?.let { { @@ -106,7 +125,7 @@ data class ChatScreen(val chatId: ID) : ChatGraph, ModalContent { val navigator = LocalCodeNavigator.current ModalContainer( - title = { state.title.localized }, + titleString = { state.title.localized }, backButton = { it is ChatScreen }, ) { val messages = vm.chatMessages.collectAsLazyPagingItems() @@ -128,7 +147,7 @@ data class ChatScreen(val chatId: ID) : ChatGraph, ModalContent { } @Parcelize -data class ChatMessageConversationScreen(val messageId: ID): ChatGraph, ModalContent { +data class ChatMessageConversationScreen(val messageId: ID) : ChatGraph, ModalContent { @IgnoredOnParcel override val key: ScreenKey = uniqueScreenKey @@ -139,7 +158,43 @@ data class ChatMessageConversationScreen(val messageId: ID): ChatGraph, ModalCon val state by vm.stateFlow.collectAsState() ModalContainer( - title = { state.title }, + title = { + if (state.user == null) { + SheetTitleText(text = state.title) + return@ModalContainer + } + val user = state.user!! + Row(modifier = Modifier + .padding(start = CodeTheme.dimens.staticGrid.x6) + .align(Alignment.CenterStart), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2) + ) { + AsyncImage( + modifier = Modifier + .padding(start = CodeTheme.dimens.grid.x7) + .size(30.dp) + .clip(CircleShape), + model = ImageRequest.Builder(LocalPlatformContext.current) + .data(user.imageUrl) + .error(R.drawable.ic_placeholder_user) + .build(), + contentDescription = null, + ) + Column { + Text(text = user.username, + style = CodeTheme.typography.subtitle2 + ) + state.lastSeen?.let { + Text( + text = "Last seen ${it.formatDateRelatively()}", + style = CodeTheme.typography.caption, + color = BrandLight, + ) + } + } + } + }, backButton = { it is ChatMessageConversationScreen }, ) { val messages = vm.messages.collectAsLazyPagingItems() diff --git a/app/src/main/java/com/getcode/navigation/screens/MainScreens.kt b/app/src/main/java/com/getcode/navigation/screens/MainScreens.kt index 0b8461c3b..f5e24fa8f 100644 --- a/app/src/main/java/com/getcode/navigation/screens/MainScreens.kt +++ b/app/src/main/java/com/getcode/navigation/screens/MainScreens.kt @@ -167,7 +167,6 @@ data object AccountModal : MainGraph, ModalRoot { val viewModel = getActivityScopedViewModel() ModalContainer( displayLogo = true, - title = { null }, onLogoClicked = { viewModel.dispatchEvent(AccountSheetViewModel.Event.LogoClicked) }, closeButton = { if (navigator.isVisible) { diff --git a/app/src/main/java/com/getcode/navigation/screens/Modals.kt b/app/src/main/java/com/getcode/navigation/screens/Modals.kt index e2552d7a7..8edff7ddd 100644 --- a/app/src/main/java/com/getcode/navigation/screens/Modals.kt +++ b/app/src/main/java/com/getcode/navigation/screens/Modals.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalOverscrollConfiguration import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight @@ -29,6 +30,7 @@ import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.SheetTitle +import com.getcode.ui.components.SheetTitleText import com.getcode.ui.components.keyboardAsState import com.getcode.ui.utils.getActivityScopedViewModel import kotlinx.coroutines.delay @@ -72,7 +74,8 @@ internal fun Screen.ModalContainer( internal fun Screen.ModalContainer( navigator: CodeNavigator = LocalCodeNavigator.current, displayLogo: Boolean = false, - title: @Composable (Screen?) -> String? = { null }, + titleString: @Composable (Screen?) -> String? = { null }, + title: @Composable BoxScope.() -> Unit = { }, backButton: (Screen?) -> Boolean = { false }, onBackClicked: (() -> Unit)? = null, closeButton: (Screen?) -> Boolean = { false }, @@ -113,12 +116,9 @@ internal fun Screen.ModalContainer( SheetTitle( modifier = Modifier, title = { - val screenName = (lastItem as? NamedScreen)?.name - val sheetName by remember(lastItem) { - derivedStateOf { screenName } - } - val name = title(lastItem) ?: sheetName - name.takeIf { !displayLogo && lastItem == this@ModalContainer } + titleString(this@ModalContainer)?.let { + SheetTitleText(text = it) + } ?: title() }, displayLogo = displayLogo, onLogoClicked = onLogoClicked, diff --git a/app/src/main/java/com/getcode/ui/components/SheetTitle.kt b/app/src/main/java/com/getcode/ui/components/SheetTitle.kt index f047843a5..642745dc2 100644 --- a/app/src/main/java/com/getcode/ui/components/SheetTitle.kt +++ b/app/src/main/java/com/getcode/ui/components/SheetTitle.kt @@ -24,10 +24,23 @@ import com.getcode.theme.topBarHeight import com.getcode.ui.utils.rememberedClickable import com.getcode.ui.utils.unboundedClickable +@Composable +fun BoxScope.SheetTitleText(modifier: Modifier = Modifier, text: String) { + Text( + text = text, + color = Color.White, + style = CodeTheme.typography.h6.copy( + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ), + modifier = modifier.align(Alignment.Center) + ) +} + @Composable fun SheetTitle( modifier: Modifier = Modifier, - title: (@Composable () -> String?)? = null, + title: @Composable BoxScope.() -> Unit = { }, displayLogo: Boolean = false, onLogoClicked: () -> Unit = { }, backButton: Boolean = false, @@ -90,17 +103,7 @@ fun SheetTitle( ) { onLogoClicked() } ) } else { - Text( - text = title?.invoke().orEmpty(), - color = Color.White, - style = CodeTheme.typography.h6.copy( - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center - ), - modifier = Modifier - .align(Alignment.Center) - .fillMaxWidth() - ) + title() } } } @@ -110,6 +113,8 @@ fun SheetTitle( @Composable fun TitlePreview() { SheetTitle( - title = { "Sheet Title" } + title = { + SheetTitleText(text = "Sheet Title") + } ) } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/main/chat/conversation/ChatConversationScreen.kt b/app/src/main/java/com/getcode/view/main/chat/conversation/ChatConversationScreen.kt index e58e1c78b..4dc6006f1 100644 --- a/app/src/main/java/com/getcode/view/main/chat/conversation/ChatConversationScreen.kt +++ b/app/src/main/java/com/getcode/view/main/chat/conversation/ChatConversationScreen.kt @@ -2,19 +2,35 @@ package com.getcode.view.main.chat.conversation +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement 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.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material.Surface +import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemKey import com.getcode.R @@ -27,6 +43,7 @@ import com.getcode.ui.components.conversation.ChatInput import com.getcode.ui.components.conversation.MessageBubble import com.getcode.ui.components.conversation.utils.HandleMessageChanges import com.getcode.util.toInstantFromMillis +import kotlinx.coroutines.delay @Composable fun ChatConversationScreen( @@ -35,6 +52,11 @@ fun ChatConversationScreen( dispatchEvent: (ConversationViewModel.Event) -> Unit, ) { CodeScaffold( + topBar = { + IdentityRevealHeader(state = state) { + dispatchEvent(ConversationViewModel.Event.RevealIdentity) + } + }, bottomBar = { Column( modifier = Modifier @@ -69,7 +91,7 @@ fun ChatConversationScreen( AnnouncementMessage( text = stringResource( id = R.string.title_chat_announcement_identityRevealed, - state.user.orEmpty() + state.user?.username.orEmpty() ) ) } @@ -78,7 +100,7 @@ fun ChatConversationScreen( AnnouncementMessage( text = stringResource( id = R.string.title_chat_announcement_identityRevealedToYou, - state.user.orEmpty() + state.user?.username.orEmpty() ) ) } @@ -109,7 +131,7 @@ fun ChatConversationScreen( AnnouncementMessage( text = stringResource( id = R.string.title_chat_announcement_thanksReceived, - state.user.orEmpty() + state.user?.username.orEmpty() ) ) } @@ -118,8 +140,72 @@ fun ChatConversationScreen( } } } - + HandleMessageChanges(listState = lazyListState, items = messages) } } +@Composable +private fun IdentityRevealHeader( + state: ConversationViewModel.State, + onClick: () -> Unit +) { + var showRevealHeader by remember { + mutableStateOf(false) + } + + LaunchedEffect(state.identityRevealed, state.user) { + if (!state.identityRevealed) { + delay(500) + } + showRevealHeader = !state.identityRevealed && state.user != null + } + + AnimatedContent( + targetState = showRevealHeader, + label = "show/hide identity reveal header" + ) { show -> + if (show) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = CodeTheme.dimens.grid.x2), + color = CodeTheme.colors.background, + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Your messages are showing up anonymously.", + style = CodeTheme.typography.button.copy(fontWeight = FontWeight.W700), + ) + val text = buildAnnotatedString { + pushStringAnnotation( + tag = "reveal", + annotation = "reveal identity" + ) + withStyle(style = SpanStyle(textDecoration = TextDecoration.Underline)) { + append("Tap to Reveal Your Identity") + } + } + + ClickableText( + text = text, + style = CodeTheme.typography.button.copy( + color = Color.White, + fontWeight = FontWeight.W700), + ) { offset -> + text.getStringAnnotations( + tag = "reveal", + start = offset, + end = offset + ).firstOrNull()?.let { onClick() } + } + } + } + } + } + +} + diff --git a/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt b/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt index 818ecb4e6..2b7b4fdbf 100644 --- a/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt @@ -10,13 +10,17 @@ import androidx.paging.cachedIn import com.getcode.model.Conversation import com.getcode.model.ID import com.getcode.model.KinAmount +import com.getcode.model.TipMetadata +import com.getcode.model.TwitterUser import com.getcode.network.ConversationController +import com.getcode.solana.keys.PublicKey import com.getcode.util.CurrencyUtils import com.getcode.util.formatted import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel2 import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filterIsInstance @@ -26,6 +30,8 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant import timber.log.Timber import javax.inject.Inject @@ -47,8 +53,15 @@ class ConversationViewModel @Inject constructor( val tipAmountFormatted: String?, val textFieldState: TextFieldState, val identityRevealed: Boolean, - val user: String?, + val user: User?, + val lastSeen: Instant? ) { + data class User( + val username: String, + val publicKey: PublicKey, + val imageUrl: String?, + ) + companion object { val Default = State( messageId = null, @@ -59,6 +72,7 @@ class ConversationViewModel @Inject constructor( textFieldState = TextFieldState(), identityRevealed = false, user = null, + lastSeen = null ) } } @@ -66,19 +80,27 @@ class ConversationViewModel @Inject constructor( sealed interface Event { data class OnMessageIdChanged(val id: ID?) : Event data class OnConversationChanged(val conversation: Conversation) : Event - data class OnUserRevealed(val user: String): Event - data class OnTitleChanged(val title: String): Event - data class OnTipAmountFormatted(val amount: String): Event + data class OnUserRevealed( + val username: String, + val publicKey: PublicKey, + val imageUrl: String?, + ) : Event + + data class OnUserActivity(val activity: Instant) : Event + data class OnTitleChanged(val title: String) : Event + data class OnTipAmountFormatted(val amount: String) : Event data object SendMessage : Event data object RevealIdentity : Event + + data object OnIdentityRevealed: Event } init { stateFlow .map { it.messageId } .filterNotNull() - .mapNotNull { conversationController.getConversationForMessage(it) } - .distinctUntilChangedBy { it.id } + .flatMapLatest { conversationController.observeConversationForMessage(it) } + .filterNotNull() .onEach { dispatchEvent(Dispatchers.Main, Event.OnConversationChanged(it)) } .launchIn(viewModelScope) @@ -87,8 +109,10 @@ class ConversationViewModel @Inject constructor( .filterNotNull() .distinctUntilChanged() .mapNotNull { - val currency = currencyUtils.getCurrency(it.rate.currency.name) ?: return@mapNotNull null - val title = it.formatted(currency = currency, resources = resources, suffix = "Tipper") + val currency = + currencyUtils.getCurrency(it.rate.currency.name) ?: return@mapNotNull null + val title = + it.formatted(currency = currency, resources = resources, suffix = "Tipper") val formatted = it.formatted(currency = currency, resources = resources) title to formatted } @@ -107,6 +131,13 @@ class ConversationViewModel @Inject constructor( textFieldState.clearText() conversationController.sendMessage(it.conversationId!!, text) }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { stateFlow.value.messageId } + .onEach { delay(300) } + .onEach { conversationController.revealIdentity(it) } + .launchIn(viewModelScope) } val messages = stateFlow @@ -123,7 +154,8 @@ class ConversationViewModel @Inject constructor( is Event.OnConversationChanged -> { state -> state.copy( conversationId = event.conversation.id, - tipAmount = event.conversation.tipAmount + tipAmount = event.conversation.tipAmount, + identityRevealed = event.conversation.hasRevealedIdentity ) } @@ -144,8 +176,22 @@ class ConversationViewModel @Inject constructor( state.copy(messageId = event.id) } + is Event.OnIdentityRevealed -> { state -> + state.copy(identityRevealed = true) + } + is Event.OnUserRevealed -> { state -> - state.copy(user = event.user) + state.copy( + user = State.User( + username = event.username, + publicKey = event.publicKey, + imageUrl = event.imageUrl, + ) + ) + } + + is Event.OnUserActivity -> { state -> + state.copy(lastSeen = event.activity) } } } diff --git a/app/src/main/java/com/getcode/view/main/invites/InvitesSheet.kt b/app/src/main/java/com/getcode/view/main/invites/InvitesSheet.kt index 88f083276..05a44e161 100644 --- a/app/src/main/java/com/getcode/view/main/invites/InvitesSheet.kt +++ b/app/src/main/java/com/getcode/view/main/invites/InvitesSheet.kt @@ -12,6 +12,7 @@ import com.getcode.R import com.getcode.theme.CodeTheme import com.getcode.ui.components.PermissionCheck import com.getcode.ui.components.SheetTitle +import com.getcode.ui.components.SheetTitleText import com.getcode.ui.components.getPermissionLauncher @Preview @@ -50,7 +51,7 @@ fun InvitesSheet(upPress: () -> Unit = {}) { else stringResource(id = R.string.title_inviteFriend) SheetTitle( - title = { title }, + title = { SheetTitleText(text = title) }, onCloseIconClicked = upPress ) From 83894cc7690aa006e7c4d6a9d01e6358060f262f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 10 May 2024 17:02:35 -0400 Subject: [PATCH 3/6] feat: update to share UI components between Chat and Tip chat conversations Signed-off-by: Brandon McAnsh --- api/src/main/java/com/getcode/model/Chat.kt | 36 +- .../java/com/getcode/model/Conversation.kt | 11 +- .../AnnouncementMessage.kt | 5 +- .../{conversation => chat}/DateWithStatus.kt | 5 +- .../ui/components/chat/EncryptedContent.kt | 42 +++ .../getcode/ui/components/chat/MessageNode.kt | 344 +++++++----------- .../MessageTextContent.kt} | 22 +- .../ui/components/chat/TipChatActions.kt | 63 ++++ .../ui/components/chat/utils/ChatItem.kt | 19 + .../utils/HandleMessageChanges.kt | 23 +- app/src/main/java/com/getcode/util/Pair.kt | 3 + .../com/getcode/view/main/chat/ChatScreen.kt | 1 + .../getcode/view/main/chat/ChatViewModel.kt | 19 +- .../conversation/ChatConversationScreen.kt | 123 ++++--- .../conversation/ConversationViewModel.kt | 86 ++++- 15 files changed, 482 insertions(+), 320 deletions(-) rename app/src/main/java/com/getcode/ui/components/{conversation => chat}/AnnouncementMessage.kt (85%) rename app/src/main/java/com/getcode/ui/components/{conversation => chat}/DateWithStatus.kt (96%) create mode 100644 app/src/main/java/com/getcode/ui/components/chat/EncryptedContent.kt rename app/src/main/java/com/getcode/ui/components/{conversation/MessageBubble.kt => chat/MessageTextContent.kt} (90%) create mode 100644 app/src/main/java/com/getcode/ui/components/chat/TipChatActions.kt create mode 100644 app/src/main/java/com/getcode/ui/components/chat/utils/ChatItem.kt create mode 100644 app/src/main/java/com/getcode/util/Pair.kt diff --git a/api/src/main/java/com/getcode/model/Chat.kt b/api/src/main/java/com/getcode/model/Chat.kt index f7ef3b338..079f75c5a 100644 --- a/api/src/main/java/com/getcode/model/Chat.kt +++ b/api/src/main/java/com/getcode/model/Chat.kt @@ -160,7 +160,7 @@ data class ChatMessage( val id: ID, val cursor: Cursor, val dateMillis: Long, - val contents: List + val contents: List, ) { val hasEncryptedContent: Boolean get() { @@ -192,12 +192,34 @@ data class ChatMessage( } sealed interface MessageContent { - data class Localized(val value: String) : MessageContent - data class Exchange(val amount: GenericAmount, val verb: Verb, val thanked: Boolean = false) : - MessageContent - - data class SodiumBox(val data: EncryptedData) : MessageContent - data class Decrypted(val data: String) : MessageContent + val isAnnouncement: Boolean + val status: MessageStatus + + data class Localized( + val value: String, + override val isAnnouncement: Boolean = false, + override val status: MessageStatus = MessageStatus.Unknown + ) : MessageContent + + data class Exchange( + val amount: GenericAmount, + val verb: Verb, + val thanked: Boolean = false, + override val isAnnouncement: Boolean = false, + override val status: MessageStatus = MessageStatus.Unknown + ) : MessageContent + + data class SodiumBox( + val data: EncryptedData, + override val isAnnouncement: Boolean = false, + override val status: MessageStatus = MessageStatus.Unknown + ) : MessageContent + + data class Decrypted( + val data: String, + override val isAnnouncement: Boolean = false, + override val status: MessageStatus = MessageStatus.Unknown + ) : MessageContent companion object { operator fun invoke(proto: Content): MessageContent? { diff --git a/api/src/main/java/com/getcode/model/Conversation.kt b/api/src/main/java/com/getcode/model/Conversation.kt index 3a66e6d7d..9cbc850ef 100644 --- a/api/src/main/java/com/getcode/model/Conversation.kt +++ b/api/src/main/java/com/getcode/model/Conversation.kt @@ -161,5 +161,14 @@ sealed interface ConversationMessageContent { } enum class MessageStatus { - Incoming, Sent, Delivered, Read + Incoming, Sent, Delivered, Read, Unknown; + + fun isOutgoing() = when (this) { + Incoming -> false + Sent, + Delivered, + Read -> true + Unknown -> false + } + fun isValid() = this != Unknown } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/conversation/AnnouncementMessage.kt b/app/src/main/java/com/getcode/ui/components/chat/AnnouncementMessage.kt similarity index 85% rename from app/src/main/java/com/getcode/ui/components/conversation/AnnouncementMessage.kt rename to app/src/main/java/com/getcode/ui/components/chat/AnnouncementMessage.kt index 391aee458..e2ec0eaf5 100644 --- a/app/src/main/java/com/getcode/ui/components/conversation/AnnouncementMessage.kt +++ b/app/src/main/java/com/getcode/ui/components/chat/AnnouncementMessage.kt @@ -1,4 +1,4 @@ -package com.getcode.ui.components.conversation +package com.getcode.ui.components.chat import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -13,14 +13,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import com.getcode.theme.BrandDark import com.getcode.theme.CodeTheme -import com.getcode.ui.components.chat.MessageNodeDefaults @Composable fun AnnouncementMessage( modifier: Modifier = Modifier, text: String, ) { - Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { Column( modifier = Modifier .background( diff --git a/app/src/main/java/com/getcode/ui/components/conversation/DateWithStatus.kt b/app/src/main/java/com/getcode/ui/components/chat/DateWithStatus.kt similarity index 96% rename from app/src/main/java/com/getcode/ui/components/conversation/DateWithStatus.kt rename to app/src/main/java/com/getcode/ui/components/chat/DateWithStatus.kt index 66b5fffaf..11085e6bd 100644 --- a/app/src/main/java/com/getcode/ui/components/conversation/DateWithStatus.kt +++ b/app/src/main/java/com/getcode/ui/components/chat/DateWithStatus.kt @@ -1,4 +1,4 @@ -package com.getcode.ui.components.conversation +package com.getcode.ui.components.chat import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -21,7 +21,6 @@ import com.getcode.R import com.getcode.model.MessageStatus import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme -import com.getcode.ui.components.chat.MessageNodeDefaults import com.getcode.util.formatTimeRelatively import kotlinx.datetime.Clock import kotlinx.datetime.Instant @@ -52,7 +51,7 @@ internal fun DateWithStatus( color = BrandLight, maxLines = 1 ) - if (status != MessageStatus.Incoming) { + if (status.isValid() && status != MessageStatus.Incoming) { Icon( modifier = Modifier .requiredWidth(width = DateWithStatusDefaults.IconWidth) diff --git a/app/src/main/java/com/getcode/ui/components/chat/EncryptedContent.kt b/app/src/main/java/com/getcode/ui/components/chat/EncryptedContent.kt new file mode 100644 index 000000000..956b1b109 --- /dev/null +++ b/app/src/main/java/com/getcode/ui/components/chat/EncryptedContent.kt @@ -0,0 +1,42 @@ +package com.getcode.ui.components.chat + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import com.getcode.R +import com.getcode.theme.BrandLight +import com.getcode.theme.CodeTheme +import com.getcode.util.formatTimeRelatively +import kotlinx.datetime.Instant + +@Composable +internal fun EncryptedContent(modifier: Modifier = Modifier, date: Instant) { + Column( + modifier = modifier + // add top padding to accommodate ascents + .padding(top = CodeTheme.dimens.grid.x1), + ) { + Image( + modifier = Modifier + .padding(CodeTheme.dimens.grid.x2) + .size(CodeTheme.dimens.staticGrid.x6) + .align(Alignment.CenterHorizontally), + painter = painterResource(id = R.drawable.lock_app_dashed), + colorFilter = ColorFilter.tint(CodeTheme.colors.onBackground), + contentDescription = null + ) + Text( + modifier = Modifier.align(Alignment.End), + text = date.formatTimeRelatively(), + style = CodeTheme.typography.caption, + color = BrandLight, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/chat/MessageNode.kt b/app/src/main/java/com/getcode/ui/components/chat/MessageNode.kt index e958b87a7..127bd1f2f 100644 --- a/app/src/main/java/com/getcode/ui/components/chat/MessageNode.kt +++ b/app/src/main/java/com/getcode/ui/components/chat/MessageNode.kt @@ -1,62 +1,34 @@ package com.getcode.ui.components.chat -import androidx.compose.animation.animateColorAsState -import androidx.compose.foundation.Image import androidx.compose.foundation.background 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CornerBasedShape import androidx.compose.foundation.shape.CornerSize -import androidx.compose.material.Icon -import androidx.compose.material.IconButton +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ChatBubbleOutline -import androidx.compose.material.icons.rounded.Favorite -import androidx.compose.material.icons.rounded.FavoriteBorder -import androidx.compose.material.icons.rounded.HeartBroken import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf 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.ColorFilter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import com.getcode.LocalBetaFlags import com.getcode.LocalExchange -import com.getcode.R -import com.getcode.model.KinAmount import com.getcode.model.MessageContent -import com.getcode.model.Rate +import com.getcode.model.MessageStatus import com.getcode.model.Verb import com.getcode.model.orOneToOne -import com.getcode.theme.Alert import com.getcode.theme.BrandDark -import com.getcode.theme.BrandLight +import com.getcode.theme.ChatOutgoing import com.getcode.theme.CodeTheme -import com.getcode.theme.extraLarge -import com.getcode.theme.extraSmall -import com.getcode.ui.components.ButtonState -import com.getcode.ui.components.CodeButton import com.getcode.ui.components.chat.utils.localizedText -import com.getcode.ui.utils.unboundedClickable -import com.getcode.util.formatTimeRelatively -import com.getcode.utils.FormatUtils -import com.getcode.view.main.giveKin.KinValueHint +import com.getcode.ui.utils.addIf import com.getcode.view.main.home.components.PriceWithFlag import kotlinx.datetime.Instant @@ -84,60 +56,95 @@ fun MessageNode( date: Instant, isPreviousSameMessage: Boolean, isNextSameMessage: Boolean, - thankUser: () -> Unit, - openMessageChat: () -> Unit, + showTipActions: Boolean = true, + thankUser: () -> Unit = { }, + openMessageChat: () -> Unit = { }, ) { Box( modifier = modifier .padding(vertical = CodeTheme.dimens.grid.x1) ) { - Box( - modifier = Modifier - .fillMaxWidth(0.895f) - .background( - color = BrandDark, - shape = when { - isPreviousSameMessage && isNextSameMessage -> MessageNodeDefaults.MiddleSameShape - isPreviousSameMessage -> MessageNodeDefaults.PreviousSameShape - isNextSameMessage -> MessageNodeDefaults.NextSameShape - else -> MessageNodeDefaults.DefaultShape - } + val color = if (contents is MessageContent.Exchange && !contents.verb.increasesBalance) { + ChatOutgoing + } else { + BrandDark + } + + val isAnnouncement = + remember { (contents as? MessageContent.Localized)?.isAnnouncement ?: false } + + when (contents) { + is MessageContent.Exchange -> { + MessagePayment( + modifier = Modifier + .fillMaxWidth(0.895f) + .background( + color = color, + shape = when { + isAnnouncement -> MessageNodeDefaults.DefaultShape + isPreviousSameMessage && isNextSameMessage -> MessageNodeDefaults.MiddleSameShape + isPreviousSameMessage -> MessageNodeDefaults.PreviousSameShape + isNextSameMessage -> MessageNodeDefaults.NextSameShape + else -> MessageNodeDefaults.DefaultShape + } + ), + contents = contents, + showTipActions = showTipActions, + thankUser = thankUser, + date = date, + openMessageChat = openMessageChat ) - .padding(CodeTheme.dimens.grid.x2), - contentAlignment = Alignment.Center - ) { - when (contents) { - is MessageContent.Exchange -> { - MessagePayment( - contents = contents, - thankUser = thankUser, - date = date, - openMessageChat = openMessageChat - ) - } + } - is MessageContent.Localized -> { + is MessageContent.Localized -> { + if (contents.isAnnouncement) { + AnnouncementMessage( + modifier = Modifier + .align(Alignment.Center), + text = contents.localizedText + ) + } else { MessageText( - modifier = Modifier.fillMaxWidth(), - text = contents.localizedText, - date = date + modifier = Modifier + .fillMaxWidth() + .padding(CodeTheme.dimens.grid.x2), + content = contents.localizedText, + date = date, + status = contents.status, + isFromSelf = contents.status.isOutgoing() ) } + } - is MessageContent.SodiumBox -> { - MessageEncrypted( - modifier = Modifier.fillMaxWidth(), - date = date - ) - } + is MessageContent.SodiumBox -> { + EncryptedContent( + modifier = Modifier + .fillMaxWidth(0.895f) + .background( + color = color, + shape = when { + isAnnouncement -> MessageNodeDefaults.DefaultShape + isPreviousSameMessage && isNextSameMessage -> MessageNodeDefaults.MiddleSameShape + isPreviousSameMessage -> MessageNodeDefaults.PreviousSameShape + isNextSameMessage -> MessageNodeDefaults.NextSameShape + else -> MessageNodeDefaults.DefaultShape + } + ) + .padding(CodeTheme.dimens.grid.x2), + date = date + ) + } - is MessageContent.Decrypted -> { - MessageText( - modifier = Modifier.fillMaxWidth(), - text = contents.data, - date = date - ) - } + is MessageContent.Decrypted -> { + MessageText( + modifier = Modifier + .fillMaxWidth() + .padding(CodeTheme.dimens.grid.x2), + content = contents.data, + date = date, + status = contents.status, + isFromSelf = contents.status.isOutgoing() + ) } } } @@ -148,12 +155,16 @@ private fun MessagePayment( modifier: Modifier = Modifier, contents: MessageContent.Exchange, date: Instant, + status: MessageStatus = MessageStatus.Unknown, + showTipActions: Boolean = true, thankUser: () -> Unit, openMessageChat: () -> Unit, ) { Column( modifier = modifier // payments have an extra 10.dp inner padding + .padding(CodeTheme.dimens.grid.x1) + .background(CodeTheme.colors.background, RoundedCornerShape(3.dp)) // small - padding .padding(CodeTheme.dimens.grid.x2), verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), horizontalAlignment = Alignment.CenterHorizontally, @@ -168,155 +179,60 @@ private fun MessagePayment( derivedStateOf { contents.amount.amountUsing(rate) } } - if (contents.verb == Verb.Returned) { - PriceWithFlag( - currencyCode = amount.rate.currency, - amount = amount, - text = { price -> - Text( - text = price, - color = Color.White, - style = CodeTheme.typography.h3 - ) - } - ) - Text( - text = contents.verb.localizedText, - style = CodeTheme.typography.body1.copy(fontWeight = FontWeight.W500) - ) - } else { - Text( - text = contents.verb.localizedText, - style = CodeTheme.typography.body1.copy(fontWeight = FontWeight.W500) - ) - PriceWithFlag( - currencyCode = amount.rate.currency, - amount = amount, - text = { price -> - Text( - text = price, - color = Color.White, - style = CodeTheme.typography.h3 - ) - } - ) - } - - val tipChatsEnabled = LocalBetaFlags.current.tipsChatEnabled - var thanked by remember(contents.thanked) { - mutableStateOf(contents.thanked) - } - - val sendThanks = { - thanked = true - thankUser() - } - - if (tipChatsEnabled && contents.verb is Verb.ReceivedTip) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), - ) { - CodeButton( - modifier = Modifier.weight(1f), - enabled = !thanked, - buttonState = if (thanked) ButtonState.Bordered else ButtonState.Filled, - onClick = sendThanks, - shape = CodeTheme.shapes.extraSmall, - text = if (thanked) stringResource(R.string.action_thanked) else stringResource(R.string.action_thank) + Column( + modifier = Modifier + .padding(top = CodeTheme.dimens.grid.x3) + .padding(horizontal = CodeTheme.dimens.grid.x6), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (contents.verb == Verb.Returned) { + PriceWithFlag( + currencyCode = amount.rate.currency, + amount = amount, + text = { price -> + Text( + text = price, + color = Color.White, + style = CodeTheme.typography.h3 + ) + } ) - CodeButton( - modifier = Modifier.weight(1f), - buttonState = ButtonState.Filled, - onClick = openMessageChat, - shape = CodeTheme.shapes.extraSmall, - text = stringResource(R.string.action_message) + Text( + text = contents.verb.localizedText, + style = CodeTheme.typography.body1.copy(fontWeight = FontWeight.W500) ) - } - Text( - modifier = Modifier.align(Alignment.End), - text = date.formatTimeRelatively(), - style = CodeTheme.typography.caption, - color = BrandLight, - ) - } else { - Spacer(modifier = Modifier.weight(1f)) - } - } -} - -@Composable -private fun CodeTransactionDisplay( - modifier: Modifier = Modifier, - amount: KinAmount, - verb: Verb, -) { - Column( - modifier = modifier - .background(CodeTheme.colors.background) - .padding(vertical = CodeTheme.dimens.grid.x2), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - PriceWithFlag( - currencyCode = amount.rate.currency, - amount = amount, - text = { price -> - val prefix = if (verb.increasesBalance) "+" else "-" + } else { Text( - text = "$prefix$price", - color = Color.White, - style = CodeTheme.typography.h3 + text = contents.verb.localizedText, + style = CodeTheme.typography.body1.copy(fontWeight = FontWeight.W500) + ) + PriceWithFlag( + currencyCode = amount.rate.currency, + amount = amount, + text = { price -> + Text( + text = price, + color = Color.White, + style = CodeTheme.typography.h3 + ) + } ) } - ) - KinValueHint( - modifier = Modifier.align(Alignment.CenterHorizontally), - captionText = FormatUtils.formatWholeRoundDown(amount.kin.toKinValueDouble()), - ) - } -} + } -@Composable -private fun MessageText(modifier: Modifier = Modifier, text: String, date: Instant) { - Column( - modifier = modifier - // add top padding to accommodate ascents - .padding(top = CodeTheme.dimens.grid.x1), - ) { - Text( - text = text, - style = CodeTheme.typography.body1.copy(fontWeight = FontWeight.W500) + TipChatActions( + contents = contents, + showTipActions = showTipActions, + thankUser = thankUser, + openMessageChat = openMessageChat ) - Text( - modifier = Modifier.align(Alignment.End), - text = date.formatTimeRelatively(), - style = CodeTheme.typography.caption, - color = BrandLight, - ) - } -} -@Composable -private fun MessageEncrypted(modifier: Modifier = Modifier, date: Instant) { - Column( - modifier = modifier - // add top padding to accommodate ascents - .padding(top = CodeTheme.dimens.grid.x1), - ) { - Image( + DateWithStatus( modifier = Modifier - .padding(CodeTheme.dimens.grid.x2) - .size(CodeTheme.dimens.staticGrid.x6) - .align(Alignment.CenterHorizontally), - painter = painterResource(id = R.drawable.lock_app_dashed), - colorFilter = ColorFilter.tint(CodeTheme.colors.onBackground), - contentDescription = null - ) - Text( - modifier = Modifier.align(Alignment.End), - text = date.formatTimeRelatively(), - style = CodeTheme.typography.caption, - color = BrandLight, + .align(Alignment.End), + date = date, + status = status ) } } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/conversation/MessageBubble.kt b/app/src/main/java/com/getcode/ui/components/chat/MessageTextContent.kt similarity index 90% rename from app/src/main/java/com/getcode/ui/components/conversation/MessageBubble.kt rename to app/src/main/java/com/getcode/ui/components/chat/MessageTextContent.kt index f39ea02fb..ee539d230 100644 --- a/app/src/main/java/com/getcode/ui/components/conversation/MessageBubble.kt +++ b/app/src/main/java/com/getcode/ui/components/chat/MessageTextContent.kt @@ -1,15 +1,12 @@ -package com.getcode.ui.components.conversation +package com.getcode.ui.components.chat import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints 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.requiredHeight import androidx.compose.foundation.layout.widthIn import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -26,22 +23,23 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.rememberTextMeasurer -import com.getcode.model.ConversationMessageContent import com.getcode.model.MessageStatus import com.getcode.theme.BrandDark import com.getcode.theme.CodeTheme -import com.getcode.ui.components.chat.MessageNodeDefaults import com.getcode.util.formatDateRelatively import kotlinx.datetime.Instant @Composable -fun MessageBubble( +fun MessageText( modifier: Modifier = Modifier, - content: ConversationMessageContent.Text, + content: String, + isFromSelf: Boolean, date: Instant, + status: MessageStatus = MessageStatus.Unknown, ) { - val alignment = if (content.isFromSelf) Alignment.CenterEnd else Alignment.CenterStart - val color = if (content.isFromSelf) Color(0xFF443091) else BrandDark + val alignment = if (isFromSelf) Alignment.CenterEnd else Alignment.CenterStart + val color = if (isFromSelf) Color(0xFF443091) else BrandDark + BoxWithConstraints(modifier = modifier.fillMaxWidth(), contentAlignment = alignment) { BoxWithConstraints( modifier = Modifier @@ -61,7 +59,7 @@ fun MessageBubble( ) { MessageContent( maxWidth = maxWidthPx, - message = content.message, date = date, status = content.status) + message = content, date = date, status = status) } } } @@ -87,7 +85,7 @@ private fun rememberAlignmentRule( val dateStatusWidth = remember(message, date) { val result = textMeasurer.measure( text = date.formatDateRelatively(), - style = dateTextStyle, + style = dateTextStyle, maxLines = 1 ) result.size.width + spacingPx + iconSizePx diff --git a/app/src/main/java/com/getcode/ui/components/chat/TipChatActions.kt b/app/src/main/java/com/getcode/ui/components/chat/TipChatActions.kt new file mode 100644 index 000000000..97cbaa29c --- /dev/null +++ b/app/src/main/java/com/getcode/ui/components/chat/TipChatActions.kt @@ -0,0 +1,63 @@ +package com.getcode.ui.components.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +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.res.stringResource +import com.getcode.LocalBetaFlags +import com.getcode.R +import com.getcode.model.MessageContent +import com.getcode.model.Verb +import com.getcode.theme.CodeTheme +import com.getcode.theme.extraSmall +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton + +@Composable +internal fun TipChatActions( + contents: MessageContent.Exchange, + showTipActions: Boolean, + thankUser: () -> Unit, + openMessageChat: () -> Unit +) { + val tipChatsEnabled = LocalBetaFlags.current.tipsChatEnabled + var thanked by remember(contents.thanked) { + mutableStateOf(contents.thanked) + } + + val sendThanks = { + thanked = true + thankUser() + } + + if (tipChatsEnabled && contents.verb is Verb.ReceivedTip) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + CodeButton( + modifier = Modifier.weight(1f), + enabled = !thanked, + buttonState = if (thanked) ButtonState.Bordered else ButtonState.Filled, + onClick = sendThanks, + shape = CodeTheme.shapes.extraSmall, + text = if (thanked) stringResource(R.string.action_thanked) else stringResource( + R.string.action_thank + ) + ) + CodeButton( + modifier = Modifier.weight(1f), + buttonState = ButtonState.Filled, + onClick = openMessageChat, + shape = CodeTheme.shapes.extraSmall, + text = stringResource(R.string.action_message) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/chat/utils/ChatItem.kt b/app/src/main/java/com/getcode/ui/components/chat/utils/ChatItem.kt new file mode 100644 index 000000000..b22a95e91 --- /dev/null +++ b/app/src/main/java/com/getcode/ui/components/chat/utils/ChatItem.kt @@ -0,0 +1,19 @@ +package com.getcode.ui.components.chat.utils + +import com.getcode.model.ID +import com.getcode.model.MessageContent +import kotlinx.datetime.Instant +import java.util.UUID + +typealias ChatMessageIndice = Triple + +sealed class ChatItem(val key: Any) { + data class Message( + val id: String = UUID.randomUUID().toString(), + val chatMessageId: ID, + val message: MessageContent, + val date: Instant, + ) : ChatItem(id) + + data class Date(val date: String) : ChatItem(date) +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/conversation/utils/HandleMessageChanges.kt b/app/src/main/java/com/getcode/ui/components/conversation/utils/HandleMessageChanges.kt index 9ebb799a7..6a08764d1 100644 --- a/app/src/main/java/com/getcode/ui/components/conversation/utils/HandleMessageChanges.kt +++ b/app/src/main/java/com/getcode/ui/components/conversation/utils/HandleMessageChanges.kt @@ -12,17 +12,20 @@ import androidx.compose.runtime.snapshotFlow import androidx.paging.compose.LazyPagingItems import com.getcode.model.ConversationMessage import com.getcode.model.ConversationMessageContent +import com.getcode.model.MessageContent +import com.getcode.ui.components.chat.utils.ChatItem import com.getcode.ui.utils.isScrolledToTheBeginning import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map @Composable internal fun HandleMessageChanges( listState: LazyListState, - items: LazyPagingItems, + items: LazyPagingItems, ) { var lastMessageSent by rememberSaveable { mutableLongStateOf(0L) @@ -38,18 +41,22 @@ internal fun HandleMessageChanges( snapshotFlow { items.itemSnapshotList } .map { it.firstOrNull() } .filterNotNull() - .distinctUntilChangedBy { it.dateMillis } - .filter { it.content is ConversationMessageContent.Text } + .filterIsInstance() + .distinctUntilChangedBy { it.date } + .filter { it.message is MessageContent.Localized || it.message is MessageContent.Exchange } .collect { newMessage -> - val content = newMessage.content as? ConversationMessageContent.Text ?: return@collect - if (content.isFromSelf && newMessage.dateMillis > lastMessageSent) { + val date = newMessage.date + val isTextMessage = newMessage.message is MessageContent.Localized + val isExchangeMessage = newMessage.message is MessageContent.Exchange + + if (newMessage.message.status.isOutgoing() && newMessage.date.toEpochMilliseconds() > lastMessageSent) { listState.handleAndReplayAfter(300) { scrollToItem(0) - lastMessageSent = newMessage.dateMillis + lastMessageSent = newMessage.date.toEpochMilliseconds() } } else { listState.handleAndReplayAfter(300) { - if (listState.isScrolledToTheBeginning() && newMessage.dateMillis > lastMessageReceived) { + if (listState.isScrolledToTheBeginning() && newMessage.date.toEpochMilliseconds() > lastMessageReceived) { // Android 10 (specifically the S1?) we have to utilize a mimic for IME nested scrolling // using the [LazyListState#isScrollInProgress] which animateScrollToItem triggers // thus causing the IME to be dismissed when we trigger the sync. @@ -59,7 +66,7 @@ internal fun HandleMessageChanges( listState.animateScrollToItem(0) } } - lastMessageReceived = newMessage.dateMillis + lastMessageReceived = newMessage.date.toEpochMilliseconds() } } } diff --git a/app/src/main/java/com/getcode/util/Pair.kt b/app/src/main/java/com/getcode/util/Pair.kt new file mode 100644 index 000000000..77117f968 --- /dev/null +++ b/app/src/main/java/com/getcode/util/Pair.kt @@ -0,0 +1,3 @@ +package com.getcode.util + +infix fun Pair.to(that: C): Triple = Triple(first, second, that) \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/main/chat/ChatScreen.kt b/app/src/main/java/com/getcode/view/main/chat/ChatScreen.kt index 79db82140..61531c6f7 100644 --- a/app/src/main/java/com/getcode/view/main/chat/ChatScreen.kt +++ b/app/src/main/java/com/getcode/view/main/chat/ChatScreen.kt @@ -34,6 +34,7 @@ import com.getcode.ui.components.Row import com.getcode.ui.components.VerticalDivider import com.getcode.ui.components.chat.MessageNode import com.getcode.ui.components.chat.localized +import com.getcode.ui.components.chat.utils.ChatItem import com.getcode.ui.utils.withTopBorder @Composable diff --git a/app/src/main/java/com/getcode/view/main/chat/ChatViewModel.kt b/app/src/main/java/com/getcode/view/main/chat/ChatViewModel.kt index 7552a3e06..88483f89c 100644 --- a/app/src/main/java/com/getcode/view/main/chat/ChatViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/chat/ChatViewModel.kt @@ -14,6 +14,8 @@ import com.getcode.network.ConversationController import com.getcode.network.HistoryController import com.getcode.network.repository.BetaFlagsRepository import com.getcode.network.repository.base58 +import com.getcode.ui.components.chat.utils.ChatItem +import com.getcode.ui.components.chat.utils.ChatMessageIndice import com.getcode.util.formatDateRelatively import com.getcode.util.toInstantFromMillis import com.getcode.view.BaseViewModel2 @@ -36,20 +38,6 @@ import timber.log.Timber import java.util.UUID import javax.inject.Inject -typealias ChatMessageIndice = Triple - -sealed class ChatItem(val key: Any) { - data class Message( - val id: String = UUID.randomUUID().toString(), - val chatMessageId: ID, - val message: MessageContent, - val date: Instant, - - ) : ChatItem(id) - - data class Date(val date: String) : ChatItem(date) -} - @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class ChatViewModel @Inject constructor( @@ -185,10 +173,11 @@ class ChatViewModel @Inject constructor( } else { contents } + ChatItem.Message( chatMessageId = id, message = message, - date = date + date = date, ) } } diff --git a/app/src/main/java/com/getcode/view/main/chat/conversation/ChatConversationScreen.kt b/app/src/main/java/com/getcode/view/main/chat/conversation/ChatConversationScreen.kt index 4dc6006f1..11a798341 100644 --- a/app/src/main/java/com/getcode/view/main/chat/conversation/ChatConversationScreen.kt +++ b/app/src/main/java/com/getcode/view/main/chat/conversation/ChatConversationScreen.kt @@ -25,7 +25,6 @@ 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.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -33,22 +32,18 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemKey -import com.getcode.R -import com.getcode.model.ConversationMessage -import com.getcode.model.ConversationMessageContent import com.getcode.theme.CodeTheme import com.getcode.ui.components.CodeScaffold -import com.getcode.ui.components.conversation.AnnouncementMessage +import com.getcode.ui.components.chat.MessageNode +import com.getcode.ui.components.chat.utils.ChatItem import com.getcode.ui.components.conversation.ChatInput -import com.getcode.ui.components.conversation.MessageBubble import com.getcode.ui.components.conversation.utils.HandleMessageChanges -import com.getcode.util.toInstantFromMillis import kotlinx.coroutines.delay @Composable fun ChatConversationScreen( state: ConversationViewModel.State, - messages: LazyPagingItems, + messages: LazyPagingItems, dispatchEvent: (ConversationViewModel.Event) -> Unit, ) { CodeScaffold( @@ -83,60 +78,77 @@ fun ChatConversationScreen( ) { items( count = messages.itemCount, - key = messages.itemKey { item -> item.id }, + key = messages.itemKey { item -> item.key }, ) { index -> - val message = messages[index] - when (val content = message?.content) { - ConversationMessageContent.IdentityRevealed -> { - AnnouncementMessage( - text = stringResource( - id = R.string.title_chat_announcement_identityRevealed, - state.user?.username.orEmpty() - ) - ) - } + when (val item = messages[index]) { + is ChatItem.Date -> { - ConversationMessageContent.IdentityRevealedToYou -> { - AnnouncementMessage( - text = stringResource( - id = R.string.title_chat_announcement_identityRevealedToYou, - state.user?.username.orEmpty() - ) - ) } - is ConversationMessageContent.Text -> { - MessageBubble( - content = content, - date = message.dateMillis.toInstantFromMillis() - ) - } - - ConversationMessageContent.ThanksSent -> { - AnnouncementMessage( - text = stringResource(id = R.string.title_chat_announcement_thanksSent) - ) - } - - ConversationMessageContent.TipMessage -> { - AnnouncementMessage( - text = stringResource( - id = R.string.title_chat_announcement_tipHeader, - state.tipAmountFormatted.orEmpty() - ) - ) - } - - ConversationMessageContent.ThanksReceived -> { - AnnouncementMessage( - text = stringResource( - id = R.string.title_chat_announcement_thanksReceived, - state.user?.username.orEmpty() - ) + is ChatItem.Message -> { + MessageNode( + modifier = Modifier.fillMaxWidth(), + contents = item.message, + date = item.date, + isPreviousSameMessage = false, + isNextSameMessage = false, + showTipActions = false, ) +// when (val contents = item.message) { +// is MessageContent.Decrypted -> MessageBubble( +// contents = contents, +// alignment = when { +// item.isFromSelf -> Alignment.CenterEnd +// else -> Alignment.CenterStart +// } +// ) { +// MessageText( +// modifier = Modifier +// .align(Alignment.TopStart) +// .padding(CodeTheme.dimens.grid.x2), +// text = contents.data, +// date = item.date +// ) +// } +// +// is MessageContent.Exchange -> MessageBubble( +// contents = contents, +// alignment = when { +// item.isFromSelf -> Alignment.CenterEnd +// else -> Alignment.CenterStart +// } +// ) { +// +// } +// +// is MessageContent.Localized -> MessageBubble( +// contents = contents, +// alignment = Alignment.Center +// ) { +// if (contents.isAnnouncement) { +// AnnouncementMessage( +// modifier = Modifier.align(Alignment.Center), +// text = contents.localizedText +// ) +// } else { +// MessageText( +// modifier = Modifier +// .align(Alignment.TopStart) +// .padding(CodeTheme.dimens.grid.x2), +// text = contents.localizedText, +// date = item.date +// ) +// } +// } +// +// is MessageContent.SodiumBox -> MessageBubble(contents = contents) { +// +// } +// } } else -> Unit + } } } @@ -194,7 +206,8 @@ private fun IdentityRevealHeader( text = text, style = CodeTheme.typography.button.copy( color = Color.White, - fontWeight = FontWeight.W700), + fontWeight = FontWeight.W700 + ), ) { offset -> text.getStringAnnotations( tag = "reveal", diff --git a/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt b/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt index 2b7b4fdbf..198de4d64 100644 --- a/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt @@ -6,21 +6,31 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.text2.input.TextFieldState import androidx.compose.foundation.text2.input.clearText import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData import androidx.paging.cachedIn +import androidx.paging.map +import com.getcode.R import com.getcode.model.Conversation +import com.getcode.model.ConversationMessageContent import com.getcode.model.ID import com.getcode.model.KinAmount +import com.getcode.model.MessageContent +import com.getcode.model.MessageStatus import com.getcode.model.TipMetadata import com.getcode.model.TwitterUser import com.getcode.network.ConversationController import com.getcode.solana.keys.PublicKey +import com.getcode.ui.components.chat.utils.ChatItem import com.getcode.util.CurrencyUtils import com.getcode.util.formatted import com.getcode.util.resources.ResourceHelper +import com.getcode.util.to +import com.getcode.util.toInstantFromMillis import com.getcode.view.BaseViewModel2 import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filterIsInstance @@ -140,11 +150,83 @@ class ConversationViewModel @Inject constructor( .launchIn(viewModelScope) } - val messages = stateFlow + val messages: Flow> = stateFlow .map { it.conversationId } .filterNotNull() .flatMapLatest { conversationController.conversationPagingData(it) } - .cachedIn(viewModelScope) + .map { page -> + val state = stateFlow.value + val username = state.user?.username.orEmpty() + val tipAmount = state.tipAmountFormatted.orEmpty() + + page.map { message -> + val content = when (val contents = message.content) { + ConversationMessageContent.IdentityRevealed -> { + MessageContent.Localized( + value = resources.getString( + resourceId = R.string.title_chat_announcement_identityRevealed, + username + ), + status = MessageStatus.Unknown, + isAnnouncement = true, + ) + } + ConversationMessageContent.IdentityRevealedToYou -> { + MessageContent.Localized( + value = resources.getString( + resourceId = R.string.title_chat_announcement_identityRevealedToYou, + username + ), + status = MessageStatus.Unknown, + isAnnouncement = true, + ) + } + is ConversationMessageContent.Text -> { + MessageContent.Localized( + value = contents.message, + status = contents.status, + isAnnouncement = false, + ) + } + ConversationMessageContent.ThanksReceived -> { + MessageContent.Localized( + value = resources.getString( + resourceId = R.string.title_chat_announcement_thanksReceived, + username + ), + status = MessageStatus.Unknown, + isAnnouncement = true, + ) + } + ConversationMessageContent.ThanksSent -> { + MessageContent.Localized( + value = resources.getString( + resourceId = R.string.title_chat_announcement_thanksSent, + ), + status = MessageStatus.Unknown, + isAnnouncement = true, + ) + } + ConversationMessageContent.TipMessage -> { + MessageContent.Localized( + value = resources.getString( + resourceId = R.string.title_chat_announcement_tipHeader, + tipAmount + ), + status = MessageStatus.Unknown, + isAnnouncement = true, + ) + } + } + + ChatItem.Message( + id = message.idBase58, + chatMessageId = stateFlow.value.messageId!!, + message = content, + date = message.dateMillis.toInstantFromMillis(), + ) + } + } internal companion object { From 8e915877289a34fec0062cc811539060795c4f81 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 10 May 2024 21:26:57 -0400 Subject: [PATCH 4/6] fix(history/chats): update chat messages when fetched from pager source Signed-off-by: Brandon McAnsh --- .../getcode/network/ConversationController.kt | 18 ++++++++++++--- .../com/getcode/network/HistoryController.kt | 22 +++++++++++++++++-- .../com/getcode/network/client/Client_Chat.kt | 3 ++- .../getcode/network/service/ChatService.kt | 3 ++- .../network/source/ChatMessagePagingSource.kt | 5 +++-- .../getcode/ui/components/chat/MessageNode.kt | 1 + .../getcode/view/main/chat/ChatViewModel.kt | 1 + 7 files changed, 44 insertions(+), 9 deletions(-) diff --git a/api/src/main/java/com/getcode/network/ConversationController.kt b/api/src/main/java/com/getcode/network/ConversationController.kt index 3eca248de..b054c830f 100644 --- a/api/src/main/java/com/getcode/network/ConversationController.kt +++ b/api/src/main/java/com/getcode/network/ConversationController.kt @@ -74,11 +74,23 @@ class ConversationMockController @Inject constructor( ) { conversationPagingSource(conversationId) }.flow override suspend fun createConversation(messageId: ID) { + Timber.d("creating conversation: ${messageId.base58}") val message = - historyController.chats.value?.find { it.messages.firstOrNull { it.id == messageId } != null } - ?.messages?.find { it.id == messageId } ?: return + historyController.chats.value?.find { + Timber.d("messages=${it.messages.joinToString { it.id.base58 }}") + it.messages.firstOrNull { it.id == messageId } != null + }?.messages?.find { it.id == messageId } + + if (message == null) { + Timber.e("No message for ${messageId.base58} found") + return + } - val conversation = ConversationMockProvider.createConversation(exchange, message) ?: return + val conversation = ConversationMockProvider.createConversation(exchange, message) + if (conversation == null) { + Timber.e("Failed to create conversation!") + return + } db.conversationDao().upsertConversations(conversation) diff --git a/api/src/main/java/com/getcode/network/HistoryController.kt b/api/src/main/java/com/getcode/network/HistoryController.kt index 753f840c8..671b67bde 100644 --- a/api/src/main/java/com/getcode/network/HistoryController.kt +++ b/api/src/main/java/com/getcode/network/HistoryController.kt @@ -59,7 +59,26 @@ class HistoryController @Inject constructor( private fun chatMessagePager(chatId: ID) = Pager(pagingConfig) { val chat = _chats.value?.find { it.id == chatId } - pagerMap[chatId] ?: ChatMessagePagingSource(client, owner()!!, chat).also { + pagerMap[chatId] ?: ChatMessagePagingSource( + client = client, + owner = owner()!!, + chat = chat, + onMessagesFetched = { messages -> + chat ?: return@ChatMessagePagingSource + val updatedMessages = (chat.messages + messages).distinctBy { it.id } + val updatedChat = chat.copy(messages = updatedMessages) + _chats.update { chats -> + val index = chats.orEmpty().indexOfFirst { it.id == chat.id } + if (index >= 0) { + chats.orEmpty().toMutableList().apply { + this[index] = updatedChat + }.toList() + } else { + chats + } + } + } + ).also { pagerMap[chatId] = it } } @@ -82,7 +101,6 @@ class HistoryController @Inject constructor( chatFlows.clear() val containers = fetchChatsWithoutMessages() - Timber.d("chats fetched = ${containers.count()}") _chats.value = containers val updatedWithMessages = mutableListOf() diff --git a/api/src/main/java/com/getcode/network/client/Client_Chat.kt b/api/src/main/java/com/getcode/network/client/Client_Chat.kt index 103a5b896..44f8b6462 100644 --- a/api/src/main/java/com/getcode/network/client/Client_Chat.kt +++ b/api/src/main/java/com/getcode/network/client/Client_Chat.kt @@ -8,6 +8,7 @@ import com.getcode.model.Cursor import com.getcode.model.Domain import com.getcode.model.ID import com.getcode.model.Title +import com.getcode.network.repository.base58 import com.getcode.network.repository.encodeBase64 import timber.log.Timber @@ -50,7 +51,7 @@ suspend fun Client.fetchMessagesFor(owner: KeyPair, chat: Chat, cursor: Cursor? } } .onSuccess { - Timber.d("messages fetched=${it.count()} for ${chat.id.toByteArray().encodeBase64()}") + Timber.d("messages fetched=${it.count()} for ${chat.id.base58}") Timber.d("start=${it.minOf { it.dateMillis }}, end=${it.maxOf { it.dateMillis }}") }.onFailure { Timber.e(t = it, "Failed fetching messages.") diff --git a/api/src/main/java/com/getcode/network/service/ChatService.kt b/api/src/main/java/com/getcode/network/service/ChatService.kt index 28d077b51..02da0f8d8 100644 --- a/api/src/main/java/com/getcode/network/service/ChatService.kt +++ b/api/src/main/java/com/getcode/network/service/ChatService.kt @@ -10,6 +10,7 @@ import com.getcode.model.Cursor import com.getcode.model.ID import com.getcode.network.api.ChatApi import com.getcode.network.core.NetworkOracle +import com.getcode.network.repository.base58 import com.getcode.utils.ErrorUtils import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -162,7 +163,7 @@ class ChatService @Inject constructor( } ChatService.GetMessagesResponse.Result.NOT_FOUND -> { - val error = Throwable("Error: messages not found for chat $chatId") + val error = Throwable("Error: messages not found for chat ${chatId.base58}") Timber.e(t = error) Result.failure(error) } diff --git a/api/src/main/java/com/getcode/network/source/ChatMessagePagingSource.kt b/api/src/main/java/com/getcode/network/source/ChatMessagePagingSource.kt index ac8a53dba..546be613a 100644 --- a/api/src/main/java/com/getcode/network/source/ChatMessagePagingSource.kt +++ b/api/src/main/java/com/getcode/network/source/ChatMessagePagingSource.kt @@ -14,11 +14,11 @@ class ChatMessagePagingSource( private val client: Client, private val owner: KeyPair, private val chat: Chat?, + private val onMessagesFetched: (List) -> Unit, ) : PagingSource() { override suspend fun load( params: LoadParams ): LoadResult { - // Start refresh at page 1 if undefined. val nextCursor = params.key chat ?: return LoadResult.Error(Throwable("Chat not found")) @@ -29,10 +29,11 @@ class ChatMessagePagingSource( } val messages = response.getOrDefault(emptyList()) + onMessagesFetched(messages) return LoadResult.Page( data = messages, prevKey = null, // Only paging forward. - nextKey = messages.last().cursor + nextKey = messages.lastOrNull()?.cursor ) } diff --git a/app/src/main/java/com/getcode/ui/components/chat/MessageNode.kt b/app/src/main/java/com/getcode/ui/components/chat/MessageNode.kt index 127bd1f2f..eee050117 100644 --- a/app/src/main/java/com/getcode/ui/components/chat/MessageNode.kt +++ b/app/src/main/java/com/getcode/ui/components/chat/MessageNode.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.CornerBasedShape import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape diff --git a/app/src/main/java/com/getcode/view/main/chat/ChatViewModel.kt b/app/src/main/java/com/getcode/view/main/chat/ChatViewModel.kt index 88483f89c..8642c3ece 100644 --- a/app/src/main/java/com/getcode/view/main/chat/ChatViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/chat/ChatViewModel.kt @@ -85,6 +85,7 @@ class ChatViewModel @Inject constructor( init { stateFlow .map { it.chatId } + .onEach { Timber.d("chatid=${it?.base58}") } .filterNotNull() .onEach { historyController.advanceReadPointer(it) } .flatMapLatest { historyController.chats } From 4a62c11870def35f5bd13ead3bc0b58447e4e0a1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 10 May 2024 23:28:33 -0400 Subject: [PATCH 5/6] feat: create shared MessageList component; align Exchange messages based on sender Signed-off-by: Brandon McAnsh --- api/src/main/java/com/getcode/model/Chat.kt | 5 +- .../{conversation => chat}/ChatInput.kt | 2 +- .../getcode/ui/components/chat/DateBubble.kt | 20 ++++ .../ui/components/chat/DateWithStatus.kt | 7 +- .../getcode/ui/components/chat/MessageList.kt | 103 +++++++++++++++++ .../getcode/ui/components/chat/MessageNode.kt | 11 +- .../ui/components/chat/MessageTextContent.kt | 5 +- .../ui/components/chat/TipChatActions.kt | 2 + .../utils/HandleMessageChanges.kt | 5 +- .../com/getcode/view/main/chat/ChatScreen.kt | 107 +++--------------- .../conversation/ChatConversationScreen.kt | 93 ++------------- 11 files changed, 165 insertions(+), 195 deletions(-) rename app/src/main/java/com/getcode/ui/components/{conversation => chat}/ChatInput.kt (98%) create mode 100644 app/src/main/java/com/getcode/ui/components/chat/DateBubble.kt create mode 100644 app/src/main/java/com/getcode/ui/components/chat/MessageList.kt rename app/src/main/java/com/getcode/ui/components/{conversation => chat}/utils/HandleMessageChanges.kt (94%) diff --git a/api/src/main/java/com/getcode/model/Chat.kt b/api/src/main/java/com/getcode/model/Chat.kt index 079f75c5a..c7f3e5482 100644 --- a/api/src/main/java/com/getcode/model/Chat.kt +++ b/api/src/main/java/com/getcode/model/Chat.kt @@ -227,6 +227,7 @@ sealed interface MessageContent { Content.TypeCase.LOCALIZED -> Localized(proto.localized.keyOrText) Content.TypeCase.EXCHANGE_DATA -> { val verb = Verb(proto.exchangeData.verb) + val messageStatus = if (verb.increasesBalance) MessageStatus.Incoming else MessageStatus.Delivered when (proto.exchangeData.exchangeDataCase) { ChatService.ExchangeDataContent.ExchangeDataCase.EXACT -> { val exact = proto.exchangeData.exact @@ -239,7 +240,7 @@ sealed interface MessageContent { ) ) - Exchange(GenericAmount.Exact(kinAmount), verb) + Exchange(GenericAmount.Exact(kinAmount), verb, status = messageStatus) } ChatService.ExchangeDataContent.ExchangeDataCase.PARTIAL -> { @@ -251,7 +252,7 @@ sealed interface MessageContent { amount = partial.nativeAmount ) - Exchange(GenericAmount.Partial(fiat), verb) + Exchange(GenericAmount.Partial(fiat), verb, status = messageStatus) } ChatService.ExchangeDataContent.ExchangeDataCase.EXCHANGEDATA_NOT_SET -> return null diff --git a/app/src/main/java/com/getcode/ui/components/conversation/ChatInput.kt b/app/src/main/java/com/getcode/ui/components/chat/ChatInput.kt similarity index 98% rename from app/src/main/java/com/getcode/ui/components/conversation/ChatInput.kt rename to app/src/main/java/com/getcode/ui/components/chat/ChatInput.kt index d938250f7..1a8bca049 100644 --- a/app/src/main/java/com/getcode/ui/components/conversation/ChatInput.kt +++ b/app/src/main/java/com/getcode/ui/components/chat/ChatInput.kt @@ -1,4 +1,4 @@ -package com.getcode.ui.components.conversation +package com.getcode.ui.components.chat import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentTransitionScope diff --git a/app/src/main/java/com/getcode/ui/components/chat/DateBubble.kt b/app/src/main/java/com/getcode/ui/components/chat/DateBubble.kt new file mode 100644 index 000000000..88a97ef55 --- /dev/null +++ b/app/src/main/java/com/getcode/ui/components/chat/DateBubble.kt @@ -0,0 +1,20 @@ +package com.getcode.ui.components.chat + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.getcode.theme.BrandDark +import com.getcode.ui.components.Pill + +@Composable +internal fun DateBubble( + modifier: Modifier = Modifier, + date: String, +) = Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Pill( + text = date, + backgroundColor = BrandDark + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/chat/DateWithStatus.kt b/app/src/main/java/com/getcode/ui/components/chat/DateWithStatus.kt index 11085e6bd..6e50f9291 100644 --- a/app/src/main/java/com/getcode/ui/components/chat/DateWithStatus.kt +++ b/app/src/main/java/com/getcode/ui/components/chat/DateWithStatus.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.unit.Dp import com.getcode.R import com.getcode.model.MessageStatus import com.getcode.theme.BrandLight +import com.getcode.theme.ChatOutgoing import com.getcode.theme.CodeTheme import com.getcode.util.formatTimeRelatively import kotlinx.datetime.Clock @@ -83,7 +84,7 @@ private fun Preview_DateWithStatus() { modifier = Modifier .wrapContentWidth() .background( - color = Color(0xFF443091), + color = ChatOutgoing, shape = MessageNodeDefaults.DefaultShape ) .padding(CodeTheme.dimens.grid.x2) @@ -94,7 +95,7 @@ private fun Preview_DateWithStatus() { modifier = Modifier .wrapContentWidth() .background( - color = Color(0xFF443091), + color = ChatOutgoing, shape = MessageNodeDefaults.DefaultShape ) .padding(CodeTheme.dimens.grid.x2) @@ -105,7 +106,7 @@ private fun Preview_DateWithStatus() { modifier = Modifier .wrapContentWidth() .background( - color = Color(0xFF443091), + color = ChatOutgoing, shape = MessageNodeDefaults.DefaultShape ) .padding(CodeTheme.dimens.grid.x2) diff --git a/app/src/main/java/com/getcode/ui/components/chat/MessageList.kt b/app/src/main/java/com/getcode/ui/components/chat/MessageList.kt new file mode 100644 index 000000000..09950b276 --- /dev/null +++ b/app/src/main/java/com/getcode/ui/components/chat/MessageList.kt @@ -0,0 +1,103 @@ +package com.getcode.ui.components.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.itemContentType +import androidx.paging.compose.itemKey +import com.getcode.model.ID +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.chat.utils.ChatItem +import com.getcode.util.formatDateRelatively + +sealed interface MessageListEvent { + data class ThankUser(val messageId: ID): MessageListEvent + data class OpenMessageChat(val messageId: ID): MessageListEvent +} +@Composable +fun MessageList( + modifier: Modifier = Modifier, + listState: LazyListState = rememberLazyListState(), + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + messages: LazyPagingItems, + dispatch: (MessageListEvent) -> Unit = { }, +) { + LazyColumn( + modifier = modifier, + state = listState, + reverseLayout = true, + + contentPadding = PaddingValues( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.inset, + ), + verticalArrangement = verticalArrangement, + ) { + items( + count = messages.itemCount, + key = messages.itemKey { item -> item.key }, + contentType = messages.itemContentType { item -> + when (item) { + is ChatItem.Date -> "separators" + is ChatItem.Message -> "messages" + } + } + ) { index -> + when (val item = messages[index]) { + is ChatItem.Date -> DateBubble( + modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x2), + date = item.date + ) + is ChatItem.Message -> { + // reverse layout so +1 to get previous + val prev = runCatching { messages[index + 1] } + .map { it as? ChatItem.Message } + .map { it?.chatMessageId } + .getOrNull() + // reverse layout so -1 to get next + val next = runCatching { messages[index - 1] } + .map { it as? ChatItem.Message } + .map { it?.chatMessageId } + .getOrNull() + + MessageNode( + modifier = Modifier.fillMaxWidth(), + contents = item.message, + date = item.date, + isPreviousSameMessage = prev == item.chatMessageId, + isNextSameMessage = next == item.chatMessageId, + thankUser = { dispatch(MessageListEvent.ThankUser(item.chatMessageId)) }, + openMessageChat = { dispatch(MessageListEvent.OpenMessageChat(item.chatMessageId)) } + ) + } + + else -> Unit + } + } + // add last separator + // this isn't handled by paging separators due to no `beforeItem` to reference against + // at end of list due to reverseLayout + if (messages.itemCount > 0) { + (messages[messages.itemCount - 1] as? ChatItem.Message)?.date?.let { date -> + item { + val dateString = remember(date) { + date.formatDateRelatively() + } + DateBubble( + modifier = Modifier.padding(bottom = CodeTheme.dimens.grid.x2), + date = dateString + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/chat/MessageNode.kt b/app/src/main/java/com/getcode/ui/components/chat/MessageNode.kt index eee050117..6bdb3be1f 100644 --- a/app/src/main/java/com/getcode/ui/components/chat/MessageNode.kt +++ b/app/src/main/java/com/getcode/ui/components/chat/MessageNode.kt @@ -3,9 +3,11 @@ package com.getcode.ui.components.chat import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.CornerBasedShape import androidx.compose.foundation.shape.CornerSize @@ -61,7 +63,7 @@ fun MessageNode( thankUser: () -> Unit = { }, openMessageChat: () -> Unit = { }, ) { - Box( + BoxWithConstraints( modifier = modifier .padding(vertical = CodeTheme.dimens.grid.x1) ) { @@ -78,7 +80,8 @@ fun MessageNode( is MessageContent.Exchange -> { MessagePayment( modifier = Modifier - .fillMaxWidth(0.895f) + .align(if (contents.verb.increasesBalance) Alignment.CenterStart else Alignment.CenterEnd) + .widthIn(max = maxWidth * 0.75f) .background( color = color, shape = when { @@ -92,6 +95,7 @@ fun MessageNode( contents = contents, showTipActions = showTipActions, thankUser = thankUser, + status = contents.status, date = date, openMessageChat = openMessageChat ) @@ -120,7 +124,8 @@ fun MessageNode( is MessageContent.SodiumBox -> { EncryptedContent( modifier = Modifier - .fillMaxWidth(0.895f) + .align(if (contents.status.isOutgoing()) Alignment.CenterEnd else Alignment.CenterStart) + .widthIn(max = maxWidth * 0.75f) .background( color = color, shape = when { diff --git a/app/src/main/java/com/getcode/ui/components/chat/MessageTextContent.kt b/app/src/main/java/com/getcode/ui/components/chat/MessageTextContent.kt index ee539d230..0eba47bc8 100644 --- a/app/src/main/java/com/getcode/ui/components/chat/MessageTextContent.kt +++ b/app/src/main/java/com/getcode/ui/components/chat/MessageTextContent.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.rememberTextMeasurer import com.getcode.model.MessageStatus import com.getcode.theme.BrandDark +import com.getcode.theme.ChatOutgoing import com.getcode.theme.CodeTheme import com.getcode.util.formatDateRelatively import kotlinx.datetime.Instant @@ -38,12 +39,12 @@ fun MessageText( status: MessageStatus = MessageStatus.Unknown, ) { val alignment = if (isFromSelf) Alignment.CenterEnd else Alignment.CenterStart - val color = if (isFromSelf) Color(0xFF443091) else BrandDark + val color = if (isFromSelf) ChatOutgoing else BrandDark BoxWithConstraints(modifier = modifier.fillMaxWidth(), contentAlignment = alignment) { BoxWithConstraints( modifier = Modifier - .widthIn(max = maxWidth * 0.895f) + .widthIn(max = maxWidth * 0.75f) .background( color = color, shape = MessageNodeDefaults.DefaultShape diff --git a/app/src/main/java/com/getcode/ui/components/chat/TipChatActions.kt b/app/src/main/java/com/getcode/ui/components/chat/TipChatActions.kt index 97cbaa29c..be3fe547c 100644 --- a/app/src/main/java/com/getcode/ui/components/chat/TipChatActions.kt +++ b/app/src/main/java/com/getcode/ui/components/chat/TipChatActions.kt @@ -36,6 +36,7 @@ internal fun TipChatActions( thankUser() } + if (showTipActions) { if (tipChatsEnabled && contents.verb is Verb.ReceivedTip) { Row( verticalAlignment = Alignment.CenterVertically, @@ -60,4 +61,5 @@ internal fun TipChatActions( ) } } + } } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/conversation/utils/HandleMessageChanges.kt b/app/src/main/java/com/getcode/ui/components/chat/utils/HandleMessageChanges.kt similarity index 94% rename from app/src/main/java/com/getcode/ui/components/conversation/utils/HandleMessageChanges.kt rename to app/src/main/java/com/getcode/ui/components/chat/utils/HandleMessageChanges.kt index 6a08764d1..e6df473a1 100644 --- a/app/src/main/java/com/getcode/ui/components/conversation/utils/HandleMessageChanges.kt +++ b/app/src/main/java/com/getcode/ui/components/chat/utils/HandleMessageChanges.kt @@ -1,4 +1,4 @@ -package com.getcode.ui.components.conversation.utils +package com.getcode.ui.components.chat.utils import android.os.Build import androidx.compose.foundation.lazy.LazyListState @@ -10,10 +10,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.paging.compose.LazyPagingItems -import com.getcode.model.ConversationMessage -import com.getcode.model.ConversationMessageContent import com.getcode.model.MessageContent -import com.getcode.ui.components.chat.utils.ChatItem import com.getcode.ui.utils.isScrolledToTheBeginning import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChangedBy diff --git a/app/src/main/java/com/getcode/view/main/chat/ChatScreen.kt b/app/src/main/java/com/getcode/view/main/chat/ChatScreen.kt index 61531c6f7..7a2b9a2a7 100644 --- a/app/src/main/java/com/getcode/view/main/chat/ChatScreen.kt +++ b/app/src/main/java/com/getcode/view/main/chat/ChatScreen.kt @@ -1,38 +1,26 @@ package com.getcode.view.main.chat -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.PaddingValues 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.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.paging.compose.LazyPagingItems -import androidx.paging.compose.itemContentType -import androidx.paging.compose.itemKey import com.getcode.R import com.getcode.manager.BottomBarManager -import com.getcode.theme.BrandDark -import com.getcode.theme.CodeTheme -import com.getcode.util.formatDateRelatively import com.getcode.ui.components.ButtonState import com.getcode.ui.components.CodeButton -import com.getcode.ui.components.Pill import com.getcode.ui.components.Row import com.getcode.ui.components.VerticalDivider -import com.getcode.ui.components.chat.MessageNode +import com.getcode.ui.components.chat.MessageList +import com.getcode.ui.components.chat.MessageListEvent import com.getcode.ui.components.chat.localized import com.getcode.ui.components.chat.utils.ChatItem import com.getcode.ui.utils.withTopBorder @@ -45,78 +33,21 @@ fun ChatScreen( ) { val listState = rememberLazyListState() + val context = LocalContext.current + val title = state.title.localized + Column(modifier = Modifier.fillMaxSize()) { - LazyColumn( + MessageList( modifier = Modifier.weight(1f), - state = listState, - reverseLayout = true, - contentPadding = PaddingValues( - horizontal = CodeTheme.dimens.inset, - vertical = CodeTheme.dimens.inset, - ), - verticalArrangement = Arrangement.Top, - ) { - items( - count = messages.itemCount, - key = messages.itemKey { item -> item.key }, - contentType = messages.itemContentType { item -> - when (item) { - is ChatItem.Date -> "separators" - is ChatItem.Message -> "messages" - } - } - ) { index -> - when (val item = messages[index]) { - is ChatItem.Date -> DateBubble( - modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x2), - date = item.date - ) - is ChatItem.Message -> { - // reverse layout so +1 to get previous - val prev = runCatching { messages[index + 1] } - .map { it as? ChatItem.Message } - .map { it?.chatMessageId } - .getOrNull() - // reverse layout so -1 to get next - val next = runCatching { messages[index - 1] } - .map { it as? ChatItem.Message } - .map { it?.chatMessageId } - .getOrNull() - - MessageNode( - modifier = Modifier.fillMaxWidth(), - contents = item.message, - date = item.date, - isPreviousSameMessage = prev == item.chatMessageId, - isNextSameMessage = next == item.chatMessageId, - thankUser = { dispatch(ChatViewModel.Event.ThankUser(item.chatMessageId)) }, - openMessageChat = { dispatch(ChatViewModel.Event.OpenMessageChat(item.chatMessageId)) } - ) - } - - else -> Unit + listState = listState, + messages = messages, + dispatch = { + when (it) { + is MessageListEvent.OpenMessageChat -> dispatch(ChatViewModel.Event.OpenMessageChat(it.messageId)) + is MessageListEvent.ThankUser -> dispatch(ChatViewModel.Event.ThankUser(it.messageId)) } } - // add last separator - // this isn't handled by paging separators due to no `beforeItem` to reference against - // at end of list due to reverseLayout - if (messages.itemCount > 0) { - (messages[messages.itemCount - 1] as? ChatItem.Message)?.date?.let { date -> - item { - val dateString = remember(date) { - date.formatDateRelatively() - } - DateBubble( - modifier = Modifier.padding(bottom = CodeTheme.dimens.grid.x2), - date = dateString - ) - } - } - } - } - - val context = LocalContext.current - val title = state.title.localized + ) Row( modifier = Modifier @@ -182,16 +113,4 @@ fun ChatScreen( } } } -} - - -@Composable -private fun DateBubble( - modifier: Modifier = Modifier, - date: String, -) = Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - Pill( - text = date, - backgroundColor = BrandDark - ) } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/main/chat/conversation/ChatConversationScreen.kt b/app/src/main/java/com/getcode/view/main/chat/conversation/ChatConversationScreen.kt index 11a798341..244c575aa 100644 --- a/app/src/main/java/com/getcode/view/main/chat/conversation/ChatConversationScreen.kt +++ b/app/src/main/java/com/getcode/view/main/chat/conversation/ChatConversationScreen.kt @@ -36,8 +36,9 @@ import com.getcode.theme.CodeTheme import com.getcode.ui.components.CodeScaffold import com.getcode.ui.components.chat.MessageNode import com.getcode.ui.components.chat.utils.ChatItem -import com.getcode.ui.components.conversation.ChatInput -import com.getcode.ui.components.conversation.utils.HandleMessageChanges +import com.getcode.ui.components.chat.ChatInput +import com.getcode.ui.components.chat.MessageList +import com.getcode.ui.components.chat.utils.HandleMessageChanges import kotlinx.coroutines.delay @Composable @@ -64,94 +65,14 @@ fun ChatConversationScreen( } ) { padding -> val lazyListState = rememberLazyListState() - LazyColumn( + MessageList( modifier = Modifier .fillMaxSize() .padding(padding), - state = lazyListState, - reverseLayout = true, - contentPadding = PaddingValues( - horizontal = CodeTheme.dimens.inset, - vertical = CodeTheme.dimens.inset, - ), + messages = messages, + listState = lazyListState, verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3, Alignment.Top), - ) { - items( - count = messages.itemCount, - key = messages.itemKey { item -> item.key }, - ) { index -> - when (val item = messages[index]) { - is ChatItem.Date -> { - - } - - is ChatItem.Message -> { - MessageNode( - modifier = Modifier.fillMaxWidth(), - contents = item.message, - date = item.date, - isPreviousSameMessage = false, - isNextSameMessage = false, - showTipActions = false, - ) -// when (val contents = item.message) { -// is MessageContent.Decrypted -> MessageBubble( -// contents = contents, -// alignment = when { -// item.isFromSelf -> Alignment.CenterEnd -// else -> Alignment.CenterStart -// } -// ) { -// MessageText( -// modifier = Modifier -// .align(Alignment.TopStart) -// .padding(CodeTheme.dimens.grid.x2), -// text = contents.data, -// date = item.date -// ) -// } -// -// is MessageContent.Exchange -> MessageBubble( -// contents = contents, -// alignment = when { -// item.isFromSelf -> Alignment.CenterEnd -// else -> Alignment.CenterStart -// } -// ) { -// -// } -// -// is MessageContent.Localized -> MessageBubble( -// contents = contents, -// alignment = Alignment.Center -// ) { -// if (contents.isAnnouncement) { -// AnnouncementMessage( -// modifier = Modifier.align(Alignment.Center), -// text = contents.localizedText -// ) -// } else { -// MessageText( -// modifier = Modifier -// .align(Alignment.TopStart) -// .padding(CodeTheme.dimens.grid.x2), -// text = contents.localizedText, -// date = item.date -// ) -// } -// } -// -// is MessageContent.SodiumBox -> MessageBubble(contents = contents) { -// -// } -// } - } - - else -> Unit - - } - } - } + ) HandleMessageChanges(listState = lazyListState, items = messages) } From f29b9439e1417fbd510695116bb17d8cdd43fee0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 11 May 2024 19:33:38 -0400 Subject: [PATCH 6/6] feat: add send tip within tip chat UI Signed-off-by: Brandon McAnsh --- .../com.getcode.db.AppDatabase/10.json | 14 +-- .../com/getcode/analytics/AnalyticsManager.kt | 1 + .../java/com/getcode/db/ConversationDao.kt | 46 +++++----- .../com/getcode/mapper/ConversationMapper.kt | 39 +++++++++ .../java/com/getcode/model/Conversation.kt | 6 +- .../main/java/com/getcode/model/Feature.kt | 10 +++ .../main/java/com/getcode/model/PrefBool.kt | 1 + .../getcode/network/ConversationController.kt | 4 +- .../network/repository/BetaFlagsRepository.kt | 8 +- .../network/repository/FeatureRepository.kt | 5 ++ .../source/ConversationPagingSource.kt | 20 ++--- .../getcode/navigation/screens/ChatScreens.kt | 12 ++- .../navigation/screens/ModalScreens.kt | 40 ++++++--- .../com/getcode/navigation/screens/Modals.kt | 8 +- .../com/getcode/navigation/screens/Screens.kt | 6 +- .../getcode/ui/components/chat/ChatInput.kt | 41 +++++++-- .../view/main/account/BetaFlagsScreen.kt | 11 ++- .../view/main/account/BetaFlagsViewModel.kt | 85 +++++++++++++++---- .../conversation/ChatConversationScreen.kt | 9 +- .../conversation/ConversationViewModel.kt | 42 ++++++--- .../com/getcode/view/main/home/HomeScan.kt | 2 +- .../getcode/view/main/tip/EnterTipScreen.kt | 5 +- app/src/main/res/values/strings-universal.xml | 2 + app/src/main/res/values/strings.xml | 2 + 24 files changed, 301 insertions(+), 118 deletions(-) create mode 100644 api/src/main/java/com/getcode/mapper/ConversationMapper.kt diff --git a/api/schemas/com.getcode.db.AppDatabase/10.json b/api/schemas/com.getcode.db.AppDatabase/10.json index e97511fb5..a089215e8 100644 --- a/api/schemas/com.getcode.db.AppDatabase/10.json +++ b/api/schemas/com.getcode.db.AppDatabase/10.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 10, - "identityHash": "d5660e6875cc3237f2282f65ccd11b66", + "identityHash": "f2095500e8b757e790d9d45cc1820bb3", "entities": [ { "tableName": "CurrencyRate", @@ -238,14 +238,8 @@ }, { "tableName": "conversations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `messageIdBase58` TEXT NOT NULL, `cursorBase58` TEXT NOT NULL, `tipAmount` TEXT NOT NULL, `createdByUser` INTEGER NOT NULL, `hasRevealedIdentity` INTEGER NOT NULL, `user` TEXT, `userImage` TEXT, `lastActivity` INTEGER, PRIMARY KEY(`idBase58`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageIdBase58` TEXT NOT NULL, `cursorBase58` TEXT NOT NULL, `tipAmount` TEXT NOT NULL, `createdByUser` INTEGER NOT NULL, `hasRevealedIdentity` INTEGER NOT NULL, `user` TEXT, `userImage` TEXT, `lastActivity` INTEGER, PRIMARY KEY(`messageIdBase58`))", "fields": [ - { - "fieldPath": "idBase58", - "columnName": "idBase58", - "affinity": "TEXT", - "notNull": true - }, { "fieldPath": "messageIdBase58", "columnName": "messageIdBase58", @@ -298,7 +292,7 @@ "primaryKey": { "autoGenerate": false, "columnNames": [ - "idBase58" + "messageIdBase58" ] }, "indices": [], @@ -384,7 +378,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd5660e6875cc3237f2282f65ccd11b66')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f2095500e8b757e790d9d45cc1820bb3')" ] } } \ No newline at end of file diff --git a/api/src/main/java/com/getcode/analytics/AnalyticsManager.kt b/api/src/main/java/com/getcode/analytics/AnalyticsManager.kt index 511634e63..4942466bd 100644 --- a/api/src/main/java/com/getcode/analytics/AnalyticsManager.kt +++ b/api/src/main/java/com/getcode/analytics/AnalyticsManager.kt @@ -334,6 +334,7 @@ class AnalyticsManager @Inject constructor( Debug("Debug Screen"), ForceUpgrade("Force Upgrade"), BuyMoreKin("Buy More Kin Screen"), + SendKin("Send Kin Screen"), } enum class BillPresentationStyle(val value: String) { diff --git a/api/src/main/java/com/getcode/db/ConversationDao.kt b/api/src/main/java/com/getcode/db/ConversationDao.kt index a6a71dac9..7c125ea83 100644 --- a/api/src/main/java/com/getcode/db/ConversationDao.kt +++ b/api/src/main/java/com/getcode/db/ConversationDao.kt @@ -18,18 +18,18 @@ interface ConversationDao { suspend fun upsertConversations(vararg conversation: Conversation) @Transaction - @Query("SELECT * FROM conversations WHERE idBase58 = :id") + @Query("SELECT * FROM conversations WHERE messageIdBase58 = :id") fun observeConversationWithMessages(id: String): Flow - fun observeConversationWithMessages(id: ID): Flow { - return observeConversationWithMessages(id.base58) + fun observeConversationWithMessages(messageId: ID): Flow { + return observeConversationWithMessages(messageId.base58) } - @Query("SELECT * FROM conversations WHERE idBase58 = :conversationId") - fun observeConversation(conversationId: String): Flow + @Query("SELECT * FROM conversations WHERE messageIdBase58 = :messageId") + fun observeConversation(messageId: String): Flow - fun observeConversation(conversationId: ID): Flow { - return observeConversation(conversationId.base58) + fun observeConversation(messageId: ID): Flow { + return observeConversation(messageId.base58) } @Query("SELECT * FROM conversations WHERE messageIdBase58 = :messageId") @@ -39,11 +39,11 @@ interface ConversationDao { return observeConversationForMessage(messageId.base58) } - @Query("SELECT * FROM conversations WHERE idBase58 = :conversationId") - suspend fun findConversation(conversationId: String): Conversation? + @Query("SELECT * FROM conversations WHERE messageIdBase58 = :messageId") + suspend fun findConversation(messageId: String): Conversation? - suspend fun findConversation(conversationId: ID): Conversation? { - return findConversation(conversationId.base58) + suspend fun findConversation(messageId: ID): Conversation? { + return findConversation(messageId.base58) } @Query("SELECT * FROM conversations WHERE messageIdBase58 = :messageId") @@ -56,25 +56,25 @@ interface ConversationDao { @Query("SELECT * FROM conversations") suspend fun queryConversations(): List - @Query("SELECT EXISTS (SELECT * FROM messages WHERE conversationIdBase58 = :conversationId AND content LIKE '%1|%')") - suspend fun hasTipMessage(conversationId: String): Boolean + @Query("SELECT EXISTS (SELECT * FROM messages WHERE conversationIdBase58 = :messageId AND content LIKE '%1|%')") + suspend fun hasTipMessage(messageId: String): Boolean - suspend fun hasTipMessage(conversationId: ID): Boolean { - return hasTipMessage(conversationId.base58) + suspend fun hasTipMessage(messageId: ID): Boolean { + return hasTipMessage(messageId.base58) } - @Query("SELECT EXISTS (SELECT * FROM messages WHERE conversationIdBase58 = :conversationId AND content LIKE '%2|%')") - suspend fun hasThanked(conversationId: String): Boolean + @Query("SELECT EXISTS (SELECT * FROM messages WHERE conversationIdBase58 = :messageId AND content LIKE '%2|%')") + suspend fun hasThanked(messageId: String): Boolean - suspend fun hasThanked(conversationId: ID): Boolean { - return hasThanked(conversationId.base58) + suspend fun hasThanked(messageId: ID): Boolean { + return hasThanked(messageId.base58) } - @Query("SELECT EXISTS (SELECT * FROM messages WHERE conversationIdBase58 = :conversationId AND content LIKE '%4|%')") - suspend fun hasRevealedIdentity(conversationId: String): Boolean + @Query("SELECT EXISTS (SELECT * FROM messages WHERE conversationIdBase58 = :messageId AND content LIKE '%4|%')") + suspend fun hasRevealedIdentity(messageId: String): Boolean - suspend fun hasRevealedIdentity(conversationId: ID): Boolean { - return hasRevealedIdentity(conversationId.base58) + suspend fun hasRevealedIdentity(messageId: ID): Boolean { + return hasRevealedIdentity(messageId.base58) } @Query("DELETE FROM conversations") diff --git a/api/src/main/java/com/getcode/mapper/ConversationMapper.kt b/api/src/main/java/com/getcode/mapper/ConversationMapper.kt new file mode 100644 index 000000000..35a971522 --- /dev/null +++ b/api/src/main/java/com/getcode/mapper/ConversationMapper.kt @@ -0,0 +1,39 @@ +package com.getcode.mapper + +import com.getcode.model.ChatMessage +import com.getcode.model.Conversation +import com.getcode.model.KinAmount +import com.getcode.model.MessageContent +import com.getcode.model.Rate +import com.getcode.model.orOneToOne +import com.getcode.network.exchange.Exchange +import com.getcode.network.repository.base58 +import javax.inject.Inject + +class ConversationMapper @Inject constructor( + private val exchange: Exchange, +) : Mapper { + override fun map(from: ChatMessage): Conversation { + val exchangeMessage = from.contents.firstOrNull { + it is MessageContent.Exchange + } as? MessageContent.Exchange + + val tipAmount = if (exchangeMessage != null) { + val rate = exchange.rateFor(exchangeMessage.amount.currencyCode).orOneToOne() + exchangeMessage.amount.amountUsing(rate) + } else { + KinAmount.newInstance(0, Rate.oneToOne) + } + + return Conversation( + messageIdBase58 = from.id.base58, + cursorBase58 = from.cursor.base58, + tipAmount = tipAmount, + createdByUser = false, // TODO: ? + hasRevealedIdentity = false, // TODO: ? + lastActivity = null, + user = null, // TODO: ? + userImage = null, + ) + } +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/Conversation.kt b/api/src/main/java/com/getcode/model/Conversation.kt index 9cbc850ef..88d0abdc5 100644 --- a/api/src/main/java/com/getcode/model/Conversation.kt +++ b/api/src/main/java/com/getcode/model/Conversation.kt @@ -19,7 +19,6 @@ import kotlinx.serialization.modules.SerializersModule @Entity(tableName = "conversations") data class Conversation( @PrimaryKey - val idBase58: String, val messageIdBase58: String, val cursorBase58: String, val tipAmount: KinAmount, @@ -29,8 +28,6 @@ data class Conversation( val userImage: String?, val lastActivity: Long?, ) { - @Ignore - val id: ID = Base58.decode(idBase58).toList() @Ignore val messageId: ID = Base58.decode(messageIdBase58).toList() @Ignore @@ -39,7 +36,6 @@ data class Conversation( override fun toString(): String { return """ { - id:${idBase58}, messageId:${messageIdBase58}, tipAmount:$tipAmount, createByUser:$createdByUser, @@ -72,7 +68,7 @@ data class ConversationMessage( data class ConversationWithMessages( @Embedded val user: Conversation, @Relation( - parentColumn = "idBase58", + parentColumn = "messageIdBase58", entityColumn = "conversationIdBase58" ) val messages: List, diff --git a/api/src/main/java/com/getcode/model/Feature.kt b/api/src/main/java/com/getcode/model/Feature.kt index fdc9dd8db..fec7fe36e 100644 --- a/api/src/main/java/com/getcode/model/Feature.kt +++ b/api/src/main/java/com/getcode/model/Feature.kt @@ -15,6 +15,16 @@ data class TipCardFeature( override val available: Boolean = true, // always enabled ): Feature +data class TipChatFeature( + override val enabled: Boolean = false, + override val available: Boolean = true, // always enabled +): Feature + +data class TipChatCashFeature( + override val enabled: Boolean = false, + override val available: Boolean = true, // always enabled +): Feature + data class RequestKinFeature( override val enabled: Boolean = false, diff --git a/api/src/main/java/com/getcode/model/PrefBool.kt b/api/src/main/java/com/getcode/model/PrefBool.kt index 08ff0f7a1..8c49cb6c2 100644 --- a/api/src/main/java/com/getcode/model/PrefBool.kt +++ b/api/src/main/java/com/getcode/model/PrefBool.kt @@ -37,4 +37,5 @@ sealed class PrefsBool(val value: String) { data object CHAT_UNSUB_ENABLED: PrefsBool("chat_unsub_enabled"), BetaFlag data object TIPS_ENABLED : PrefsBool("tips_enabled"), BetaFlag data object TIPS_CHAT_ENABLED: PrefsBool("tips_chat_enabled"), BetaFlag + data object TIPS_CHAT_CASH_ENABLED: PrefsBool("tips_chat_cash_enabled"), BetaFlag } \ No newline at end of file diff --git a/api/src/main/java/com/getcode/network/ConversationController.kt b/api/src/main/java/com/getcode/network/ConversationController.kt index b054c830f..af0313ad6 100644 --- a/api/src/main/java/com/getcode/network/ConversationController.kt +++ b/api/src/main/java/com/getcode/network/ConversationController.kt @@ -95,7 +95,7 @@ class ConversationMockController @Inject constructor( db.conversationDao().upsertConversations(conversation) val tipMessage = ConversationMockProvider.createMessage( - conversation.id, + conversation.messageId, ConversationMessageContent.TipMessage ) @@ -105,7 +105,7 @@ class ConversationMockController @Inject constructor( override suspend fun hasThanked(messageId: ID): Boolean { val conversation = db.conversationDao().findConversationForMessage(messageId) ?: return false - return db.conversationDao().hasThanked(conversation.id) + return db.conversationDao().hasThanked(conversation.messageId) } override suspend fun thankTipper(messageId: ID) { diff --git a/api/src/main/java/com/getcode/network/repository/BetaFlagsRepository.kt b/api/src/main/java/com/getcode/network/repository/BetaFlagsRepository.kt index dd78a0f58..f90d5881e 100644 --- a/api/src/main/java/com/getcode/network/repository/BetaFlagsRepository.kt +++ b/api/src/main/java/com/getcode/network/repository/BetaFlagsRepository.kt @@ -18,6 +18,7 @@ data class BetaOptions( val chatUnsubEnabled: Boolean, val tipsEnabled: Boolean, val tipsChatEnabled: Boolean, + val tipsChatCashEnabled: Boolean, ) { companion object { // Default states for various beta flags in app. @@ -32,7 +33,8 @@ data class BetaOptions( establishCodeRelationship = false, chatUnsubEnabled = false, tipsEnabled = false, - tipsChatEnabled = false + tipsChatEnabled = false, + tipsChatCashEnabled = false ) } } @@ -63,8 +65,9 @@ class BetaFlagsRepository @Inject constructor( observeBetaFlag(PrefsBool.CHAT_UNSUB_ENABLED, default = defaults.chatUnsubEnabled), observeBetaFlag(PrefsBool.TIPS_ENABLED, default = defaults.tipsEnabled), observeBetaFlag(PrefsBool.TIPS_CHAT_ENABLED, default = defaults.tipsChatEnabled), + observeBetaFlag(PrefsBool.TIPS_CHAT_CASH_ENABLED, default = defaults.tipsChatCashEnabled), observeBetaFlag(PrefsBool.DISPLAY_ERRORS, default = defaults.displayErrors), - ) { network, buckets, vibez, times, giveRequests, buyKin, relationship, chatUnsub, tips, tipsChat, errors -> + ) { network, buckets, vibez, times, giveRequests, buyKin, relationship, chatUnsub, tips, tipsChat, tipsChatCash, errors -> BetaOptions( showNetworkDropOff = network, canViewBuckets = buckets, @@ -76,6 +79,7 @@ class BetaFlagsRepository @Inject constructor( chatUnsubEnabled = chatUnsub, tipsEnabled = tips, tipsChatEnabled = tipsChat, + tipsChatCashEnabled = tipsChatCash, displayErrors = errors ) } diff --git a/api/src/main/java/com/getcode/network/repository/FeatureRepository.kt b/api/src/main/java/com/getcode/network/repository/FeatureRepository.kt index 2aa26cc7f..d2d88f04b 100644 --- a/api/src/main/java/com/getcode/network/repository/FeatureRepository.kt +++ b/api/src/main/java/com/getcode/network/repository/FeatureRepository.kt @@ -4,6 +4,8 @@ import com.getcode.model.BuyModuleFeature import com.getcode.model.PrefsBool import com.getcode.model.RequestKinFeature import com.getcode.model.TipCardFeature +import com.getcode.model.TipChatCashFeature +import com.getcode.model.TipChatFeature import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -22,5 +24,8 @@ class FeatureRepository @Inject constructor( val tipCards = betaFlags.observe().map { TipCardFeature(it.tipsEnabled) } + val tipChat = betaFlags.observe().map { TipChatFeature(it.tipsChatEnabled) } + val tipChatCash = betaFlags.observe().map { TipChatCashFeature(it.tipsChatCashEnabled) } + val requestKin = betaFlags.observe().map { RequestKinFeature(it.giveRequestsEnabled) } } \ No newline at end of file diff --git a/api/src/main/java/com/getcode/network/source/ConversationPagingSource.kt b/api/src/main/java/com/getcode/network/source/ConversationPagingSource.kt index 67c407706..52b778253 100644 --- a/api/src/main/java/com/getcode/network/source/ConversationPagingSource.kt +++ b/api/src/main/java/com/getcode/network/source/ConversationPagingSource.kt @@ -22,7 +22,7 @@ object ConversationMockProvider { suspend fun createConversation(exchange: Exchange, message: ChatMessage): Conversation? { val ret = db.conversationDao().findConversationForMessage(message.id) - val hasTipMessage = ret?.let { db.conversationDao().hasTipMessage(it.id) } ?: false + val hasTipMessage = ret?.let { db.conversationDao().hasTipMessage(it.messageId) } ?: false if (hasTipMessage) return null val tipAmountRaw = message.contents.filterIsInstance() @@ -32,12 +32,9 @@ object ConversationMockProvider { val rate = exchange.rateFor(tipAmountRaw.currencyCode).orOneToOne() val tipAmount = tipAmountRaw.amountUsing(rate) - val id = generateId() - val conversation = Conversation( - idBase58 = id.base58, messageIdBase58 = message.id.base58, - cursorBase58 = id.base58, + cursorBase58 = message.id.base58, tipAmount = tipAmount, createdByUser = true, hasRevealedIdentity = false, @@ -46,7 +43,7 @@ object ConversationMockProvider { lastActivity = null, ) - Timber.d("Created conversation ${id.base58} from ${tipAmount.fiat}") + Timber.d("Created conversation from ${tipAmount.fiat}") return conversation } @@ -67,27 +64,24 @@ object ConversationMockProvider { } suspend fun thankTipper(messageId: ID): ConversationMessage? { - val conversation = - db.conversationDao().findConversationForMessage(messageId) ?: return null - - if (db.conversationDao().hasThanked(conversation.id)) { + if (db.conversationDao().hasThanked(messageId)) { return null } - return createMessage(conversation.id, ConversationMessageContent.ThanksSent) + return createMessage(messageId, ConversationMessageContent.ThanksSent) } suspend fun revealIdentity(messageId: ID): ConversationMessage? { val conversation = db.conversationDao().findConversationForMessage(messageId) ?: return null - if (db.conversationDao().hasRevealedIdentity(conversation.id)) { + if (db.conversationDao().hasRevealedIdentity(conversation.messageId)) { return null } db.conversationDao().upsertConversations(conversation.copy(hasRevealedIdentity = true)) - return createMessage(conversation.id, ConversationMessageContent.IdentityRevealed) + return createMessage(conversation.messageId, ConversationMessageContent.IdentityRevealed) } private fun generateId() = UUID.randomUUID().toByteArray().toList() diff --git a/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt b/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt index 7d4e13a5b..2b2e5edd5 100644 --- a/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt +++ b/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt @@ -45,6 +45,8 @@ import com.getcode.view.main.chat.ChatScreen import com.getcode.view.main.chat.ChatViewModel import com.getcode.view.main.chat.conversation.ChatConversationScreen import com.getcode.view.main.chat.conversation.ConversationViewModel +import com.getcode.view.main.giveKin.GiveKinScreen +import com.getcode.view.main.home.HomeViewModel import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -147,13 +149,14 @@ data class ChatScreen(val chatId: ID) : ChatGraph, ModalContent { } @Parcelize -data class ChatMessageConversationScreen(val messageId: ID) : ChatGraph, ModalContent { +data class ChatMessageConversationScreen(val messageId: ID) : AppScreen(), ChatGraph, ModalContent { @IgnoredOnParcel override val key: ScreenKey = uniqueScreenKey @Composable override fun Content() { val navigator = LocalCodeNavigator.current + val homeViewModel = getViewModel() val vm = getViewModel() val state by vm.stateFlow.collectAsState() @@ -201,6 +204,13 @@ data class ChatMessageConversationScreen(val messageId: ID) : ChatGraph, ModalCo ChatConversationScreen(state, messages, vm::dispatchEvent) } + LaunchedEffect(vm) { + vm.eventFlow + .filterIsInstance() + .onEach { + navigator.push(EnterTipModal(isInChat = true)) + }.launchIn(this) + } LaunchedEffect(messageId) { vm.dispatchEvent(ConversationViewModel.Event.OnMessageIdChanged(messageId)) diff --git a/app/src/main/java/com/getcode/navigation/screens/ModalScreens.kt b/app/src/main/java/com/getcode/navigation/screens/ModalScreens.kt index 0715ae3f9..ddbf3c29d 100644 --- a/app/src/main/java/com/getcode/navigation/screens/ModalScreens.kt +++ b/app/src/main/java/com/getcode/navigation/screens/ModalScreens.kt @@ -371,28 +371,48 @@ data class BuyMoreKinModal( } @Parcelize -data object EnterTipModal : MainGraph, ModalRoot { +data class EnterTipModal(val isInChat: Boolean = false) : MainGraph, ModalRoot { @IgnoredOnParcel override val key: ScreenKey = uniqueScreenKey override val name: String - @Composable get() = stringResource(id = R.string.action_tipKin) + @Composable get() = + if (isInChat) stringResource(R.string.title_sendKin) + else stringResource(id = R.string.action_tipKin) @Composable override fun Content() { val navigator = LocalCodeNavigator.current - ModalContainer( - closeButton = { - if (navigator.isVisible) { - it is EnterTipModal - } else { - navigator.progress > 0f + if (isInChat) { + ModalContainer( + backButton = { + if (navigator.isVisible) { + it is EnterTipModal + } else { + navigator.progress > 0f + } + } + ) { + EnterTipScreen(getViewModel()) { result -> + navigator.popWithResult(result) + } + } + } else { + ModalContainer( + closeButton = { + if (navigator.isVisible) { + it is EnterTipModal + } else { + navigator.progress > 0f + } + } + ) { + EnterTipScreen(getViewModel()) { result -> + navigator.hideWithResult(result) } } - ) { - EnterTipScreen(getViewModel()) } } diff --git a/app/src/main/java/com/getcode/navigation/screens/Modals.kt b/app/src/main/java/com/getcode/navigation/screens/Modals.kt index 8edff7ddd..f446aa426 100644 --- a/app/src/main/java/com/getcode/navigation/screens/Modals.kt +++ b/app/src/main/java/com/getcode/navigation/screens/Modals.kt @@ -38,7 +38,7 @@ import kotlinx.coroutines.launch @Composable -internal fun Screen.ModalContainer( +internal fun NamedScreen.ModalContainer( closeButton: (Screen?) -> Boolean = { false }, screenContent: @Composable () -> Unit ) { @@ -53,7 +53,7 @@ internal fun Screen.ModalContainer( } @Composable -internal fun Screen.ModalContainer( +internal fun NamedScreen.ModalContainer( displayLogo: Boolean = false, onLogoClicked: () -> Unit = { }, closeButton: (Screen?) -> Boolean = { false }, @@ -71,10 +71,10 @@ internal fun Screen.ModalContainer( @OptIn(ExperimentalFoundationApi::class) @Composable -internal fun Screen.ModalContainer( +internal fun NamedScreen.ModalContainer( navigator: CodeNavigator = LocalCodeNavigator.current, displayLogo: Boolean = false, - titleString: @Composable (Screen?) -> String? = { null }, + titleString: @Composable (NamedScreen?) -> String? = { name }, title: @Composable BoxScope.() -> Unit = { }, backButton: (Screen?) -> Boolean = { false }, onBackClicked: (() -> Unit)? = null, diff --git a/app/src/main/java/com/getcode/navigation/screens/Screens.kt b/app/src/main/java/com/getcode/navigation/screens/Screens.kt index ee55f29f6..b29ba60a7 100644 --- a/app/src/main/java/com/getcode/navigation/screens/Screens.kt +++ b/app/src/main/java/com/getcode/navigation/screens/Screens.kt @@ -7,11 +7,11 @@ import timber.log.Timber sealed interface NamedScreen { - val name: String - @Composable get() = "" + val name: String? + @Composable get() = null val hasName: Boolean - @Composable get() = name.isNotEmpty() + @Composable get() = !name.isNullOrEmpty() } abstract class AppScreen: Screen { diff --git a/app/src/main/java/com/getcode/ui/components/chat/ChatInput.kt b/app/src/main/java/com/getcode/ui/components/chat/ChatInput.kt index 1a8bca049..c37817867 100644 --- a/app/src/main/java/com/getcode/ui/components/chat/ChatInput.kt +++ b/app/src/main/java/com/getcode/ui/components/chat/ChatInput.kt @@ -5,12 +5,14 @@ import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -26,8 +28,11 @@ 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.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.getcode.LocalBetaFlags +import com.getcode.R import com.getcode.theme.ChatOutgoing import com.getcode.theme.CodeTheme import com.getcode.theme.extraLarge @@ -40,7 +45,9 @@ import com.getcode.ui.utils.withTopBorder fun ChatInput( modifier: Modifier = Modifier, state: TextFieldState = rememberTextFieldState(), - onSend: () -> Unit, + sendCashEnabled: Boolean = false, + onSendMessage: () -> Unit, + onSendCash: () -> Unit, ) { Row( modifier = modifier @@ -51,6 +58,27 @@ fun ChatInput( horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), verticalAlignment = Alignment.Bottom ) { + if (sendCashEnabled) { + Box( + modifier = Modifier + .align(Alignment.Bottom) + .border(width = 1.dp, color = Color.White, shape = CircleShape) + .clip(CircleShape) + .clickable { onSendCash() } + .size(ChatInput_Size) + .padding(8.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier + .size(CodeTheme.dimens.staticGrid.x6), + painter = painterResource(id = R.drawable.ic_kin_white), + tint = Color.White, + contentDescription = "Send message" + ) + } + } + TextInput( modifier = Modifier .weight(1f), @@ -65,6 +93,7 @@ fun ChatInput( ) ) AnimatedContent( + modifier = Modifier.fillMaxHeight(), targetState = state.text.isNotEmpty(), label = "show/hide send button", transitionSpec = { @@ -81,7 +110,7 @@ fun ChatInput( .align(Alignment.Bottom) .background(ChatOutgoing, shape = CircleShape) .clip(CircleShape) - .clickable { onSend() } + .clickable { onSendMessage() } .size(ChatInput_Size) .padding(8.dp), contentAlignment = Alignment.Center, @@ -104,9 +133,11 @@ fun ChatInput( @Composable private fun Preview_ChatInput() { CodeTheme { - ChatInput { - - } + ChatInput( + sendCashEnabled = true, + onSendMessage = {}, + onSendCash = {} + ) } } diff --git a/app/src/main/java/com/getcode/view/main/account/BetaFlagsScreen.kt b/app/src/main/java/com/getcode/view/main/account/BetaFlagsScreen.kt index a4dd14c7f..68a73c658 100644 --- a/app/src/main/java/com/getcode/view/main/account/BetaFlagsScreen.kt +++ b/app/src/main/java/com/getcode/view/main/account/BetaFlagsScreen.kt @@ -1,5 +1,6 @@ package com.getcode.view.main.account +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding @@ -20,6 +21,7 @@ import com.getcode.theme.CodeTheme import com.getcode.ui.utils.rememberedClickable import com.getcode.ui.components.CodeSwitch +@OptIn(ExperimentalFoundationApi::class) @Composable fun BetaFlagsScreen( viewModel: BetaFlagsViewModel, @@ -89,6 +91,12 @@ fun BetaFlagsScreen( stringResource(id = R.string.beta_tipchats_description), state.tipsChatEnabled, ) { viewModel.dispatchEvent(BetaFlagsViewModel.Event.EnableTipChats(it)) }, + BetaFeature( + PrefsBool.TIPS_CHAT_CASH_ENABLED, + R.string.beta_tipchats_cash, + stringResource(id = R.string.beta_tipchats_cash_description), + state.tipsChatCashEnabled, + ) { viewModel.dispatchEvent(BetaFlagsViewModel.Event.EnableTipsChatCash(it)) }, BetaFeature( PrefsBool.LOG_SCAN_TIMES, R.string.beta_scan_times, @@ -107,6 +115,7 @@ fun BetaFlagsScreen( items(options) { option -> Row( modifier = Modifier + .animateItemPlacement() .rememberedClickable { option.onChange(!option.dataState) } .padding(horizontal = CodeTheme.dimens.grid.x3) .padding(end = CodeTheme.dimens.grid.x3), @@ -146,7 +155,7 @@ fun BetaFlagsScreen( private fun BetaFlagsViewModel.State.canMutate(flag: PrefsBool): Boolean { return when (flag) { PrefsBool.BUY_MODULE_ENABLED -> false - PrefsBool.TIPS_CHAT_ENABLED -> tipsEnabled + PrefsBool.TIPS_CHAT_CASH_ENABLED -> tipsChatEnabled else -> true } } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/main/account/BetaFlagsViewModel.kt b/app/src/main/java/com/getcode/view/main/account/BetaFlagsViewModel.kt index 2d7ad96a7..34c94510d 100644 --- a/app/src/main/java/com/getcode/view/main/account/BetaFlagsViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/account/BetaFlagsViewModel.kt @@ -33,6 +33,7 @@ class BetaFlagsViewModel @Inject constructor( val chatUnsubEnabled: Boolean = false, val tipsEnabled: Boolean = false, val tipsChatEnabled: Boolean = false, + val tipsChatCashEnabled: Boolean = false, ) sealed interface Event { @@ -49,6 +50,7 @@ class BetaFlagsViewModel @Inject constructor( data class EnableCodeRelationshipEstablish(val enabled: Boolean) : Event data class EnableChatUnsubscribe(val enabled: Boolean) : Event data class EnableTipChats(val enabled: Boolean) : Event + data class EnableTipsChatCash(val enabled: Boolean) : Event } init { @@ -61,24 +63,69 @@ class BetaFlagsViewModel @Inject constructor( eventFlow .onEach { event -> - when (event) { - is Event.EnableBuyKin -> prefRepository.set(PrefsBool.BUY_MODULE_ENABLED, event.enabled) - is Event.EnableChatUnsubscribe -> prefRepository.set(PrefsBool.CHAT_UNSUB_ENABLED, event.enabled) - is Event.EnableCodeRelationshipEstablish -> prefRepository.set(PrefsBool.ESTABLISH_CODE_RELATIONSHIP, event.enabled) - is Event.EnableGiveRequests -> prefRepository.set(PrefsBool.GIVE_REQUESTS_ENABLED, event.enabled) - is Event.EnableTipCard -> prefRepository.set(PrefsBool.TIPS_ENABLED, event.enabled) - is Event.EnableTipChats -> prefRepository.set(PrefsBool.TIPS_CHAT_ENABLED, event.enabled) - is Event.SetLogScanTimes -> prefRepository.set(PrefsBool.LOG_SCAN_TIMES, event.log) - is Event.SetVibrateOnScan -> prefRepository.set(PrefsBool.VIBRATE_ON_SCAN, event.vibrate) - is Event.ShowErrors -> { - prefRepository.set(PrefsBool.DISPLAY_ERRORS, event.display) - ErrorUtils.setDisplayErrors(event.display) - } - is Event.ShowNetworkDropOff -> prefRepository.set(PrefsBool.SHOW_CONNECTIVITY_STATUS, event.show) - is Event.UseDebugBuckets -> prefRepository.set(PrefsBool.BUCKET_DEBUGGER_ENABLED, event.enabled) - - is Event.UpdateSettings -> Unit - } + when (event) { + is Event.EnableBuyKin -> prefRepository.set( + PrefsBool.BUY_MODULE_ENABLED, + event.enabled + ) + + is Event.EnableChatUnsubscribe -> prefRepository.set( + PrefsBool.CHAT_UNSUB_ENABLED, + event.enabled + ) + + is Event.EnableCodeRelationshipEstablish -> prefRepository.set( + PrefsBool.ESTABLISH_CODE_RELATIONSHIP, + event.enabled + ) + + is Event.EnableGiveRequests -> prefRepository.set( + PrefsBool.GIVE_REQUESTS_ENABLED, + event.enabled + ) + + is Event.EnableTipCard -> prefRepository.set( + PrefsBool.TIPS_ENABLED, + event.enabled + ) + + is Event.EnableTipChats -> prefRepository.set( + PrefsBool.TIPS_CHAT_ENABLED, + event.enabled + ) + + is Event.EnableTipsChatCash -> prefRepository.set( + PrefsBool.TIPS_CHAT_CASH_ENABLED, + event.enabled + ) + + is Event.SetLogScanTimes -> prefRepository.set( + PrefsBool.LOG_SCAN_TIMES, + event.log + ) + + is Event.SetVibrateOnScan -> prefRepository.set( + PrefsBool.VIBRATE_ON_SCAN, + event.vibrate + ) + + is Event.ShowErrors -> { + prefRepository.set(PrefsBool.DISPLAY_ERRORS, event.display) + ErrorUtils.setDisplayErrors(event.display) + } + + is Event.ShowNetworkDropOff -> prefRepository.set( + PrefsBool.SHOW_CONNECTIVITY_STATUS, + event.show + ) + + is Event.UseDebugBuckets -> prefRepository.set( + PrefsBool.BUCKET_DEBUGGER_ENABLED, + event.enabled + ) + + is Event.UpdateSettings -> Unit + } }.launchIn(viewModelScope) } @@ -99,6 +146,7 @@ class BetaFlagsViewModel @Inject constructor( chatUnsubEnabled = chatUnsubEnabled, tipsEnabled = tipsEnabled, tipsChatEnabled = tipsChatEnabled, + tipsChatCashEnabled = tipsChatCashEnabled, ) } } @@ -113,6 +161,7 @@ class BetaFlagsViewModel @Inject constructor( is Event.EnableCodeRelationshipEstablish, is Event.EnableChatUnsubscribe, is Event.EnableTipChats, + is Event.EnableTipsChatCash, is Event.ShowErrors -> { state -> state } } } diff --git a/app/src/main/java/com/getcode/view/main/chat/conversation/ChatConversationScreen.kt b/app/src/main/java/com/getcode/view/main/chat/conversation/ChatConversationScreen.kt index 244c575aa..7821c8577 100644 --- a/app/src/main/java/com/getcode/view/main/chat/conversation/ChatConversationScreen.kt +++ b/app/src/main/java/com/getcode/view/main/chat/conversation/ChatConversationScreen.kt @@ -6,12 +6,10 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement 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.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.ClickableText import androidx.compose.material.Surface @@ -31,10 +29,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import androidx.paging.compose.LazyPagingItems -import androidx.paging.compose.itemKey import com.getcode.theme.CodeTheme import com.getcode.ui.components.CodeScaffold -import com.getcode.ui.components.chat.MessageNode import com.getcode.ui.components.chat.utils.ChatItem import com.getcode.ui.components.chat.ChatInput import com.getcode.ui.components.chat.MessageList @@ -60,7 +56,10 @@ fun ChatConversationScreen( ) { ChatInput( state = state.textFieldState, - onSend = { dispatchEvent(ConversationViewModel.Event.SendMessage) }) + sendCashEnabled = state.tipChatCash.enabled, + onSendMessage = { dispatchEvent(ConversationViewModel.Event.SendMessage) }, + onSendCash = { dispatchEvent(ConversationViewModel.Event.SendCash) } + ) } } ) { padding -> diff --git a/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt b/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt index 198de4d64..36f2c80ca 100644 --- a/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt @@ -7,24 +7,23 @@ import androidx.compose.foundation.text2.input.TextFieldState import androidx.compose.foundation.text2.input.clearText import androidx.lifecycle.viewModelScope import androidx.paging.PagingData -import androidx.paging.cachedIn import androidx.paging.map import com.getcode.R import com.getcode.model.Conversation import com.getcode.model.ConversationMessageContent +import com.getcode.model.Feature import com.getcode.model.ID import com.getcode.model.KinAmount import com.getcode.model.MessageContent import com.getcode.model.MessageStatus -import com.getcode.model.TipMetadata -import com.getcode.model.TwitterUser +import com.getcode.model.TipChatCashFeature import com.getcode.network.ConversationController +import com.getcode.network.repository.FeatureRepository import com.getcode.solana.keys.PublicKey import com.getcode.ui.components.chat.utils.ChatItem import com.getcode.util.CurrencyUtils import com.getcode.util.formatted import com.getcode.util.resources.ResourceHelper -import com.getcode.util.to import com.getcode.util.toInstantFromMillis import com.getcode.view.BaseViewModel2 import dagger.hilt.android.lifecycle.HiltViewModel @@ -32,7 +31,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest @@ -40,7 +38,6 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach -import kotlinx.datetime.Clock import kotlinx.datetime.Instant import timber.log.Timber import javax.inject.Inject @@ -50,6 +47,7 @@ class ConversationViewModel @Inject constructor( conversationController: ConversationController, currencyUtils: CurrencyUtils, resources: ResourceHelper, + features: FeatureRepository, ) : BaseViewModel2( initialState = State.Default, updateStateForEvent = updateStateForEvent @@ -57,11 +55,11 @@ class ConversationViewModel @Inject constructor( data class State( val messageId: ID?, - val conversationId: ID?, val title: String, val tipAmount: KinAmount?, val tipAmountFormatted: String?, val textFieldState: TextFieldState, + val tipChatCash: Feature, val identityRevealed: Boolean, val user: User?, val lastSeen: Instant? @@ -75,7 +73,7 @@ class ConversationViewModel @Inject constructor( companion object { val Default = State( messageId = null, - conversationId = null, + tipChatCash = TipChatCashFeature(), title = "Anonymous Tipper", tipAmount = null, tipAmountFormatted = null, @@ -96,13 +94,16 @@ class ConversationViewModel @Inject constructor( val imageUrl: String?, ) : Event + data class OnTipsChatCashChanged(val module: Feature) : Event + data class OnUserActivity(val activity: Instant) : Event data class OnTitleChanged(val title: String) : Event data class OnTipAmountFormatted(val amount: String) : Event + data object SendCash : Event data object SendMessage : Event data object RevealIdentity : Event - data object OnIdentityRevealed: Event + data object OnIdentityRevealed : Event } init { @@ -131,6 +132,10 @@ class ConversationViewModel @Inject constructor( dispatchEvent(Event.OnTipAmountFormatted(formattedAmount)) }.launchIn(viewModelScope) + features.tipChatCash + .onEach { dispatchEvent(Event.OnTipsChatCashChanged(it)) } + .launchIn(viewModelScope) + eventFlow .filterIsInstance() .map { stateFlow.value } @@ -139,19 +144,19 @@ class ConversationViewModel @Inject constructor( val text = textFieldState.text.toString() Timber.d("sending message of $text") textFieldState.clearText() - conversationController.sendMessage(it.conversationId!!, text) + conversationController.sendMessage(it.messageId!!, text) }.launchIn(viewModelScope) eventFlow .filterIsInstance() - .mapNotNull { stateFlow.value.messageId } + .mapNotNull { stateFlow.value.messageId } .onEach { delay(300) } .onEach { conversationController.revealIdentity(it) } .launchIn(viewModelScope) } val messages: Flow> = stateFlow - .map { it.conversationId } + .map { it.messageId } .filterNotNull() .flatMapLatest { conversationController.conversationPagingData(it) } .map { page -> @@ -171,6 +176,7 @@ class ConversationViewModel @Inject constructor( isAnnouncement = true, ) } + ConversationMessageContent.IdentityRevealedToYou -> { MessageContent.Localized( value = resources.getString( @@ -181,6 +187,7 @@ class ConversationViewModel @Inject constructor( isAnnouncement = true, ) } + is ConversationMessageContent.Text -> { MessageContent.Localized( value = contents.message, @@ -188,6 +195,7 @@ class ConversationViewModel @Inject constructor( isAnnouncement = false, ) } + ConversationMessageContent.ThanksReceived -> { MessageContent.Localized( value = resources.getString( @@ -198,6 +206,7 @@ class ConversationViewModel @Inject constructor( isAnnouncement = true, ) } + ConversationMessageContent.ThanksSent -> { MessageContent.Localized( value = resources.getString( @@ -207,6 +216,7 @@ class ConversationViewModel @Inject constructor( isAnnouncement = true, ) } + ConversationMessageContent.TipMessage -> { MessageContent.Localized( value = resources.getString( @@ -235,12 +245,17 @@ class ConversationViewModel @Inject constructor( when (event) { is Event.OnConversationChanged -> { state -> state.copy( - conversationId = event.conversation.id, tipAmount = event.conversation.tipAmount, identityRevealed = event.conversation.hasRevealedIdentity ) } + is Event.OnTipsChatCashChanged -> { state -> + state.copy( + tipChatCash = event.module + ) + } + is Event.OnTitleChanged -> { state -> state.copy( title = event.title @@ -252,6 +267,7 @@ class ConversationViewModel @Inject constructor( } Event.RevealIdentity, + Event.SendCash, is Event.SendMessage -> { state -> state } is Event.OnMessageIdChanged -> { state -> diff --git a/app/src/main/java/com/getcode/view/main/home/HomeScan.kt b/app/src/main/java/com/getcode/view/main/home/HomeScan.kt index b3e668282..166ea12c8 100644 --- a/app/src/main/java/com/getcode/view/main/home/HomeScan.kt +++ b/app/src/main/java/com/getcode/view/main/home/HomeScan.kt @@ -137,7 +137,7 @@ fun HomeScreen( homeViewModel.eventFlow .filterIsInstance() .onEach { - navigator.show(EnterTipModal) + navigator.show(EnterTipModal()) }.launchIn(this) } diff --git a/app/src/main/java/com/getcode/view/main/tip/EnterTipScreen.kt b/app/src/main/java/com/getcode/view/main/tip/EnterTipScreen.kt index 08c3be50c..31fc208f9 100644 --- a/app/src/main/java/com/getcode/view/main/tip/EnterTipScreen.kt +++ b/app/src/main/java/com/getcode/view/main/tip/EnterTipScreen.kt @@ -36,7 +36,8 @@ import timber.log.Timber @Composable fun EnterTipScreen( - viewModel: TipPaymentViewModel = hiltViewModel() + viewModel: TipPaymentViewModel = hiltViewModel(), + onSendTip: (HomeResult.ConfirmTip) -> Unit, ) { val context = LocalContext.current val navigator = LocalCodeNavigator.current @@ -107,7 +108,7 @@ fun EnterTipScreen( composeScope.launch { val amount = viewModel.onSubmit() ?: return@launch val result = HomeResult.ConfirmTip(amount) - navigator.hideWithResult(result) + onSendTip(result) } }, enabled = dataState.continueEnabled, diff --git a/app/src/main/res/values/strings-universal.xml b/app/src/main/res/values/strings-universal.xml index 1d729e2fa..370b0e16b 100644 --- a/app/src/main/res/values/strings-universal.xml +++ b/app/src/main/res/values/strings-universal.xml @@ -17,6 +17,7 @@ Show Connectivity Status Tip Card Tip Chats + Tip Chats Cash Log Scan Processing Times Show Errors If enabled, you\'ll gain the ability to tap the balance on the Balance screen to inspect individual bucket balances. @@ -29,6 +30,7 @@ If enabled, an option to unsubscribe from a chat will appear for supported chats. If enabled, you\'ll gain the ability to share a tip card. If enabled, you\'ll gain the ability to chat with tippers. + If enabled, you\'ll gain the ability to send Kin in Tip Chats. %1$s %2$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b7548942c..5591af413 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -35,4 +35,6 @@ This person tipped you %1$s 🙏 You thanked them 🙏 %1$s thanked you for your tip + + Send Kin