From b4224a5ef065fdd5397f4bf34dc0c86161a854d3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 15:31:45 -0400 Subject: [PATCH 01/12] feat(blocklist): observe whether a user is blocked Adds BlockedUserDao.observeIsBlocked and exposes it through BlocklistCoordinator. blockUser now writes the local row so the flow flips on block as well as unblock. --- .../shared/blocklist/build.gradle.kts | 3 + .../app/blocklist/BlocklistCoordinator.kt | 10 ++- .../app/blocklist/BlocklistCoordinatorTest.kt | 85 +++++++++++++++++++ .../app/persistence/dao/BlockedUserDao.kt | 5 ++ .../app/persistence/dao/BlockedUserDaoTest.kt | 70 +++++++++++++++ .../sources/BlockedUserDataSource.kt | 17 ++++ 6 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/shared/blocklist/src/test/kotlin/com/flipcash/app/blocklist/BlocklistCoordinatorTest.kt create mode 100644 apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/BlockedUserDaoTest.kt diff --git a/apps/flipcash/shared/blocklist/build.gradle.kts b/apps/flipcash/shared/blocklist/build.gradle.kts index ef985b5df6..443f36d337 100644 --- a/apps/flipcash/shared/blocklist/build.gradle.kts +++ b/apps/flipcash/shared/blocklist/build.gradle.kts @@ -14,4 +14,7 @@ dependencies { implementation(project(":apps:flipcash:shared:persistence:sources")) implementation(project(":apps:flipcash:shared:chat")) implementation(project(":services:flipcash")) + + testImplementation(kotlin("test")) + testImplementation(libs.bundles.unit.testing) } diff --git a/apps/flipcash/shared/blocklist/src/main/kotlin/com/flipcash/app/blocklist/BlocklistCoordinator.kt b/apps/flipcash/shared/blocklist/src/main/kotlin/com/flipcash/app/blocklist/BlocklistCoordinator.kt index bd3860929a..e51b8dba68 100644 --- a/apps/flipcash/shared/blocklist/src/main/kotlin/com/flipcash/app/blocklist/BlocklistCoordinator.kt +++ b/apps/flipcash/shared/blocklist/src/main/kotlin/com/flipcash/app/blocklist/BlocklistCoordinator.kt @@ -65,9 +65,17 @@ class BlocklistCoordinator @Inject constructor( }.flow.map { page -> page.map { entity -> dataSource.toProfile(entity) } } } - /** Blocks [userId] and hides the DM so it drops out of the Tips feed immediately. */ + /** + * Whether [userId] is on the blocklist. Backed by the local table, which [blockUser] and + * [unblock] write through, so it flips as soon as either succeeds. + */ + fun observeIsBlocked(userId: ID): Flow = + dataSource.observeIsBlocked(userId).distinctUntilChanged() + + /** Blocks [userId], caching the row, and hides the DM so it drops out of the Tips feed immediately. */ suspend fun blockUser(userId: ID): Result = blocklistController.blockUser(userId).onSuccess { + dataSource.insert(userId, blockedAtEpochMs = System.currentTimeMillis()) // TIP_DM ids are derivable, so no network lookup is needed to find the chat to hide. chatCoordinator.generateChatId(userId).getOrNull() ?.let { chatCoordinator.setChatHidden(it, hidden = true) } diff --git a/apps/flipcash/shared/blocklist/src/test/kotlin/com/flipcash/app/blocklist/BlocklistCoordinatorTest.kt b/apps/flipcash/shared/blocklist/src/test/kotlin/com/flipcash/app/blocklist/BlocklistCoordinatorTest.kt new file mode 100644 index 0000000000..8057b17684 --- /dev/null +++ b/apps/flipcash/shared/blocklist/src/test/kotlin/com/flipcash/app/blocklist/BlocklistCoordinatorTest.kt @@ -0,0 +1,85 @@ +package com.flipcash.app.blocklist + +import app.cash.turbine.test +import com.flipcash.app.persistence.sources.BlockedUserDataSource +import com.flipcash.services.controllers.BlocklistController +import com.flipcash.shared.chat.ChatCoordinator +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BlocklistCoordinatorTest { + + private val userId = listOf(1, 2, 3) + private val blocked = MutableStateFlow(false) + + private val blocklistController = mockk() + private val dataSource = mockk(relaxed = true) { + every { observeIsBlocked(any()) } returns blocked + } + private val chatCoordinator = mockk(relaxed = true) { + coEvery { generateChatId(any()) } returns Result.failure(IllegalStateException()) + } + + private val coordinator = BlocklistCoordinator( + blocklistController = blocklistController, + profileController = mockk(), + dataSource = dataSource, + chatCoordinator = chatCoordinator, + userManager = mockk(relaxed = true), + ) + + @Test + fun `observeIsBlocked passes the data source value through and drops repeats`() = runTest { + coordinator.observeIsBlocked(userId).test { + assertFalse(awaitItem()) + blocked.value = true + assertTrue(awaitItem()) + blocked.value = false + assertFalse(awaitItem()) + } + } + + @Test + fun `blockUser writes the row through on success`() = runTest { + coEvery { blocklistController.blockUser(userId) } returns Result.success(Unit) + + assertTrue(coordinator.blockUser(userId).isSuccess) + + coVerify { dataSource.insert(userId, any()) } + } + + @Test + fun `blockUser leaves the table alone on failure`() = runTest { + coEvery { blocklistController.blockUser(userId) } returns Result.failure(IllegalStateException()) + + assertTrue(coordinator.blockUser(userId).isFailure) + + coVerify(exactly = 0) { dataSource.insert(any(), any()) } + } + + @Test + fun `unblock deletes the row on success`() = runTest { + coEvery { blocklistController.unblockUser(userId) } returns Result.success(Unit) + + assertTrue(coordinator.unblock(userId).isSuccess) + + coVerify { dataSource.delete(userId) } + } + + @Test + fun `unblock keeps the row on failure`() = runTest { + coEvery { blocklistController.unblockUser(userId) } returns Result.failure(IllegalStateException()) + + assertEquals(true, coordinator.unblock(userId).isFailure) + + coVerify(exactly = 0) { dataSource.delete(any()) } + } +} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/BlockedUserDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/BlockedUserDao.kt index 3b13075618..c09cd0481f 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/BlockedUserDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/BlockedUserDao.kt @@ -6,6 +6,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow import com.flipcash.app.persistence.entities.BlockedUserEntity import com.flipcash.app.persistence.entities.BlockedUserWithProfile @@ -21,6 +22,10 @@ interface BlockedUserDao { @Query("SELECT * FROM blocked_users ORDER BY blocked_at_epoch_ms DESC") suspend fun getAll(): List + /** Emits whether [userIdHex] is on the cached blocklist, and again whenever that changes. */ + @Query("SELECT EXISTS(SELECT 1 FROM blocked_users WHERE user_id_hex = :userIdHex)") + fun observeIsBlocked(userIdHex: String): Flow + @Transaction @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(entities: List) diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/BlockedUserDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/BlockedUserDaoTest.kt new file mode 100644 index 0000000000..c6e7b7b82b --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/BlockedUserDaoTest.kt @@ -0,0 +1,70 @@ +package com.flipcash.app.persistence.dao + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import app.cash.turbine.test +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.entities.BlockedUserEntity +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +class BlockedUserDaoTest { + + private lateinit var db: FlipcashDatabase + private lateinit var dao: BlockedUserDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, FlipcashDatabase::class.java) + .allowMainThreadQueries() + .build() + dao = db.blockedUserDao() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun `observeIsBlocked is false for a user who is not on the list`() = runTest { + dao.upsert(listOf(BlockedUserEntity("aa", 1L))) + + dao.observeIsBlocked("bb").test { + assertFalse(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `observeIsBlocked is true for a user who is on the list`() = runTest { + dao.upsert(listOf(BlockedUserEntity("aa", 1L))) + + dao.observeIsBlocked("aa").test { + assertTrue(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `observeIsBlocked flips when the user is blocked then unblocked`() = runTest { + dao.observeIsBlocked("aa").test { + assertFalse(awaitItem()) + + dao.upsert(listOf(BlockedUserEntity("aa", 1L))) + assertTrue(awaitItem()) + + dao.delete("aa") + assertFalse(awaitItem()) + } + } +} diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/BlockedUserDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/BlockedUserDataSource.kt index ef52898c90..02443246a8 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/BlockedUserDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/BlockedUserDataSource.kt @@ -4,6 +4,7 @@ import androidx.paging.PagingSource import androidx.paging.PagingState import androidx.room.withTransaction import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.entities.BlockedUserEntity import com.flipcash.app.persistence.entities.BlockedUserWithProfile import com.flipcash.app.persistence.sources.mapper.blocklist.BlockedUserEntityToProfileMapper import com.flipcash.app.persistence.sources.mapper.blocklist.BlockedUserToEntityMapper @@ -11,6 +12,8 @@ import com.flipcash.app.persistence.sources.mapper.blocklist.ResolvedBlockedUser import com.flipcash.app.core.blocklist.BlockedUserProfile import com.getcode.opencode.model.core.ID import com.getcode.utils.hexEncodedString +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import javax.inject.Inject import javax.inject.Singleton @@ -32,6 +35,10 @@ class BlockedUserDataSource @Inject constructor( } } + /** Whether [userId] is blocked; `false` until the database is initialized. */ + fun observeIsBlocked(userId: ID): Flow = + db?.blockedUserDao()?.observeIsBlocked(userId.hexEncodedString()) ?: flowOf(false) + fun toProfile(entity: BlockedUserWithProfile): BlockedUserProfile = toProfileMapper.map(entity) suspend fun upsert(resolved: List) { @@ -55,6 +62,16 @@ class BlockedUserDataSource @Inject constructor( db?.blockedUserDao()?.deleteAll() } + /** + * Records a block the user just made, so [observeIsBlocked] flips without waiting for the next + * blocklist refresh. Only the blocklist row is written; the profile fills in on refresh. + */ + suspend fun insert(userId: ID, blockedAtEpochMs: Long) { + db?.blockedUserDao()?.upsert( + listOf(BlockedUserEntity(userId.hexEncodedString(), blockedAtEpochMs)) + ) + } + suspend fun delete(userId: ID) { db?.blockedUserDao()?.delete(userId.hexEncodedString()) } From 85a39a9d34b3662a97eedeccee9ab9d3cca63505 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 15:33:09 -0400 Subject: [PATCH 02/12] feat(messenger): decide the profile's pinned action and menu --- .../screens/profile/ChatProfileScreen.kt | 2 + .../screens/profile/ProfileMenuItems.kt | 5 + .../screens/profile/ProfilePinnedAction.kt | 42 +++++++++ .../screens/profile/ProfileActionsTest.kt | 94 +++++++++++++++++++ 4 files changed, 143 insertions(+) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfilePinnedAction.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileActionsTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index 82d41edb61..b938f82fde 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -231,6 +231,8 @@ internal fun PersonProfileScreen( // Not flowNavigator: Report is a top-level route rather than a step of // this flow, and LocalCodeNavigator hands a non-FlowStep route up to its // parent. So it opens over the chat rather than inside it. + // Not a row yet: the profile screen rework adds the Unblock row and its handler. + ChatProfileAction.Unblock -> Unit ChatProfileAction.Report -> (state.participant as? ChatParticipant.TipUser)?.let { participant -> navigator.push( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt index 53746e1ab8..ab46fc5757 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt @@ -23,8 +23,13 @@ internal sealed interface ChatProfileAction { data object Block : ChatProfileAction data object Mute : ChatProfileAction data object Report : ChatProfileAction + data object Unblock : ChatProfileAction } +/** Report and Block render in the destructive colour. */ +internal val ChatProfileAction.isDestructive: Boolean + get() = this == ChatProfileAction.Report || this == ChatProfileAction.Block + internal data object ReportUser : FullMenuItem() { override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Outlined.Feedback) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfilePinnedAction.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfilePinnedAction.kt new file mode 100644 index 0000000000..084ca25dd1 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfilePinnedAction.kt @@ -0,0 +1,42 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import com.getcode.opencode.model.financial.Fiat + +/** The one primary action pinned to the bottom of another user's profile. */ +internal sealed interface ProfilePinnedAction { + data object Unblock : ProfilePinnedAction + data object OpenChat : ProfilePinnedAction + + /** A null [fee] means "Start Chatting" opens the amount entry rather than a confirmation. */ + data class StartChatting(val fee: Fiat?) : ProfilePinnedAction +} + +/** + * Null for your own profile. Blocked wins over an existing DM, because a blocked DM is hidden. + */ +internal fun resolvePinnedAction( + isSelf: Boolean, + isBlocked: Boolean, + dmExists: Boolean, + fee: Fiat?, +): ProfilePinnedAction? = when { + isSelf -> null + isBlocked -> ProfilePinnedAction.Unblock + dmExists -> ProfilePinnedAction.OpenChat + else -> ProfilePinnedAction.StartChatting(fee) +} + +/** + * The rows under another user's profile, in order: the reversible and routine first, the one that + * ends the conversation last. Muting is the DM's, so it only appears once one exists. + */ +internal fun profileMenuItems(isBlocked: Boolean, hasDm: Boolean): List = + if (isBlocked) { + listOf(ChatProfileAction.Report, ChatProfileAction.Unblock) + } else { + buildList { + if (hasDm) add(ChatProfileAction.Mute) + add(ChatProfileAction.Report) + add(ChatProfileAction.Block) + } + } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileActionsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileActionsTest.kt new file mode 100644 index 0000000000..132e61651a --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileActionsTest.kt @@ -0,0 +1,94 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import com.getcode.opencode.model.financial.Fiat +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ProfileActionsTest { + + private val fee = Fiat(1.0) + + // -- resolvePinnedAction -- + + @Test + fun `your own profile has no pinned action`() { + assertNull(resolvePinnedAction(isSelf = true, isBlocked = false, dmExists = false, fee = fee)) + assertNull(resolvePinnedAction(isSelf = true, isBlocked = true, dmExists = true, fee = fee)) + } + + @Test + fun `blocked wins over an existing DM`() { + assertEquals( + ProfilePinnedAction.Unblock, + resolvePinnedAction(isSelf = false, isBlocked = true, dmExists = true, fee = fee), + ) + } + + @Test + fun `blocked with no DM is still unblock`() { + assertEquals( + ProfilePinnedAction.Unblock, + resolvePinnedAction(isSelf = false, isBlocked = true, dmExists = false, fee = null), + ) + } + + @Test + fun `an existing DM opens the chat`() { + assertEquals( + ProfilePinnedAction.OpenChat, + resolvePinnedAction(isSelf = false, isBlocked = false, dmExists = true, fee = fee), + ) + } + + @Test + fun `no DM starts chatting with the fee`() { + assertEquals( + ProfilePinnedAction.StartChatting(fee), + resolvePinnedAction(isSelf = false, isBlocked = false, dmExists = false, fee = fee), + ) + } + + @Test + fun `no DM and no fee starts chatting without a fee`() { + assertEquals( + ProfilePinnedAction.StartChatting(null), + resolvePinnedAction(isSelf = false, isBlocked = false, dmExists = false, fee = null), + ) + } + + // -- profileMenuItems -- + + @Test + fun `blocked menu is report then unblock, with or without a DM`() { + val expected = listOf(ChatProfileAction.Report, ChatProfileAction.Unblock) + assertEquals(expected, profileMenuItems(isBlocked = true, hasDm = true)) + assertEquals(expected, profileMenuItems(isBlocked = true, hasDm = false)) + } + + @Test + fun `menu with a DM leads with mute`() { + assertEquals( + listOf(ChatProfileAction.Mute, ChatProfileAction.Report, ChatProfileAction.Block), + profileMenuItems(isBlocked = false, hasDm = true), + ) + } + + @Test + fun `menu without a DM has no mute`() { + assertEquals( + listOf(ChatProfileAction.Report, ChatProfileAction.Block), + profileMenuItems(isBlocked = false, hasDm = false), + ) + } + + @Test + fun `report and block are destructive, mute and unblock are not`() { + assertTrue(ChatProfileAction.Report.isDestructive) + assertTrue(ChatProfileAction.Block.isDestructive) + assertFalse(ChatProfileAction.Mute.isDestructive) + assertFalse(ChatProfileAction.Unblock.isDestructive) + } +} From 338d67e2e179073be43af3c360ac5fea92321558 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 15:46:22 -0400 Subject: [PATCH 03/12] feat(messenger): own the other-user profile's chat, block, fee and payment state ChatProfileViewModel now tracks the tip DM, block and mute state, the start-chatting fee, the E2EE flag and the pinned action, and can pay the fee to start a chat. The give-cash gate and the fee payment move out of ChatViewModel into StartChattingPayer so the chat and the profile share one copy. ChatInitPaymentSheet takes a sendComplete flow instead of the chat's event flow. --- .../flipcash/app/messenger/ChatFlowScreen.kt | 21 +- .../flipcash/app/messenger/ProfileScreen.kt | 7 +- .../app/messenger/internal/ChatViewModel.kt | 74 +---- .../internal/payment/StartChattingModule.kt | 13 + .../internal/payment/StartChattingPayer.kt | 199 ++++++++++++ .../screens/cash/ChatInitPaymentSheet.kt | 13 +- .../screens/profile/ChatProfileScreen.kt | 28 +- .../screens/profile/ChatProfileViewModel.kt | 304 +++++++++++++++++- .../screens/profile/StartChattingSheet.kt | 37 +++ .../messenger/internal/ChatEventStallTest.kt | 9 +- .../internal/ChatGroupAnalyticsTest.kt | 9 +- .../internal/ChatGroupCashLinkTest.kt | 9 +- .../internal/ChatMentionPickerTest.kt | 2 +- .../internal/ChatOpenTranscriptTest.kt | 2 +- .../internal/ChatSendFailureAnalyticsTest.kt | 9 +- .../profile/ChatProfileViewModelTest.kt | 164 +++++++++- 16 files changed, 802 insertions(+), 98 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/payment/StartChattingModule.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/payment/StartChattingPayer.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/StartChattingSheet.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 97ddde6fa1..e485a6944a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -260,9 +260,11 @@ private fun FlowInitPaymentScreen() { fee = state.chatInitFee, token = state.token, sendProgress = state.sendProgress, - eventFlow = viewModel.eventFlow, onConfirm = { viewModel.dispatchEvent(ChatViewModel.Event.OnInitPaymentConfirmed) }, onSendComplete = { resultBack.returnValue(ChatSendResult) }, + sendComplete = remember(viewModel) { + viewModel.eventFlow.filterIsInstance().map { } + }, ) } @@ -313,13 +315,28 @@ private fun FlowGroupInviteSheet() { @Composable private fun FlowChatProfileScreen(participant: ChatParticipant, origin: ProfileOrigin) { val viewModel = flowSharedViewModel() + val chatViewModel = flowSharedViewModel() val flowNavigator = rememberFlowNavigator() + val rootNavigator = LocalCodeNavigator.current LaunchedEffect(viewModel, participant) { viewModel.dispatchEvent(ChatProfileViewModel.Event.OnParticipantSet(participant)) } - ChatProfileScreen(viewModel, flowSharedViewModel(), origin) + ChatProfileScreen( + viewModel = viewModel, + chatViewModel = flowSharedViewModel(), + origin = origin, + onOpenChat = { chatId -> + val chatState = chatViewModel.stateFlow.value + if (origin == ProfileOrigin.Chat && chatState.chatId == chatId) { + // The chat is the one underneath: back to it rather than a second copy. + flowNavigator.back() + } else { + rootNavigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId))) + } + }, + ) LaunchedEffect(viewModel) { viewModel.eventFlow diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt index 23ea0a1db5..c38b09cbc1 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ProfileAddress import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.messenger.internal.screens.profile.ChatProfileViewModel @@ -62,7 +63,7 @@ fun ProfileScreen(address: ProfileAddress) { LaunchedEffect(viewModel, state.participant) { state.participant?.let { viewModel.dispatchEvent( - ChatProfileViewModel.Event.OnParticipantSet(it, fromServer = true) + ChatProfileViewModel.Event.OnParticipantSet(it, isFullProfile = true) ) } } @@ -100,6 +101,10 @@ fun ProfileScreen(address: ProfileAddress) { chat = null, cashSymbol = state.cashSymbol, onBack = { navigator.pop() }, + // Reached by link, so there is no chat underneath to return to. + onOpenChat = { chatId -> + navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId))) + }, ) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index ee39719123..7ebebb5101 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -15,7 +15,6 @@ import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.flatMap import androidx.paging.map -import com.flipcash.analytics.AddMoneySource import com.flipcash.analytics.CashLinkChoice import com.flipcash.analytics.GroupAccess as AnalyticsGroupAccess import com.flipcash.analytics.GroupGateFunding @@ -23,7 +22,6 @@ import com.flipcash.analytics.GroupInviteMethod import com.flipcash.analytics.GroupInviteSheetSource import com.flipcash.analytics.GroupInviteSource import com.flipcash.analytics.State as AnalyticsState -import com.flipcash.analytics.events.AddMoneyEvents import com.flipcash.analytics.events.ChatEvents import com.flipcash.analytics.events.GroupEvents import com.flipcash.analytics.events.TransferEvents @@ -41,7 +39,7 @@ import com.flipcash.app.core.tokens.brandedName import com.flipcash.app.core.tokens.isReserve import com.flipcash.app.core.ui.ConfirmationStyle import com.flipcash.app.core.util.Linkify -import com.flipcash.app.funding.PurchaseMethodController +import com.flipcash.app.messenger.internal.payment.StartChattingPayer import com.flipcash.app.messenger.internal.link.CashCardTap import com.flipcash.app.messenger.internal.link.ClaimReplyTargets import com.flipcash.app.messenger.internal.link.LinkCardClassifier @@ -218,7 +216,7 @@ internal class ChatViewModel @Inject constructor( private val tokenCoordinator: TokenCoordinator, private val exchange: Exchange, private val verifiedFiatCalculator: VerifiedFiatCalculator, - private val purchaseMethodController: PurchaseMethodController, + private val startChattingPayer: StartChattingPayer, private val userManager: UserManager, private val resources: ResourceHelper, private val analytics: FlipcashAnalytics, @@ -2629,12 +2627,11 @@ internal class ChatViewModel @Inject constructor( // Event.OnSendRequested). .filter { stateFlow.value.participant != null || stateFlow.value.chatType == ChatType.GROUP } .onEach { - if (!tokenCoordinator.hasGiveableBalance()) { - if (!tokenCoordinator.hasBalance()) { - presentAddMoney() - } else { - presentDiscoverCurrencies() - } + if (!startChattingPayer.mayProceed( + onAddMoney = { dispatchEvent(Event.PresentDepositOptions) }, + onDiscoverCurrencies = { dispatchEvent(Event.OpenScreen(AppRoute.Token.Discovery, asSheet = true)) }, + ) + ) { return@onEach } // The payment that opens a tip DM costs exactly the recipient's fee, so there is @@ -2653,8 +2650,7 @@ internal class ChatViewModel @Inject constructor( eventFlow .filterIsInstance() .onEach { - analytics.track(AddMoneyEvents.opened(AddMoneySource.CHAT)) - purchaseMethodController.presentDepositOptions()?.let { route -> + startChattingPayer.depositRoute()?.let { route -> dispatchEvent(Event.OpenScreen(route)) } }.launchIn(viewModelScope) @@ -2889,7 +2885,9 @@ internal class ChatViewModel @Inject constructor( val balanceInLocal = balance.convertingTo(rate) val isOverBalance = amount.valueGreaterThan(balanceInLocal) if (isOverBalance) { - presentInsufficientBalance() + startChattingPayer.presentInsufficientBalance( + onAddMoney = { dispatchEvent(Event.PresentDepositOptions) }, + ) } return isOverBalance } @@ -2930,56 +2928,6 @@ internal class ChatViewModel @Inject constructor( } } - /** - * Over balance, with something in the account: the same prompt the tip card raises, offering - * the way out of it. [presentAddMoney] covers the empty account, which has nothing to enter a - * smaller amount than. - */ - private fun presentInsufficientBalance() { - BottomBarManager.showInfo( - title = resources.getString(R.string.title_insufficientBalance), - message = resources.getString(R.string.description_insufficientBalanceToUse), - actions = listOf( - BottomBarAction( - text = resources.getString(R.string.action_addMoney) - ) { - dispatchEvent(Event.PresentDepositOptions) - }, - ), - showCancel = true, - ) - } - - private fun presentAddMoney() { - BottomBarManager.showInfo( - title = resources.getString(R.string.title_noBalanceYet), - message = resources.getString(R.string.description_noBalanceYetToSend), - actions = listOf( - BottomBarAction( - text = resources.getString(R.string.action_addMoney) - ) { - dispatchEvent(Event.PresentDepositOptions) - }, - ), - showCancel = true, - ) - } - - private fun presentDiscoverCurrencies() { - BottomBarManager.showInfo( - title = resources.getString(R.string.title_noCommunityCurrenciesYet), - message = resources.getString(R.string.description_noCommunityCurrenciesYet), - actions = listOf( - BottomBarAction( - text = resources.getString(R.string.action_discoverCurrencies) - ) { - dispatchEvent(Event.OpenScreen(AppRoute.Token.Discovery, asSheet = true)) - }, - ), - showCancel = true, - ) - } - companion object { /** * How often a visible claimable voucher is re-asked about. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/payment/StartChattingModule.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/payment/StartChattingModule.kt new file mode 100644 index 0000000000..caaa5fcd95 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/payment/StartChattingModule.kt @@ -0,0 +1,13 @@ +package com.flipcash.app.messenger.internal.payment + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent + +@Module +@InstallIn(ViewModelComponent::class) +internal abstract class StartChattingModule { + @Binds + abstract fun bindStartChattingPayer(impl: DefaultStartChattingPayer): StartChattingPayer +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/payment/StartChattingPayer.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/payment/StartChattingPayer.kt new file mode 100644 index 0000000000..db9f23f95c --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/payment/StartChattingPayer.kt @@ -0,0 +1,199 @@ +package com.flipcash.app.messenger.internal.payment + +import com.flipcash.analytics.AddMoneySource +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.AddMoneyEvents +import com.flipcash.analytics.events.TransferEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.analytics +import com.flipcash.app.core.AppRoute +import com.flipcash.app.funding.PurchaseMethodController +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.features.messenger.R +import com.flipcash.services.models.TipAction +import com.flipcash.services.models.TipOrigin +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.user.UserManager +import com.flipcash.shared.payments.TipPaymentDelegate +import com.getcode.manager.BottomBarAction +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiatCalculator +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.core.errors.ComputeVerifiedFiatError +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Token +import com.getcode.util.resources.ResourceHelper +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +/** + * The money side of paying a recipient's fee to start a chat: the gate before anything is offered, + * and the payment itself. Lifted out of `ChatViewModel` so the chat and the other-user profile + * share one copy of the alerts and the checks. + * + * Nothing here navigates. An alert's action calls back into the caller, which owns where "Add + * Money" and "Discover Currencies" lead. + */ +internal interface StartChattingPayer { + + /** The token the sender's payment would come from. */ + fun observeSelectedToken(): Flow + + /** + * Whether the sender holds anything to pay with. When not, an alert has been raised offering + * the way out and the caller should stop: [onAddMoney] for an empty account, + * [onDiscoverCurrencies] for one that only holds reserves. + */ + suspend fun mayProceed(onAddMoney: () -> Unit, onDiscoverCurrencies: () -> Unit): Boolean + + /** Over balance, with something in the account. [onAddMoney] is the alert's action. */ + fun presentInsufficientBalance(onAddMoney: () -> Unit) + + /** The deposit screen to open for an "Add Money" action, or null when there is none to open. */ + suspend fun depositRoute(): AppRoute? + + /** + * Pays [fee] to [recipient] from the selected token, as a tip-card tip. + * + * A failure that is a [PaymentBlocked] has already told the user why (balance, limit, stale + * rates); any other failure has not, and is the caller's to present. + */ + suspend fun pay(recipient: ID, fee: Fiat, onAddMoney: () -> Unit): Result + + /** A payment that was stopped by a check, after the user was told. Not worth a second message. */ + class PaymentBlocked : Exception() +} + +internal class DefaultStartChattingPayer @Inject constructor( + private val tokenCoordinator: TokenCoordinator, + private val exchange: Exchange, + private val userManager: UserManager, + private val verifiedFiatCalculator: VerifiedFiatCalculator, + private val tipPaymentDelegate: TipPaymentDelegate, + private val purchaseMethodController: PurchaseMethodController, + private val analytics: FlipcashAnalytics, + private val resources: ResourceHelper, +) : StartChattingPayer { + + override fun observeSelectedToken(): Flow = + tokenCoordinator.observeSelectedTokenMint() + .flatMapLatest { mint -> + tokenCoordinator.tokenBalances.map { tokens -> + tokens.find { it.token.address == mint }?.token + } + } + .filterNotNull() + + override suspend fun mayProceed( + onAddMoney: () -> Unit, + onDiscoverCurrencies: () -> Unit, + ): Boolean { + if (tokenCoordinator.hasGiveableBalance()) return true + if (!tokenCoordinator.hasBalance()) { + presentAddMoney(onAddMoney) + } else { + presentDiscoverCurrencies(onDiscoverCurrencies) + } + return false + } + + override fun presentInsufficientBalance(onAddMoney: () -> Unit) { + BottomBarManager.showInfo( + title = resources.getString(R.string.title_insufficientBalance), + message = resources.getString(R.string.description_insufficientBalanceToUse), + actions = listOf( + BottomBarAction(text = resources.getString(R.string.action_addMoney)) { onAddMoney() }, + ), + showCancel = true, + ) + } + + override suspend fun depositRoute(): AppRoute? { + analytics.track(AddMoneyEvents.opened(AddMoneySource.CHAT)) + return purchaseMethodController.presentDepositOptions() + } + + override suspend fun pay(recipient: ID, fee: Fiat, onAddMoney: () -> Unit): Result { + val owner = userManager.accountCluster + ?: return Result.failure(IllegalStateException("no account")) + val token = observeSelectedToken().first() + val rate = exchange.preferredRate + val balance = tokenCoordinator.balanceForToken(token) + + if (fee.valueGreaterThan(balance.convertingTo(rate))) { + presentInsufficientBalance(onAddMoney) + return Result.failure(StartChattingPayer.PaymentBlocked()) + } + if (tipPaymentDelegate.exceedsSendLimit(fee)) { + BottomBarManager.showAlert( + resources.getString(R.string.error_title_sendLimitReached), + resources.getString(R.string.error_description_sendLimitReached), + ) + return Result.failure(StartChattingPayer.PaymentBlocked()) + } + + val verifiedFiat = verifiedFiatCalculator.compute( + amount = fee, + token = token, + balance = balance, + rate = rate, + ).getOrElse { error -> + val (title, message) = when (error) { + is ComputeVerifiedFiatError.AmountBelowMinimum -> + R.string.error_title_amountTooSmall to R.string.error_description_amountTooSmall + else -> R.string.error_title_staleRates to R.string.error_description_staleRates + } + BottomBarManager.showAlert( + title = resources.getString(title), + message = resources.getString(message), + ) + return Result.failure(StartChattingPayer.PaymentBlocked()) + } + + val result = tipPaymentDelegate.send( + userId = recipient, + verifiedFiat = verifiedFiat, + token = token, + source = owner.withTimelockForToken(token), + origin = TipOrigin.TIPCARD, + action = TipAction.TIP, + ) + val sentAmount = verifiedFiat.localFiat.analytics + analytics.track( + result.fold( + onSuccess = { TransferEvents.sentTip(AnalyticsState.SUCCESS, sentAmount, null) }, + onFailure = { TransferEvents.sentTip(AnalyticsState.FAILURE, sentAmount, it.analytics) }, + ) + ) + return result + } + + private fun presentAddMoney(onAddMoney: () -> Unit) { + BottomBarManager.showInfo( + title = resources.getString(R.string.title_noBalanceYet), + message = resources.getString(R.string.description_noBalanceYetToSend), + actions = listOf( + BottomBarAction(text = resources.getString(R.string.action_addMoney)) { onAddMoney() }, + ), + showCancel = true, + ) + } + + private fun presentDiscoverCurrencies(onDiscover: () -> Unit) { + BottomBarManager.showInfo( + title = resources.getString(R.string.title_noCommunityCurrenciesYet), + message = resources.getString(R.string.description_noCommunityCurrenciesYet), + actions = listOf( + BottomBarAction(text = resources.getString(R.string.action_discoverCurrencies)) { + onDiscover() + }, + ), + showCancel = true, + ) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatInitPaymentSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatInitPaymentSheet.kt index 1c0afee31b..ff158dfa40 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatInitPaymentSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatInitPaymentSheet.kt @@ -16,7 +16,6 @@ import androidx.compose.ui.unit.dp import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.TokenPurpose import com.flipcash.app.core.ui.TokenSelectionPill -import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.features.messenger.R import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.opencode.model.financial.Token @@ -25,7 +24,7 @@ import com.getcode.theme.White10 import com.getcode.ui.components.SlideToConfirm import com.getcode.view.LoadingSuccessState import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -39,21 +38,23 @@ import kotlinx.coroutines.flow.onEach * * @param fee the fee, already formatted in the sender's preferred currency. Null while it resolves, * which the conversation gates on before opening this sheet. + * @param sendComplete emits once the send has finished, for a host that hears about it as an event + * and wants [onSendComplete] to follow. A host that already knows (the profile, which holds the + * sheet itself) leaves it empty. */ @Composable internal fun ChatInitPaymentSheet( fee: String?, token: Token?, sendProgress: LoadingSuccessState, - eventFlow: Flow, onConfirm: () -> Unit, onSendComplete: () -> Unit, + sendComplete: Flow = emptyFlow(), ) { val navigator = LocalCodeNavigator.current - LaunchedEffect(eventFlow) { - eventFlow - .filterIsInstance() + LaunchedEffect(sendComplete) { + sendComplete .onEach { onSendComplete() } .launchIn(this) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index b938f82fde..3e57f3e0fa 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -77,6 +78,7 @@ internal fun ChatProfileScreen( viewModel: ChatProfileViewModel, chatViewModel: ChatViewModel, origin: ProfileOrigin = ProfileOrigin.Chat, + onOpenChat: (ChatId) -> Unit, ) { val flowNavigator = rememberFlowNavigator() val chatState by chatViewModel.stateFlow.collectAsStateWithLifecycle() @@ -92,6 +94,7 @@ internal fun ChatProfileScreen( ), cashSymbol = chatState.cashSymbol, onBack = { flowNavigator.back() }, + onOpenChat = onOpenChat, ) } @@ -136,9 +139,30 @@ internal fun PersonProfileScreen( chat: ProfileChat?, cashSymbol: String, onBack: () -> Unit, + onOpenChat: (ChatId) -> Unit, ) { val navigator = LocalCodeNavigator.current val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + // The host decides what opening the chat means (pop back to it, or push it); everything else + // the profile can ask for is the same wherever it is. + LaunchedEffect(viewModel) { + viewModel.eventFlow.collect { event -> + when (event) { + is ChatProfileViewModel.Event.OpenChat -> onOpenChat(event.chatId) + is ChatProfileViewModel.Event.OpenSendCash -> + navigator.push(event.participant.dmRoute(openSendCash = true)) + is ChatProfileViewModel.Event.OpenScreen -> navigator.push(event.route) + else -> Unit + } + } + } + + StartChattingSheet( + state = state, + onConfirm = { viewModel.dispatchEvent(ChatProfileViewModel.Event.ConfirmStartChatting) }, + onDismiss = { viewModel.dispatchEvent(ChatProfileViewModel.Event.DismissPaymentSheet) }, + ) val isTipDm = chat?.chatType == ChatType.TIP_DM val share = rememberProfileShare() @@ -231,8 +255,8 @@ internal fun PersonProfileScreen( // Not flowNavigator: Report is a top-level route rather than a step of // this flow, and LocalCodeNavigator hands a non-FlowStep route up to its // parent. So it opens over the chat rather than inside it. - // Not a row yet: the profile screen rework adds the Unblock row and its handler. - ChatProfileAction.Unblock -> Unit + ChatProfileAction.Unblock -> + viewModel.dispatchEvent(ChatProfileViewModel.Event.Unblock) ChatProfileAction.Report -> (state.participant as? ChatParticipant.TipUser)?.let { participant -> navigator.push( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt index bb36999d2a..88af4fe389 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt @@ -3,31 +3,48 @@ package com.flipcash.app.messenger.internal.screens.profile import androidx.lifecycle.viewModelScope import com.flipcash.app.blocklist.BlocklistCoordinator import com.flipcash.app.contacts.ContactCoordinator +import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.app.messenger.internal.payment.StartChattingPayer import com.flipcash.features.messenger.R import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.chat.E2eePolicy import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.isMutedAt import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.payments.TipPaymentDelegate import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Token import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel import com.getcode.view.LoadingSuccessState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +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 kotlinx.coroutines.withTimeoutOrNull import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant +@OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel internal class ChatProfileViewModel @Inject constructor( private val contactCoordinator: ContactCoordinator, @@ -37,6 +54,10 @@ internal class ChatProfileViewModel @Inject constructor( private val profiles: ProfileController, private val dispatchers: DispatcherProvider, private val resources: ResourceHelper, + private val chatCoordinator: ChatCoordinator, + private val tipPaymentDelegate: TipPaymentDelegate, + private val e2eePolicy: E2eePolicy, + private val startChattingPayer: StartChattingPayer, ) : BaseViewModel( initialState = State(selfId = userManager.accountId), updateStateForEvent = updateStateForEvent, @@ -48,17 +69,61 @@ internal class ChatProfileViewModel @Inject constructor( val processingState: LoadingSuccessState = LoadingSuccessState(), /** The viewer's own user id, so the screen can tell their own profile from someone else's. */ val selfId: ID? = null, - ) + /** + * The participant is the server's whole profile. A chat member or a contact carries only a + * name, a username and a photo, so a missing bio or cover means nothing until this is true. + */ + val isFullProfileLoaded: Boolean = false, + /** + * The profile fetch has finished, whether it succeeded or not. The start-chatting fee is a + * field of the full profile, so the pinned action waits for this rather than state a + * regional fee the person did not set. + */ + val profileSettled: Boolean = false, + /** The tip DM with this person, derived rather than fetched; it may not exist yet. */ + val dmChatId: ChatId? = null, + /** Whether that DM has members, which is what makes it exist. */ + val dmExists: Boolean = false, + val isBlocked: Boolean = false, + val isMuted: Boolean = false, + /** Null until it has loaded. */ + val fee: Fiat? = null, + /** The E2EE footer's claim for the DM, decided by the policy from its metadata. */ + val isEncrypted: Boolean = false, + /** The token a start-chatting payment would come from. */ + val token: Token? = null, + val paymentSheetVisible: Boolean = false, + val sendProgress: LoadingSuccessState = LoadingSuccessState(), + ) { + /** Null for your own profile, for anyone but a tip user, and until [profileSettled]. */ + val pinnedAction: ProfilePinnedAction? + get() { + val person = participant as? ChatParticipant.TipUser ?: return null + if (!profileSettled) return null + return resolvePinnedAction( + isSelf = person.userId == selfId, + isBlocked = isBlocked, + dmExists = dmExists, + fee = fee, + ) + } + + val menuItems: List + get() = profileMenuItems(isBlocked = isBlocked, hasDm = dmExists) + } sealed interface Event { /** - * [fromServer] says the participant's profile was just fetched, as a link's lookup does, so - * its join date is current and there is no need to fetch it again. + * [isFullProfile] says the participant is the server's whole profile, as a link's lookup + * hands over, so its join date is current and there is no need to fetch it again. A + * partial participant (a chat member, a contact) renders at once and is fetched. */ data class OnParticipantSet( val participant: ChatParticipant, - val fromServer: Boolean = false, + val isFullProfile: Boolean = false, ) : Event + /** The fetch for a partial participant failed; the cached one stays. */ + data object ProfileFetchFailed : Event data class JoinDateLoaded(val joinDate: Instant?) : Event /** The participant as the server has them, replacing the cached one the screen opened on. */ data class ProfileLoaded(val participant: ChatParticipant.TipUser) : Event @@ -66,6 +131,37 @@ internal class ChatProfileViewModel @Inject constructor( data class BlockConfirmed(val participant: ChatParticipant.TipUser) : Event data class BlockProcessing(val loading: Boolean = false, val success: Boolean = false): Event data object BlockSuccessful: Event + + data class DmChatIdResolved(val chatId: ChatId?) : Event + data class DmExistsChanged(val exists: Boolean) : Event + data class BlockedChanged(val isBlocked: Boolean) : Event + data class ChatStateChanged(val isMuted: Boolean, val isEncrypted: Boolean) : Event + data class FeeLoaded(val fee: Fiat?) : Event + data class TokenUpdated(val token: Token) : Event + + /** The pinned button was tapped; what it does is [State.pinnedAction]'s. */ + data object PinnedActionTapped : Event + data object Unblock : Event + /** The slide on the fee sheet was confirmed. */ + data object ConfirmStartChatting : Event + data object ShowPaymentSheet : Event + data object DismissPaymentSheet : Event + data class PaymentProgress( + val loading: Boolean = false, + val success: Boolean = false, + ) : Event + /** An Add Money alert action: asks for the deposit screen, which arrives as [OpenScreen]. */ + data object PresentDepositOptions : Event + + /** Outgoing: the host decides whether that is a pop back to the chat or a push. */ + data class OpenChat(val chatId: ChatId) : Event + /** + * Outgoing: no fee is known, so the amount is entered on the chat's keypad. + * + * TODO(profile-refresh slice 5): this route goes away when the chat becomes by-chat-id only. + */ + data class OpenSendCash(val participant: ChatParticipant.TipUser) : Event + data class OpenScreen(val route: AppRoute, val asSheet: Boolean = false) : Event } init { @@ -75,7 +171,7 @@ internal class ChatProfileViewModel @Inject constructor( .distinctUntilChanged() .onEach { event -> val (userId, profile) = event.participant as ChatParticipant.TipUser - if (event.fromServer) { + if (event.isFullProfile) { dispatchEvent(Event.JoinDateLoaded(profile.joinedAt)) return@onEach } @@ -85,6 +181,8 @@ internal class ChatProfileViewModel @Inject constructor( val fetched = profiles.getProfileForUser(userId).getOrNull() if (fetched != null) { dispatchEvent(Event.ProfileLoaded(ChatParticipant.TipUser(userId, fetched))) + } else { + dispatchEvent(Event.ProfileFetchFailed) } dispatchEvent(Event.JoinDateLoaded(fetched?.joinedAt ?: profile.joinedAt)) } @@ -144,14 +242,197 @@ internal class ChatProfileViewModel @Inject constructor( } } .launchIn(viewModelScope) + + initProfileObservers() + initPinnedActionHandlers() + } + + private fun initProfileObservers() { + val userIds = stateFlow + .map { (it.participant as? ChatParticipant.TipUser)?.userId } + .filterNotNull() + .distinctUntilChanged() + + userIds + .onEach { userId -> + dispatchEvent(Event.DmChatIdResolved(chatCoordinator.generateChatId(userId).getOrNull())) + } + .launchIn(viewModelScope) + + userIds + .flatMapLatest { blocklist.observeIsBlocked(it) } + .onEach { dispatchEvent(Event.BlockedChanged(it)) } + .launchIn(viewModelScope) + + val chatIds = stateFlow.map { it.dmChatId }.filterNotNull().distinctUntilChanged() + + // A block hides the DM but leaves its members, so this stays true through one; the pinned + // action puts blocked first for that reason. + chatIds + .flatMapLatest { chatCoordinator.observeMembers(it) } + .map { it.isNotEmpty() } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.DmExistsChanged(it)) } + .launchIn(viewModelScope) + + chatIds + .flatMapLatest { chatCoordinator.observeMetadata(it) } + .map { membership -> + Event.ChatStateChanged( + // Evaluated on emission; a timed mute that lapses has no event of its own. + isMuted = membership?.metadata?.viewerState.isMutedAt(), + isEncrypted = membership?.metadata?.let(e2eePolicy::shouldEncrypt) ?: false, + ) + } + .distinctUntilChanged() + .onEach { dispatchEvent(it) } + .launchIn(viewModelScope) + + stateFlow + .map { state -> (state.participant as? ChatParticipant.TipUser)?.takeIf { state.profileSettled } } + .distinctUntilChanged() + .flatMapLatest { person -> + if (person == null) emptyFlow() else tipPaymentDelegate.startChattingFee(person.profile) + } + .onEach { dispatchEvent(Event.FeeLoaded(it)) } + .launchIn(viewModelScope) + + startChattingPayer.observeSelectedToken() + .onEach { dispatchEvent(Event.TokenUpdated(it)) } + .launchIn(viewModelScope) + } + + private fun initPinnedActionHandlers() { + eventFlow + .filterIsInstance() + .onEach { + val state = stateFlow.value + when (val action = state.pinnedAction) { + ProfilePinnedAction.Unblock -> dispatchEvent(Event.Unblock) + ProfilePinnedAction.OpenChat -> state.dmChatId?.let { dispatchEvent(Event.OpenChat(it)) } + is ProfilePinnedAction.StartChatting -> startChatting(action.fee) + null -> Unit + } + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { (stateFlow.value.participant as? ChatParticipant.TipUser)?.userId } + .onEach { userId -> + blocklist.unblock(userId).onFailure { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToUnblock), + message = resources.getString(R.string.error_description_failedToUnblock), + ) + } + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + startChattingPayer.depositRoute()?.let { dispatchEvent(Event.OpenScreen(it)) } + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { confirmStartChatting() } + .launchIn(viewModelScope) + } + + /** The gate first, then the fee sheet when the fee is known and the chat's keypad when not. */ + private suspend fun startChatting(fee: Fiat?) { + val person = stateFlow.value.participant as? ChatParticipant.TipUser ?: return + val mayProceed = startChattingPayer.mayProceed( + onAddMoney = { dispatchEvent(Event.PresentDepositOptions) }, + onDiscoverCurrencies = { + dispatchEvent(Event.OpenScreen(AppRoute.Token.Discovery, asSheet = true)) + }, + ) + if (!mayProceed) return + if (fee != null) { + dispatchEvent(Event.ShowPaymentSheet) + } else { + dispatchEvent(Event.OpenSendCash(person)) + } + } + + private suspend fun confirmStartChatting() { + val state = stateFlow.value + val person = state.participant as? ChatParticipant.TipUser ?: return + val fee = state.fee ?: return + if (!state.sendProgress.isIdle) return + + dispatchEvent(Event.PaymentProgress(loading = true)) + startChattingPayer.pay( + recipient = person.userId, + fee = fee, + onAddMoney = { dispatchEvent(Event.PresentDepositOptions) }, + ).onSuccess { paidChatId -> + dispatchEvent(Event.PaymentProgress(success = true)) + delay(SUCCESS_HOLD) + dispatchEvent(Event.DismissPaymentSheet) + val chatId = paidChatId ?: stateFlow.value.dmChatId ?: return@onSuccess + // The server creates the DM from the payment, so its members arrive a moment after. + // If they never do, stay: the pinned action flips to Open Chat when they land. + val arrived = withTimeoutOrNull(MEMBERS_TIMEOUT) { + chatCoordinator.observeMembers(chatId).first { it.isNotEmpty() } + } + if (arrived != null) dispatchEvent(Event.OpenChat(chatId)) + }.onFailure { cause -> + dispatchEvent(Event.PaymentProgress()) + // A blocked payment has said why already. + if (cause !is StartChattingPayer.PaymentBlocked) { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_cashFailedToSend), + message = resources.getString(R.string.error_description_cashFailedToSend), + ) + } + } } companion object { + private val SUCCESS_HOLD = 400.milliseconds + private val MEMBERS_TIMEOUT = 10.seconds + + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { - is Event.OnParticipantSet -> { state -> state.copy(participant = event.participant) } + is Event.OnParticipantSet -> { state -> + state.copy( + participant = event.participant, + isFullProfileLoaded = event.isFullProfile, + profileSettled = event.isFullProfile, + ) + } is Event.JoinDateLoaded -> { state -> state.copy(joinDate = event.joinDate) } - is Event.ProfileLoaded -> { state -> state.copy(participant = event.participant) } + is Event.ProfileLoaded -> { state -> + state.copy( + participant = event.participant, + isFullProfileLoaded = true, + profileSettled = true, + ) + } + Event.ProfileFetchFailed -> { state -> state.copy(profileSettled = true) } + is Event.DmChatIdResolved -> { state -> state.copy(dmChatId = event.chatId) } + is Event.DmExistsChanged -> { state -> state.copy(dmExists = event.exists) } + is Event.BlockedChanged -> { state -> state.copy(isBlocked = event.isBlocked) } + is Event.ChatStateChanged -> { state -> + state.copy(isMuted = event.isMuted, isEncrypted = event.isEncrypted) + } + is Event.FeeLoaded -> { state -> state.copy(fee = event.fee) } + is Event.TokenUpdated -> { state -> state.copy(token = event.token) } + Event.ShowPaymentSheet -> { state -> + state.copy(paymentSheetVisible = true, sendProgress = LoadingSuccessState()) + } + Event.DismissPaymentSheet -> { state -> + state.copy(paymentSheetVisible = false, sendProgress = LoadingSuccessState()) + } + is Event.PaymentProgress -> { state -> + state.copy(sendProgress = LoadingSuccessState(event.loading, event.success)) + } is Event.BlockProcessing -> { state -> val current = state.processingState state.copy( @@ -163,7 +444,14 @@ internal class ChatProfileViewModel @Inject constructor( } is Event.BlockUser, is Event.BlockConfirmed, - is Event.BlockSuccessful -> { state -> state } + is Event.BlockSuccessful, + Event.PinnedActionTapped, + Event.Unblock, + Event.ConfirmStartChatting, + Event.PresentDepositOptions, + is Event.OpenChat, + is Event.OpenSendCash, + is Event.OpenScreen -> { state -> state } } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/StartChattingSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/StartChattingSheet.kt new file mode 100644 index 0000000000..4f3846c391 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/StartChattingSheet.kt @@ -0,0 +1,37 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import com.flipcash.app.messenger.internal.screens.cash.ChatInitPaymentSheet +import com.getcode.theme.CodeTheme + +/** + * The fee confirmation, held by the profile itself rather than pushed as a route. + * + * In-screen on purpose: once the payment lands the profile is still the top entry, so opening the + * DM is a push (or a pop back to the chat) from where the person is standing. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun StartChattingSheet( + state: ChatProfileViewModel.State, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + if (!state.paymentSheetVisible) return + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = CodeTheme.colors.background, + ) { + ChatInitPaymentSheet( + fee = state.fee?.formatted(), + token = state.token, + sendProgress = state.sendProgress, + onConfirm = onConfirm, + onSendComplete = {}, + ) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt index a06c304f51..f3bbbef7bb 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt @@ -9,7 +9,7 @@ import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.contacts.DeviceContact -import com.flipcash.app.funding.PurchaseMethodController +import com.flipcash.app.messenger.internal.payment.StartChattingPayer import com.flipcash.app.messenger.internal.link.LinkCardClassifier import com.flipcash.app.messenger.internal.link.LinkCardResolver import com.flipcash.app.session.CashLinkClaims @@ -83,7 +83,10 @@ class ChatEventStallTest { private val tokenCoordinator = mockk(relaxed = true) private val exchange = mockk(relaxed = true) private val verifiedFiatCalculator = mockk(relaxed = true) - private val purchaseMethodController = mockk(relaxed = true) + private val startChattingPayer = mockk(relaxed = true) { + // The gate is the payer's now; these tests exercise what follows it. + coEvery { mayProceed(any(), any()) } returns true + } private val userManager = mockk(relaxed = true) private val resources = mockk(relaxed = true) private val analytics = RecordingAnalytics() @@ -136,7 +139,7 @@ class ChatEventStallTest { tokenCoordinator = tokenCoordinator, exchange = exchange, verifiedFiatCalculator = verifiedFiatCalculator, - purchaseMethodController = purchaseMethodController, + startChattingPayer = startChattingPayer, userManager = userManager, resources = resources, analytics = analytics, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt index 5a2dfd4c17..b8bc35f42c 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt @@ -10,7 +10,7 @@ import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.persistence.sources.UserProfileDataSource import com.flipcash.app.core.MainCoroutineRule -import com.flipcash.app.funding.PurchaseMethodController +import com.flipcash.app.messenger.internal.payment.StartChattingPayer import com.flipcash.app.messenger.internal.link.LinkCardClassifier import com.flipcash.app.messenger.internal.link.LinkCardResolver import com.flipcash.app.session.CashLinkClaims @@ -88,7 +88,10 @@ class ChatGroupAnalyticsTest { private val tokenCoordinator = mockk(relaxed = true) private val exchange = mockk(relaxed = true) private val verifiedFiatCalculator = mockk(relaxed = true) - private val purchaseMethodController = mockk(relaxed = true) + private val startChattingPayer = mockk(relaxed = true) { + // The gate is the payer's now; these tests exercise what follows it. + coEvery { mayProceed(any(), any()) } returns true + } private val userManager = mockk(relaxed = true) private val resources = mockk(relaxed = true) private val analytics = RecordingAnalytics() @@ -144,7 +147,7 @@ class ChatGroupAnalyticsTest { tokenCoordinator = tokenCoordinator, exchange = exchange, verifiedFiatCalculator = verifiedFiatCalculator, - purchaseMethodController = purchaseMethodController, + startChattingPayer = startChattingPayer, userManager = userManager, resources = resources, analytics = analytics, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt index 1928a9f34d..52c9611637 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt @@ -8,7 +8,7 @@ import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.contacts.DeviceContact -import com.flipcash.app.funding.PurchaseMethodController +import com.flipcash.app.messenger.internal.payment.StartChattingPayer import com.flipcash.app.messenger.internal.link.LinkCardClassifier import com.flipcash.app.messenger.internal.link.LinkCardResolver import com.flipcash.app.session.CashLinkClaims @@ -85,7 +85,10 @@ class ChatGroupCashLinkTest { private val tokenCoordinator = mockk(relaxed = true) private val exchange = mockk(relaxed = true) private val verifiedFiatCalculator = mockk(relaxed = true) - private val purchaseMethodController = mockk(relaxed = true) + private val startChattingPayer = mockk(relaxed = true) { + // The gate is the payer's now; these tests exercise what follows it. + coEvery { mayProceed(any(), any()) } returns true + } private val userManager = mockk(relaxed = true) private val resources = mockk(relaxed = true) private val analytics = RecordingAnalytics() @@ -148,7 +151,7 @@ class ChatGroupCashLinkTest { tokenCoordinator = tokenCoordinator, exchange = exchange, verifiedFiatCalculator = verifiedFiatCalculator, - purchaseMethodController = purchaseMethodController, + startChattingPayer = startChattingPayer, userManager = userManager, resources = resources, analytics = analytics, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt index a41a81b7fd..5c65accd49 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt @@ -95,7 +95,7 @@ class ChatMentionPickerTest { tokenCoordinator = tokenCoordinator, exchange = exchange, verifiedFiatCalculator = mockk(relaxed = true), - purchaseMethodController = mockk(relaxed = true), + startChattingPayer = mockk(relaxed = true), userManager = userManager, resources = mockk(relaxed = true), analytics = RecordingAnalytics(), diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt index 5a8e20a6a0..335a1a38ef 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt @@ -93,7 +93,7 @@ class ChatOpenTranscriptTest { tokenCoordinator = mockk(relaxed = true), exchange = exchange, verifiedFiatCalculator = mockk(relaxed = true), - purchaseMethodController = mockk(relaxed = true), + startChattingPayer = mockk(relaxed = true), userManager = userManager, resources = mockk(relaxed = true), analytics = RecordingAnalytics(), diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt index db1b8c418b..80abcabac8 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt @@ -9,7 +9,7 @@ import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.contacts.DeviceContact -import com.flipcash.app.funding.PurchaseMethodController +import com.flipcash.app.messenger.internal.payment.StartChattingPayer import com.flipcash.app.messenger.internal.link.LinkCardClassifier import com.flipcash.app.messenger.internal.link.LinkCardResolver import com.flipcash.app.session.CashLinkClaims @@ -83,7 +83,10 @@ class ChatSendFailureAnalyticsTest { private val tokenCoordinator = mockk(relaxed = true) private val exchange = mockk(relaxed = true) private val verifiedFiatCalculator = mockk(relaxed = true) - private val purchaseMethodController = mockk(relaxed = true) + private val startChattingPayer = mockk(relaxed = true) { + // The gate is the payer's now; these tests exercise what follows it. + coEvery { mayProceed(any(), any()) } returns true + } private val userManager = mockk(relaxed = true) private val resources = mockk(relaxed = true) private val analytics = RecordingAnalytics() @@ -136,7 +139,7 @@ class ChatSendFailureAnalyticsTest { tokenCoordinator = tokenCoordinator, exchange = exchange, verifiedFiatCalculator = verifiedFiatCalculator, - purchaseMethodController = purchaseMethodController, + startChattingPayer = startChattingPayer, userManager = userManager, resources = resources, analytics = analytics, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt index c587b028af..c9c745269a 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt @@ -6,7 +6,22 @@ import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.flipcash.app.messenger.internal.payment.StartChattingPayer +import com.flipcash.services.chat.E2eePolicy import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.payments.TipPaymentDelegate +import com.getcode.opencode.model.financial.Fiat +import app.cash.turbine.test +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceTimeBy +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.assertIs import com.flipcash.services.models.UserProfile import com.flipcash.services.user.UserManager import com.getcode.util.resources.ResourceHelper @@ -20,6 +35,10 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import com.getcode.opencode.model.financial.CurrencyCode +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch import kotlin.time.Instant /** Where a person's profile gets its name and join date from. */ @@ -36,16 +55,44 @@ class ChatProfileViewModelTest { private val profiles = mockk() private val userManager = mockk { every { accountId } returns List(16) { 1 } } + private val dmChatId = ChatId(ByteArray(32) { 7 }) + private val members = MutableStateFlow>(emptyList()) + private val blocked = MutableStateFlow(false) + private val fee = MutableStateFlow(null) + + private val chatCoordinator = mockk(relaxed = true) { + coEvery { generateChatId(any()) } returns Result.success(dmChatId) + every { observeMembers(any()) } returns members + every { observeMetadata(any()) } returns flowOf(null) + } + private val blocklist = mockk(relaxed = true) { + every { observeIsBlocked(any()) } returns blocked + } + private val tipPaymentDelegate = mockk(relaxed = true) { + every { startChattingFee(any()) } returns fee + } + private val payer = mockk(relaxed = true) { + every { observeSelectedToken() } returns emptyFlow() + coEvery { mayProceed(any(), any()) } returns true + } + private fun viewModel() = ChatProfileViewModel( contactCoordinator = mockk(relaxed = true), userManager = userManager, featureFlags = mockk(relaxed = true), - blocklist = mockk(relaxed = true), + blocklist = blocklist, profiles = profiles, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), resources = mockk(relaxed = true), + chatCoordinator = chatCoordinator, + tipPaymentDelegate = tipPaymentDelegate, + e2eePolicy = mockk(relaxed = true), + startChattingPayer = payer, ) + private fun openFull(model: ChatProfileViewModel) = + model.dispatchEvent(ChatProfileViewModel.Event.OnParticipantSet(participant(serverJoin), isFullProfile = true)) + private fun participant(joinedAt: Instant?, displayName: String = "Sally") = ChatParticipant.TipUser( userId = theirId, profile = UserProfile( @@ -102,10 +149,123 @@ class ChatProfileViewModelTest { val model = viewModel() model.dispatchEvent( - ChatProfileViewModel.Event.OnParticipantSet(participant(serverJoin), fromServer = true) + ChatProfileViewModel.Event.OnParticipantSet(participant(serverJoin), isFullProfile = true) ) assertEquals(serverJoin, model.stateFlow.value.joinDate) coVerify(exactly = 0) { profiles.getProfileForUser(any()) } } + + @Test + fun `a partial participant fetches the full profile and marks it loaded`() = runTest { + coEvery { profiles.getProfileForUser(theirId) } returns + Result.success(participant(serverJoin).profile) + val model = viewModel() + + model.dispatchEvent(ChatProfileViewModel.Event.OnParticipantSet(participant(cachedJoin))) + + assertTrue(model.stateFlow.value.isFullProfileLoaded) + } + + @Test + fun `a partial participant is not full until the fetch lands`() = runTest { + coEvery { profiles.getProfileForUser(theirId) } returns Result.failure(Exception("offline")) + val model = viewModel() + + model.dispatchEvent(ChatProfileViewModel.Event.OnParticipantSet(participant(cachedJoin))) + + assertFalse(model.stateFlow.value.isFullProfileLoaded) + } + + @Test + fun `pinned action goes from start chatting to open chat once members appear`() = runTest { + fee.value = Fiat(1, CurrencyCode.USD) + val model = viewModel() + assertNull(model.stateFlow.value.pinnedAction) + + openFull(model) + assertEquals(ProfilePinnedAction.StartChatting(fee.value), model.stateFlow.value.pinnedAction) + + members.value = listOf(mockk()) + assertEquals(ProfilePinnedAction.OpenChat, model.stateFlow.value.pinnedAction) + } + + @Test + fun `blocked beats an existing dm`() = runTest { + members.value = listOf(mockk()) + blocked.value = true + val model = viewModel() + + openFull(model) + + assertEquals(ProfilePinnedAction.Unblock, model.stateFlow.value.pinnedAction) + } + + @Test + fun `tapping start chatting with no fee asks for the keypad`() = runTest { + val model = viewModel() + openFull(model) + + model.eventFlow.test { + model.dispatchEvent(ChatProfileViewModel.Event.PinnedActionTapped) + assertIs(awaitItem()) + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `tapping start chatting with a fee opens the sheet`() = runTest { + fee.value = Fiat(1, CurrencyCode.USD) + val model = viewModel() + openFull(model) + + model.dispatchEvent(ChatProfileViewModel.Event.PinnedActionTapped) + + assertTrue(model.stateFlow.value.paymentSheetVisible) + } + + @Test + fun `a payment whose dm appears opens the chat`() = runTest { + fee.value = Fiat(1, CurrencyCode.USD) + coEvery { payer.pay(any(), any(), any()) } returns Result.success(dmChatId) + val model = viewModel() + openFull(model) + + model.eventFlow.test { + model.dispatchEvent(ChatProfileViewModel.Event.ConfirmStartChatting) + members.value = listOf(mockk()) + var item = awaitItem() + while (item !is ChatProfileViewModel.Event.OpenChat) item = awaitItem() + assertEquals(dmChatId, item.chatId) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `a payment whose dm never appears stays on the profile`() = runTest { + fee.value = Fiat(1, CurrencyCode.USD) + coEvery { payer.pay(any(), any(), any()) } returns Result.success(dmChatId) + val model = viewModel() + openFull(model) + val seen = mutableListOf() + val job = launch(UnconfinedTestDispatcher(testScheduler)) { model.eventFlow.toList(seen) } + + model.dispatchEvent(ChatProfileViewModel.Event.ConfirmStartChatting) + advanceTimeBy(11_000) + + assertTrue(seen.none { it is ChatProfileViewModel.Event.OpenChat }) + job.cancel() + } + + @Test + fun `unblock failing calls through to the blocklist`() = runTest { + coEvery { blocklist.unblock(any()) } returns Result.failure(Exception("nope")) + val model = viewModel() + openFull(model) + + model.dispatchEvent(ChatProfileViewModel.Event.Unblock) + + coVerify(exactly = 1) { blocklist.unblock(theirId) } + } } From cdc9790a00716c4bd80371f4df5bf69ff8b1ca2f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 16:00:52 -0400 Subject: [PATCH 04/12] feat(messenger): rebuild another user's profile on the shared header Another person's profile now uses the shared ProfileHeader, stats card and status chips. Mute, Report and Block move behind a frosted top-bar menu, and the Message and Send Cash shortcuts give way to one pinned button: Start Chatting, Open Chat or Unblock, with the E2EE footer above it. The start-chatting fee sheet is shown from the screen. Share offers Share Profile and Copy Link only. Screens can ask the root toast host for bottom clearance (ToastBottomClearance), so a toast rests above a pinned button instead of under it. joinedLabel moves to common-ui for both profile screens. --- .../app/internal/ui/navigation/AppContent.kt | 6 +- .../core/src/main/res/values/strings.xml | 8 + .../app/menu/internal/MenuScreenViewModel.kt | 1 + .../flipcash/app/messenger/ChatFlowScreen.kt | 2 - .../flipcash/app/messenger/ProfileScreen.kt | 2 - .../screens/profile/ChatProfileScreen.kt | 468 +++++++----------- .../screens/profile/ChatProfileViewModel.kt | 20 +- .../internal/screens/profile/E2eeFooter.kt | 5 +- .../screens/profile/ProfileMenuItems.kt | 84 ++-- .../screens/profile/ProfilePinnedAction.kt | 14 + .../internal/screens/profile/ProfileShare.kt | 86 ++++ .../screens/profile/ProfileShortcuts.kt | 203 -------- .../internal/ChatIdentityScreenshotTest.kt | 60 --- .../messenger/internal/E2eeScreenshotTest.kt | 37 -- .../internal/MentionDestinationTest.kt | 11 - .../profile/ChatProfileViewModelTest.kt | 19 + .../screens/profile/ProfileActionsTest.kt | 21 + .../profile/ProfileShortcutRecipientTest.kt | 71 --- .../shared/common/ui/profile}/JoinedLabel.kt | 4 +- .../common/ui/profile}/JoinedLabelTest.kt | 2 +- .../ui/components/toast/FloatingToastHost.kt | 40 ++ .../toast/FloatingToastHostStateTest.kt | 20 + 22 files changed, 484 insertions(+), 700 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShare.kt delete mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt delete mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcutRecipientTest.kt rename apps/flipcash/{features/menu/src/main/kotlin/com/flipcash/app/menu/internal => shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile}/JoinedLabel.kt (93%) rename apps/flipcash/{features/menu/src/test/kotlin/com/flipcash/app/menu/internal => shared/common-ui/src/test/kotlin/com/flipcash/shared/common/ui/profile}/JoinedLabelTest.kt (95%) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt index f6966ce2e6..b257904aa0 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt @@ -319,7 +319,11 @@ private fun AppToastHost( val gap = CodeTheme.dimens.grid.x2 val systemBottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() val bottom by animateDpAsState( - targetValue = if (barVisibility.isVisible) barHeight() + gap else systemBottom + CodeTheme.dimens.grid.x3, + targetValue = maxOf( + if (barVisibility.isVisible) barHeight() + gap else systemBottom + CodeTheme.dimens.grid.x3, + // A screen with a button pinned to the bottom asks for the toast to rest above it. + hostState.bottomClearance.let { if (it > 0.dp) it + gap else 0.dp }, + ), label = "toastBottom", ) FloatingToastHost( diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index c3c32e6c1b..bf130a126b 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -931,6 +931,7 @@ options name the state they produce ("1 Week") rather than the act ("Mute for 1 week"), which is also what lets "Never" sit among them. --> Mute Notifications + Unmute Notifications You\'ll still receive messages and see unread counts. Only notifications are silenced @@ -1281,6 +1282,13 @@ Block %1$s? You won’t see messages from them, but you will still receive cash they send you. Flipcash won’t tell them you blocked them Unblock + + Blocked + + Start Chatting + Open Chat + + More Unblock %1$s? The conversation with them will reappear in Tips No One Blocked diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index 67931d34f1..7cbb367352 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -20,6 +20,7 @@ import com.flipcash.services.user.UserManager import com.flipcash.shared.payments.TipPaymentDelegate import com.flipcash.shared.tipping.TippingCoordinator import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.shared.common.ui.profile.joinedLabel import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.opencode.model.financial.Fiat diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index e485a6944a..f07de36f29 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -325,8 +325,6 @@ private fun FlowChatProfileScreen(participant: ChatParticipant, origin: ProfileO ChatProfileScreen( viewModel = viewModel, - chatViewModel = flowSharedViewModel(), - origin = origin, onOpenChat = { chatId -> val chatState = chatViewModel.stateFlow.value if (origin == ProfileOrigin.Chat && chatState.chatId == chatId) { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt index c38b09cbc1..894043192f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt @@ -98,8 +98,6 @@ fun ProfileScreen(address: ProfileAddress) { } else { PersonProfileScreen( viewModel = viewModel, - chat = null, - cashSymbol = state.cashSymbol, onBack = { navigator.pop() }, // Reached by link, so there is no chat underneath to return to. onOpenChat = { chatId -> diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index 3e57f3e0fa..b9ecb970c7 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -1,143 +1,91 @@ package com.flipcash.app.messenger.internal.screens.profile import android.os.Parcelable -import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.NotificationsOff +import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material3.Icon -import androidx.compose.material3.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.draw.clip -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatParticipant -import com.flipcash.app.core.chat.ChatStep -import com.flipcash.app.core.chat.ProfileOrigin import com.flipcash.app.core.chat.ReportSubject -import com.flipcash.app.core.AppRoute -import com.getcode.navigation.core.LocalCodeNavigator -import com.flipcash.app.menu.MenuItem -import com.flipcash.app.menu.MenuList -import com.flipcash.app.messenger.internal.ChatMuteStatusChip -import com.flipcash.app.messenger.internal.ChatViewModel -import com.flipcash.app.messenger.internal.asSubject -import com.flipcash.app.messenger.internal.screens.components.ChatSubjectAvatar import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatType -import com.flipcash.services.models.chat.ViewerState +import com.flipcash.shared.common.ui.ContactAvatar +import com.flipcash.shared.common.ui.profile.ProfileActionButton +import com.flipcash.shared.common.ui.profile.ProfileHeader +import com.flipcash.shared.common.ui.profile.ProfileStatsCard +import com.flipcash.shared.common.ui.profile.ProfileStatusChip +import com.flipcash.shared.common.ui.profile.joinedLabel +import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarWithTitle -import com.getcode.ui.theme.CodeCircularProgressIndicator +import com.getcode.ui.components.CircularIconButton +import com.getcode.ui.components.toast.ToastBottomClearance +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton import com.getcode.ui.theme.CodeScaffold -import com.getcode.util.resources.LocalResources -import com.flipcash.app.messenger.internal.joinedLine -import com.getcode.view.LoadingSuccessState -import kotlin.time.Instant - +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState +import com.flipcash.app.core.chat.ChatStep /** - * A DM counterparty's profile. - * - * Two view models rather than one, because the screen says two kinds of thing about two different - * subjects. [viewModel] holds the person — their profile, their join date, whether they are - * blocked — and is the screen's own. [chatViewModel] is the conversation's, shared with the - * transcript, and it is what muting goes through: a mute is held by the chat, not by the person, - * and the same mute is reachable from a group's profile where there is no person at all. + * A DM counterparty's profile, hosted inside the chat's flow so back returns to the chat. * - * The mute row is shown for a tip DM only. This route is also how a group member's profile opens, - * and there the chat behind it is the group — a mute row on a member's profile would silence the - * whole group from a screen that names one person. The muted chip follows the row for the same - * reason: the group's mute is not this person's, so a member's profile doesn't show it. Contact DMs - * never reach this screen ([com.flipcash.app.messenger.internal.ChatSubject.Contact] answers - * `canViewProfile` false). - * - * The Message and Send Cash shortcuts follow the same split, the other way round: they show on a - * group member's profile and not on a tip DM's, where they would only reopen the chat behind it. - * [profileShortcutRecipient] has the whole rule. Share shows on every person's profile. - * - * A tapped `@handle` naming someone other than a tip DM's counterpart opens with - * [ProfileOrigin.Mention]. That person is not who the chat is with, so the screen leaves the chat - * out, as a `flipcash.com` link's profile does: no Mute, and Message shows. + * The chat is the flow's [ChatViewModel], but nothing here reads it: this screen's own + * [ChatProfileViewModel] works out for itself whether a DM with this person exists, and every row + * and the pinned button act on that. */ @Composable internal fun ChatProfileScreen( viewModel: ChatProfileViewModel, - chatViewModel: ChatViewModel, - origin: ProfileOrigin = ProfileOrigin.Chat, onOpenChat: (ChatId) -> Unit, ) { val flowNavigator = rememberFlowNavigator() - val chatState by chatViewModel.stateFlow.collectAsStateWithLifecycle() - PersonProfileScreen( viewModel = viewModel, - chat = profileChat( - origin = origin, - chatId = chatState.chatId, - chatType = chatState.chatType, - viewerState = chatState.viewerState, - isEncrypted = chatState.isEncrypted, - ), - cashSymbol = chatState.cashSymbol, onBack = { flowNavigator.back() }, onOpenChat = onOpenChat, ) } /** - * The chat behind a profile opened from inside it, or null for a mention, whose person the chat is - * not with. - */ -internal fun profileChat( - origin: ProfileOrigin, - chatId: ChatId?, - chatType: ChatType, - viewerState: ViewerState?, - isEncrypted: Boolean = false, -): ProfileChat? = when (origin) { - ProfileOrigin.Chat -> ProfileChat(chatId, chatType, viewerState, isEncrypted) - ProfileOrigin.Mention -> null -} - -/** - * The conversation a person's profile was opened from, for the parts of the screen that act on the - * chat rather than the person: the mute row and the muted chip. - */ -internal data class ProfileChat( - val chatId: ChatId?, - val chatType: ChatType, - val viewerState: ViewerState?, - /** [com.flipcash.services.chat.E2eePolicy]'s answer for this chat; the footer follows it. */ - val isEncrypted: Boolean = false, -) - -/** - * A person's profile, from a chat or from a link: the header, the shortcuts under it, and the - * Mute, Report and Block rows. - * - * [chat] is null when no conversation is behind the screen, as for a `flipcash.com` link. That - * leaves out the mute row and chip, which belong to a chat, and keeps Message, which a tip DM's - * profile hides. See [ChatProfileScreen] for the rules. + * Another person's profile, from a chat or from a link: the shared header with the Blocked and + * Muted chips and Share, the stats card, and the one action pinned to the bottom (Start Chatting, + * Open Chat or Unblock). Mute, Report and Block are behind the top bar's ⋯. */ @Composable internal fun PersonProfileScreen( viewModel: ChatProfileViewModel, - chat: ProfileChat?, - cashSymbol: String, onBack: () -> Unit, onOpenChat: (ChatId) -> Unit, ) { @@ -163,209 +111,173 @@ internal fun PersonProfileScreen( onConfirm = { viewModel.dispatchEvent(ChatProfileViewModel.Event.ConfirmStartChatting) }, onDismiss = { viewModel.dispatchEvent(ChatProfileViewModel.Event.DismissPaymentSheet) }, ) - val isTipDm = chat?.chatType == ChatType.TIP_DM + + val person = state.participant as? ChatParticipant.TipUser + val isSelf = person != null && person.userId == state.selfId + val pinned = if (isSelf) null else state.pinnedAction val share = rememberProfileShare() + var shareOpen by remember { mutableStateOf(false) } + var menuOpen by remember { mutableStateOf(false) } - CodeScaffold( - topBar = { - AppBarWithTitle(onBackIconClicked = onBack) - }, - bottomBar = { - if (chat?.isEncrypted == true) { - E2eeFooter( - isEncrypted = true, - onLearnMore = { navigator.push(AppRoute.Messaging.E2eeDmInfo) }, - ) - } - }, - ) { innerPadding -> - MenuList( + val density = LocalDensity.current + var pinnedHeight by remember { mutableStateOf(0.dp) } + // Nothing is pinned, so nothing for a toast to clear. + val clearance = if (pinned != null || (state.dmExists && state.isEncrypted)) pinnedHeight else 0.dp + ToastBottomClearance(clearance) + + val hazeState = rememberHazeState() + + CodeScaffold { padding -> + Box( modifier = Modifier .fillMaxSize() - .padding(innerPadding), - // Mute first, then report, block last: the reversible and routine sits above the one - // that asks someone else to look, which sits above the one that ends the conversation. - // Same shape as the group's profile, where leaving holds the last place. - items = buildList> { - if (isTipDm) { - add(MuteDm) - } - add(ReportUser) - add(BlockUser) - }, - header = { - val person = state.participant as? ChatParticipant.TipUser - val recipient = profileShortcutRecipient( - participant = state.participant, - chatType = chat?.chatType, - selfId = state.selfId, - ) + .padding(padding), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = clearance), + ) { ProfileHeader( - participant = state.participant, - joinDate = state.joinDate, - // Only where the mute is this person's chat; see the KDoc above. - viewerState = chat?.viewerState?.takeIf { isTipDm }, - // Not flowNavigator, for the reason Report isn't: the DM is a top-level route, - // so LocalCodeNavigator hands it up and it opens over this chat. - // Share for anyone with a link; Message only where profileShortcutRecipient - // allows it. - shortcuts = person?.let { user -> - { - ProfileShortcuts( - cashSymbol = cashSymbol, - onMessage = recipient?.let { { navigator.push(it.dmRoute()) } }, - onSendCash = { - recipient?.let { navigator.push(it.dmRoute(openSendCash = true)) } - }, - onShare = { share(user) }, + modifier = Modifier.hazeSource(hazeState), + cover = person?.profile?.coverPicture, + access = person?.let { BlobAccessContext.profile(it.userId) } ?: BlobAccessContext.Owned, + avatar = { modifier -> + ContactAvatar( + image = person?.profile?.profilePicture, + displayName = person?.displayName.orEmpty(), + access = person?.let { BlobAccessContext.profile(it.userId) } + ?: BlobAccessContext.Owned, + modifier = modifier, + ) + }, + title = state.participant?.let { + it.name ?: stringResource(R.string.title_unnamedUser) + }.orEmpty(), + subtitle = state.participant?.handle?.takeIf { it != state.participant?.name }, + // Until the whole profile is here a missing bio means nothing, so it is not shown. + body = person?.profile?.bio?.takeIf { state.isFullProfileLoaded }, + actions = { + if (state.isBlocked) { + ProfileStatusChip( + icon = Icons.Outlined.Block, + text = stringResource(R.string.label_blocked), + ) + } + if (state.isMuted) { + ProfileStatusChip( + icon = Icons.Outlined.NotificationsOff, + text = stringResource(R.string.label_muted), + ) + } + if (person != null) { + ProfileActionButton( + icon = ImageVector.vectorResource(R.drawable.ic_share_os), + contentDescription = stringResource(R.string.action_share), + onClick = { shareOpen = true }, ) } }, + ) + ProfileStatsCard( modifier = Modifier - .fillMaxWidth() - // The gap under the header is the header's own, because MenuList puts - // nothing between its header slot and the first row. 40dp matches what - // iOS spends here (its 16pt stack spacing plus the row block's 24pt top - // inset); without it the mute chip sits against the first row and the - // screen reads as one block rather than a title above a list. - .padding( - top = CodeTheme.dimens.grid.x7, - // None under the shortcuts: the first row's own 25dp inset is the - // gap, and ProfileHeader matches it above them so they sit centered - // between the join date and the list. - bottom = if (person != null) { - 0.dp - } else { - CodeTheme.dimens.grid.x8 - }, - ), + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.staticGrid.x4), + // The fee is a field of the full profile; before it settles there is no honest number. + minimumToChat = state.fee?.takeIf { state.profileSettled }?.formatted(), + joined = joinedLabel(state.joinDate), ) - }, - onItemClick = { item -> - when (item.action) { - ChatProfileAction.Block -> - viewModel.dispatchEvent(ChatProfileViewModel.Event.BlockUser) - // Both muting and unmuting go through the picker, which is why this row - // navigates either way rather than acting on one of them here. The outer - // navigator, as with Report: the sheet is a top-level route shared with the - // chat list, so it opens over the chat rather than inside it. - ChatProfileAction.Mute -> chat?.chatId?.let { chatId -> - navigator.push(AppRoute.Messaging.MuteChat(chatId, chat.chatType)) - } - // Not flowNavigator: Report is a top-level route rather than a step of - // this flow, and LocalCodeNavigator hands a non-FlowStep route up to its - // parent. So it opens over the chat rather than inside it. - ChatProfileAction.Unblock -> - viewModel.dispatchEvent(ChatProfileViewModel.Event.Unblock) - ChatProfileAction.Report -> - (state.participant as? ChatParticipant.TipUser)?.let { participant -> - navigator.push( - AppRoute.Messaging.Report( - ReportSubject.User(participant.userId) + } + + AppBarWithTitle( + onBackIconClicked = onBack, + hazeState = hazeState, + endContent = { + if (person != null && !isSelf) { + Box { + CircularIconButton(hazeState = hazeState, onClick = { menuOpen = true }) { size -> + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.action_moreProfileActions), + tint = Color.White, + modifier = Modifier.requiredSize(size), ) + } + ProfileMenu( + expanded = menuOpen, + items = state.menuItems, + isMuted = state.isMuted, + onDismiss = { menuOpen = false }, + onItem = { item -> + when (item) { + ChatProfileAction.Block -> + viewModel.dispatchEvent(ChatProfileViewModel.Event.BlockUser) + ChatProfileAction.Unblock -> + viewModel.dispatchEvent(ChatProfileViewModel.Event.Unblock) + // The picker is where muting and unmuting both live. Pushed + // on the outer navigator: it is a top-level route. + ChatProfileAction.Mute -> state.dmChatId?.let { chatId -> + navigator.push(AppRoute.Messaging.MuteChat(chatId, ChatType.TIP_DM)) + } + ChatProfileAction.Report -> + navigator.push( + AppRoute.Messaging.Report(ReportSubject.User(person.userId)) + ) + } + }, ) } + } + }, + ) + + val showFooter = pinned != null && state.dmExists && state.isEncrypted + if (pinned != null) { + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .onSizeChanged { pinnedHeight = with(density) { it.height.toDp() } } + .background(CodeTheme.colors.background) + .navigationBarsPadding(), + ) { + if (showFooter) { + E2eeFooter( + isEncrypted = true, + onLearnMore = { navigator.push(AppRoute.Messaging.E2eeDmInfo) }, + clearNavigationBar = false, + ) + } + if (pinned != null) { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x3), + buttonState = ButtonState.Filled, + text = pinned.label(), + onClick = { viewModel.dispatchEvent(ChatProfileViewModel.Event.PinnedActionTapped) }, + ) + } } - }, - endSlot = { item -> - val loading = item.action == ChatProfileAction.Block && - state.processingState.state == LoadingSuccessState.State.Loading - if (loading) { - CodeCircularProgressIndicator( - strokeWidth = CodeTheme.dimens.thickBorder, - color = CodeTheme.colors.textSecondary, - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x5), - ) - } else { - Icon( - painter = painterResource(R.drawable.ic_chevron_right), - contentDescription = null, - tint = CodeTheme.colors.textSecondary, - ) - } - }, + } + } + } + + if (shareOpen && person != null) { + ProfileShareSheetHost( + person = person, + onShare = { share(person) }, + onCopyLink = viewModel::copyLink, + onDismiss = { shareOpen = false }, ) } } -@VisibleForTesting @Composable -internal fun ProfileHeader( - participant: ChatParticipant?, - joinDate: Instant?, - modifier: Modifier = Modifier, - viewerState: ViewerState? = null, - shortcuts: (@Composable () -> Unit)? = null, -) { - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - ChatSubjectAvatar( - subject = participant.asSubject(), - modifier = Modifier - .size(CodeTheme.dimens.staticGrid.x17) - .clip(CircleShape), - ) - Text( - // Wider than the 5dp that binds the identity lines below it, so the name reads as the - // start of that block rather than as another line of the picture. - modifier = Modifier.padding(top = CodeTheme.dimens.grid.x3), - // Left empty only while the participant loads; a person with neither a name nor a - // handle is still called something. - text = participant?.let { it.name ?: stringResource(R.string.title_unnamedUser) }.orEmpty(), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - // The handle sits under the name, the same shape as the info card's identity line - // (node 9443:8928). Left out when the line above is already the handle, so a name-less - // account doesn't read it twice. - participant?.handle?.takeIf { it != participant.name }?.let { handle -> - Text( - modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1), - text = handle, - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - joinDate?.let { instant -> - Text( - modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1), - text = joinedLine(instant, LocalResources.current), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - ) - } - // Below everything that describes the person: these act on them, like the rows under the - // header, but they're the routine ones, so they sit closest to the name. 25dp, the same as - // the first row's inset below them, so they sit centered between the join date and the - // list. Nothing between them and the join date, so they sit at one height on every - // person's profile, muted or not. - shortcuts?.let { content -> - Box(modifier = Modifier.padding(top = CodeTheme.dimens.grid.x5)) { - content() - } - } - // Last, because it is the only line here that is the viewer's setting rather than a fact - // about the person, and the only one that can stop being true while the screen is open. - // Only where there is a mute to show, the DM's own person; there it holds its line so the - // rows below don't move on mute or unmute. 16dp matches iOS, set by eye on device. - viewerState?.let { state -> - ChatMuteStatusChip( - viewerState = state, - modifier = Modifier.padding( - top = if (shortcuts != null) { - CodeTheme.dimens.staticGrid.x4 - } else { - CodeTheme.dimens.grid.x2 - }, - ), - reserveSpace = true, - ) - } - } +private fun ProfilePinnedAction.label(): String = when (this) { + is ProfilePinnedAction.StartChatting -> + if (fee != null) stringResource(labelRes(), fee.formatted()) else stringResource(labelRes()) + else -> stringResource(labelRes()) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt index 88af4fe389..3e258ce46c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt @@ -1,10 +1,15 @@ package com.flipcash.app.messenger.internal.screens.profile +import android.content.ClipboardManager import androidx.lifecycle.viewModelScope import com.flipcash.app.blocklist.BlocklistCoordinator import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.core.extensions.setText +import com.flipcash.app.core.tipping.TipCardOwner +import com.flipcash.app.core.toast.SystemToastController +import com.flipcash.app.core.util.Linkify import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.messenger.internal.payment.StartChattingPayer import com.flipcash.features.messenger.R @@ -58,6 +63,8 @@ internal class ChatProfileViewModel @Inject constructor( private val tipPaymentDelegate: TipPaymentDelegate, private val e2eePolicy: E2eePolicy, private val startChattingPayer: StartChattingPayer, + private val clipboardManager: ClipboardManager, + private val toastController: SystemToastController, ) : BaseViewModel( initialState = State(selfId = userManager.accountId), updateStateForEvent = updateStateForEvent, @@ -164,11 +171,22 @@ internal class ChatProfileViewModel @Inject constructor( data class OpenScreen(val route: AppRoute, val asSheet: Boolean = false) : Event } + /** Puts the person's profile link on the clipboard, the one Share hands out. */ + fun copyLink() { + val person = stateFlow.value.participant as? ChatParticipant.TipUser ?: return + clipboardManager.setText( + text = Linkify.tipcard(TipCardOwner.preferringUsername(person.profile.username, person.userId)), + label = resources.getString(R.string.title_clipboardLabelTipCardLink), + ) + toastController.showToast(R.string.action_copied, replacePrevious = true) + } + init { eventFlow .filterIsInstance() .filter { it.participant is ChatParticipant.TipUser } - .distinctUntilChanged() + // Not distinct: the reducer resets the settled flag on every set, so a repeat of the + // same participant (a second visit in a flow that keeps this view model) has to fetch. .onEach { event -> val (userId, profile) = event.participant as ChatParticipant.TipUser if (event.isFullProfile) { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/E2eeFooter.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/E2eeFooter.kt index 2889467b5b..944ebcb9d7 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/E2eeFooter.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/E2eeFooter.kt @@ -39,11 +39,14 @@ internal fun E2eeFooter( isEncrypted: Boolean, onLearnMore: () -> Unit, modifier: Modifier = Modifier, + // Off where something below the footer already clears the system bar, as the pinned button on + // a person's profile does. + clearNavigationBar: Boolean = true, ) { Column( modifier = modifier .fillMaxWidth() - .navigationBarsPadding() + .then(if (clearNavigationBar) Modifier.navigationBarsPadding() else Modifier) .padding(horizontal = 24.dp, vertical = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp), diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt index ab46fc5757..e23004ad5a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt @@ -1,23 +1,23 @@ package com.flipcash.app.messenger.internal.screens.profile -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Block -import androidx.compose.material.icons.outlined.Feedback +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.stringResource -import com.flipcash.app.menu.FullMenuItem +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp import com.flipcash.features.messenger.R +import com.getcode.theme.CodeTheme +import com.getcode.theme.extraLarge /** * What a DM counterparty's profile can act on. * - * Its own type rather than [ChatProfileViewModel.Event] for the same reason [GroupProfileAction] - * is one: the rows do different kinds of thing and answer to different view models. Blocking is - * this screen's own business, while muting is the conversation's — the chat is what gets muted, - * not the person — so naming the actions here lets one [com.flipcash.app.menu.MenuList] carry both - * and the screen route each tap to whichever holds it. + * Its own type rather than [ChatProfileViewModel.Event] because the rows do different kinds of + * thing and answer to different places. Blocking is this screen's own business, while muting is + * the conversation's, so naming the actions here lets one menu carry both and the screen route + * each tap to whichever holds it. */ internal sealed interface ChatProfileAction { data object Block : ChatProfileAction @@ -30,25 +30,49 @@ internal sealed interface ChatProfileAction { internal val ChatProfileAction.isDestructive: Boolean get() = this == ChatProfileAction.Report || this == ChatProfileAction.Block -internal data object ReportUser : FullMenuItem() { - override val icon: Painter - @Composable get() = rememberVectorPainter(Icons.Outlined.Feedback) - - override val name: String - @Composable get() = stringResource(R.string.title_report) - - override val action: ChatProfileAction = ChatProfileAction.Report +/** + * The row's string. Muting names the opposite once the chat is muted; the picker it opens is where + * the duration is chosen either way. + */ +@androidx.annotation.StringRes +internal fun ChatProfileAction.labelRes(isMuted: Boolean): Int = when (this) { + ChatProfileAction.Mute -> if (isMuted) R.string.title_unmuteChat else R.string.title_muteChat + ChatProfileAction.Report -> R.string.title_report + ChatProfileAction.Block -> R.string.title_block + ChatProfileAction.Unblock -> R.string.action_unblock } -internal data object BlockUser : FullMenuItem() { - override val icon: Painter - @Composable get() = rememberVectorPainter(Icons.Outlined.Block) - - override val name: String - @Composable get() = stringResource(R.string.title_block) - - override val action: ChatProfileAction = ChatProfileAction.Block +/** The ⋯ button's menu, rows in [items]' order. */ +@Composable +internal fun ProfileMenu( + expanded: Boolean, + items: List, + isMuted: Boolean, + onDismiss: () -> Unit, + onItem: (ChatProfileAction) -> Unit, +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismiss, + // Same surface as the chat's overflow menu, dropped clear of the button. + containerColor = CodeTheme.colors.brandLight, + shape = CodeTheme.shapes.extraLarge, + offset = DpOffset(x = 0.dp, y = CodeTheme.dimens.grid.x2), + ) { + items.forEach { item -> + DropdownMenuItem( + text = { + Text( + text = stringResource(item.labelRes(isMuted)), + style = CodeTheme.typography.textSmall, + color = if (item.isDestructive) CodeTheme.colors.error else CodeTheme.colors.textMain, + ) + }, + onClick = { + onDismiss() + onItem(item) + }, + ) + } + } } - -/** The DM's mute row. Shared definition; see [MuteChatItem]. */ -internal val MuteDm = MuteChatItem(ChatProfileAction.Mute) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfilePinnedAction.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfilePinnedAction.kt index 084ca25dd1..945e4ebaaa 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfilePinnedAction.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfilePinnedAction.kt @@ -1,5 +1,7 @@ package com.flipcash.app.messenger.internal.screens.profile +import androidx.annotation.StringRes +import com.flipcash.features.messenger.R import com.getcode.opencode.model.financial.Fiat /** The one primary action pinned to the bottom of another user's profile. */ @@ -40,3 +42,15 @@ internal fun profileMenuItems(isBlocked: Boolean, hasDm: Boolean): List R.string.action_unblock + ProfilePinnedAction.OpenChat -> R.string.action_openChat + is ProfilePinnedAction.StartChatting -> + if (fee != null) R.string.action_sendToStartChatting else R.string.action_startChatting +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShare.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShare.kt new file mode 100644 index 0000000000..76d22cdcb1 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShare.kt @@ -0,0 +1,86 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.res.stringResource +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.shareable.LocalShareController +import com.flipcash.app.shareable.Shareable +import com.flipcash.features.messenger.R +import com.flipcash.shared.common.ui.profile.ProfileShareRow +import com.flipcash.shared.common.ui.profile.ProfileShareSheet +import com.getcode.theme.CodeTheme +import kotlinx.coroutines.launch + +/** The DM with [this] person, opened by user, with a payment already started when [openSendCash]. */ +internal fun ChatParticipant.TipUser.dmRoute(openSendCash: Boolean = false) = + AppRoute.Messaging.Chat( + identifier = ChatIdentifier.ByUser(userId, profile), + openSendCash = openSendCash, + ) + +/** Hands a person's profile to the system share sheet. */ +@Composable +internal fun rememberProfileShare(): (ChatParticipant.TipUser) -> Unit { + val shareController = LocalShareController.current + val scope = rememberCoroutineScope() + return remember(shareController, scope) { + { user -> + scope.launch { + shareController.present( + Shareable.Profile( + userId = user.userId, + displayName = user.profile.displayName, + username = user.profile.username, + ) + ) + } + } + } +} + +private const val RowShare = "share" +private const val RowCopy = "copy" + +/** + * Share Profile and Copy Link for someone else's profile. No profile-card row: the card is the + * viewer's own to show. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ProfileShareSheetHost( + person: ChatParticipant.TipUser, + onShare: () -> Unit, + onCopyLink: () -> Unit, + onDismiss: () -> Unit, +) { + val handle = person.profile.username + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = CodeTheme.colors.background, + ) { + ProfileShareSheet( + title = stringResource(R.string.title_shareUserProfile), + subtitle = if (handle.isNullOrEmpty()) { + person.profile.displayName + } else { + stringResource(R.string.subtitle_shareUserProfile, person.profile.displayName, handle) + }, + rows = listOf( + ProfileShareRow(RowShare, R.drawable.ic_share_os, stringResource(R.string.action_shareProfileLink)), + ProfileShareRow(RowCopy, R.drawable.ic_copy, stringResource(R.string.action_copyLink)), + ), + onRow = { row -> + onDismiss() + if (row.id == RowShare) onShare() else onCopyLink() + }, + ) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt deleted file mode 100644 index 5bd103337a..0000000000 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt +++ /dev/null @@ -1,203 +0,0 @@ -package com.flipcash.app.messenger.internal.screens.profile - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.indication -import androidx.compose.foundation.interaction.MutableInteractionSource -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.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.chat.ChatIdentifier -import com.flipcash.app.core.chat.ChatParticipant -// Used by the held-back Send Cash shortcut below. -// import com.flipcash.app.messenger.internal.screens.components.CondensedSymbolFontSize -import com.flipcash.app.shareable.LocalShareController -import com.flipcash.app.shareable.Shareable -import com.flipcash.features.messenger.R -import com.flipcash.services.models.chat.ChatType -import com.getcode.opencode.model.core.ID -import com.getcode.theme.CodeTheme -import kotlinx.coroutines.launch - -/** - * Who the profile's Message and Send Cash shortcuts would open a DM with, or null to leave them - * out. Share doesn't follow this rule: every person's profile has it. - * - * - Not from a tip DM: a DM's profile is always the other person in it, so the shortcuts would - * only reopen the chat the viewer came from. - * - Not for the viewer's own profile: there is no DM with yourself. - * - Only for a [ChatParticipant.TipUser]: the DM route is addressed by user id, which a contact - * doesn't have. Contact DMs don't reach this screen anyway. - * - * Blocking isn't checked. The chat the shortcut opens handles a blocked user the way any other - * entry to it does. - */ -internal fun profileShortcutRecipient( - participant: ChatParticipant?, - // Null when no chat is behind the profile, as for one a link opened. - chatType: ChatType?, - selfId: ID?, -): ChatParticipant.TipUser? { - if (chatType == ChatType.TIP_DM) return null - val user = participant as? ChatParticipant.TipUser ?: return null - if (selfId != null && user.userId == selfId) return null - return user -} - -/** - * The DM a profile's shortcuts open with [this] person: Message opens it, Send Cash opens it with - * a payment already started. - */ -internal fun ChatParticipant.TipUser.dmRoute(openSendCash: Boolean = false) = - AppRoute.Messaging.Chat( - identifier = ChatIdentifier.ByUser(userId, profile), - openSendCash = openSendCash, - ) - -/** - * What a profile's Share shortcut does: hands the person's `flipcash.com` link to the Sharesheet, - * in the handle form when they have one, as the You tab shares your own. - */ -@Composable -internal fun rememberProfileShare(): (ChatParticipant.TipUser) -> Unit { - val shareController = LocalShareController.current - val scope = rememberCoroutineScope() - return remember(shareController, scope) { - { user -> - scope.launch { - shareController.present( - Shareable.Profile( - userId = user.userId, - displayName = user.profile.displayName, - username = user.profile.username, - ) - ) - } - } - } -} - -/** - * The Message, Send Cash and Share shortcuts under a profile's identity lines. A null callback - * leaves its shortcut out. - * - * Two fixed-width columns, so the pair stays centered and the circles stay the same distance apart - * whatever the labels say. The Send Cash glyph is [cashSymbol] rather than an icon, so it names the - * currency the send will be in, at the size the chat's condensed send button draws it. - */ -@Composable -internal fun ProfileShortcuts( - cashSymbol: String, - onMessage: (() -> Unit)?, - onSendCash: () -> Unit, - modifier: Modifier = Modifier, - onShare: (() -> Unit)? = null, -) { - Row( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x3), - ) { - if (onMessage != null) { - ProfileShortcut( - label = stringResource(R.string.action_message), - onClick = onMessage, - ) { - Icon( - painter = painterResource(R.drawable.ic_chat_bubble), - contentDescription = null, - tint = CodeTheme.colors.textMain, - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), - ) - } - } - // Send Cash is held back for now. The route flag and the chat's once-ready dispatch behind - // it stay wired, so bringing it back is uncommenting this. - // ProfileShortcut( - // label = stringResource(R.string.action_sendCash), - // onClick = onSendCash, - // ) { - // Text( - // text = cashSymbol, - // color = CodeTheme.colors.textMain, - // style = CodeTheme.typography.textMedium.copy(fontSize = CondensedSymbolFontSize), - // maxLines = 1, - // softWrap = false, - // ) - // } - if (onShare != null) { - ProfileShortcut( - label = stringResource(R.string.action_shareProfile), - onClick = onShare, - ) { - Icon( - painter = painterResource(R.drawable.ic_share_os), - contentDescription = null, - tint = CodeTheme.colors.textMain, - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), - ) - } - } - } -} - -@Composable -private fun ProfileShortcut( - label: String, - onClick: () -> Unit, - glyph: @Composable () -> Unit, -) { - // The whole column takes the tap, so the label is as much a target as the circle, but only the - // circle shows the ripple. - val interactionSource = remember { MutableInteractionSource() } - Column( - modifier = Modifier - .width(CodeTheme.dimens.staticGrid.x20) - .clickable( - interactionSource = interactionSource, - indication = null, - role = Role.Button, - onClick = onClick, - ), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Box( - modifier = Modifier - .size(CodeTheme.dimens.staticGrid.x9) - .clip(CircleShape) - .background(CodeTheme.colors.surfaceVariant) - .indication(interactionSource, ripple()), - contentAlignment = Alignment.Center, - ) { - glyph() - } - Text( - modifier = Modifier.padding(top = CodeTheme.dimens.staticGrid.x1), - text = label, - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } -} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt index 8836f9e822..da3b1abc68 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt @@ -15,8 +15,6 @@ import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.messenger.internal.screens.components.ChatTopBar import com.flipcash.app.messenger.internal.screens.components.ChatInfoCard -import com.flipcash.app.messenger.internal.screens.profile.ProfileHeader -import com.flipcash.app.messenger.internal.screens.profile.ProfileShortcuts import com.flipcash.app.theme.FlipcashPreview import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatType @@ -174,64 +172,6 @@ class ChatIdentityScreenshotTest { capture("chat_top_bar_long_name.png") } - @Test - fun rendersProfileHeaderIdentityStates() { - val joinDate = Instant.fromEpochMilliseconds(1_700_000_000_000) - composeRule.mainClock.autoAdvance = false - composeRule.setContent { - FlipcashPreview(showBackground = true) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { - listOf(knownContact, namedTipUser, handleOnlyTipUser).forEach { participant -> - ProfileHeader( - participant = participant, - joinDate = joinDate, - modifier = Modifier.width(300.dp), - ) - } - } - } - } - repeat(10) { composeRule.mainClock.advanceTimeByFrame() } - - capture("chat_profile_header_identity.png") - } - - @Test - fun rendersProfileHeaderShortcuts() { - val joinDate = Instant.fromEpochMilliseconds(1_700_000_000_000) - composeRule.mainClock.autoAdvance = false - composeRule.setContent { - FlipcashPreview(showBackground = true) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { - // Dollar and euro, so a symbol change is visible in the glyph. - listOf("$", "€").forEach { symbol -> - ProfileHeader( - participant = namedTipUser, - joinDate = joinDate, - modifier = Modifier.width(300.dp), - shortcuts = { - ProfileShortcuts( - cashSymbol = symbol, - onMessage = {}, - onSendCash = {}, - ) - }, - ) - } - } - } - } - repeat(10) { composeRule.mainClock.advanceTimeByFrame() } - - capture("chat_profile_header_shortcuts.png") - } - private fun capture(name: String) { val root: View = composeRule.activity.findViewById(android.R.id.content) val width = root.width.takeIf { it > 0 } ?: 1080 diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/E2eeScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/E2eeScreenshotTest.kt index 881d823c23..718746f8d3 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/E2eeScreenshotTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/E2eeScreenshotTest.kt @@ -16,15 +16,12 @@ import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.menu.MenuList import com.flipcash.app.messenger.internal.screens.E2eeLearnMoreSheet import com.flipcash.app.messenger.internal.screens.E2eeSheetKind -import com.flipcash.app.messenger.internal.screens.profile.BlockUser import com.flipcash.app.messenger.internal.screens.profile.E2eeFooter import com.flipcash.app.messenger.internal.screens.profile.GroupProfileHeader import com.flipcash.app.messenger.internal.screens.profile.InviteToGroup import com.flipcash.app.messenger.internal.screens.profile.LeaveChat import com.flipcash.app.messenger.internal.screens.profile.MuteChat -import com.flipcash.app.messenger.internal.screens.profile.ProfileHeader import com.flipcash.app.messenger.internal.screens.profile.ReportGroup -import com.flipcash.app.messenger.internal.screens.profile.ReportUser import com.flipcash.app.theme.FlipcashPreview import com.flipcash.features.messenger.R import com.flipcash.services.models.UserProfile @@ -56,40 +53,6 @@ class E2eeScreenshotTest { @get:Rule val composeRule = createAndroidComposeRule() - @Test - fun rendersDmProfileWithFooter() = render("e2ee_dm_profile.png") { - val person = ChatParticipant.TipUser( - userId = listOf(1.toByte()), - profile = UserProfile.Empty.copy(displayName = "Grace Hopper", username = "grace_hopper"), - ) - CodeScaffold( - topBar = { AppBarWithTitle(onBackIconClicked = {}) }, - bottomBar = { E2eeFooter(isEncrypted = true, onLearnMore = {}) }, - ) { innerPadding -> - MenuList( - modifier = Modifier.fillMaxSize().padding(innerPadding), - items = listOf(ReportUser, BlockUser), - header = { - ProfileHeader( - participant = person, - joinDate = null, - modifier = Modifier - .fillMaxWidth() - .padding(top = CodeTheme.dimens.grid.x7, bottom = CodeTheme.dimens.grid.x8), - ) - }, - onItemClick = {}, - endSlot = { - Icon( - painter = painterResource(R.drawable.ic_chevron_right), - contentDescription = null, - tint = CodeTheme.colors.textSecondary, - ) - }, - ) - } - } - @Test fun rendersGroupInfo() = render("e2ee_group_info.png") { val group = ChatSubject.Group( diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MentionDestinationTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MentionDestinationTest.kt index d0ce51cd08..2d4df75405 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MentionDestinationTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MentionDestinationTest.kt @@ -3,11 +3,8 @@ package com.flipcash.app.messenger.internal import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.chat.ProfileOrigin import com.flipcash.app.messenger.internal.link.UserLinkLookup -import com.flipcash.app.messenger.internal.screens.profile.profileShortcutRecipient -import com.flipcash.app.messenger.internal.screens.profile.profileChat import com.flipcash.services.models.GetUserProfileError import com.flipcash.services.models.UserProfile -import com.flipcash.services.models.chat.ChatType import com.flipcash.shared.chat.models.LinkCard import java.io.IOException import kotlin.test.Test @@ -79,12 +76,4 @@ class MentionDestinationTest { mentionDestination("satoshi", Result.failure(IOException()), counterpart = null), ) } - - @Test - fun `a mention offers chat actions but not mute`() { - val other = ChatParticipant.TipUser(otherId, profile(otherId)) - val chat = profileChat(ProfileOrigin.Mention, chatId = null, ChatType.TIP_DM, viewerState = null) - assertNull(chat) - assertEquals(other, profileShortcutRecipient(other, chat?.chatType, selfId)) - } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt index c9c745269a..390f3071b0 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt @@ -88,6 +88,8 @@ class ChatProfileViewModelTest { tipPaymentDelegate = tipPaymentDelegate, e2eePolicy = mockk(relaxed = true), startChattingPayer = payer, + clipboardManager = mockk(relaxed = true), + toastController = mockk(relaxed = true), ) private fun openFull(model: ChatProfileViewModel) = @@ -167,6 +169,23 @@ class ChatProfileViewModelTest { assertTrue(model.stateFlow.value.isFullProfileLoaded) } + @Test + fun `opening the same person again from the same flow settles again`() = runTest { + coEvery { profiles.getProfileForUser(theirId) } returns + Result.success(participant(serverJoin).profile) + val model = viewModel() + val cached = ChatProfileViewModel.Event.OnParticipantSet(participant(cachedJoin)) + + // The chat's flow keeps one view model, so a second tap on the same avatar re-sets the + // same participant. The reducer resets the settled flag; the fetch has to run again. + model.dispatchEvent(cached) + model.dispatchEvent(cached) + + assertTrue(model.stateFlow.value.profileSettled) + assertTrue(model.stateFlow.value.isFullProfileLoaded) + coVerify(exactly = 2) { profiles.getProfileForUser(theirId) } + } + @Test fun `a partial participant is not full until the fetch lands`() = runTest { coEvery { profiles.getProfileForUser(theirId) } returns Result.failure(Exception("offline")) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileActionsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileActionsTest.kt index 132e61651a..511c572fff 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileActionsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileActionsTest.kt @@ -1,5 +1,6 @@ package com.flipcash.app.messenger.internal.screens.profile +import com.flipcash.features.messenger.R import com.getcode.opencode.model.financial.Fiat import org.junit.Test import kotlin.test.assertEquals @@ -91,4 +92,24 @@ class ProfileActionsTest { assertFalse(ChatProfileAction.Mute.isDestructive) assertFalse(ChatProfileAction.Unblock.isDestructive) } + + // -- labelRes -- + + @Test + fun `the pinned button names what it does`() { + assertEquals(R.string.action_unblock, ProfilePinnedAction.Unblock.labelRes()) + assertEquals(R.string.action_openChat, ProfilePinnedAction.OpenChat.labelRes()) + } + + @Test + fun `start chatting names the price only when there is one`() { + assertEquals( + R.string.action_sendToStartChatting, + ProfilePinnedAction.StartChatting(fee).labelRes(), + ) + assertEquals( + R.string.action_startChatting, + ProfilePinnedAction.StartChatting(null).labelRes(), + ) + } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcutRecipientTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcutRecipientTest.kt deleted file mode 100644 index e71f28a81e..0000000000 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcutRecipientTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.flipcash.app.messenger.internal.screens.profile - -import com.flipcash.app.core.chat.ChatParticipant -import com.flipcash.app.core.chat.ProfileOrigin -import com.flipcash.app.core.contacts.DeviceContact -import com.flipcash.services.models.UserProfile -import com.flipcash.services.models.chat.ChatType -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -/** When a profile offers Message and Send Cash. Kept in step with the iOS profile's rule. */ -class ProfileShortcutRecipientTest { - - private val self = listOf(1.toByte()) - private val member = ChatParticipant.TipUser( - userId = listOf(2.toByte()), - profile = UserProfile.Empty.copy(username = "grace_hopper"), - ) - - @Test - fun `a group member's profile offers the shortcuts`() { - assertEquals(member, profileShortcutRecipient(member, ChatType.GROUP, self)) - } - - @Test - fun `a tip DM's profile doesn't, because they would reopen the chat behind it`() { - assertNull(profileShortcutRecipient(member, ChatType.TIP_DM, self)) - } - - @Test - fun `the viewer's own profile doesn't`() { - val me = member.copy(userId = self) - assertNull(profileShortcutRecipient(me, ChatType.GROUP, self)) - } - - @Test - fun `a contact or a missing participant doesn't`() { - val contact = ChatParticipant.Contact( - DeviceContact( - e164 = "+15551234567", - androidContactId = 1L, - displayName = "Ada Lovelace", - photoUri = null, - displayNumber = "(555) 123-4567", - ) - ) - assertNull(profileShortcutRecipient(contact, ChatType.GROUP, self)) - assertNull(profileShortcutRecipient(null, ChatType.GROUP, self)) - } - - @Test - fun `someone mentioned in a tip DM gets the shortcuts and no Mute, since the chat is not with them`() { - val chat = profileChat(ProfileOrigin.Mention, chatId = null, ChatType.TIP_DM, viewerState = null) - assertNull(chat) - assertEquals(member, profileShortcutRecipient(member, chat?.chatType, self)) - } - - @Test - fun `a mention of the viewer still doesn't`() { - val me = member.copy(userId = self) - assertNull(profileShortcutRecipient(me, chatType = null, self)) - } - - @Test - fun `a profile opened from the chat keeps it, so a tip DM's counterpart offers Mute`() { - val chat = profileChat(ProfileOrigin.Chat, chatId = null, ChatType.TIP_DM, viewerState = null) - assertEquals(ChatType.TIP_DM, chat?.chatType) - assertNull(profileShortcutRecipient(member, chat?.chatType, self)) - } -} diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/JoinedLabel.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/JoinedLabel.kt similarity index 93% rename from apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/JoinedLabel.kt rename to apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/JoinedLabel.kt index 0811b2e495..dda86b7cac 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/JoinedLabel.kt +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/JoinedLabel.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.menu.internal +package com.flipcash.shared.common.ui.profile import android.text.format.DateFormat import java.time.ZoneId @@ -14,7 +14,7 @@ import kotlin.time.Instant * The pattern comes from [DateFormat.getBestDateTimePattern] for the `MMMMy` skeleton, so word * order and connectives follow [locale] ("octubre de 2026" in Spanish). */ -internal fun joinedLabel( +fun joinedLabel( joinedAt: Instant?, locale: Locale = Locale.getDefault(), zone: ZoneId = ZoneId.systemDefault(), diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/JoinedLabelTest.kt b/apps/flipcash/shared/common-ui/src/test/kotlin/com/flipcash/shared/common/ui/profile/JoinedLabelTest.kt similarity index 95% rename from apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/JoinedLabelTest.kt rename to apps/flipcash/shared/common-ui/src/test/kotlin/com/flipcash/shared/common/ui/profile/JoinedLabelTest.kt index 96cb8967c3..6527417a3d 100644 --- a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/JoinedLabelTest.kt +++ b/apps/flipcash/shared/common-ui/src/test/kotlin/com/flipcash/shared/common/ui/profile/JoinedLabelTest.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.menu.internal +package com.flipcash.shared.common.ui.profile import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToastHost.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToastHost.kt index 3e0a4fcaf8..02d55c3514 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToastHost.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToastHost.kt @@ -18,10 +18,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.SnackbarDuration import androidx.compose.material.SnackbarResult import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -36,6 +38,7 @@ import androidx.compose.ui.platform.LocalAccessibilityManager import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.dismiss import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.getcode.ui.components.glass.FloatingChrome import dev.chrisbanes.haze.HazeState @@ -89,6 +92,28 @@ class FloatingToastHostState { if (value) current?.dismiss() } + private val coverings = mutableStateMapOf() + + /** + * How far up from the bottom of the screen the tallest thing a screen has pinned there reaches, + * so the host can rest its toast above it. Zero when no screen has asked. + */ + val bottomClearance: Dp + get() = coverings.values.maxOrNull() ?: 0.dp + + /** + * Records that [owner] has something [height] tall (measured from the screen's bottom edge, + * system bars included) pinned to the bottom. Calling again replaces the owner's earlier + * height; [clearBottomClearance] withdraws it. + */ + fun setBottomClearance(owner: Any, height: Dp) { + coverings[owner] = height + } + + fun clearBottomClearance(owner: Any) { + coverings.remove(owner) + } + private var nextSlot = 0L /** @@ -274,3 +299,18 @@ private fun SwipeToDismiss( // How far below its resting place a rising toast starts: enough to tuck its squashed bottom edge // behind the bar it grows out of. + +/** + * Asks the root host to rest its toasts above something this screen pins to the bottom, for as long + * as the screen is composed. [height] is measured from the screen's bottom edge, system bars + * included. A no-op where no root host is installed. + */ +@Composable +fun ToastBottomClearance(height: Dp) { + val host = LocalFloatingToastHost.current ?: return + val owner = remember { Any() } + DisposableEffect(host, owner, height) { + host.setBottomClearance(owner, height) + onDispose { host.clearBottomClearance(owner) } + } +} diff --git a/ui/components/src/test/kotlin/com/getcode/ui/components/toast/FloatingToastHostStateTest.kt b/ui/components/src/test/kotlin/com/getcode/ui/components/toast/FloatingToastHostStateTest.kt index d1995ca34f..02404381d9 100644 --- a/ui/components/src/test/kotlin/com/getcode/ui/components/toast/FloatingToastHostStateTest.kt +++ b/ui/components/src/test/kotlin/com/getcode/ui/components/toast/FloatingToastHostStateTest.kt @@ -1,6 +1,7 @@ package com.getcode.ui.components.toast import androidx.compose.material.SnackbarResult +import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.async import kotlinx.coroutines.test.runTest @@ -75,4 +76,23 @@ class FloatingToastHostStateTest { host.current!!.dismiss() second.await() } + + @Test + fun `clearance is the tallest owner and drops back when one is cleared`() { + val host = FloatingToastHostState() + val a = Any() + val b = Any() + assertEquals(0.dp, host.bottomClearance) + + host.setBottomClearance(a, 80.dp) + host.setBottomClearance(b, 120.dp) + assertEquals(120.dp, host.bottomClearance) + + host.setBottomClearance(b, 40.dp) + assertEquals(80.dp, host.bottomClearance) + + host.clearBottomClearance(a) + host.clearBottomClearance(b) + assertEquals(0.dp, host.bottomClearance) + } } From 5e1aff36dd71a021a3b4a751bc14f6011d1564cb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 16:23:17 -0400 Subject: [PATCH 05/12] fix(messenger): keep the other-user profile's payment and per-person state straight Paying the chat fee waited on the DM inside the event collector, so every other event stalled behind it for up to ten seconds, and a queued tap could pay the same person twice because the sheet's show and dismiss reset the only guard. The pay and the wait now run in their own coroutine, and a per-recipient paid marker (paidRecipients) that the sheet cannot clear gates payment and turns the pinned action into a loading Open Chat until the DM appears. The flow-shared view model is reused across people, so opening a different person now resets the join date, block, mute, encryption, DM, fee and sheet state, and ends the previous person's DM subscriptions. Opening the same person again keeps the settled profile while it refetches in the background, and a fetch that lands after the screen moved on is dropped. Also adds DefaultStartChattingPayer tests, lets the fee sheet animate out, and notes on ChatViewModel's duplicate pay path that it goes with the paid-DM gate. --- .../app/messenger/internal/ChatViewModel.kt | 2 + .../screens/profile/ChatProfileScreen.kt | 27 +-- .../screens/profile/ChatProfileViewModel.kt | 180 +++++++++++++----- .../screens/profile/ProfilePinnedAction.kt | 7 +- .../screens/profile/StartChattingSheet.kt | 21 +- .../payment/DefaultStartChattingPayerTest.kt | 124 ++++++++++++ .../profile/ChatProfileViewModelTest.kt | 175 ++++++++++++++++- 7 files changed, 465 insertions(+), 71 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/payment/DefaultStartChattingPayerTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 7ebebb5101..f1a916d819 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -2617,6 +2617,8 @@ internal class ChatViewModel @Inject constructor( .onEach { onConfirmRequested() } .launchIn(viewModelScope) + // Duplicates StartChattingPayer.pay (minus its send-limit check); this fee-payment path goes + // away with the paid-DM gate in profile-refresh slice 5. eventFlow.filterIsInstance() .onEach { onConfirmRequested(fixedAmount = minAmountFlow.value) } .launchIn(viewModelScope) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index b9ecb970c7..c9fab42ceb 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -94,10 +95,11 @@ internal fun PersonProfileScreen( // The host decides what opening the chat means (pop back to it, or push it); everything else // the profile can ask for is the same wherever it is. + val currentOnOpenChat by rememberUpdatedState(onOpenChat) LaunchedEffect(viewModel) { viewModel.eventFlow.collect { event -> when (event) { - is ChatProfileViewModel.Event.OpenChat -> onOpenChat(event.chatId) + is ChatProfileViewModel.Event.OpenChat -> currentOnOpenChat(event.chatId) is ChatProfileViewModel.Event.OpenSendCash -> navigator.push(event.participant.dmRoute(openSendCash = true)) is ChatProfileViewModel.Event.OpenScreen -> navigator.push(event.route) @@ -122,7 +124,7 @@ internal fun PersonProfileScreen( val density = LocalDensity.current var pinnedHeight by remember { mutableStateOf(0.dp) } // Nothing is pinned, so nothing for a toast to clear. - val clearance = if (pinned != null || (state.dmExists && state.isEncrypted)) pinnedHeight else 0.dp + val clearance = if (pinned != null) pinnedHeight else 0.dp ToastBottomClearance(clearance) val hazeState = rememberHazeState() @@ -249,17 +251,16 @@ internal fun PersonProfileScreen( clearNavigationBar = false, ) } - if (pinned != null) { - CodeButton( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset) - .padding(bottom = CodeTheme.dimens.grid.x3), - buttonState = ButtonState.Filled, - text = pinned.label(), - onClick = { viewModel.dispatchEvent(ChatProfileViewModel.Event.PinnedActionTapped) }, - ) - } + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x3), + buttonState = ButtonState.Filled, + text = pinned.label(), + isLoading = pinned == ProfilePinnedAction.OpeningChat, + onClick = { viewModel.dispatchEvent(ChatProfileViewModel.Event.PinnedActionTapped) }, + ) } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt index 3e258ce46c..4ce698c51f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt @@ -30,7 +30,11 @@ import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel import com.getcode.view.LoadingSuccessState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow @@ -101,6 +105,13 @@ internal class ChatProfileViewModel @Inject constructor( val token: Token? = null, val paymentSheetVisible: Boolean = false, val sendProgress: LoadingSuccessState = LoadingSuccessState(), + /** + * People whose fee was paid, or is being paid, from this view model. The fee sheet's own + * show and dismiss reset [sendProgress], so this is what remembers that a payment is out: + * a payment can only be repeated once the DM it bought has had time to appear, and never + * while it is in flight. + */ + val paidRecipients: Set = emptySet(), ) { /** Null for your own profile, for anyone but a tip user, and until [profileSettled]. */ val pinnedAction: ProfilePinnedAction? @@ -112,6 +123,7 @@ internal class ChatProfileViewModel @Inject constructor( isBlocked = isBlocked, dmExists = dmExists, fee = fee, + paid = person.userId in paidRecipients, ) } @@ -151,6 +163,10 @@ internal class ChatProfileViewModel @Inject constructor( data object Unblock : Event /** The slide on the fee sheet was confirmed. */ data object ConfirmStartChatting : Event + /** The fee for [recipient] is out; nothing may pay it again until the DM shows or it fails. */ + data class PaymentStarted(val recipient: ID) : Event + /** The payment for [recipient] did not go through, so it may be tried again. */ + data class PaymentFailed(val recipient: ID) : Event data object ShowPaymentSheet : Event data object DismissPaymentSheet : Event data class PaymentProgress( @@ -185,13 +201,14 @@ internal class ChatProfileViewModel @Inject constructor( eventFlow .filterIsInstance() .filter { it.participant is ChatParticipant.TipUser } - // Not distinct: the reducer resets the settled flag on every set, so a repeat of the - // same participant (a second visit in a flow that keeps this view model) has to fetch. - .onEach { event -> + // Not distinct: a repeat of the same participant (a second visit in a flow that keeps + // this view model) has to fetch. Latest: a fetch for someone the screen has moved on + // from is dropped rather than landing on the next person. + .collectLatestIn(viewModelScope) { event -> val (userId, profile) = event.participant as ChatParticipant.TipUser if (event.isFullProfile) { dispatchEvent(Event.JoinDateLoaded(profile.joinedAt)) - return@onEach + return@collectLatestIn } // The participant came from the cache, which carries no join date and may carry a // roster page's blank name. The server profile replaces it whole; the cached one @@ -204,7 +221,6 @@ internal class ChatProfileViewModel @Inject constructor( } dispatchEvent(Event.JoinDateLoaded(fetched?.joinedAt ?: profile.joinedAt)) } - .launchIn(viewModelScope) eventFlow .filterIsInstance() @@ -282,27 +298,39 @@ internal class ChatProfileViewModel @Inject constructor( .onEach { dispatchEvent(Event.BlockedChanged(it)) } .launchIn(viewModelScope) - val chatIds = stateFlow.map { it.dmChatId }.filterNotNull().distinctUntilChanged() + // Nullable on purpose: moving to a person with no DM yet has to end the last person's + // subscriptions, and coming back to one has to start them again. + val chatIds = stateFlow.map { it.dmChatId }.distinctUntilChanged() // A block hides the DM but leaves its members, so this stays true through one; the pinned // action puts blocked first for that reason. chatIds - .flatMapLatest { chatCoordinator.observeMembers(it) } - .map { it.isNotEmpty() } - .distinctUntilChanged() + .flatMapLatest { chatId -> + if (chatId == null) { + emptyFlow() + } else { + chatCoordinator.observeMembers(chatId).map { it.isNotEmpty() }.distinctUntilChanged() + } + } .onEach { dispatchEvent(Event.DmExistsChanged(it)) } .launchIn(viewModelScope) chatIds - .flatMapLatest { chatCoordinator.observeMetadata(it) } - .map { membership -> - Event.ChatStateChanged( - // Evaluated on emission; a timed mute that lapses has no event of its own. - isMuted = membership?.metadata?.viewerState.isMutedAt(), - isEncrypted = membership?.metadata?.let(e2eePolicy::shouldEncrypt) ?: false, - ) + .flatMapLatest { chatId -> + if (chatId == null) { + emptyFlow() + } else { + chatCoordinator.observeMetadata(chatId) + .map { membership -> + Event.ChatStateChanged( + // Evaluated on emission; a timed mute that lapses has no event of its own. + isMuted = membership?.metadata?.viewerState.isMutedAt(), + isEncrypted = membership?.metadata?.let(e2eePolicy::shouldEncrypt) ?: false, + ) + } + .distinctUntilChanged() + } } - .distinctUntilChanged() .onEach { dispatchEvent(it) } .launchIn(viewModelScope) @@ -329,7 +357,7 @@ internal class ChatProfileViewModel @Inject constructor( ProfilePinnedAction.Unblock -> dispatchEvent(Event.Unblock) ProfilePinnedAction.OpenChat -> state.dmChatId?.let { dispatchEvent(Event.OpenChat(it)) } is ProfilePinnedAction.StartChatting -> startChatting(action.fee) - null -> Unit + ProfilePinnedAction.OpeningChat, null -> Unit } } .launchIn(viewModelScope) @@ -377,36 +405,48 @@ internal class ChatProfileViewModel @Inject constructor( } } - private suspend fun confirmStartChatting() { + /** + * Pays, then waits for the DM to appear, all off the event collector: the wait can run to + * [MEMBERS_TIMEOUT], and a collector held that long stalls every other event, including a + * second tap that would pay again. The person and chat are read once, up front. + */ + private fun confirmStartChatting() { val state = stateFlow.value val person = state.participant as? ChatParticipant.TipUser ?: return val fee = state.fee ?: return - if (!state.sendProgress.isIdle) return + if (!state.sendProgress.isIdle || person.userId in state.paidRecipients) return + val knownChatId = state.dmChatId + dispatchEvent(Event.PaymentStarted(person.userId)) dispatchEvent(Event.PaymentProgress(loading = true)) - startChattingPayer.pay( - recipient = person.userId, - fee = fee, - onAddMoney = { dispatchEvent(Event.PresentDepositOptions) }, - ).onSuccess { paidChatId -> - dispatchEvent(Event.PaymentProgress(success = true)) - delay(SUCCESS_HOLD) - dispatchEvent(Event.DismissPaymentSheet) - val chatId = paidChatId ?: stateFlow.value.dmChatId ?: return@onSuccess - // The server creates the DM from the payment, so its members arrive a moment after. - // If they never do, stay: the pinned action flips to Open Chat when they land. - val arrived = withTimeoutOrNull(MEMBERS_TIMEOUT) { - chatCoordinator.observeMembers(chatId).first { it.isNotEmpty() } - } - if (arrived != null) dispatchEvent(Event.OpenChat(chatId)) - }.onFailure { cause -> - dispatchEvent(Event.PaymentProgress()) - // A blocked payment has said why already. - if (cause !is StartChattingPayer.PaymentBlocked) { - BottomBarManager.showError( - title = resources.getString(R.string.error_title_cashFailedToSend), - message = resources.getString(R.string.error_description_cashFailedToSend), - ) + viewModelScope.launch { + startChattingPayer.pay( + recipient = person.userId, + fee = fee, + onAddMoney = { dispatchEvent(Event.PresentDepositOptions) }, + ).onSuccess { paidChatId -> + dispatchEvent(Event.PaymentProgress(success = true)) + delay(SUCCESS_HOLD) + dispatchEvent(Event.DismissPaymentSheet) + val chatId = paidChatId ?: knownChatId ?: return@onSuccess + // The server creates the DM from the payment, so its members arrive a moment + // after. If they never do, stay: the pinned action flips to Open Chat when they land. + val arrived = withTimeoutOrNull(MEMBERS_TIMEOUT) { + chatCoordinator.observeMembers(chatId).first { it.isNotEmpty() } + } + // Not if the screen has moved on to someone else in the meantime. + val stillHere = (stateFlow.value.participant as? ChatParticipant.TipUser)?.userId == person.userId + if (arrived != null && stillHere) dispatchEvent(Event.OpenChat(chatId)) + }.onFailure { cause -> + dispatchEvent(Event.PaymentFailed(person.userId)) + dispatchEvent(Event.PaymentProgress()) + // A blocked payment has said why already. + if (cause !is StartChattingPayer.PaymentBlocked) { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_cashFailedToSend), + message = resources.getString(R.string.error_description_cashFailedToSend), + ) + } } } } @@ -418,16 +458,13 @@ internal class ChatProfileViewModel @Inject constructor( val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { - is Event.OnParticipantSet -> { state -> - state.copy( - participant = event.participant, - isFullProfileLoaded = event.isFullProfile, - profileSettled = event.isFullProfile, - ) - } + is Event.OnParticipantSet -> { state -> state.withParticipantSet(event) } is Event.JoinDateLoaded -> { state -> state.copy(joinDate = event.joinDate) } is Event.ProfileLoaded -> { state -> - state.copy( + // A fetch that lands after the screen moved on is someone else's. + if ((state.participant as? ChatParticipant.TipUser)?.userId != event.participant.userId) { + state + } else state.copy( participant = event.participant, isFullProfileLoaded = true, profileSettled = true, @@ -442,6 +479,12 @@ internal class ChatProfileViewModel @Inject constructor( } is Event.FeeLoaded -> { state -> state.copy(fee = event.fee) } is Event.TokenUpdated -> { state -> state.copy(token = event.token) } + is Event.PaymentStarted -> { state -> + state.copy(paidRecipients = state.paidRecipients.toMutableSet().apply { add(event.recipient) }) + } + is Event.PaymentFailed -> { state -> + state.copy(paidRecipients = state.paidRecipients.toMutableSet().apply { remove(event.recipient) }) + } Event.ShowPaymentSheet -> { state -> state.copy(paymentSheetVisible = true, sendProgress = LoadingSuccessState()) } @@ -473,4 +516,37 @@ internal class ChatProfileViewModel @Inject constructor( } } } -} \ No newline at end of file +} + +/** + * Another person resets everything that was the last one's; the same person again keeps what is + * known, so the pinned button and bio do not vanish while the background refetch lands. + */ +private fun ChatProfileViewModel.State.withParticipantSet( + event: ChatProfileViewModel.Event.OnParticipantSet, +): ChatProfileViewModel.State { + val incoming = event.participant + val sameUser = incoming is ChatParticipant.TipUser && + (participant as? ChatParticipant.TipUser)?.userId == incoming.userId + if (sameUser) { + return copy( + // The server's profile stays rather than being swapped for the cached one. + participant = if (isFullProfileLoaded && !event.isFullProfile) participant else incoming, + isFullProfileLoaded = isFullProfileLoaded || event.isFullProfile, + profileSettled = profileSettled || event.isFullProfile, + ) + } + return ChatProfileViewModel.State( + participant = incoming, + selfId = selfId, + isFullProfileLoaded = event.isFullProfile, + profileSettled = event.isFullProfile, + // Not per person: the selected token, and who has been paid. + token = token, + paidRecipients = paidRecipients, + ) +} + +private fun Flow.collectLatestIn(scope: CoroutineScope, block: suspend (T) -> Unit) { + scope.launch { collectLatest(block) } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfilePinnedAction.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfilePinnedAction.kt index 945e4ebaaa..34aaa253ff 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfilePinnedAction.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfilePinnedAction.kt @@ -9,6 +9,9 @@ internal sealed interface ProfilePinnedAction { data object Unblock : ProfilePinnedAction data object OpenChat : ProfilePinnedAction + /** The fee is paid and the DM has not appeared yet; shown disabled, so it cannot be paid twice. */ + data object OpeningChat : ProfilePinnedAction + /** A null [fee] means "Start Chatting" opens the amount entry rather than a confirmation. */ data class StartChatting(val fee: Fiat?) : ProfilePinnedAction } @@ -21,10 +24,12 @@ internal fun resolvePinnedAction( isBlocked: Boolean, dmExists: Boolean, fee: Fiat?, + paid: Boolean = false, ): ProfilePinnedAction? = when { isSelf -> null isBlocked -> ProfilePinnedAction.Unblock dmExists -> ProfilePinnedAction.OpenChat + paid -> ProfilePinnedAction.OpeningChat else -> ProfilePinnedAction.StartChatting(fee) } @@ -50,7 +55,7 @@ internal fun profileMenuItems(isBlocked: Boolean, hasDm: Boolean): List R.string.action_unblock - ProfilePinnedAction.OpenChat -> R.string.action_openChat + ProfilePinnedAction.OpenChat, ProfilePinnedAction.OpeningChat -> R.string.action_openChat is ProfilePinnedAction.StartChatting -> if (fee != null) R.string.action_sendToStartChatting else R.string.action_startChatting } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/StartChattingSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/StartChattingSheet.kt index 4f3846c391..6e5eb540ed 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/StartChattingSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/StartChattingSheet.kt @@ -4,6 +4,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.rememberModalBottomSheetState 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 com.flipcash.app.messenger.internal.screens.cash.ChatInitPaymentSheet import com.getcode.theme.CodeTheme @@ -20,10 +25,22 @@ internal fun StartChattingSheet( onConfirm: () -> Unit, onDismiss: () -> Unit, ) { - if (!state.paymentSheetVisible) return + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + // The sheet stays composed until it has slid away, so a dismissal from the view model (a + // payment that landed) animates out like a swipe does rather than vanishing. + var composed by remember { mutableStateOf(false) } + LaunchedEffect(state.paymentSheetVisible) { + if (state.paymentSheetVisible) { + composed = true + } else if (composed) { + sheetState.hide() + composed = false + } + } + if (!composed) return ModalBottomSheet( onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + sheetState = sheetState, containerColor = CodeTheme.colors.background, ) { ChatInitPaymentSheet( diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/payment/DefaultStartChattingPayerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/payment/DefaultStartChattingPayerTest.kt new file mode 100644 index 0000000000..35bdfff90d --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/payment/DefaultStartChattingPayerTest.kt @@ -0,0 +1,124 @@ +package com.flipcash.app.messenger.internal.payment + +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.funding.PurchaseMethodController +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.user.UserManager +import com.flipcash.shared.payments.TipPaymentDelegate +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiatCalculator +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.financial.TokenWithBalance +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** The checks that stand between a tap on "Start Chatting" and money leaving. */ +class DefaultStartChattingPayerTest { + + private val token = mockk(relaxed = true) + private val balance = mockk(relaxed = true) + private val fee = mockk(relaxed = true) + + private val tokenCoordinator = mockk(relaxed = true) { + every { observeSelectedTokenMint() } returns flowOf(token.address) + every { tokenBalances } returns flowOf(listOf(mockk(relaxed = true) { + every { this@mockk.token } returns this@DefaultStartChattingPayerTest.token + })) + every { balanceForToken(any()) } returns balance + } + private val exchange = mockk(relaxed = true) { every { preferredRate } returns mockk(relaxed = true) } + private val userManager = mockk(relaxed = true) + private val calculator = mockk(relaxed = true) + private val tips = mockk(relaxed = true) { + every { exceedsSendLimit(any()) } returns false + } + private val resources = mockk(relaxed = true) { + every { getString(any()) } answers { "res:${firstArg()}" } + } + + private fun payer() = DefaultStartChattingPayer( + tokenCoordinator = tokenCoordinator, + exchange = exchange, + userManager = userManager, + verifiedFiatCalculator = calculator, + tipPaymentDelegate = tips, + purchaseMethodController = mockk(relaxed = true), + analytics = mockk(relaxed = true), + resources = resources, + ) + + @Before + fun setUp() { + BottomBarManager.clear() + every { userManager.accountCluster } returns mockk(relaxed = true) + every { fee.valueGreaterThan(any()) } returns false + } + + @After + fun tearDown() = BottomBarManager.clear() + + private fun assertBlockedWith(result: Result, title: Int) { + assertIs(result.exceptionOrNull()) + assertEquals("res:$title", BottomBarManager.messages.value.single().title) + coVerify(exactly = 0) { tips.send(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `over balance is blocked with the insufficient balance alert`() = runTest { + every { fee.valueGreaterThan(any()) } returns true + + val result = payer().pay(listOf(1), fee, onAddMoney = {}) + + assertBlockedWith(result, R.string.title_insufficientBalance) + } + + @Test + fun `over the send limit is blocked with the limit alert`() = runTest { + every { tips.exceedsSendLimit(any()) } returns true + + val result = payer().pay(listOf(1), fee, onAddMoney = {}) + + assertBlockedWith(result, R.string.error_title_sendLimitReached) + } + + @Test + fun `no account fails without an alert or a payment`() = runTest { + every { userManager.accountCluster } returns null + + val result = payer().pay(listOf(1), fee, onAddMoney = {}) + + assertTrue(result.exceptionOrNull() !is StartChattingPayer.PaymentBlocked) + assertTrue(result.isFailure) + assertTrue(BottomBarManager.messages.value.isEmpty()) + coVerify(exactly = 0) { tips.send(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `a payment that clears every check is sent and returns the chat`() = runTest { + val chatId = ChatId(ByteArray(32) { 7 }) + coEvery { calculator.compute(any(), any(), any(), any()) } returns Result.success(mockk(relaxed = true)) + coEvery { tips.send(any(), any(), any(), any(), any(), any()) } returns Result.success(chatId) + + val result = payer().pay(listOf(1), fee, onAddMoney = {}) + + assertEquals(chatId, result.getOrNull()) + assertTrue(BottomBarManager.messages.value.isEmpty()) + coVerify(exactly = 1) { tips.send(any(), any(), any(), any(), any(), any()) } + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt index 390f3071b0..28d643b826 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt @@ -95,15 +95,19 @@ class ChatProfileViewModelTest { private fun openFull(model: ChatProfileViewModel) = model.dispatchEvent(ChatProfileViewModel.Event.OnParticipantSet(participant(serverJoin), isFullProfile = true)) - private fun participant(joinedAt: Instant?, displayName: String = "Sally") = ChatParticipant.TipUser( - userId = theirId, + private fun participant( + joinedAt: Instant?, + displayName: String = "Sally", + id: List = theirId, + ) = ChatParticipant.TipUser( + userId = id, profile = UserProfile( displayName = displayName, socialAccounts = emptyList(), phoneNumber = null, email = null, joinedAt = joinedAt, - userId = theirId, + userId = id, username = "sally_streamer", ), ) @@ -287,4 +291,169 @@ class ChatProfileViewModelTest { coVerify(exactly = 1) { blocklist.unblock(theirId) } } + + @Test + fun `other events are not stalled while a paid chat waits for its members`() = runTest { + fee.value = Fiat(1, CurrencyCode.USD) + coEvery { payer.pay(any(), any(), any()) } returns Result.success(dmChatId) + coEvery { blocklist.unblock(any()) } returns Result.success(Unit) + val model = viewModel() + openFull(model) + + model.dispatchEvent(ChatProfileViewModel.Event.ConfirmStartChatting) + advanceTimeBy(1_000) + model.dispatchEvent(ChatProfileViewModel.Event.Unblock) + + coVerify(exactly = 1) { blocklist.unblock(theirId) } + } + + @Test + fun `a paid chat waiting for its members cannot be paid for again`() = runTest { + fee.value = Fiat(1, CurrencyCode.USD) + coEvery { payer.pay(any(), any(), any()) } returns Result.success(dmChatId) + val model = viewModel() + openFull(model) + + model.dispatchEvent(ChatProfileViewModel.Event.ConfirmStartChatting) + advanceTimeBy(1_000) + // The sheet's own show and dismiss both reset the sheet's progress; neither may reopen payment. + model.dispatchEvent(ChatProfileViewModel.Event.PinnedActionTapped) + model.dispatchEvent(ChatProfileViewModel.Event.ShowPaymentSheet) + model.dispatchEvent(ChatProfileViewModel.Event.ConfirmStartChatting) + model.dispatchEvent(ChatProfileViewModel.Event.DismissPaymentSheet) + model.dispatchEvent(ChatProfileViewModel.Event.ConfirmStartChatting) + advanceTimeBy(11_000) + + coVerify(exactly = 1) { payer.pay(any(), any(), any()) } + assertFalse(model.stateFlow.value.paymentSheetVisible) + assertEquals(ProfilePinnedAction.OpeningChat, model.stateFlow.value.pinnedAction) + } + + @Test + fun `a failed payment can be tried again`() = runTest { + fee.value = Fiat(1, CurrencyCode.USD) + coEvery { payer.pay(any(), any(), any()) } returns Result.failure(Exception("nope")) + val model = viewModel() + openFull(model) + + model.dispatchEvent(ChatProfileViewModel.Event.ConfirmStartChatting) + model.dispatchEvent(ChatProfileViewModel.Event.ConfirmStartChatting) + + coVerify(exactly = 2) { payer.pay(any(), any(), any()) } + assertTrue(model.stateFlow.value.pinnedAction is ProfilePinnedAction.StartChatting) + } + + @Test + fun `a generic payment failure is reported, a blocked one is not`() = runTest { + fee.value = Fiat(1, CurrencyCode.USD) + val model = viewModel() + openFull(model) + com.getcode.manager.BottomBarManager.clear() + + coEvery { payer.pay(any(), any(), any()) } returns + Result.failure(StartChattingPayer.PaymentBlocked()) + model.dispatchEvent(ChatProfileViewModel.Event.ConfirmStartChatting) + assertTrue(com.getcode.manager.BottomBarManager.messages.value.isEmpty()) + + coEvery { payer.pay(any(), any(), any()) } returns Result.failure(Exception("nope")) + model.dispatchEvent(ChatProfileViewModel.Event.ConfirmStartChatting) + assertEquals(1, com.getcode.manager.BottomBarManager.messages.value.size) + com.getcode.manager.BottomBarManager.clear() + } + + // The reducer, for the flow-shared view model that is reused across people. + + private fun reduce(state: ChatProfileViewModel.State, event: ChatProfileViewModel.Event) = + ChatProfileViewModel.updateStateForEvent(event)(state) + + private val otherId: List = List(16) { 3 } + + @Test + fun `opening a different person clears everything that was the last one's`() { + val dirty = ChatProfileViewModel.State( + selfId = List(16) { 1 }, + participant = participant(serverJoin), + joinDate = serverJoin, + isFullProfileLoaded = true, + profileSettled = true, + dmChatId = dmChatId, + dmExists = true, + isBlocked = true, + isMuted = true, + fee = Fiat(1, CurrencyCode.USD), + isEncrypted = true, + paymentSheetVisible = true, + sendProgress = com.getcode.view.LoadingSuccessState(loading = true), + ) + + val next = reduce( + dirty, + ChatProfileViewModel.Event.OnParticipantSet(participant(null, "Bo", otherId)), + ) + + assertEquals(otherId, (next.participant as ChatParticipant.TipUser).userId) + assertNull(next.joinDate) + assertFalse(next.isFullProfileLoaded) + assertFalse(next.profileSettled) + assertNull(next.dmChatId) + assertFalse(next.dmExists) + assertFalse(next.isBlocked) + assertFalse(next.isMuted) + assertNull(next.fee) + assertFalse(next.isEncrypted) + assertFalse(next.paymentSheetVisible) + assertTrue(next.sendProgress.isIdle) + assertEquals(dirty.selfId, next.selfId) + } + + @Test + fun `opening a different person keeps what they have paid for`() { + val paid = ChatProfileViewModel.State( + participant = participant(serverJoin), + paidRecipients = setOf(theirId), + ) + + val next = reduce( + paid, + ChatProfileViewModel.Event.OnParticipantSet(participant(null, "Bo", otherId)), + ) + + assertEquals(setOf(theirId), next.paidRecipients) + } + + @Test + fun `opening the same person again keeps the profile settled`() { + val settled = ChatProfileViewModel.State( + participant = participant(serverJoin), + joinDate = serverJoin, + isFullProfileLoaded = true, + profileSettled = true, + dmExists = true, + fee = Fiat(1, CurrencyCode.USD), + ) + + val next = reduce( + settled, + ChatProfileViewModel.Event.OnParticipantSet(participant(cachedJoin)), + ) + + assertTrue(next.isFullProfileLoaded) + assertTrue(next.profileSettled) + assertEquals(serverJoin, next.joinDate) + assertTrue(next.dmExists) + // The server's profile is not swapped back for the cached one the host handed over. + assertEquals(serverJoin, (next.participant as ChatParticipant.TipUser).profile.joinedAt) + } + + @Test + fun `a profile that arrives for the person who was left is ignored`() { + val onBo = ChatProfileViewModel.State(participant = participant(null, "Bo", otherId)) + + val next = reduce( + onBo, + ChatProfileViewModel.Event.ProfileLoaded(participant(serverJoin)), + ) + + assertEquals(onBo, next) + } } From 68576ccc3bfa077a92e2e964af4c793afa22d74b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 16:39:49 -0400 Subject: [PATCH 06/12] refactor(common-ui): share the profile's pinned action bar Moves the other-user profile's pinned bottom area (optional line above, filled button, optional text action, nav-bar inset, height measurement, toast clearance) into ProfilePinnedActionBar so the group profile can reuse it. --- .../screens/profile/ChatProfileScreen.kt | 57 ++++-------- .../ui/profile/ProfilePinnedActionBar.kt | 88 +++++++++++++++++++ 2 files changed, 107 insertions(+), 38 deletions(-) create mode 100644 apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfilePinnedActionBar.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index c9fab42ceb..1ce97c2e8e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -1,12 +1,9 @@ package com.flipcash.app.messenger.internal.screens.profile import android.os.Parcelable -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.rememberScrollState @@ -27,8 +24,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp @@ -43,6 +38,7 @@ import com.flipcash.services.models.chat.ChatType import com.flipcash.shared.common.ui.ContactAvatar import com.flipcash.shared.common.ui.profile.ProfileActionButton import com.flipcash.shared.common.ui.profile.ProfileHeader +import com.flipcash.shared.common.ui.profile.ProfilePinnedActionBar import com.flipcash.shared.common.ui.profile.ProfileStatsCard import com.flipcash.shared.common.ui.profile.ProfileStatusChip import com.flipcash.shared.common.ui.profile.joinedLabel @@ -51,9 +47,6 @@ import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.CircularIconButton -import com.getcode.ui.components.toast.ToastBottomClearance -import com.getcode.ui.theme.ButtonState -import com.getcode.ui.theme.CodeButton import com.getcode.ui.theme.CodeScaffold import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState @@ -121,11 +114,10 @@ internal fun PersonProfileScreen( var shareOpen by remember { mutableStateOf(false) } var menuOpen by remember { mutableStateOf(false) } - val density = LocalDensity.current var pinnedHeight by remember { mutableStateOf(0.dp) } - // Nothing is pinned, so nothing for a toast to clear. + // Nothing is pinned, so nothing for the content to leave room for. The bar registers its own + // toast clearance while it is shown. val clearance = if (pinned != null) pinnedHeight else 0.dp - ToastBottomClearance(clearance) val hazeState = rememberHazeState() @@ -234,34 +226,23 @@ internal fun PersonProfileScreen( }, ) - val showFooter = pinned != null && state.dmExists && state.isEncrypted if (pinned != null) { - Column( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .onSizeChanged { pinnedHeight = with(density) { it.height.toDp() } } - .background(CodeTheme.colors.background) - .navigationBarsPadding(), - ) { - if (showFooter) { - E2eeFooter( - isEncrypted = true, - onLearnMore = { navigator.push(AppRoute.Messaging.E2eeDmInfo) }, - clearNavigationBar = false, - ) - } - CodeButton( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset) - .padding(bottom = CodeTheme.dimens.grid.x3), - buttonState = ButtonState.Filled, - text = pinned.label(), - isLoading = pinned == ProfilePinnedAction.OpeningChat, - onClick = { viewModel.dispatchEvent(ChatProfileViewModel.Event.PinnedActionTapped) }, - ) - } + ProfilePinnedActionBar( + modifier = Modifier.align(Alignment.BottomCenter), + text = pinned.label(), + isLoading = pinned == ProfilePinnedAction.OpeningChat, + onClick = { viewModel.dispatchEvent(ChatProfileViewModel.Event.PinnedActionTapped) }, + above = if (state.dmExists && state.isEncrypted) { + { + E2eeFooter( + isEncrypted = true, + onLearnMore = { navigator.push(AppRoute.Messaging.E2eeDmInfo) }, + clearNavigationBar = false, + ) + } + } else null, + onHeightChanged = { pinnedHeight = it }, + ) } } } diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfilePinnedActionBar.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfilePinnedActionBar.kt new file mode 100644 index 0000000000..ddff188a9f --- /dev/null +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfilePinnedActionBar.kt @@ -0,0 +1,88 @@ +package com.flipcash.shared.common.ui.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +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.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.toast.ToastBottomClearance +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton + +/** + * The one action a profile pins to the bottom of its screen: a filled button, with an optional + * [above] line over it (a footer, a shortfall) and an optional text action ([secondaryText]) + * under it. + * + * The bar sits on the screen's background, clears the navigation bar, and measures itself, + * system bar included. While it is composed it asks the root toast host to rest toasts above that + * height, and withdraws the request when it leaves, so show it only when something is pinned. The + * same height goes to [onHeightChanged] for the caller's scroll content to leave room for; it is + * the caller's to ignore once the bar is gone. + * + * Align it to the bottom of its parent through [modifier]. + */ +@Composable +fun ProfilePinnedActionBar( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + isLoading: Boolean = false, + above: (@Composable () -> Unit)? = null, + secondaryText: String? = null, + onSecondaryClick: (() -> Unit)? = null, + onHeightChanged: (Dp) -> Unit = {}, +) { + val density = LocalDensity.current + var height by remember { mutableStateOf(0.dp) } + ToastBottomClearance(height) + + Column( + modifier = modifier + .fillMaxWidth() + // Measured before the inset is applied, so the height includes the system bar. + .onSizeChanged { + val measured = with(density) { it.height.toDp() } + height = measured + onHeightChanged(measured) + } + .background(CodeTheme.colors.background) + .navigationBarsPadding(), + ) { + above?.invoke() + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x3), + buttonState = ButtonState.Filled, + text = text, + enabled = enabled, + isLoading = isLoading, + onClick = onClick, + ) + if (secondaryText != null && onSecondaryClick != null) { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x3), + buttonState = ButtonState.Subtle, + text = secondaryText, + onClick = onSecondaryClick, + ) + } + } +} From 2bb0ae9991e4315c1b62536ee98d837816d0e63f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 16:41:49 -0400 Subject: [PATCH 07/12] fix(messenger): keep the start-chatting sheet up until the chat opens The fee sheet now stays up, showing its success, until the DM's members arrive, and cannot be dismissed by swipe, scrim, back or DismissPaymentSheet while a payment is out. When the members land it hides and the chat opens. If they never do within 10s the sheet goes and the profile stays, as on iOS. A failed payment makes it dismissable again. --- .../screens/profile/ChatProfileScreen.kt | 4 + .../screens/profile/ChatProfileViewModel.kt | 40 +++++++-- .../screens/profile/StartChattingSheet.kt | 11 ++- .../profile/ChatProfileViewModelTest.kt | 84 +++++++++++++++++++ 4 files changed, 130 insertions(+), 9 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index 1ce97c2e8e..15d3640466 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -1,6 +1,7 @@ package com.flipcash.app.messenger.internal.screens.profile import android.os.Parcelable +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -101,6 +102,9 @@ internal fun PersonProfileScreen( } } + // Paying and waiting for the chat to open: there is no leaving until it has. + BackHandler(enabled = state.paymentInProgress) {} + StartChattingSheet( state = state, onConfirm = { viewModel.dispatchEvent(ChatProfileViewModel.Event.ConfirmStartChatting) }, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt index 4ce698c51f..00a587bc19 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt @@ -112,6 +112,11 @@ internal class ChatProfileViewModel @Inject constructor( * while it is in flight. */ val paidRecipients: Set = emptySet(), + /** + * A start-chatting payment is out, or paid and waiting for its chat: the fee sheet stays up + * and cannot be dismissed until the chat is ready, or the payment fails. + */ + val paymentInProgress: Boolean = false, ) { /** Null for your own profile, for anyone but a tip user, and until [profileSettled]. */ val pinnedAction: ProfilePinnedAction? @@ -169,6 +174,9 @@ internal class ChatProfileViewModel @Inject constructor( data class PaymentFailed(val recipient: ID) : Event data object ShowPaymentSheet : Event data object DismissPaymentSheet : Event + + /** The paid chat is ready, or has been given up on: the sheet may go. */ + data object PaymentFinished : Event data class PaymentProgress( val loading: Boolean = false, val success: Boolean = false, @@ -427,13 +435,21 @@ internal class ChatProfileViewModel @Inject constructor( ).onSuccess { paidChatId -> dispatchEvent(Event.PaymentProgress(success = true)) delay(SUCCESS_HOLD) - dispatchEvent(Event.DismissPaymentSheet) - val chatId = paidChatId ?: knownChatId ?: return@onSuccess + val chatId = paidChatId ?: knownChatId + if (chatId == null) { + dispatchEvent(Event.PaymentFinished) + dispatchEvent(Event.DismissPaymentSheet) + return@onSuccess + } // The server creates the DM from the payment, so its members arrive a moment - // after. If they never do, stay: the pinned action flips to Open Chat when they land. + // after. The sheet stays up, showing its success, until they do. If they never do + // (as on iOS) the sheet goes and the profile stays: the pinned action flips to Open + // Chat when they land. val arrived = withTimeoutOrNull(MEMBERS_TIMEOUT) { chatCoordinator.observeMembers(chatId).first { it.isNotEmpty() } } + dispatchEvent(Event.PaymentFinished) + dispatchEvent(Event.DismissPaymentSheet) // Not if the screen has moved on to someone else in the meantime. val stillHere = (stateFlow.value.participant as? ChatParticipant.TipUser)?.userId == person.userId if (arrived != null && stillHere) dispatchEvent(Event.OpenChat(chatId)) @@ -480,17 +496,27 @@ internal class ChatProfileViewModel @Inject constructor( is Event.FeeLoaded -> { state -> state.copy(fee = event.fee) } is Event.TokenUpdated -> { state -> state.copy(token = event.token) } is Event.PaymentStarted -> { state -> - state.copy(paidRecipients = state.paidRecipients.toMutableSet().apply { add(event.recipient) }) + state.copy( + paidRecipients = state.paidRecipients.toMutableSet().apply { add(event.recipient) }, + paymentInProgress = true, + ) } is Event.PaymentFailed -> { state -> - state.copy(paidRecipients = state.paidRecipients.toMutableSet().apply { remove(event.recipient) }) + state.copy( + paidRecipients = state.paidRecipients.toMutableSet().apply { remove(event.recipient) }, + paymentInProgress = false, + ) } Event.ShowPaymentSheet -> { state -> - state.copy(paymentSheetVisible = true, sendProgress = LoadingSuccessState()) + if (state.paymentInProgress) state + else state.copy(paymentSheetVisible = true, sendProgress = LoadingSuccessState()) } + // Not while a payment is out: the sheet is the only place its progress shows. Event.DismissPaymentSheet -> { state -> - state.copy(paymentSheetVisible = false, sendProgress = LoadingSuccessState()) + if (state.paymentInProgress) state + else state.copy(paymentSheetVisible = false, sendProgress = LoadingSuccessState()) } + Event.PaymentFinished -> { state -> state.copy(paymentInProgress = false) } is Event.PaymentProgress -> { state -> state.copy(sendProgress = LoadingSuccessState(event.loading, event.success)) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/StartChattingSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/StartChattingSheet.kt index 6e5eb540ed..e79275502d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/StartChattingSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/StartChattingSheet.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import com.flipcash.app.messenger.internal.screens.cash.ChatInitPaymentSheet import com.getcode.theme.CodeTheme @@ -25,7 +26,13 @@ internal fun StartChattingSheet( onConfirm: () -> Unit, onDismiss: () -> Unit, ) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + // Read at the time of each gesture or hide, not captured: the view model lifts the lock just + // before it asks the sheet to go. + val locked by rememberUpdatedState(state.paymentInProgress) + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { !locked }, + ) // The sheet stays composed until it has slid away, so a dismissal from the view model (a // payment that landed) animates out like a swipe does rather than vanishing. var composed by remember { mutableStateOf(false) } @@ -39,7 +46,7 @@ internal fun StartChattingSheet( } if (!composed) return ModalBottomSheet( - onDismissRequest = onDismiss, + onDismissRequest = { if (!locked) onDismiss() }, sheetState = sheetState, containerColor = CodeTheme.colors.background, ) { diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt index 28d643b826..28f03f6fa1 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt @@ -325,10 +325,94 @@ class ChatProfileViewModelTest { advanceTimeBy(11_000) coVerify(exactly = 1) { payer.pay(any(), any(), any()) } + assertEquals(ProfilePinnedAction.OpeningChat, model.stateFlow.value.pinnedAction) + } + + @Test + fun `the sheet stays up and locked after a successful payment until the members arrive`() = runTest { + fee.value = Fiat(1, CurrencyCode.USD) + coEvery { payer.pay(any(), any(), any()) } returns Result.success(dmChatId) + val model = viewModel() + openFull(model) + model.dispatchEvent(ChatProfileViewModel.Event.ShowPaymentSheet) + + model.dispatchEvent(ChatProfileViewModel.Event.ConfirmStartChatting) + advanceTimeBy(5_000) + + assertTrue(model.stateFlow.value.paymentSheetVisible) + assertTrue(model.stateFlow.value.paymentInProgress) + assertTrue(model.stateFlow.value.sendProgress.success) + } + + @Test + fun `dismissing the sheet is ignored while a payment is out`() = runTest { + fee.value = Fiat(1, CurrencyCode.USD) + coEvery { payer.pay(any(), any(), any()) } returns Result.success(dmChatId) + val model = viewModel() + openFull(model) + model.dispatchEvent(ChatProfileViewModel.Event.ShowPaymentSheet) + model.dispatchEvent(ChatProfileViewModel.Event.ConfirmStartChatting) + advanceTimeBy(1_000) + + model.dispatchEvent(ChatProfileViewModel.Event.DismissPaymentSheet) + + assertTrue(model.stateFlow.value.paymentSheetVisible) + assertTrue(model.stateFlow.value.sendProgress.success) + } + + @Test + fun `members arriving hides the sheet and opens the chat`() = runTest { + fee.value = Fiat(1, CurrencyCode.USD) + coEvery { payer.pay(any(), any(), any()) } returns Result.success(dmChatId) + val model = viewModel() + openFull(model) + val seen = mutableListOf() + val job = launch(UnconfinedTestDispatcher(testScheduler)) { model.eventFlow.toList(seen) } + model.dispatchEvent(ChatProfileViewModel.Event.ShowPaymentSheet) + model.dispatchEvent(ChatProfileViewModel.Event.ConfirmStartChatting) + advanceTimeBy(2_000) + assertTrue(model.stateFlow.value.paymentSheetVisible) + + members.value = listOf(mockk()) + advanceTimeBy(1_000) + assertFalse(model.stateFlow.value.paymentSheetVisible) + assertFalse(model.stateFlow.value.paymentInProgress) + assertTrue(seen.any { it is ChatProfileViewModel.Event.OpenChat }) + job.cancel() + } + + @Test + fun `members never arriving releases the sheet and stays on the profile`() = runTest { + fee.value = Fiat(1, CurrencyCode.USD) + coEvery { payer.pay(any(), any(), any()) } returns Result.success(dmChatId) + val model = viewModel() + openFull(model) + model.dispatchEvent(ChatProfileViewModel.Event.ShowPaymentSheet) + + model.dispatchEvent(ChatProfileViewModel.Event.ConfirmStartChatting) + advanceTimeBy(11_000) + + assertFalse(model.stateFlow.value.paymentSheetVisible) + assertFalse(model.stateFlow.value.paymentInProgress) assertEquals(ProfilePinnedAction.OpeningChat, model.stateFlow.value.pinnedAction) } + @Test + fun `a failed payment leaves the sheet dismissable`() = runTest { + fee.value = Fiat(1, CurrencyCode.USD) + coEvery { payer.pay(any(), any(), any()) } returns Result.failure(Exception("nope")) + val model = viewModel() + openFull(model) + model.dispatchEvent(ChatProfileViewModel.Event.ShowPaymentSheet) + + model.dispatchEvent(ChatProfileViewModel.Event.ConfirmStartChatting) + assertFalse(model.stateFlow.value.paymentInProgress) + model.dispatchEvent(ChatProfileViewModel.Event.DismissPaymentSheet) + + assertFalse(model.stateFlow.value.paymentSheetVisible) + } + @Test fun `a failed payment can be tried again`() = runTest { fee.value = Fiat(1, CurrencyCode.USD) From 5abacf20b402d16774bddb654e08640e9cec0d10 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 18:39:20 -0400 Subject: [PATCH 08/12] fix(messenger): keep the status bar light under the start-chatting sheet --- .../screens/profile/LightStatusBarIcons.kt | 28 +++++++++++++++++++ .../internal/screens/profile/ProfileShare.kt | 1 + .../screens/profile/StartChattingSheet.kt | 1 + 3 files changed, 30 insertions(+) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/LightStatusBarIcons.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/LightStatusBarIcons.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/LightStatusBarIcons.kt new file mode 100644 index 0000000000..85f6061a59 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/LightStatusBarIcons.kt @@ -0,0 +1,28 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import android.os.Build +import android.view.WindowInsetsController +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.platform.LocalView + +/** + * Keeps the status bar icons light inside a Material3 `ModalBottomSheet`. + * + * The sheet draws in its own dialog window, which does not inherit the activity's status bar + * appearance and falls back to dark icons, near-invisible on the dark-only UI. Call from the + * sheet's content so [LocalView] is the dialog's view. Mirrors what `ModalBottomSheetScene` does + * for navigation-hosted sheets. + */ +@Composable +internal fun LightStatusBarIcons() { + val view = LocalView.current + SideEffect { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + view.rootView.windowInsetsController?.setSystemBarsAppearance( + 0, + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS, + ) + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShare.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShare.kt index 76d22cdcb1..5ad8106907 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShare.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShare.kt @@ -66,6 +66,7 @@ internal fun ProfileShareSheetHost( sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), containerColor = CodeTheme.colors.background, ) { + LightStatusBarIcons() ProfileShareSheet( title = stringResource(R.string.title_shareUserProfile), subtitle = if (handle.isNullOrEmpty()) { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/StartChattingSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/StartChattingSheet.kt index e79275502d..18bd3f6872 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/StartChattingSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/StartChattingSheet.kt @@ -50,6 +50,7 @@ internal fun StartChattingSheet( sheetState = sheetState, containerColor = CodeTheme.colors.background, ) { + LightStatusBarIcons() ChatInitPaymentSheet( fee = state.fee?.formatted(), token = state.token, From e2f5eaef7fb9dc69a4d36f5f898901cc0185256b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 18:51:02 -0400 Subject: [PATCH 09/12] fix(messenger): open the profile menu over its button, in the error text colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Report and Block used the error colour; menus show destructive rows in errorText. The menu dropped below the ⋯ button; it now opens over it. --- .../internal/screens/profile/ChatProfileScreen.kt | 7 ++++++- .../internal/screens/profile/ProfileMenuItems.kt | 8 +++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index 15d3640466..a5a5423d8c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -1,5 +1,7 @@ package com.flipcash.app.messenger.internal.screens.profile +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import android.os.Parcelable import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box @@ -193,7 +195,9 @@ internal fun PersonProfileScreen( hazeState = hazeState, endContent = { if (person != null && !isSelf) { - Box { + val density = LocalDensity.current + var menuAnchorHeight by remember { mutableStateOf(0.dp) } + Box(modifier = Modifier.onSizeChanged { menuAnchorHeight = with(density) { it.height.toDp() } }) { CircularIconButton(hazeState = hazeState, onClick = { menuOpen = true }) { size -> Icon( imageVector = Icons.Rounded.MoreVert, @@ -206,6 +210,7 @@ internal fun PersonProfileScreen( expanded = menuOpen, items = state.menuItems, isMuted = state.isMuted, + anchorHeight = menuAnchorHeight, onDismiss = { menuOpen = false }, onItem = { item -> when (item) { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt index e23004ad5a..458ce46369 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt @@ -5,6 +5,7 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import com.flipcash.features.messenger.R @@ -48,16 +49,17 @@ internal fun ProfileMenu( expanded: Boolean, items: List, isMuted: Boolean, + anchorHeight: Dp, onDismiss: () -> Unit, onItem: (ChatProfileAction) -> Unit, ) { DropdownMenu( expanded = expanded, onDismissRequest = onDismiss, - // Same surface as the chat's overflow menu, dropped clear of the button. + // Same surface as the chat's overflow menu, opened over the button that anchors it. containerColor = CodeTheme.colors.brandLight, shape = CodeTheme.shapes.extraLarge, - offset = DpOffset(x = 0.dp, y = CodeTheme.dimens.grid.x2), + offset = DpOffset(x = 0.dp, y = -anchorHeight), ) { items.forEach { item -> DropdownMenuItem( @@ -65,7 +67,7 @@ internal fun ProfileMenu( Text( text = stringResource(item.labelRes(isMuted)), style = CodeTheme.typography.textSmall, - color = if (item.isDestructive) CodeTheme.colors.error else CodeTheme.colors.textMain, + color = if (item.isDestructive) CodeTheme.colors.errorText else CodeTheme.colors.textMain, ) }, onClick = { From 4587a0fbdfd552e898a1b7962a13feb7019b92cf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 18:55:54 -0400 Subject: [PATCH 10/12] feat(messenger): give the profile menu's rows icons Each row gets the glyph iOS uses for it: a slashed bell for Mute, a speech bubble for Report, a no-entry sign for Block and a check circle for Unblock. Report and Block tint the icon in the error text colour with the label; Mute shows the plain bell once the chat is muted, since the row then unmutes. --- .../screens/profile/ProfileMenuItems.kt | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt index 458ce46369..5958bf0ac2 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt @@ -1,9 +1,17 @@ package com.flipcash.app.messenger.internal.screens.profile +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Feedback +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.NotificationsOff import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset @@ -43,6 +51,17 @@ internal fun ChatProfileAction.labelRes(isMuted: Boolean): Int = when (this) { ChatProfileAction.Unblock -> R.string.action_unblock } +/** + * The row's glyph, matching iOS' `bell.slash`, `flag`, `nosign` and `checkmark.circle`. Muting + * shows the plain bell once the chat is muted, since the row then turns notifications back on. + */ +internal fun ChatProfileAction.icon(isMuted: Boolean): ImageVector = when (this) { + ChatProfileAction.Mute -> if (isMuted) Icons.Outlined.Notifications else Icons.Outlined.NotificationsOff + ChatProfileAction.Report -> Icons.Outlined.Feedback + ChatProfileAction.Block -> Icons.Outlined.Block + ChatProfileAction.Unblock -> Icons.Outlined.CheckCircle +} + /** The ⋯ button's menu, rows in [items]' order. */ @Composable internal fun ProfileMenu( @@ -62,12 +81,16 @@ internal fun ProfileMenu( offset = DpOffset(x = 0.dp, y = -anchorHeight), ) { items.forEach { item -> + val tint = if (item.isDestructive) CodeTheme.colors.errorText else CodeTheme.colors.textMain DropdownMenuItem( + leadingIcon = { + Icon(imageVector = item.icon(isMuted), contentDescription = null, tint = tint) + }, text = { Text( text = stringResource(item.labelRes(isMuted)), style = CodeTheme.typography.textSmall, - color = if (item.isDestructive) CodeTheme.colors.errorText else CodeTheme.colors.textMain, + color = tint, ) }, onClick = { From 87ed74c4072ea999cb9bb9cf6e3c758a5ef7b4f0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 22:32:53 -0400 Subject: [PATCH 11/12] fix(messenger): keep the profile menu's mute row as Mute Notifications iOS ProfileMenuItem.mute is always "Mute Notifications" with bell.slash, and the sheet it opens handles unmuting. The Android row flipped to "Unmute Notifications" and a plain bell once the chat was muted. --- .../screens/profile/ChatProfileScreen.kt | 1 - .../screens/profile/ProfileMenuItems.kt | 23 ++++++++----------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index a5a5423d8c..0b38b8480a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -209,7 +209,6 @@ internal fun PersonProfileScreen( ProfileMenu( expanded = menuOpen, items = state.menuItems, - isMuted = state.isMuted, anchorHeight = menuAnchorHeight, onDismiss = { menuOpen = false }, onItem = { item -> diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt index 5958bf0ac2..12d721366a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt @@ -4,7 +4,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Block import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.Feedback -import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.NotificationsOff import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -40,23 +39,20 @@ internal val ChatProfileAction.isDestructive: Boolean get() = this == ChatProfileAction.Report || this == ChatProfileAction.Block /** - * The row's string. Muting names the opposite once the chat is muted; the picker it opens is where - * the duration is chosen either way. + * The row's string. Muting reads "Mute Notifications" whether or not the chat is muted, as on iOS; + * the sheet it opens handles both muting and unmuting. */ @androidx.annotation.StringRes -internal fun ChatProfileAction.labelRes(isMuted: Boolean): Int = when (this) { - ChatProfileAction.Mute -> if (isMuted) R.string.title_unmuteChat else R.string.title_muteChat +internal fun ChatProfileAction.labelRes(): Int = when (this) { + ChatProfileAction.Mute -> R.string.title_muteChat ChatProfileAction.Report -> R.string.title_report ChatProfileAction.Block -> R.string.title_block ChatProfileAction.Unblock -> R.string.action_unblock } -/** - * The row's glyph, matching iOS' `bell.slash`, `flag`, `nosign` and `checkmark.circle`. Muting - * shows the plain bell once the chat is muted, since the row then turns notifications back on. - */ -internal fun ChatProfileAction.icon(isMuted: Boolean): ImageVector = when (this) { - ChatProfileAction.Mute -> if (isMuted) Icons.Outlined.Notifications else Icons.Outlined.NotificationsOff +/** The row's glyph, matching iOS' `bell.slash`, `exclamationmark.bubble`, `nosign` and `checkmark.circle`. */ +internal fun ChatProfileAction.icon(): ImageVector = when (this) { + ChatProfileAction.Mute -> Icons.Outlined.NotificationsOff ChatProfileAction.Report -> Icons.Outlined.Feedback ChatProfileAction.Block -> Icons.Outlined.Block ChatProfileAction.Unblock -> Icons.Outlined.CheckCircle @@ -67,7 +63,6 @@ internal fun ChatProfileAction.icon(isMuted: Boolean): ImageVector = when (this) internal fun ProfileMenu( expanded: Boolean, items: List, - isMuted: Boolean, anchorHeight: Dp, onDismiss: () -> Unit, onItem: (ChatProfileAction) -> Unit, @@ -84,11 +79,11 @@ internal fun ProfileMenu( val tint = if (item.isDestructive) CodeTheme.colors.errorText else CodeTheme.colors.textMain DropdownMenuItem( leadingIcon = { - Icon(imageVector = item.icon(isMuted), contentDescription = null, tint = tint) + Icon(imageVector = item.icon(), contentDescription = null, tint = tint) }, text = { Text( - text = stringResource(item.labelRes(isMuted)), + text = stringResource(item.labelRes()), style = CodeTheme.typography.textSmall, color = tint, ) From 1e4ba6bc0496cab2f7db16d0dbd3e675196bad77 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 23:01:41 -0400 Subject: [PATCH 12/12] fix(messenger): match iOS on the profile's chips, menu and pinned bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Muted chip reads "Muted until