diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 54426c2173..0f87080e7b 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -31,6 +31,9 @@ import com.flipcash.app.tipping.NewGroupFlowScreen import com.flipcash.app.tipping.ChatsScreen import com.flipcash.shared.transactionhistory.ActivityHistoryScreen 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.chat.ProfileOrigin import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.currency.RegionSelectionScreen import com.flipcash.app.deposit.DepositFlowScreen @@ -121,9 +124,16 @@ fun appEntryProvider( // Messaging annotatedEntry { key -> - ChatFlowScreen(route = key, resultStateRegistry = resultStateRegistry) + // A chat is never opened by user id any more; the profile is where a person without a DM + // is reached. A saved route that still carries one lands there instead of on a gated chat. + val identifier = key.identifier + if (identifier is ChatIdentifier.ByUser) { + ProfileScreen(ProfileAddress.ById(identifier.userId), ProfileOrigin.Link) + } else { + ChatFlowScreen(route = key, resultStateRegistry = resultStateRegistry) + } } - annotatedEntry { key -> ProfileScreen(key.address) } + annotatedEntry { key -> ProfileScreen(key.address, key.origin) } annotatedEntry { NewChatScreen() } annotatedEntry { ArchivedChatsScreen() } annotatedEntry { FindByUsernameScreen() } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 8c292363b2..4c7a7eaa1b 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -5,6 +5,7 @@ import androidx.navigation3.runtime.NavKey import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.core.chat.ProfileAddress +import com.flipcash.app.core.chat.ProfileOrigin import com.flipcash.app.core.chat.ReportSubject import com.flipcash.app.core.reporting.ReportStep import com.flipcash.app.core.chat.NewGroupStep @@ -359,9 +360,6 @@ sealed interface AppRoute : NavKey, Parcelable { // Open straight into composing a reply with the keyboard up. Only the post-tip // hand-off (see TipCardDecorator) sets this; normal opens default to keyboard-closed. val openKeyboard: Boolean = false, - // Open with send cash already started: the fee sheet if this payment opens the DM, the - // keypad if the DM exists. Only a profile's "Send Cash" shortcut sets this. - val openSendCash: Boolean = false, ) : Messaging, FlowRoute { override val initialStack: List get() = listOf(ChatStep.Conversation) @@ -400,7 +398,10 @@ sealed interface AppRoute : NavKey, Parcelable { */ @Serializable @Parcelize - data class Profile(val address: ProfileAddress) : Messaging + data class Profile( + val address: ProfileAddress, + val origin: ProfileOrigin = ProfileOrigin.Link, + ) : Messaging /** Node 9442:5825 — starting a chat by typing someone's `@handle`. */ @Serializable diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatIdentifier.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatIdentifier.kt index f38fb28f99..d6dfd7347d 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatIdentifier.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatIdentifier.kt @@ -30,18 +30,10 @@ sealed interface ChatIdentifier : Parcelable { } /** - * A tip DM addressed by the counterparty's Flipcash user id — the only identifier that can open - * a conversation which does not exist yet. - * - * [ByChatId] and [ByContact] both name a chat the server already has: one by its id, one by a - * phone number the server pre-derived an id for. Reaching someone by their `@handle` has - * neither, so this carries the user id, which is what the canonical TIP_DM id is derived - * from (`ChatCoordinator.generateChatId`) — deterministic and offline, so the chat opens on the - * derived id and the first tip lands in it. - * - * [profile] rides along because the caller looked it up to get [userId] in the first place: the - * header card renders from it on the first frame rather than waiting on a members fetch, which - * for a chat with no messages would have nothing to return. + * Retired: a tip DM addressed by the counterparty's Flipcash user id, which opened a gated chat + * before it existed. Nothing builds one now; a person without a DM is reached through their + * profile. It stays only so a route saved by an older build still decodes, and the app opens the + * profile for it instead of a chat. */ @Serializable @Parcelize diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index fc909b848c..1f308f9006 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -44,18 +44,6 @@ sealed interface ChatStep : FlowStep, Parcelable { @Serializable data object AmountEntry : ChatStep, NavigationRetVal, Sheet - /** - * The payment that opens a tip DM, at the fee the recipient charges. - * - * Separate from [AmountEntry] because there is nothing to enter: the fee is the price of the - * conversation, so the sheet states it and asks only for the swipe. A [WrapContentSheet] for - * the same reason — three lines of content shouldn't claim the screen. - */ - @Parcelize - @Serializable - data object InitPayment : - ChatStep, NavigationRetVal, Sheet, WrapContentSheet - /** * Nodes 10329:12104 and 10330:19549 — share or copy a group's invite link, or send it to * recent 1:1 chats. @@ -157,4 +145,20 @@ enum class ProfileOrigin { * somewhere new to go. iOS's `UserProfileOrigin.mention`. */ Mention, + + /** A `flipcash.com/...` link. The default for `AppRoute.Messaging.Profile`. Blocking pops back. */ + Link, + + /** A scanned profile card. Blocking resets to the chat list, which the scan came from. */ + Scan, + + /** A username search result. Blocking resets to the chat list, like a scan. */ + UsernameLookup, + + /** A transaction's counterparty. Blocking pops back to the transaction. */ + Transaction; + + /** Whether blocking from here leaves the stack to the chat list rather than popping one entry. */ + val resetsToChatsAfterBlock: Boolean + get() = this == Scan || this == UsernameLookup } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/DmDestination.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/DmDestination.kt new file mode 100644 index 0000000000..506f3f50ca --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/DmDestination.kt @@ -0,0 +1,19 @@ +package com.flipcash.app.core.chat + +import com.flipcash.app.core.AppRoute +import com.flipcash.services.models.chat.ChatId +import com.getcode.opencode.model.core.ID + +/** + * Where "open a DM with [userId]" lands: the chat once it exists, else the profile. + * + * [dmChatId] is the DM's id only when it is openable, which is the caller's call to make: it has + * members and the person is not blocked. Null sends the viewer to the profile, tagged with + * [origin] so the profile knows how to leave after a block. + */ +fun dmDestination(userId: ID, dmChatId: ChatId?, origin: ProfileOrigin): AppRoute = + if (dmChatId != null) { + AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(dmChatId)) + } else { + AppRoute.Messaging.Profile(ProfileAddress.ById(userId), origin) + } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt index 527b6dd805..fd89cfbb19 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt @@ -19,7 +19,7 @@ sealed interface DeeplinkType: Parcelable { @Serializable data class TokenInfo(val mint: Mint): DeeplinkType, Navigatable - @Serializable data class TipChat(val identifier: ChatIdentifier): DeeplinkType, Navigatable + @Serializable data class TipChat(val chatId: ChatId): DeeplinkType, Navigatable /** * A group chat invite — `app.flipcash.com/chat/{uuid}`. diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index b8e131dfc7..61461e0833 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1261,12 +1261,8 @@ Chat with %1$s on Flipcash Share Your Tip Card to Get Tipped Show My Tip Card - Send a Tip Send %1$s to Start Chatting Swipe to Tip - %1$s minimum - %1$s Minimum Tip - Please enter a higher amount via Tip Card Blocked - - Start Chatting Open Chat More diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/chat/ProfileOriginTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/chat/ProfileOriginTest.kt new file mode 100644 index 0000000000..1dcaa62e45 --- /dev/null +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/chat/ProfileOriginTest.kt @@ -0,0 +1,26 @@ +package com.flipcash.app.core.chat + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ProfileOriginTest { + + @Test + fun `a scan and a username lookup reset to the chat list after a block`() { + assertTrue(ProfileOrigin.Scan.resetsToChatsAfterBlock) + assertTrue(ProfileOrigin.UsernameLookup.resetsToChatsAfterBlock) + } + + @Test + fun `every other origin pops one entry after a block`() { + val pops = ProfileOrigin.entries - setOf(ProfileOrigin.Scan, ProfileOrigin.UsernameLookup) + + assertEquals( + setOf(ProfileOrigin.Chat, ProfileOrigin.Mention, ProfileOrigin.Link, ProfileOrigin.Transaction), + pops.toSet(), + ) + pops.forEach { assertFalse(it.resetsToChatsAfterBlock, "$it") } + } +} diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/DeeplinkScanPolicyTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/DeeplinkScanPolicyTest.kt index 1682bbf948..8b2a6af4fc 100644 --- a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/DeeplinkScanPolicyTest.kt +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/DeeplinkScanPolicyTest.kt @@ -21,7 +21,7 @@ class DeeplinkScanPolicyTest { fun `only payment routes are scannable`() { assertTrue(DeeplinkType.CashLink(ENTROPY).isScannable) assertTrue(DeeplinkType.TokenInfo(MINT).isScannable) - assertTrue(DeeplinkType.TipChat(ChatIdentifier.ByChatId(CHAT_ID)).isScannable) + assertTrue(DeeplinkType.TipChat(CHAT_ID).isScannable) assertTrue(DeeplinkType.Tipcard(USER_ID).isScannable) assertTrue(DeeplinkType.Profile(ProfileAddress.ByUsername(USERNAME)).isScannable) assertTrue(DeeplinkType.GroupChatInvite(CHAT_ID).isScannable) 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 f07de36f29..19666e9e89 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 @@ -5,7 +5,6 @@ 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -25,7 +24,6 @@ import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.GroupInviteViewModel -import com.flipcash.app.messenger.internal.StartSendCashOnceReady import com.flipcash.app.messenger.internal.screens.GroupInviteSheet import com.flipcash.app.messenger.internal.screens.MessengerScreen import com.flipcash.app.messenger.internal.screens.EmojiPickerViewModel @@ -33,7 +31,6 @@ import com.flipcash.app.messenger.internal.screens.ReactionPickerSheet import com.flipcash.app.messenger.internal.screens.ReactorsSheet import com.flipcash.app.messenger.internal.screens.ReactorsViewModel import com.flipcash.app.messenger.internal.screens.cash.ChatAmountEntryContent -import com.flipcash.app.messenger.internal.screens.cash.ChatInitPaymentSheet import com.flipcash.app.messenger.internal.screens.profile.ChatProfileScreen import com.flipcash.app.messenger.internal.screens.profile.ChatProfileViewModel import com.flipcash.app.messenger.internal.screens.profile.GroupProfileScreen @@ -55,10 +52,8 @@ import com.getcode.navigation.scenes.LocalBottomSheetDismissDispatcher import com.getcode.navigation.scenes.LocalSheetNavigator import com.getcode.navigation.scenes.ModalBottomSheetSceneStrategy import com.getcode.ui.utils.rememberKeyboardController -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @Composable @@ -77,8 +72,8 @@ fun ChatFlowScreen( // onRootReached, and so does system back — so this is the one place that has to do it. // Popping with the IME still up drags the screen behind it out from under the keyboard. onExit = { _, _ -> keyboard.hideIfVisible { navigator.pop() } }, - entryProvider = chatEntryProvider(route.identifier, route.openKeyboard, route.openSendCash), - // ChatStep.AmountEntry, ChatStep.InitPayment and ChatStep.InviteToGroup are Sheets, so the + entryProvider = chatEntryProvider(route.identifier, route.openKeyboard), + // ChatStep.AmountEntry and ChatStep.InviteToGroup are Sheets, so the // flow needs the sheet strategy to draw them as such; without it the step would fall // through to SinglePane and cover the thread. Amount entry // returns its result inside the flow (resultBackNavigator), so the strategy's own @@ -95,17 +90,13 @@ fun ChatFlowScreen( private fun chatEntryProvider( identifier: ChatIdentifier, openKeyboard: Boolean, - openSendCash: Boolean, ): (NavKey) -> NavEntry = entryProvider { annotatedEntry { - FlowConversationScreen(identifier, openKeyboard, openSendCash) + FlowConversationScreen(identifier, openKeyboard) } annotatedEntry { FlowAmountEntryScreen() } - annotatedEntry { - FlowInitPaymentScreen() - } annotatedEntry { FlowGroupInviteSheet() } @@ -138,7 +129,6 @@ private fun chatEntryProvider( private fun FlowConversationScreen( identifier: ChatIdentifier, openKeyboard: Boolean, - openSendCash: Boolean, ) { val viewModel = flowSharedViewModel() val navigator = LocalCodeNavigator.current @@ -177,18 +167,6 @@ private fun FlowConversationScreen( } } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .collect { - navigator.navigateForResult(ChatStep.InitPayment) { result -> - if (result is NavResultOrCanceled.ReturnValue) { - viewModel.dispatchEvent(ChatViewModel.Event.OnStartMessageInput) - } - } - } - } - LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() @@ -209,16 +187,6 @@ private fun FlowConversationScreen( } } - // After the collectors above, so the step OnSendCash navigates to has someone listening. - // Not keyed on first composition the way openKeyboard is: the handler drops the event until - // the participant is set, and picks the wrong step until the fee is known. - val sendCashReady by remember(viewModel) { - viewModel.stateFlow.map { it.sendCashReady }.distinctUntilChanged() - }.collectAsStateWithLifecycle(initialValue = false) - StartSendCashOnceReady(requested = openSendCash, ready = sendCashReady) { - viewModel.dispatchEvent(ChatViewModel.Event.OnSendCash) - } - MessengerScreen(viewModel) } @@ -247,27 +215,6 @@ private fun FlowAmountEntryScreen() { ) } -@Composable -private fun FlowInitPaymentScreen() { - val viewModel = flowSharedViewModel() - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - // Same dismissal rule as amount entry: exit through the sheet so it animates down rather than - // having its scene deleted mid-frame. - val dismissSheet = LocalBottomSheetDismissDispatcher.current - val resultBack = resultBackNavigator(exit = dismissSheet) - - ChatInitPaymentSheet( - fee = state.chatInitFee, - token = state.token, - sendProgress = state.sendProgress, - onConfirm = { viewModel.dispatchEvent(ChatViewModel.Event.OnInitPaymentConfirmed) }, - onSendComplete = { resultBack.returnValue(ChatSendResult) }, - sendComplete = remember(viewModel) { - viewModel.eventFlow.filterIsInstance().map { } - }, - ) -} - /** * The invite sheet, on the conversation's own view model. * 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 894043192f..344a379266 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 @@ -14,6 +14,7 @@ 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.chat.ProfileOrigin import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.messenger.internal.screens.profile.ChatProfileViewModel import com.flipcash.app.messenger.internal.screens.profile.PersonProfileScreen @@ -33,9 +34,13 @@ import kotlinx.coroutines.flow.filterIsInstance * The same screen a chat's profile shows, with no chat behind it: [ProfileViewModel] turns the * link's address into a person, then [ChatProfileViewModel] takes over as it does in a chat, which * is what runs Block. With no chat there is no Mute row. + * + * [origin] decides where a block lands: a link or a transaction pops back to where it came from, a + * scan or a username search resets to the chat list, since what led here has no use once the + * person is blocked. */ @Composable -fun ProfileScreen(address: ProfileAddress) { +fun ProfileScreen(address: ProfileAddress, origin: ProfileOrigin = ProfileOrigin.Link) { val lookup = hiltViewModel() val viewModel = hiltViewModel() val navigator = LocalCodeNavigator.current @@ -68,12 +73,17 @@ fun ProfileScreen(address: ProfileAddress) { } } - LaunchedEffect(viewModel) { - // No chat to leave, as a DM's profile does after a block: back to wherever the link was - // opened from. + LaunchedEffect(viewModel, origin) { + // No chat to leave, as a DM's profile does after a block. viewModel.eventFlow .filterIsInstance() - .collect { navigator.pop() } + .collect { + if (origin.resetsToChatsAfterBlock) { + navigator.navigateAll(listOf(AppRoute.Tabs.Chats)) + } else { + navigator.pop() + } + } } if (state.participant == null) { 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 f1a916d819..945a08cee1 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 @@ -289,22 +289,6 @@ internal class ChatViewModel @Inject constructor( val limits: Limits? = null, val isAnonymous: Boolean = false, val cashSymbol: String = "$", - /** - * The recipient's fee to open a DM with them, already formatted, or null when there is no - * such fee to name — a contact DM, a tip DM that already exists, or the moment before the - * profile has resolved. The call-to-action pill renders on the chat's first frame and this - * arrives over the network, so "no fee yet" and "no fee at all" are deliberately the same - * value: both mean the pill falls back to its unpriced label. - */ - val chatInitFee: String? = null, - /** - * Whether [Event.OnSendCash] would now take the path it will keep: the fee sheet for the - * payment that opens a tip DM, the keypad for any other. See [isSendCashReady]. - * - * Only a chat opened with send cash already started reads it. [chatInitFee] can't answer - * this, because it is null both before the fee resolves and when there is no fee at all. - */ - val sendCashReady: Boolean = false, // Transient "focus the message input" request. Set by OnStartMessageInput (dispatched when // returning from amount entry after a send, and on a post-tip chat open) and cleared by // OnMessageInputConsumed once the bottom bar has focused the field and shown the keyboard. @@ -672,8 +656,6 @@ internal class ChatViewModel @Inject constructor( data class OnEncryptionResolved(val isEncrypted: Boolean) : Event data class OnCurrencySymbolUpdated(val symbol: String): Event - data class OnChatInitFeeUpdated(val formatted: String?) : Event - data class OnSendCashReadinessChanged(val ready: Boolean) : Event data object RefreshContact : Event data class ChatFound(val chatId: ChatId) : Event data object OnSendCash: Event @@ -712,8 +694,6 @@ internal class ChatViewModel @Inject constructor( data object NavigateToAmountEntry : Event - /** Open the fixed-fee sheet that pays for the DM, instead of the keypad. */ - data object NavigateToInitPayment : Event data object PresentDepositOptions : Event data class OpenScreen(val route: AppRoute, val asSheet: Boolean = false): Event @@ -727,8 +707,6 @@ internal class ChatViewModel @Inject constructor( data class OpenMention(val destination: MentionDestination) : Event data object OnConfirmRequested : Event - /** Confirm the DM-opening fee. The amount comes from the fee, not from the keypad. */ - data object OnInitPaymentConfirmed : Event data class OnSendRequested( val amount: Fiat, val token: Token, @@ -1282,91 +1260,19 @@ internal class ChatViewModel @Inject constructor( }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) } - // Only the payment that opens a tip DM is a tip — it buys the conversation, and it is the one - // the recipient's fee applies to. Everything after it, and every contact DM, is a plain send. - private fun amountStyle(isTip: Boolean) = AmountEntryStyle( + // Every payment from the conversation is a plain send. The one that opens a tip DM is paid from + // the person's profile, which is where the recipient's fee applies. + private fun amountStyle() = AmountEntryStyle( // One label for both kinds of payment. The chat above the keypad already says who this is // going to and why; the slider only has to say what the gesture does. actionLabel = AmountEntryLabel.Plain(resources.getString(R.string.action_swipeToSend)), actionStyle = ConfirmationStyle.Slide, infoHint = { resources.getString(R.string.subtitle_sendHint, it) }, overMaxHint = { resources.getString(R.string.subtitle_sendHintLimitExceeded, it) }, - belowMinHint = if (isTip) { - { min -> resources.getString(R.string.subtitle_tipHintMinimum, min) } - } else null, - // A tip's ceiling is only the sender's own balance; the minimum is the recipient's rule - // and the one worth stating up front, so it holds the hint line for the whole entry. - standingHint = if (isTip) { - AmountEntryStyle.StandingHint.Floor - } else { - AmountEntryStyle.StandingHint.Ceiling - }, + standingHint = AmountEntryStyle.StandingHint.Ceiling, ) - // The counterparty of a tip DM, whose server profile carries the fee they charge to open a DM. - // Null for a contact DM: it is addressed by phone number and there is no profile to read one off. - private val tipRecipientFlow = stateFlow - .map { it.participant as? ChatParticipant.TipUser } - .distinctUntilChanged() - - private val amountStyleFlow by lazy { - openingTipRecipientFlow - .map { amountStyle(isTip = it != null) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), amountStyle(isTip = false)) - } - - /** - * Whether this conversation already exists. Members are the same signal - * [com.flipcash.shared.chat.DmChatResolver.getChatId] calls initialized: a chat the server has - * created has a member row, one derived from a user id alone does not. Null until the member - * store has answered; [isChatInitialized] reads that as false. - */ - @OptIn(ExperimentalCoroutinesApi::class) - private val chatExistence by lazy { - stateFlow.mapNotNull { it.chatId } - .distinctUntilChanged() - .flatMapLatest { chatCoordinator.observeMembers(it) } - .map { it.isNotEmpty() } - .distinctUntilChanged() - // Null until the member store has answered, which is not the same as "doesn't exist". - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) - } - - private val isChatInitialized by lazy { - chatExistence.map { it == true } - .distinctUntilChanged() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - } - - /** - * The user this payment would open a tip DM with — null once the conversation exists, and null - * for a contact DM. It decides both the floor and the word the entry uses: the fee, and calling - * the payment a tip, belong to the one that opens the chat. - */ - private val openingTipRecipientFlow by lazy { - combine(tipRecipientFlow, isChatInitialized) { recipient, initialized -> - recipient?.takeUnless { initialized } - } - .distinctUntilChanged() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) - } - - /** - * The floor the entry enforces, and only for the payment that opens a tip DM. - * - * What the recipient sets is the fee to *open* a DM with them, so it gates that first payment - * and nothing after it: once the conversation exists, sending cash in it has no minimum at all. - * A contact DM never has one. - */ - @OptIn(ExperimentalCoroutinesApi::class) - private val minAmountFlow by lazy { - openingTipRecipientFlow - .flatMapLatest { recipient -> - if (recipient == null) flowOf(null) - else tipPaymentDelegate.startChattingFee(recipient.profile) - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) - } + private val amountStyleFlow by lazy { MutableStateFlow(amountStyle()) } val amountDelegate by lazy { AmountEntryDelegate( @@ -1376,7 +1282,6 @@ internal class ChatViewModel @Inject constructor( loadingState = stateFlow.map { it.sendProgress } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), LoadingSuccessState()), maxAmount = maxAmountFlow, - minimumAmount = minAmountFlow, tokenChanges = tokenCoordinator.observeSelectedTokenMint(), ) } @@ -1632,11 +1537,7 @@ internal class ChatViewModel @Inject constructor( is ChatIdentifier.ByContact -> identifier.chatId ?: chatCoordinator.getChatId(identifier.contact).getOrNull() is ChatIdentifier.ByChatId -> identifier.chatId - // Derived, not looked up: the canonical tip-DM id is a function of the two user - // ids, so it is known before the chat exists. Opening on it means the first tip - // lands in the chat the user is already looking at. - is ChatIdentifier.ByUser -> - chatCoordinator.generateChatId(identifier.userId).getOrNull() + is ChatIdentifier.ByUser -> error(ROUTED_TO_PROFILE) } // Re-entering the same, already-open chat (e.g. returning from the amount-entry @@ -1692,10 +1593,7 @@ internal class ChatViewModel @Inject constructor( viewModelScope.launch { chatCoordinator.getOtherMember(identifier.chatId) } } } - // Identity came in with the identifier (the username lookup that produced it - // returned the profile), and the OnChatOpened reducer has already applied it. - // There is nothing to look up: a chat opened this way may have no members yet. - is ChatIdentifier.ByUser -> Unit + is ChatIdentifier.ByUser -> error(ROUTED_TO_PROFILE) } } .launchIn(viewModelScope) @@ -1920,21 +1818,6 @@ internal class ChatViewModel @Inject constructor( } }.launchIn(viewModelScope) - // The same floor the amount entry enforces, said out loud on the button that has to charge - // it. Formatted here rather than in the composable so the button has no currency logic. - minAmountFlow - .onEach { dispatchEvent(Event.OnChatInitFeeUpdated(it?.formatted())) } - .launchIn(viewModelScope) - - combine( - stateFlow.map { it.participant }.distinctUntilChanged(), - chatExistence, - minAmountFlow, - ) { participant, exists, fee -> isSendCashReady(participant, exists, fee) } - .distinctUntilChanged() - .onEach { dispatchEvent(Event.OnSendCashReadinessChanged(it)) } - .launchIn(viewModelScope) - transactionController.limits .onEach { dispatchEvent(Event.LimitsChanged(it)) } .launchIn(viewModelScope) @@ -2030,18 +1913,20 @@ internal class ChatViewModel @Inject constructor( .onEach { dispatchEvent(Event.TypingAvatarsUpdated(it)) } .launchIn(viewModelScope) - // A DM opens its composer once a payment has been exchanged. A group has no such - // exchange to wait for: its own rules say who may post, and [GroupAccess] has already - // applied them — a group that reaches the composer at all is [GroupAccess.Membered], and - // one that has not is showing the gate bar instead. Letting a group fall through to the - // DM rule left it unable to type until someone tipped into it. + // A contact DM opens its composer once a payment has been exchanged. A tip DM has no such + // wait: the payment that creates it is made from the profile, so the chat only exists + // once it has been paid, and it opens ready to type. A group has no exchange to wait for + // either: its own rules say who may post, and [GroupAccess] has already applied them — a + // group that reaches the composer at all is [GroupAccess.Membered], and one that has not + // is showing the gate bar instead. combine( stateFlow.mapNotNull { it.chatId }.distinctUntilChanged(), - stateFlow.map { it.subject is ChatSubject.Group }.distinctUntilChanged(), + stateFlow.map { it.subject is ChatSubject.Group || it.chatType == ChatType.TIP_DM } + .distinctUntilChanged(), ::Pair, ) - .flatMapLatest { (chatId, isGroup) -> - if (isGroup) { + .flatMapLatest { (chatId, alwaysOpen) -> + if (alwaysOpen) { flowOf(true) } else { chatCoordinator.observeMessages(chatId) @@ -2617,12 +2502,6 @@ 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) - eventFlow.filterIsInstance() // Contact DMs and tip DMs send to whichever participant backs the chat; a group has no // participant and sends a cash link instead. The final send branches on that type (see @@ -2636,17 +2515,8 @@ internal class ChatViewModel @Inject constructor( ) { return@onEach } - // The payment that opens a tip DM costs exactly the recipient's fee, so there is - // nothing to enter — send it straight to the sheet that states the fee. Every - // other send, including the moment before the fee resolves, keeps the keypad. A - // group has no one to charge a fee, so it always gets the keypad. - val fee = minAmountFlow.value.takeUnless { stateFlow.value.chatType == ChatType.GROUP } - if (fee != null) { - dispatchEvent(Event.NavigateToInitPayment) - } else { - amountDelegate.reset() - dispatchEvent(Event.NavigateToAmountEntry) - } + amountDelegate.reset() + dispatchEvent(Event.NavigateToAmountEntry) }.launchIn(viewModelScope) eventFlow @@ -2674,18 +2544,6 @@ internal class ChatViewModel @Inject constructor( val balance = tokenCoordinator.balanceForToken(token) - val minimum = minAmountFlow.value - if (minimum != null && amount.valueLessThan(minimum)) { - dispatchEvent(Event.SendStateUpdated()) - // Info, not alert: nothing has failed and nothing is being destroyed — - // the entry is just under the recipient's floor and needs raising. - BottomBarManager.showInfo( - title = resources.getString(R.string.error_title_tipMinimum, minimum.formatted()), - message = resources.getString(R.string.error_description_tipMinimum), - ) - return@launch - } - val verifiedFiat = verifiedFiatCalculator.compute( amount = amount, token = token, @@ -2710,28 +2568,6 @@ internal class ChatViewModel @Inject constructor( val chatId = stateFlow.value.chatId - // A tip DM's first payment comes from the "Send Tip" call to action, which is - // the whole bottom bar until that payment unlocks typing. It says tip, so it - // sends one. Every later send comes from the money button beside a composer - // that only exists once the thread is unlocked, and stays a plain send. - // - // This is the one place that decides it, and both `TipDmPayment.action` (the - // verb the server renders — "Tipped" vs "Sent") and the analytics event below - // are read off this single value rather than each re-deriving "is this a tip." - // `origin`/`location` keeps being computed off the same condition as before — - // this change doesn't touch what byte it sends. The server reads it in exactly - // one place, as the fallback when `action` is `DEFAULT`, so leaving it alone is - // what keeps a server that predates `action` resolving the same verb as one - // that reads it. - val tipAction = if ( - stateFlow.value.chatType == ChatType.TIP_DM && - !stateFlow.value.typingConstraints.enabled - ) { - TipAction.TIP - } else { - TipAction.SEND - } - val result = when (val participant = stateFlow.value.participant) { is ChatParticipant.Contact -> contactPaymentDelegate.send( contact = participant.contact, @@ -2745,8 +2581,8 @@ internal class ChatViewModel @Inject constructor( verifiedFiat = verifiedFiat, token = token, source = source, - origin = if (tipAction == TipAction.TIP) TipOrigin.TIPCARD else TipOrigin.CHAT, - action = tipAction, + origin = TipOrigin.CHAT, + action = TipAction.SEND, ) // A group has no one participant to pay, so the cash goes in as a link // that the first member to tap claims. @@ -2764,18 +2600,14 @@ internal class ChatViewModel @Inject constructor( } } - // Report what was sent. A group's send is a cash link. Otherwise the tip call - // to action above is a tip, and every other send from this screen (contact DM - // or unlocked tip DM) is a plain cash send. Same `tipAction` the wire `action` - // above was set from, not a second "is this a tip" check that could drift - // from it. + // Report what was sent. A group's send is a cash link; every other send from + // this screen is a plain cash send. The tip that opens a DM is paid from the + // profile, not here. val isCashLink = stateFlow.value.participant == null - val isTip = tipAction == TipAction.TIP val sent = { state: AnalyticsState, error: String? -> val sentAmount = verifiedFiat.localFiat.analytics when { isCashLink -> TransferEvents.sendCashLink(state, sentAmount, CashLinkChoice.GROUP_CHAT, null, error) - isTip -> TransferEvents.sentTip(state, sentAmount, error) else -> TransferEvents.sentCash(state, sentAmount, error) } } @@ -2947,6 +2779,9 @@ internal class ChatViewModel @Inject constructor( */ private const val CLAIM_THANKS_ENABLED = false + private const val ROUTED_TO_PROFILE = + "ChatIdentifier.ByUser is redirected to the profile before it reaches a ChatViewModel" + /** Long enough to coalesce a burst of typing, short enough to survive a fast exit. */ private val DRAFT_WRITE_DEBOUNCE = 300.milliseconds @@ -2965,18 +2800,7 @@ internal class ChatViewModel @Inject constructor( chatType = ChatType.CONTACT_DM, ) is ChatIdentifier.ByChatId -> state - // The counterparty is known up front, so the header card and the send gate - // resolve on the first frame. Nothing else can supply them here: a chat - // reached by username may not exist yet, and a chat with no members has no - // profile to observe. - is ChatIdentifier.ByUser -> - state.copy( - subject = ChatSubject.TipUser( - ChatParticipant.TipUser(id.userId, id.profile) - ), - chatType = ChatType.TIP_DM, - resolveState = ResolveState.Resolved, - ) + is ChatIdentifier.ByUser -> error(ROUTED_TO_PROFILE) } } is Event.OnContactFound -> { state -> @@ -3063,8 +2887,6 @@ internal class ChatViewModel @Inject constructor( ) } is Event.OnCurrencySymbolUpdated -> { state -> state.copy(cashSymbol = event.symbol) } - is Event.OnChatInitFeeUpdated -> { state -> state.copy(chatInitFee = event.formatted) } - is Event.OnSendCashReadinessChanged -> { state -> state.copy(sendCashReady = event.ready) } is Event.RefreshContact -> { state -> state } is Event.ChatFound -> { state -> state.copy(chatId = event.chatId) } Event.OnSendCash -> { state -> state } @@ -3107,13 +2929,11 @@ internal class ChatViewModel @Inject constructor( state.copy(stagedPhotos = (event.photos + state.stagedPhotos).take(MAX_STAGED_PHOTOS)) } Event.NavigateToAmountEntry -> { state -> state.copy(sendProgress = LoadingSuccessState()) } - Event.NavigateToInitPayment -> { state -> state.copy(sendProgress = LoadingSuccessState()) } is Event.PresentDepositOptions -> { state -> state } is Event.OpenScreen -> { state -> state } is Event.MentionTapped -> { state -> state } is Event.OpenMention -> { state -> state } is Event.OnConfirmRequested -> { state -> state } - is Event.OnInitPaymentConfirmed -> { state -> state } is Event.OnSendRequested -> { state -> state } is Event.SendStateUpdated -> { state -> state.copy( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/SendCashOnOpen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/SendCashOnOpen.kt deleted file mode 100644 index 829fd607ab..0000000000 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/SendCashOnOpen.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.flipcash.app.messenger.internal - -import androidx.annotation.VisibleForTesting -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import com.flipcash.app.core.chat.ChatParticipant -import com.getcode.opencode.model.financial.Fiat - -/** - * Whether [ChatViewModel.Event.OnSendCash] would now pick the step it will keep picking. - * - * The handler drops the event while there is no participant, and otherwise branches on the fee to - * open the chat: the fee sheet when there is one, the keypad when there isn't. Before the fee and - * the chat's existence are both known, that branch can land on the keypad for a payment that - * should have been the fee sheet. So a tip DM is ready only once the two agree: - * - the chat exists and there is no opening fee, or - * - the chat doesn't exist and the fee has resolved. - * - * [chatExists] is null until the member store has answered. A contact DM never has a fee, so it is - * ready as soon as it has a participant. - */ -internal fun isSendCashReady( - participant: ChatParticipant?, - chatExists: Boolean?, - openingFee: Fiat?, -): Boolean = when (participant) { - null -> false - is ChatParticipant.Contact -> true - is ChatParticipant.TipUser -> when (chatExists) { - null -> false - true -> openingFee == null - false -> openingFee != null - } -} - -/** - * Calls [onStart] once, the first time [ready] is true while [requested] is. - * - * The flag is saved, so recomposition, rotation, process death, and coming back from the step - * [onStart] opened don't call it again. - */ -@VisibleForTesting -@Composable -internal fun StartSendCashOnceReady( - requested: Boolean, - ready: Boolean, - onStart: () -> Unit, -) { - var started by rememberSaveable { mutableStateOf(false) } - val currentOnStart by rememberUpdatedState(onStart) - LaunchedEffect(requested, ready) { - if (requested && ready && !started) { - started = true - currentOnStart() - } - } -} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt index 744984ba01..69133d697e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt @@ -62,16 +62,13 @@ internal fun RowScope.SendCashButton( ) { // Beside a composer the button is always the condensed (dark, symbol-only) "$". It used to expand // to a white "Send $" whenever the composer was empty, which flashed as text came and went. - val isTipChat = state.chatType == ChatType.TIP_DM val canType = state.typingConstraints.enabled - // ...except before the first payment, when there is no composer to sit beside and this button - // is the entire bar. Condensing it there would leave a full-width transparent "$"; what the - // chat actually needs is its one call to action, so it stays white and says what it does. - val isCallToAction = isTipChat && !canType + // ...except where there is no composer to sit beside (a contact DM before its first payment), + // when this button is the entire bar and stays the white "Send $" call to action. // A group is condensed too: the group is not one counterparty to "Send $" to. val isGroupChat = state.chatType == ChatType.GROUP - val isTyping = !isCallToAction && (canType || isGroupChat) + val isTyping = canType || isGroupChat // Colors ease slowly and independently of the width/label so the fill change reads as one calm // transition instead of snapping with the resize — but NOT on the first settle. A tip chat opens @@ -146,24 +143,6 @@ internal fun RowScope.SendCashButton( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { - if (isCallToAction) { - // The fee resolves over the network, and this pill is the chat's first frame. Until it - // lands the button still has to say what it does, so it names the act without the price. - val fee = state.chatInitFee - Text( - text = if (fee != null) { - stringResource(R.string.action_sendToStartChatting, fee) - } else { - stringResource(R.string.title_sendTip) - }, - color = contentColor, - style = CodeTheme.typography.textMedium, - maxLines = 1, - softWrap = false, - ) - return@Row - } - // "action_sendCashViaSymbol" is "Send %1$s" — literally "Send " + the currency symbol. // Keep the symbol mounted at all times and only collapse the "Send " prefix, so the // symbol never crossfades against a wider label (which garbled into "$nd $"). 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 54d4049101..d6a3de5d45 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 @@ -99,8 +99,6 @@ internal fun PersonProfileScreen( viewModel.eventFlow.collect { event -> when (event) { 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) else -> Unit } @@ -278,7 +276,6 @@ internal fun PersonProfileScreen( @Composable private fun ProfilePinnedAction.label(): String = when (this) { - is ProfilePinnedAction.StartChatting -> - if (fee != null) stringResource(labelRes(), fee.formatted()) else stringResource(labelRes()) + is ProfilePinnedAction.StartChatting -> stringResource(labelRes(), fee.formatted()) 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 751dc2e3db..e737ea6be3 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 @@ -193,12 +193,6 @@ internal class ChatProfileViewModel @Inject constructor( /** 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 } @@ -372,7 +366,7 @@ internal class ChatProfileViewModel @Inject constructor( 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) + is ProfilePinnedAction.StartChatting -> startChatting() ProfilePinnedAction.OpeningChat, null -> Unit } } @@ -404,9 +398,8 @@ internal class ChatProfileViewModel @Inject constructor( .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 + /** The gate first, then the fee sheet. */ + private suspend fun startChatting() { val mayProceed = startChattingPayer.mayProceed( onAddMoney = { dispatchEvent(Event.PresentDepositOptions) }, onDiscoverCurrencies = { @@ -414,11 +407,7 @@ internal class ChatProfileViewModel @Inject constructor( }, ) if (!mayProceed) return - if (fee != null) { - dispatchEvent(Event.ShowPaymentSheet) - } else { - dispatchEvent(Event.OpenSendCash(person)) - } + dispatchEvent(Event.ShowPaymentSheet) } /** @@ -550,7 +539,6 @@ internal class ChatProfileViewModel @Inject constructor( 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/ProfilePinnedAction.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfilePinnedAction.kt index 34aaa253ff..9aae826445 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 @@ -12,12 +12,14 @@ internal sealed interface 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 + data class StartChatting(val fee: Fiat) : ProfilePinnedAction } /** - * Null for your own profile. Blocked wins over an existing DM, because a blocked DM is hidden. + * Null for your own profile, and for a person with no DM while [fee] is still null. The fee falls + * back to the regional default, so null only means it has not loaded yet; the button waits for it + * rather than offer a payment with no amount to confirm. Blocked wins over an existing DM, because + * a blocked DM is hidden. */ internal fun resolvePinnedAction( isSelf: Boolean, @@ -30,7 +32,7 @@ internal fun resolvePinnedAction( isBlocked -> ProfilePinnedAction.Unblock dmExists -> ProfilePinnedAction.OpenChat paid -> ProfilePinnedAction.OpeningChat - else -> ProfilePinnedAction.StartChatting(fee) + else -> fee?.let(ProfilePinnedAction::StartChatting) } /** @@ -49,13 +51,12 @@ internal fun profileMenuItems(isBlocked: Boolean, hasDm: Boolean): List R.string.action_unblock ProfilePinnedAction.OpenChat, ProfilePinnedAction.OpeningChat -> R.string.action_openChat - is ProfilePinnedAction.StartChatting -> - if (fee != null) R.string.action_sendToStartChatting else R.string.action_startChatting + is ProfilePinnedAction.StartChatting -> R.string.action_sendToStartChatting } 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 5ad8106907..077db911b6 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 @@ -7,8 +7,6 @@ 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 @@ -18,13 +16,6 @@ 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 { 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 52c9611637..eb87f30dac 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 @@ -203,7 +203,6 @@ class ChatGroupCashLinkTest { advanceUntilIdle() assertTrue(ChatViewModel.Event.NavigateToAmountEntry in events) - assertTrue(ChatViewModel.Event.NavigateToInitPayment !in events) } @Test diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatInitFeeReducerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatInitFeeReducerTest.kt deleted file mode 100644 index 26ec90bf6b..0000000000 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatInitFeeReducerTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.flipcash.app.messenger.internal - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -/** - * The chat-init fee is what the call-to-action pill spends its whole width saying, and it arrives - * over the network after the pill has already rendered. The reducer's job is only to carry the - * formatted value — including back to null when the conversation stops being one that needs a fee. - */ -class ChatInitFeeReducerTest { - - private fun reduce( - state: ChatViewModel.State, - event: ChatViewModel.Event, - ): ChatViewModel.State = ChatViewModel.updateStateForEvent(event)(state) - - @Test - fun `chat init fee starts null`() { - assertNull(ChatViewModel.State().chatInitFee) - } - - @Test - fun `chat init fee update stores the formatted amount`() { - val state = reduce( - ChatViewModel.State(), - ChatViewModel.Event.OnChatInitFeeUpdated("$1.00"), - ) - - assertEquals("$1.00", state.chatInitFee) - } - - @Test - fun `chat init fee clears when the fee no longer applies`() { - val withFee = reduce( - ChatViewModel.State(), - ChatViewModel.Event.OnChatInitFeeUpdated("$1.00"), - ) - - val cleared = reduce(withFee, ChatViewModel.Event.OnChatInitFeeUpdated(null)) - - assertNull(cleared.chatInitFee) - } -} 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 335a1a38ef..021d8358e1 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 @@ -3,11 +3,11 @@ package com.flipcash.app.messenger.internal import android.content.ClipboardManager import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.libs.coroutines.TestDispatcherProvider import com.flipcash.services.chat.E2eePolicy -import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType @@ -61,10 +61,8 @@ class ChatOpenTranscriptTest { private val userManager = mockk(relaxed = true) private val chatId = ChatId(UUID.randomUUID().bytes) - private val userId = UUID.randomUUID().bytes - private val openByUser = ChatViewModel.Event.OnChatOpened( - ChatIdentifier.ByUser(userId = userId, profile = mockk(relaxed = true)), - ) + private val contactCoordinator = mockk(relaxed = true) + private val openChat = ChatViewModel.Event.OnChatOpened(ChatIdentifier.ByChatId(chatId)) @Before fun setUp() { @@ -74,7 +72,8 @@ class ChatOpenTranscriptTest { every { exchange.preferredRate } returns Rate.oneToOne every { transactionController.limits } returns MutableStateFlow(null) every { tipPaymentDelegate.startChattingFee(any()) } returns flowOf(null) - coEvery { chatCoordinator.generateChatId(userId) } returns Result.success(chatId) + every { chatCoordinator.observeMetadata(chatId) } returns flowOf(null) + coEvery { contactCoordinator.lookupContactByDmChatId(any()) } returns null } @After @@ -86,7 +85,7 @@ class ChatOpenTranscriptTest { chatCoordinator = chatCoordinator, mediaUploads = noMediaUploads(), e2eePolicy = E2eePolicy(), - contactCoordinator = mockk(relaxed = true), + contactCoordinator = contactCoordinator, contactPaymentDelegate = mockk(relaxed = true), tipPaymentDelegate = tipPaymentDelegate, transactionController = transactionController, @@ -123,7 +122,7 @@ class ChatOpenTranscriptTest { fun `a DM the server has no record of is not fetched`() = runTest { coEvery { chatCoordinator.hydrateChat(chatId) } returns ChatHydration.Absent - createViewModel().dispatchEvent(openByUser) + createViewModel().dispatchEvent(openChat) advanceUntilIdle() coVerify(exactly = 0) { chatCoordinator.loadMessages(any()) } @@ -133,7 +132,7 @@ class ChatOpenTranscriptTest { fun `a chat already on the device is fetched`() = runTest { coEvery { chatCoordinator.hydrateChat(chatId) } returns ChatHydration.Stored - createViewModel().dispatchEvent(openByUser) + createViewModel().dispatchEvent(openChat) advanceUntilIdle() coVerify(exactly = 1) { chatCoordinator.loadMessages(chatId) } @@ -143,7 +142,7 @@ class ChatOpenTranscriptTest { fun `a failed lookup still fetches, since it says nothing about the chat`() = runTest { coEvery { chatCoordinator.hydrateChat(chatId) } returns ChatHydration.Unavailable - createViewModel().dispatchEvent(openByUser) + createViewModel().dispatchEvent(openChat) advanceUntilIdle() coVerify(exactly = 1) { chatCoordinator.loadMessages(chatId) } @@ -153,7 +152,7 @@ class ChatOpenTranscriptTest { fun `a fetched DM is fetched`() = runTest { coEvery { chatCoordinator.hydrateChat(chatId) } returns fetched(ChatType.TIP_DM) - createViewModel().dispatchEvent(openByUser) + createViewModel().dispatchEvent(openChat) advanceUntilIdle() coVerify(exactly = 1) { chatCoordinator.loadMessages(chatId) } 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 80abcabac8..db594129e3 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 @@ -61,8 +61,8 @@ import kotlin.test.assertTrue /** * A failed chat send used to report `State: Success` — the delegate failed, but the analytics * event said the payment went through. This pins the fixed behaviour: a failed [ContactPaymentDelegate.send] - * or [TipPaymentDelegate.send] reports `State: Failure` with an `Error` on both "Sent Cash" and - * "Sent Tip". + * or [TipPaymentDelegate.send] reports `State: Failure` with an `Error` on "Sent Cash". The send that opens a + * tip DM is paid from the profile now, so a send from the conversation is always a plain cash send. */ @OptIn(ExperimentalCoroutinesApi::class) class ChatSendFailureAnalyticsTest { @@ -185,7 +185,7 @@ class ChatSendFailureAnalyticsTest { } @Test - fun `a failed tip send reports State Failure with an Error`() = runTest(mainCoroutineRule.dispatcher) { + fun `a failed send in a tip DM reports State Failure with an Error on Sent Cash`() = runTest(mainCoroutineRule.dispatcher) { coEvery { tipPaymentDelegate.send(any(), any(), any(), any(), any(), any()) } returns Result.failure(RuntimeException("network down")) @@ -198,7 +198,7 @@ class ChatSendFailureAnalyticsTest { vm.dispatchEvent(ChatViewModel.Event.OnSendRequested(amount, token)) advanceUntilIdle() - val event = analytics.events.single { it.name == "Sent Tip" } + val event = analytics.events.single { it.name == "Sent Cash" } assertEquals(PropertyValue.Text(AnalyticsState.FAILURE.value), event.properties["State"]) assertTrue(event.properties.containsKey("Error")) } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt new file mode 100644 index 0000000000..ad68bea979 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt @@ -0,0 +1,158 @@ +package com.flipcash.app.messenger.internal + +import android.content.ClipboardManager +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.contacts.ContactCoordinator +import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.flipcash.services.chat.E2eePolicy +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatHydration +import com.flipcash.shared.chat.ChatMembership +import com.flipcash.shared.payments.TipPaymentDelegate +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.core.bytes +import com.getcode.opencode.model.financial.Rate +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.UUID + +/** + * When the composer opens. A contact DM waits for a Cash message, because the payment is what opens + * it. A tip DM has no wait: the payment that creates it is made from the profile, so the chat only + * exists once it has been paid. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ChatTypingGateTest { + + @get:Rule + var instantExecutorRule = InstantTaskExecutorRule() + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val chatCoordinator = mockk(relaxed = true) { + every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() + } + private val tipPaymentDelegate = mockk(relaxed = true) + private val transactionController = mockk(relaxed = true) + private val exchange = mockk(relaxed = true) + private val userManager = mockk(relaxed = true) + + private val contactCoordinator = mockk(relaxed = true) + private val contact = DeviceContact( + e164 = "+15551234567", + androidContactId = 1L, + displayName = "Ada Lovelace", + photoUri = null, + displayNumber = "(555) 123-4567", + ) + private val chatId = ChatId(UUID.randomUUID().bytes) + + @Before + fun setUp() { + BottomBarManager.clear() + every { userManager.accountCluster } returns mockk(relaxed = true) + every { exchange.preferredRate } returns Rate.oneToOne + every { transactionController.limits } returns MutableStateFlow(null) + every { tipPaymentDelegate.startChattingFee(any()) } returns flowOf(null) + coEvery { chatCoordinator.hydrateChat(chatId) } returns ChatHydration.Stored + every { chatCoordinator.observeMessages(chatId) } returns flowOf(emptyList()) + coEvery { contactCoordinator.resolve(any()) } returns Result.failure(IllegalStateException()) + } + + @After + fun tearDown() { + BottomBarManager.clear() + } + + private fun createViewModel(): ChatViewModel = ChatViewModel( + chatCoordinator = chatCoordinator, + mediaUploads = noMediaUploads(), + e2eePolicy = E2eePolicy(), + contactCoordinator = contactCoordinator, + contactPaymentDelegate = mockk(relaxed = true), + tipPaymentDelegate = tipPaymentDelegate, + transactionController = transactionController, + tokenCoordinator = mockk(relaxed = true), + exchange = exchange, + verifiedFiatCalculator = mockk(relaxed = true), + startChattingPayer = mockk(relaxed = true), + userManager = userManager, + resources = mockk(relaxed = true), + analytics = RecordingAnalytics(), + clipboardManager = mockk(relaxed = true), + userFlags = mockk(relaxed = true), + linkCardClassifier = mockk(relaxed = true), + linkCardResolver = mockk(relaxed = true), + cashLinkClaims = mockk(relaxed = true) { every { claimInFlight } returns MutableStateFlow(null) }, + chatCashLinks = mockk(relaxed = true), + chatDraftStore = mockk(relaxed = true), + recentReactionsStore = mockk(relaxed = true), + toastController = mockk(relaxed = true), + emojiCatalogLoader = mockk(relaxed = true), + userProfileDataSource = mockk(relaxed = true), + rosterSearch = mockk(relaxed = true), + dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), + ) + + @Test + fun `a tip DM with no messages has typing enabled`() = runTest { + every { contactCoordinator.lookupContact(any()) } returns Result.failure(NoSuchElementException()) + coEvery { contactCoordinator.lookupContactByDmChatId(any()) } returns null + every { chatCoordinator.observeMetadata(chatId) } returns flowOf( + ChatMembership( + metadata = mockk(relaxed = true) { every { this@mockk.type } returns ChatType.TIP_DM }, + isMember = true, + ), + ) + + val viewModel = createViewModel() + viewModel.dispatchEvent(ChatViewModel.Event.OnChatOpened(ChatIdentifier.ByChatId(chatId))) + advanceUntilIdle() + + val state = viewModel.stateFlow.value + assertTrue(state.chatType == ChatType.TIP_DM) + assertTrue(state.typingConstraints.resolved) + assertTrue(state.typingConstraints.enabled) + } + + @Test + fun `a contact DM with no Cash message has typing disabled`() = runTest { + every { contactCoordinator.lookupContact(contact.e164) } returns Result.success(contact) + + val viewModel = createViewModel() + viewModel.dispatchEvent( + ChatViewModel.Event.OnChatOpened(ChatIdentifier.ByContact(contact, chatId)) + ) + advanceUntilIdle() + + val state = viewModel.stateFlow.value + assertTrue(state.chatType == ChatType.CONTACT_DM) + assertTrue(state.typingConstraints.resolved) + assertFalse(state.typingConstraints.enabled) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt index b8c07eef54..e99d9c7722 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt @@ -468,49 +468,6 @@ class ChatViewModelStateTest { assertTrue(sent.draftSnapshot().isEmpty) } - private val tipUser = ChatParticipant.TipUser( - userId = listOf(9.toByte()), - profile = UserProfile.Empty.copy(username = "grace_hopper"), - ) - - /** - * Send cash opened from a profile fires on the first answer that won't change, so none of - * these may say ready: without a participant the handler drops the event, and without the - * chat's existence or its fee it can pick the keypad for a payment that owes the fee. - */ - @Test - fun `send cash is not ready while the fee state is unknown`() { - assertFalse(isSendCashReady(participant = null, chatExists = false, openingFee = Fiat(1.0))) - assertFalse(isSendCashReady(tipUser, chatExists = null, openingFee = null)) - assertFalse(isSendCashReady(tipUser, chatExists = null, openingFee = Fiat(1.0))) - // Doesn't exist yet, and the fee to open it hasn't resolved. - assertFalse(isSendCashReady(tipUser, chatExists = false, openingFee = null)) - // Exists, but the fee computed before the member store answered is still standing. - assertFalse(isSendCashReady(tipUser, chatExists = true, openingFee = Fiat(1.0))) - } - - @Test - fun `send cash is ready once the fee and the chat agree`() { - // A new DM: the fee sheet. - assertTrue(isSendCashReady(tipUser, chatExists = false, openingFee = Fiat(1.0))) - // An existing DM: the keypad, with no fee. - assertTrue(isSendCashReady(tipUser, chatExists = true, openingFee = null)) - } - - @Test - fun `a contact DM is ready to send cash as soon as it has a participant`() { - assertTrue(isSendCashReady(dm.participant, chatExists = null, openingFee = null)) - } - - @Test - fun `the reducer holds send cash readiness`() { - val ready = ChatViewModel.updateStateForEvent( - ChatViewModel.Event.OnSendCashReadinessChanged(true) - )(ChatViewModel.State()) - assertTrue(ready.sendCashReady) - assertFalse(ChatViewModel.State().sendCashReady) - } - @Test fun `a member collects a cash card and thanks the sender`() { val state = ChatViewModel.State( diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/StartSendCashOnceReadyTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/StartSendCashOnceReadyTest.kt deleted file mode 100644 index d735f0bc7c..0000000000 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/StartSendCashOnceReadyTest.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.flipcash.app.messenger.internal - -import androidx.activity.ComponentActivity -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.test.junit4.StateRestorationTester -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import kotlin.test.assertEquals - -/** - * The chat opened by a profile's Send Cash starts the send once, when the fee state is known, and - * never again for that chat: not when readiness flickers, and not when the screen is restored. - */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) -class StartSendCashOnceReadyTest { - - @get:Rule - val composeRule = createAndroidComposeRule() - - @Test - fun `waits for readiness, then starts once`() { - var ready by mutableStateOf(false) - var starts = 0 - composeRule.setContent { - StartSendCashOnceReady(requested = true, ready = ready) { starts++ } - } - composeRule.waitForIdle() - assertEquals(0, starts) - - ready = true - composeRule.waitForIdle() - assertEquals(1, starts) - - // The participant or fee changing under an open chat, then settling again. - ready = false - composeRule.waitForIdle() - ready = true - composeRule.waitForIdle() - assertEquals(1, starts) - } - - @Test - fun `a restored screen doesn't start it again`() { - val restoration = StateRestorationTester(composeRule) - var starts = 0 - restoration.setContent { - StartSendCashOnceReady(requested = true, ready = true) { starts++ } - } - composeRule.waitForIdle() - assertEquals(1, starts) - - restoration.emulateSavedInstanceStateRestore() - composeRule.waitForIdle() - assertEquals(1, starts) - } - - @Test - fun `an ordinary open never starts it`() { - var starts = 0 - composeRule.setContent { - StartSendCashOnceReady(requested = false, ready = true) { starts++ } - } - composeRule.waitForIdle() - assertEquals(0, starts) - } -} 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 28f03f6fa1..0e65b9517e 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 @@ -207,7 +207,7 @@ class ChatProfileViewModelTest { assertNull(model.stateFlow.value.pinnedAction) openFull(model) - assertEquals(ProfilePinnedAction.StartChatting(fee.value), model.stateFlow.value.pinnedAction) + assertEquals(ProfilePinnedAction.StartChatting(Fiat(1, CurrencyCode.USD)), model.stateFlow.value.pinnedAction) members.value = listOf(mockk()) assertEquals(ProfilePinnedAction.OpenChat, model.stateFlow.value.pinnedAction) @@ -225,16 +225,18 @@ class ChatProfileViewModelTest { } @Test - fun `tapping start chatting with no fee asks for the keypad`() = runTest { + fun `no fee yet pins no button, and a tap does nothing`() = runTest { val model = viewModel() openFull(model) + assertNull(model.stateFlow.value.pinnedAction) model.eventFlow.test { model.dispatchEvent(ChatProfileViewModel.Event.PinnedActionTapped) assertIs(awaitItem()) - assertIs(awaitItem()) + expectNoEvents() cancelAndIgnoreRemainingEvents() } + assertFalse(model.stateFlow.value.paymentSheetVisible) } @Test 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 511c572fff..1c79b1f803 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 @@ -53,10 +53,19 @@ class ProfileActionsTest { } @Test - fun `no DM and no fee starts chatting without a fee`() { + fun `no DM and the fee still loading pins nothing`() { + assertNull(resolvePinnedAction(isSelf = false, isBlocked = false, dmExists = false, fee = null)) + } + + @Test + fun `an existing DM or a block does not wait for the fee`() { + assertEquals( + ProfilePinnedAction.OpenChat, + resolvePinnedAction(isSelf = false, isBlocked = false, dmExists = true, fee = null), + ) assertEquals( - ProfilePinnedAction.StartChatting(null), - resolvePinnedAction(isSelf = false, isBlocked = false, dmExists = false, fee = null), + ProfilePinnedAction.Unblock, + resolvePinnedAction(isSelf = false, isBlocked = true, dmExists = false, fee = null), ) } @@ -102,14 +111,10 @@ class ProfileActionsTest { } @Test - fun `start chatting names the price only when there is one`() { + fun `start chatting names the price`() { assertEquals( R.string.action_sendToStartChatting, ProfilePinnedAction.StartChatting(fee).labelRes(), ) - assertEquals( - R.string.action_startChatting, - ProfilePinnedAction.StartChatting(null).labelRes(), - ) } } diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt index 24ee30c945..f03c598ba2 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt @@ -124,7 +124,7 @@ internal fun Scanner() { // /tip/chat/{id} deeplink. is DeeplinkType.TipChat -> listOf( AppRoute.Tabs.Chats, - AppRoute.Messaging.Chat(deeplink.identifier), + AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(deeplink.chatId)), ) // Same destination as the tapped /chat/{uuid} link: the chat screen is the // gated preview when the viewer is not yet a member. diff --git a/apps/flipcash/features/tipping/build.gradle.kts b/apps/flipcash/features/tipping/build.gradle.kts index f8eff908fa..ccde08c217 100644 --- a/apps/flipcash/features/tipping/build.gradle.kts +++ b/apps/flipcash/features/tipping/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { implementation(project(":apps:flipcash:shared:amount-entry")) implementation(project(":apps:flipcash:shared:bills")) implementation(project(":apps:flipcash:shared:blob")) + implementation(project(":apps:flipcash:shared:blocklist")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:chat-ui")) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/FindByUsernameScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/FindByUsernameScreen.kt index 482356c1c1..ce68fd5012 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/FindByUsernameScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/FindByUsernameScreen.kt @@ -41,12 +41,10 @@ import kotlinx.coroutines.flow.onEach /** * Node 9442:5825 — start a chat with someone by their public `@handle`. * - * Reached from "Find by Username" on the New Chat chooser, and left by becoming the chat itself: the - * handle resolves to a user id, which is enough to open the conversation whether or not it exists - * yet ([com.flipcash.app.core.chat.ChatIdentifier.ByUser]). The chat takes the place of this screen - * and of the chooser above the list, so backing out of the chat lands on the Chats list — both have - * done their job by then, and re-showing them would put two screens between the chat and the list it - * belongs to. + * Reached from "Find by Username" on the New Chat chooser, and left by becoming the person's DM if + * one exists, or their profile if not. That destination takes the place of this screen and of the + * chooser above the list, so backing out lands on the Chats list — both have done their job by then, + * and re-showing them would put two screens between it and the list it belongs to. */ @Composable fun FindByUsernameScreen() { @@ -77,7 +75,7 @@ fun FindByUsernameScreen() { navigator.popUntil { it !is AppRoute.Messaging.FindByUsername && it !is AppRoute.Messaging.NewChat } - navigator.push(AppRoute.Messaging.Chat(resolved.identifier)) + navigator.push(resolved.destination) } .launchIn(this) } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/FindByUsernameViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/FindByUsernameViewModel.kt index cf9a750cb4..f7b0c93dce 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/FindByUsernameViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/FindByUsernameViewModel.kt @@ -2,9 +2,12 @@ package com.flipcash.app.tipping.internal import androidx.compose.foundation.text.input.TextFieldState import androidx.lifecycle.viewModelScope -import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.blocklist.DmDestinationResolver +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ProfileOrigin import com.flipcash.app.core.extensions.onResult import com.flipcash.features.tipping.R +import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.controllers.ProfileController import com.flipcash.services.models.GetUserProfileError import com.flipcash.services.user.UserManager @@ -28,10 +31,10 @@ internal const val MinHandleLength = 2 internal const val MaxHandleLength = 15 /** - * Turning a typed `@handle` into an openable chat. + * Turning a typed `@handle` into somewhere to go. * - * One round trip: the profile fetch answers with the user's id, which is all the chat needs — the - * canonical tip-DM id is derived from it, so the conversation opens whether or not it exists yet. + * One round trip: the profile fetch answers with the user's id. The destination is their DM when one + * exists and they are not blocked, and their profile until then. * * A handle nobody has claimed is informational, not an error: the user typed it and can retype it. * Only a failed lookup is ours to apologise for. @@ -40,10 +43,13 @@ internal const val MaxHandleLength = 15 internal class FindByUsernameViewModel @Inject constructor( private val profileController: ProfileController, private val userManager: UserManager, + private val dmDestinations: DmDestinationResolver, private val resources: ResourceHelper, + dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, + defaultDispatcher = dispatchers.Default, ) { data class State( val usernameFieldState: TextFieldState = TextFieldState(), @@ -69,8 +75,8 @@ internal class FindByUsernameViewModel @Inject constructor( val success: Boolean = false, ) : Event - /** The handle resolved; [identifier] is what the chat route opens on. */ - data class UserResolved(val identifier: ChatIdentifier.ByUser) : Event + /** The handle resolved; [destination] is their DM if one exists, else their profile. */ + data class UserResolved(val destination: AppRoute) : Event } init { @@ -80,14 +86,14 @@ internal class FindByUsernameViewModel @Inject constructor( .map { stateFlow.value.usernameFieldState.text.toString().trim() } .map { username -> resolve(username) } .onResult( - onSuccess = { identifier -> + onSuccess = { destination -> // No reset back to idle afterwards. The chat replaces this screen, so idle is // a state it never shows again — but the button reaches it first: the fill // snaps from the disabled White10 to a solid White while the content is still // crossfading the checkmark out, so the last thing seen before the chat // arrives is a blank white button. Holding the checkmark leaves it alone. dispatchSuccessThen(Event.UpdateProcessingState(success = true)) { - dispatchEvent(Event.UserResolved(identifier)) + dispatchEvent(Event.UserResolved(destination)) } }, onError = { cause -> @@ -97,14 +103,14 @@ internal class FindByUsernameViewModel @Inject constructor( ).launchIn(viewModelScope) } - private suspend fun resolve(username: String): Result = + private suspend fun resolve(username: String): Result = profileController.getProfileForUsername(username) .mapCatching { profile -> // A profile with no id can't be chatted with, and is indistinguishable to the user // from a handle that doesn't exist — so it reads as one. val userId = profile.userId ?: throw GetUserProfileError.NotFound() if (userId == userManager.accountId) throw OwnHandle(username) - ChatIdentifier.ByUser(userId, profile) + dmDestinations.dmDestination(userId, ProfileOrigin.UsernameLookup) } private fun announceUnresolvable(cause: Throwable) { diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/FindByUsernameViewModelTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/FindByUsernameViewModelTest.kt new file mode 100644 index 0000000000..d5a67aa3ca --- /dev/null +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/FindByUsernameViewModelTest.kt @@ -0,0 +1,92 @@ +package com.flipcash.app.tipping.internal + +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import com.flipcash.app.blocklist.DmDestinationResolver +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.core.chat.ProfileAddress +import com.flipcash.app.core.chat.ProfileOrigin +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.user.UserManager +import com.getcode.util.resources.FakeResourceHelper +import com.getcode.view.SuccessHoldDuration +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals + +/** + * A typed handle resolves to the DM with that person when one exists, and to their profile until + * then. The destination itself is [DmDestinationResolver]'s answer; what is pinned here is that the + * lookup hands it the origin that makes a block reset to the chat list, and passes it on untouched. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class FindByUsernameViewModelTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val userId = List(32) { it.toByte() } + private val dmChatId = ChatId(ByteArray(32) { (it + 2).toByte() }) + + private val profileController = mockk() + private val userManager = mockk(relaxed = true) + private val dmDestinations = mockk() + + private val profile = AppRoute.Messaging.Profile( + ProfileAddress.ById(userId), + ProfileOrigin.UsernameLookup, + ) + private val chat = AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(dmChatId)) + + private fun TestScope.createViewModel() = FindByUsernameViewModel( + profileController = profileController, + userManager = userManager, + dmDestinations = dmDestinations, + resources = FakeResourceHelper(), + dispatchers = TestDispatchers(testScheduler), + ) + + private fun TestScope.lookUp(destination: AppRoute): AppRoute? { + val found = mockk(relaxed = true) { every { userId } returns this@FindByUsernameViewModelTest.userId } + coEvery { profileController.getProfileForUsername("sally") } returns Result.success(found) + coEvery { dmDestinations.dmDestination(userId, ProfileOrigin.UsernameLookup) } returns destination + + val viewModel = createViewModel() + var resolved: AppRoute? = null + backgroundScope.launch { + viewModel.eventFlow + .filterIsInstance() + .collect { resolved = it.destination } + } + viewModel.stateFlow.value.usernameFieldState.setTextAndPlaceCursorAtEnd("sally") + viewModel.dispatchEvent(FindByUsernameViewModel.Event.LookupUsername) + advanceTimeBy(SuccessHoldDuration + SuccessHoldDuration) + advanceUntilIdle() + return resolved + } + + @Test + fun `a handle with no DM resolves to the profile`() = runTest(mainCoroutineRule.dispatcher) { + assertEquals(profile, lookUp(profile)) + } + + @Test + fun `a handle with a DM resolves to the chat`() = runTest(mainCoroutineRule.dispatcher) { + assertEquals(chat, lookUp(chat)) + } +} diff --git a/apps/flipcash/features/transactions/build.gradle.kts b/apps/flipcash/features/transactions/build.gradle.kts index 8884ad1a81..bc4506aa2f 100644 --- a/apps/flipcash/features/transactions/build.gradle.kts +++ b/apps/flipcash/features/transactions/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { implementation(libs.compose.paging) implementation(project(":apps:flipcash:shared:transaction-history")) + implementation(project(":apps:flipcash:shared:blocklist")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:tokens")) diff --git a/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/TransactionDetailsScreen.kt b/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/TransactionDetailsScreen.kt index 9deac10772..83d5e13111 100644 --- a/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/TransactionDetailsScreen.kt +++ b/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/TransactionDetailsScreen.kt @@ -9,8 +9,6 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.transactions.internal.TransactionDetailsViewModel import com.flipcash.shared.transactionhistory.TransactionDetailsContent import com.getcode.navigation.core.LocalCodeNavigator @@ -51,12 +49,9 @@ fun TransactionDetailsScreen(id: ID) { details = transaction.details, onBack = { navigator.pop() }, onCopyId = { viewModel.dispatchEvent(TransactionDetailsViewModel.Event.CopyId) }, + viewsProfile = state.viewsProfile, onViewInChat = { - val userId = transaction.counterpartyId ?: return@TransactionDetailsContent - val profile = transaction.counterparty ?: return@TransactionDetailsContent - navigator.push( - AppRoute.Messaging.Chat(ChatIdentifier.ByUser(userId = userId, profile = profile)) - ) + state.counterpartDestination?.let { navigator.push(it) } }, onCancel = { viewModel.dispatchEvent(TransactionDetailsViewModel.Event.OnCancelRequested) }, ) diff --git a/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModel.kt b/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModel.kt index 15cb2d6787..3e6bd7cf88 100644 --- a/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModel.kt +++ b/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModel.kt @@ -2,6 +2,9 @@ package com.flipcash.app.transactions.internal import android.content.ClipboardManager import androidx.lifecycle.viewModelScope +import com.flipcash.app.blocklist.DmDestinationResolver +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ProfileOrigin import com.flipcash.app.core.extensions.onResult import com.flipcash.app.core.extensions.setText import com.flipcash.app.core.feed.MessageMetadata @@ -24,6 +27,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapNotNull @@ -49,6 +54,7 @@ class TransactionDetailsViewModel @Inject constructor( toastController: SystemToastController, userManager: UserManager, resources: ResourceHelper, + dmDestinations: DmDestinationResolver, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -63,11 +69,19 @@ class TransactionDetailsViewModel @Inject constructor( data class State( val id: ID? = null, val transaction: ResolvedTransaction? = null, - ) + /** + * Where the counterparty button goes: their DM once it exists, their profile until then. + * Null until it has resolved, or when the entry has no counterparty. + */ + val counterpartDestination: AppRoute? = null, + ) { + val viewsProfile: Boolean get() = counterpartDestination is AppRoute.Messaging.Profile + } sealed interface Event { data class OnIdProvided(val id: ID) : Event data class OnTransactionResolved(val transaction: ResolvedTransaction?) : Event + data class OnCounterpartDestinationResolved(val destination: AppRoute?) : Event data object CopyId : Event data object OnCancelRequested : Event data class CancelTransfer(val vault: PublicKey) : Event @@ -80,6 +94,18 @@ class TransactionDetailsViewModel @Inject constructor( .onEach { dispatchEvent(Event.OnTransactionResolved(it)) } .launchIn(viewModelScope) + stateFlow.map { it.transaction?.counterpartyId } + .distinctUntilChanged() + .flatMapLatest { userId -> + if (userId == null) { + flowOf(null) + } else { + dmDestinations.observeDmDestination(userId, ProfileOrigin.Transaction) + } + } + .onEach { dispatchEvent(Event.OnCounterpartDestinationResolved(it)) } + .launchIn(viewModelScope) + eventFlow .filterIsInstance() .mapNotNull { stateFlow.value.transaction?.details?.id } @@ -159,6 +185,7 @@ class TransactionDetailsViewModel @Inject constructor( when (event) { is Event.OnIdProvided -> { state -> state.copy(id = event.id) } is Event.OnTransactionResolved -> { state -> state.copy(transaction = event.transaction) } + is Event.OnCounterpartDestinationResolved -> { state -> state.copy(counterpartDestination = event.destination) } Event.CopyId -> { state -> state } Event.OnCancelRequested -> { state -> state } is Event.CancelTransfer -> { state -> state } diff --git a/apps/flipcash/features/transactions/src/test/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModelTest.kt b/apps/flipcash/features/transactions/src/test/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModelTest.kt new file mode 100644 index 0000000000..2c58c4a985 --- /dev/null +++ b/apps/flipcash/features/transactions/src/test/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModelTest.kt @@ -0,0 +1,127 @@ +package com.flipcash.app.transactions.internal + +import com.flipcash.app.blocklist.DmDestinationResolver +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.core.chat.ProfileAddress +import com.flipcash.app.core.chat.ProfileOrigin +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.user.UserManager +import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator +import com.flipcash.shared.transactionhistory.ResolvedTransaction +import com.getcode.util.resources.FakeResourceHelper +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The counterpart action on the details screen follows [DmDestinationResolver]: the profile until a + * DM with the person exists, the DM after. The screen picks "View Profile" or "View in Chat" from + * [TransactionDetailsViewModel.State.viewsProfile], so that is what is pinned here. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TransactionDetailsViewModelTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val counterpartyId = List(32) { it.toByte() } + private val entryId = List(32) { (it + 1).toByte() } + private val dmChatId = ChatId(ByteArray(32) { (it + 2).toByte() }) + + private val profile = AppRoute.Messaging.Profile( + ProfileAddress.ById(counterpartyId), + ProfileOrigin.Transaction, + ) + private val chat = AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(dmChatId)) + + private val feedCoordinator = mockk(relaxed = true) + private val dmDestinations = mockk() + + private fun TestScope.createViewModel(destinations: kotlinx.coroutines.flow.Flow): TransactionDetailsViewModel { + val transaction = mockk(relaxed = true) { + every { counterpartyId } returns this@TransactionDetailsViewModelTest.counterpartyId + } + every { feedCoordinator.transactionDetails(entryId) } returns flowOf(transaction) + every { + dmDestinations.observeDmDestination(counterpartyId, ProfileOrigin.Transaction) + } returns destinations + + return TransactionDetailsViewModel( + feedCoordinator = feedCoordinator, + tokenCoordinator = mockk(relaxed = true), + transactionController = mockk(relaxed = true), + clipboardManager = mockk(relaxed = true), + toastController = mockk(relaxed = true), + userManager = mockk(relaxed = true), + resources = FakeResourceHelper(), + dmDestinations = dmDestinations, + dispatchers = TestDispatchers(testScheduler), + ) + } + + @Test + fun `a counterparty with no DM views the profile`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel(flowOf(profile)) + vm.dispatchEvent(TransactionDetailsViewModel.Event.OnIdProvided(entryId)) + advanceUntilIdle() + + assertEquals(profile, vm.stateFlow.value.counterpartDestination) + assertTrue(vm.stateFlow.value.viewsProfile) + } + + @Test + fun `a counterparty with a DM views it in chat`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel(flowOf(chat)) + vm.dispatchEvent(TransactionDetailsViewModel.Event.OnIdProvided(entryId)) + advanceUntilIdle() + + assertEquals(chat, vm.stateFlow.value.counterpartDestination) + assertFalse(vm.stateFlow.value.viewsProfile) + } + + @Test + fun `the action flips when the resolver re-emits`() = runTest(mainCoroutineRule.dispatcher) { + val destinations = MutableSharedFlow(replay = 1) + destinations.emit(profile) + val vm = createViewModel(destinations) + vm.dispatchEvent(TransactionDetailsViewModel.Event.OnIdProvided(entryId)) + advanceUntilIdle() + assertTrue(vm.stateFlow.value.viewsProfile) + + destinations.emit(chat) + advanceUntilIdle() + assertFalse(vm.stateFlow.value.viewsProfile) + + destinations.emit(profile) + advanceUntilIdle() + assertTrue(vm.stateFlow.value.viewsProfile) + } + + @Test + fun `an entry with no counterparty has no destination`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel(flowOf(chat)) + val none = mockk(relaxed = true) { every { counterpartyId } returns null } + every { feedCoordinator.transactionDetails(entryId) } returns flowOf(none) + vm.dispatchEvent(TransactionDetailsViewModel.Event.OnIdProvided(entryId)) + advanceUntilIdle() + + assertNull(vm.stateFlow.value.counterpartDestination) + assertFalse(vm.stateFlow.value.viewsProfile) + } +} diff --git a/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/ConversionsTest.kt b/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/ConversionsTest.kt index eb072da7e6..ffb5893ed8 100644 --- a/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/ConversionsTest.kt +++ b/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/ConversionsTest.kt @@ -75,7 +75,7 @@ class ConversionsTest { DeeplinkType.Login(entropy = "e"), DeeplinkType.CashLink(entropy = "e"), DeeplinkType.TokenInfo(mint = mint), - DeeplinkType.TipChat(identifier = ChatIdentifier.ByChatId(chatId)), + DeeplinkType.TipChat(chatId), DeeplinkType.GroupChatInvite(chatId = chatId), DeeplinkType.Tipcard(userId = id), DeeplinkType.Profile(ProfileAddress.ByUsername("someone")), diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt index 004dae814b..6390aa3470 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt @@ -5,43 +5,42 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import com.flipcash.app.core.AppRoute import com.flipcash.app.core.bill.Scannable -import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.core.chat.ProfileAddress +import com.flipcash.app.core.chat.ProfileOrigin +import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.core.tipping.LocalTipCoordinator import com.flipcash.app.core.tipping.TipEvent import com.flipcash.app.session.Grabbed import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.ui.utils.ModalAnimationSpeed import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.withContext /** * Decorator owned by a [Scannable.TipCard] (node 10074:18893). Scanning a card is a way of reaching - * a person, so it ends where reaching them by `@handle` ends: in their chat, started or not. What - * used to happen here — a modal over the card taking a tip amount, sending it, then handing off to - * the chat — is now the chat's own job, and the fee it charges is the same one either route pays. + * a person, and what it shows is who they are: their profile, every time, even when a DM with them + * already exists. The profile is where the DM is opened from (Open Chat) or started (Send to Start + * Chatting), the same as on iOS. * - * The card still gets a beat on screen before the chat takes over. It is the same 450ms the modal - * used to take sliding up ([ModalAnimationSpeed.Normal] at the tip card's zero confirmation delay), - * so the scan still reads as "I found this person" rather than as a screen that flashed past. Then - * the card's pop overlaps the push into the chat, so the hand-off reads as one movement. + * The card still gets a beat on screen before the profile takes over, [CardHoldMillis], matching + * iOS, so the scan reads as "I found this person" rather than as a screen that flashed past. Then + * the card's pop overlaps the push, so the hand-off reads as one movement. */ internal data class TipCardDecorator(private val tipCard: Scannable.TipCard) : ScannableDecorator { @Composable override fun BoxScope.Content(context: ScannableDecoratorContext) { - val billState = context.billState val navigator = LocalCodeNavigator.current val tipCoordinator = LocalTipCoordinator.current val tipPresented = context.liveBill is Scannable.TipCard // The id is server-provided, so a card that resolved to a locally-constructed profile has - // no chat to open. Leave it up rather than dismissing into nothing. + // no profile to open. Leave it up rather than dismissing into nothing. val userId = tipCard.user.userId LaunchedEffect(tipPresented, userId) { if (!tipPresented || userId == null) return@LaunchedEffect - delay(ModalAnimationSpeed.Normal(billState.confirmationDelayMillis).delay.toLong()) + delay(CardHoldMillis) // Dismiss with [Grabbed] rather than [PutInWallet]: the card leaves as a 100ms // fade-and-scale instead of a 600ms slide back to the wallet, which the entry sliding // out from under it would swallow whole. [PopLeadMillis] then gives that pop a head @@ -53,8 +52,13 @@ internal data class TipCardDecorator(private val tipCard: Scannable.TipCard) : S withContext(NonCancellable) { context.onDismiss(Grabbed) delay(PopLeadMillis) - navigator.push( - AppRoute.Messaging.Chat(ChatIdentifier.ByUser(userId, tipCard.user)), + // Chats under the profile, so back and a block land on the list the scan came + // from rather than on the scanner. + navigator.navigateAll( + listOf( + AppRoute.Tabs.Chats, + AppRoute.Messaging.Profile(ProfileAddress.ById(userId), ProfileOrigin.Scan), + ) ) } } @@ -71,8 +75,11 @@ internal data class TipCardDecorator(private val tipCard: Scannable.TipCard) : S } } +/** How long the scanned card stays up before the profile replaces it. iOS holds it 750ms. */ +private const val CardHoldMillis = 750L + /** - * How long the card's exit gets to itself before the chat is pushed. Long enough for the + * How long the card's exit gets to itself before the profile is pushed. Long enough for the * fade-and-scale to be under way when the navigation transition starts, short enough that the two * still read as one movement. */ diff --git a/apps/flipcash/shared/blocklist/src/main/kotlin/com/flipcash/app/blocklist/DmDestinationResolver.kt b/apps/flipcash/shared/blocklist/src/main/kotlin/com/flipcash/app/blocklist/DmDestinationResolver.kt new file mode 100644 index 0000000000..8716a14ace --- /dev/null +++ b/apps/flipcash/shared/blocklist/src/main/kotlin/com/flipcash/app/blocklist/DmDestinationResolver.kt @@ -0,0 +1,62 @@ +package com.flipcash.app.blocklist + +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ProfileOrigin +import com.flipcash.app.core.chat.dmDestination +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Resolves a person to the DM with them, or to their profile until there is one. + * + * The same rule the profile's pinned button uses: the DM's id is derived, and the DM exists once it + * has members. A block hides the DM but leaves its members, so blocked is checked first: opening + * the chat of someone the viewer blocked would undo the block. Lives beside + * [BlocklistCoordinator] because it needs both that and the chat coordinator, which this module is + * the lowest to see. + */ +@Singleton +class DmDestinationResolver @Inject constructor( + private val chatCoordinator: ChatCoordinator, + private val blocklist: BlocklistCoordinator, +) { + /** The destination for [userId] right now. */ + suspend fun dmDestination(userId: ID, origin: ProfileOrigin): AppRoute = + observeDmDestination(userId, origin).first() + + /** The destination for [userId], following the DM appearing and blocks landing. */ + @OptIn(ExperimentalCoroutinesApi::class) + fun observeDmDestination(userId: ID, origin: ProfileOrigin): Flow = + openableDm(userId) + .map { chatId -> dmDestination(userId, chatId, origin) } + .distinctUntilChanged() + + @OptIn(ExperimentalCoroutinesApi::class) + private fun openableDm(userId: ID): Flow = flowOfDmChatId(userId) + .flatMapLatest { chatId -> + if (chatId == null) { + flowOf(null) + } else { + combine( + chatCoordinator.observeMembers(chatId).map { it.isNotEmpty() }, + blocklist.observeIsBlocked(userId), + ) { exists, blocked -> chatId.takeIf { exists && !blocked } } + } + } + + private fun flowOfDmChatId(userId: ID): Flow = flow { + emit(chatCoordinator.generateChatId(userId).getOrNull()) + } +} diff --git a/apps/flipcash/shared/blocklist/src/test/kotlin/com/flipcash/app/blocklist/DmDestinationResolverTest.kt b/apps/flipcash/shared/blocklist/src/test/kotlin/com/flipcash/app/blocklist/DmDestinationResolverTest.kt new file mode 100644 index 0000000000..683c3ccd3a --- /dev/null +++ b/apps/flipcash/shared/blocklist/src/test/kotlin/com/flipcash/app/blocklist/DmDestinationResolverTest.kt @@ -0,0 +1,92 @@ +package com.flipcash.app.blocklist + +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.chat.ProfileOrigin +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.shared.chat.ChatCoordinator +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals + +class DmDestinationResolverTest { + + private val userId = List(16) { 2 } + private val dmChatId = ChatId(ByteArray(32) { 7 }) + + private val members = MutableStateFlow>(emptyList()) + private val blocked = MutableStateFlow(false) + + private val chatCoordinator = mockk { + coEvery { generateChatId(userId) } returns Result.success(dmChatId) + every { observeMembers(dmChatId) } returns members + } + private val blocklist = mockk { + every { observeIsBlocked(userId) } returns blocked + } + private val resolver = DmDestinationResolver(chatCoordinator, blocklist) + + private val profile = AppRoute.Messaging.Profile(ProfileAddress.ById(userId), ProfileOrigin.UsernameLookup) + + @Test + fun `no members opens the profile`() = runTest { + assertEquals(profile, resolver.dmDestination(userId, ProfileOrigin.UsernameLookup)) + } + + @Test + fun `members open the dm by chat id`() = runTest { + members.value = listOf(mockk()) + + assertEquals( + AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(dmChatId)), + resolver.dmDestination(userId, ProfileOrigin.UsernameLookup), + ) + } + + @Test + fun `blocked beats an existing dm`() = runTest { + members.value = listOf(mockk()) + blocked.value = true + + assertEquals(profile, resolver.dmDestination(userId, ProfileOrigin.UsernameLookup)) + } + + @Test + fun `a chat id that cannot be derived opens the profile`() = runTest { + coEvery { chatCoordinator.generateChatId(userId) } returns Result.failure(IllegalStateException()) + members.value = listOf(mockk()) + + assertEquals(profile, resolver.dmDestination(userId, ProfileOrigin.UsernameLookup)) + } + + @Test + fun `the profile carries the origin of the caller`() = runTest { + assertEquals( + AppRoute.Messaging.Profile(ProfileAddress.ById(userId), ProfileOrigin.Transaction), + resolver.dmDestination(userId, ProfileOrigin.Transaction), + ) + } + + @Test + fun `observing follows the dm appearing and a block landing`() = runTest { + val seen = mutableListOf() + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + resolver.observeDmDestination(userId, ProfileOrigin.Transaction).collect { seen += it } + } + members.value = listOf(mockk()) + blocked.value = true + job.cancel() + + val chat = AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(dmChatId)) + val txProfile = AppRoute.Messaging.Profile(ProfileAddress.ById(userId), ProfileOrigin.Transaction) + assertEquals(listOf(txProfile, chat, txProfile), seen) + } +} diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt index edc2660020..fef0c3bc25 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt @@ -128,7 +128,7 @@ internal class AppRouter( is DeeplinkType.EmailVerification -> resolveEmailVerification(type) is DeeplinkType.TipChat -> DeeplinkAction.Navigate( - listOf(AppRoute.Tabs.Chats, AppRoute.Messaging.Chat(type.identifier)) + listOf(AppRoute.Tabs.Chats, AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(type.chatId))) ) // The same destination a group push tap resolves to (see ChatTapTargetPlanner): the @@ -413,7 +413,7 @@ private fun DeepLink.handleTipChat(): DeeplinkType.TipChat? { val chatTarget = uri.pathSegments.getOrNull(2) ?: return null val chatId = ChatId(chatTarget.decodeBase64UrlSafe().toList()) - return DeeplinkType.TipChat(ChatIdentifier.ByChatId(chatId)) + return DeeplinkType.TipChat(chatId) } /** diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt index 821f61c602..5b1a2d1049 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt @@ -356,9 +356,7 @@ class AppRouterTest { fun `classify recognizes tip chat deeplink and round-trips the chat id`() { val type = router.classify(DeepLink(Linkify.tipChatById(sampleChatId))) assertIs(type) - val identifier = type.identifier - assertIs(identifier) - assertEquals(sampleChatId, identifier.chatId) + assertEquals(sampleChatId, type.chatId) } @Test diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsContent.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsContent.kt index 483e03efba..d9e079d448 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsContent.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsContent.kt @@ -62,6 +62,8 @@ fun TransactionDetailsContent( onBack: () -> Unit = { }, onCopyId: () -> Unit = { }, onViewInChat: () -> Unit = { }, + /** The button opens the counterparty's profile rather than a chat, because no DM exists yet. */ + viewsProfile: Boolean = false, onCancel: () -> Unit = { }, iconOverride: @Composable ((Any?) -> Any?) = { it }, ) { @@ -105,7 +107,10 @@ fun TransactionDetailsContent( if (details.canViewInChat) { CodeButton( modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.action_txnDetails_viewInChat), + text = stringResource( + if (viewsProfile) R.string.action_txnDetails_viewProfile + else R.string.action_txnDetails_viewInChat + ), buttonState = ButtonState.Filled05, // [DetailsCard]'s radius, not CodeButton's default 8dp — the fill alone is // not enough to read as one more card if the corners disagree. diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt index 2aa4e58ecb..870401f404 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt @@ -78,8 +78,8 @@ internal class TransactionDetailsMapper @Inject constructor( // out rather than shown as zero. received = convert?.toAmount?.nativeAmount, canCancel = (meta as? MessageMetadata.IndirectlySentCrypto)?.canCancel == true, - // Opening the conversation needs somebody to open it with, and the profile is what the - // chat header renders from on the first frame (see `ChatIdentifier.ByUser`). + // The counterpart action opens a conversation or a profile, and both need somebody to + // open it with. canViewInChat = counterparty != null, ) } diff --git a/apps/flipcash/shared/transaction-history/src/main/res/values/strings.xml b/apps/flipcash/shared/transaction-history/src/main/res/values/strings.xml index f827a50480..ae7919239f 100644 --- a/apps/flipcash/shared/transaction-history/src/main/res/values/strings.xml +++ b/apps/flipcash/shared/transaction-history/src/main/res/values/strings.xml @@ -54,6 +54,7 @@ View in Chat + View Profile Copy transaction ID Cancel diff --git a/maestro/README.md b/maestro/README.md index e5cdf2fa5c..1501583951 100644 --- a/maestro/README.md +++ b/maestro/README.md @@ -148,7 +148,7 @@ maestro/run.sh maestro/tipping_setup.yaml that is tappable - `blocking.yaml` — block a chat participant from their profile, verify in Settings → Blocked, then unblock (leaves the account clean) -- `tip_deeplink.yaml` — open a tip-card deeplink (`TIPCARD_DEEPLINK`) → presents the tip flow +- `tip_deeplink.yaml` — open a tip-card deeplink (`TIPCARD_DEEPLINK`) → lands on that person's profile (waits for balances to sync first, else the empty-cache state trips the add-money gate) - `vanity_deeplink_self.yaml` — `flipcash.com/{LOGIN_USERNAME}` followed by the account that owns that handle → the You tab, not a tip card. Opens from cold on purpose: the handle-based diff --git a/maestro/tip_deeplink.yaml b/maestro/tip_deeplink.yaml index e18c260300..d994b3f7b4 100644 --- a/maestro/tip_deeplink.yaml +++ b/maestro/tip_deeplink.yaml @@ -1,5 +1,5 @@ appId: com.flipcash.app.android -name: "Tip deeplink — lands in the recipient's chat" +name: "Profile card deeplink — lands on the person's profile" tags: - tipping --- @@ -12,11 +12,10 @@ tags: - runFlow: subflows/navigate_to_wallet.yaml - assertVisible: { id: wallet_screen } -# Open the tip card deeplink -> the card holds for its reveal beat, then the chat takes over. -# Wait on the chat rather than on the card, and anchor on the route's own id rather than on the -# call-to-action copy, which the chat changes as the conversation progresses. +# Open the profile card deeplink -> the card holds for its reveal beat, then the person's profile +# takes over (a DM is only opened from the profile, whether or not one exists yet). Wait on the +# name rather than on the card, which shows it too, with a timeout that clears the reveal. - openLink: ${TIPCARD_DEEPLINK} - extendedWaitUntil: - visible: { id: chat_screen } + visible: "Brandon McAnsh" timeout: 12000 -- assertVisible: "Brandon McAnsh"