From 35fd0bec90db56aee369a5ce9ea36e816c1c242f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 18:17:17 -0400 Subject: [PATCH 1/4] feat(routing): open the profile until a DM exists Scanning a profile card now always opens the profile after a 750ms hold, matching iOS. Username search and transaction details resolve through DmDestinationResolver: the DM when its chat has members and the person isn't blocked, the profile otherwise. Transaction details labels the button View Profile or View in Chat accordingly. Profile routes carry a ProfileOrigin (Link, Scan, UsernameLookup, Transaction); a block pops back for link and transaction, and resets to the chat list for scan and search. --- .../ui/navigation/AppScreenContent.kt | 2 +- .../kotlin/com/flipcash/app/core/AppRoute.kt | 6 +- .../com/flipcash/app/core/chat/ChatStep.kt | 16 ++++ .../flipcash/app/core/chat/DmDestination.kt | 19 ++++ .../flipcash/app/messenger/ProfileScreen.kt | 20 +++- .../features/tipping/build.gradle.kts | 1 + .../app/tipping/FindByUsernameScreen.kt | 12 +-- .../internal/FindByUsernameViewModel.kt | 23 +++-- .../features/transactions/build.gradle.kts | 1 + .../transactions/TransactionDetailsScreen.kt | 9 +- .../internal/TransactionDetailsViewModel.kt | 29 +++++- .../app/bills/decor/TipCardDecorator.kt | 37 +++++--- .../app/blocklist/DmDestinationResolver.kt | 62 +++++++++++++ .../blocklist/DmDestinationResolverTest.kt | 92 +++++++++++++++++++ .../TransactionDetailsContent.kt | 7 +- .../src/main/res/values/strings.xml | 1 + 16 files changed, 289 insertions(+), 48 deletions(-) create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/DmDestination.kt create mode 100644 apps/flipcash/shared/blocklist/src/main/kotlin/com/flipcash/app/blocklist/DmDestinationResolver.kt create mode 100644 apps/flipcash/shared/blocklist/src/test/kotlin/com/flipcash/app/blocklist/DmDestinationResolverTest.kt 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..26175a7481 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 @@ -123,7 +123,7 @@ fun appEntryProvider( annotatedEntry { key -> 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..358cd351c2 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 @@ -400,7 +401,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/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index fc909b848c..cc9e89f10c 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 @@ -157,4 +157,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/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/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..7851be708c 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,7 +2,9 @@ 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.services.controllers.ProfileController @@ -28,10 +30,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,6 +42,7 @@ 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, ) : BaseViewModel( initialState = State(), @@ -69,8 +72,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 +83,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 +100,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/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/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/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/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 From 09728395d58e9c7bebee90f04b7148c8d6d375b9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 18:20:12 -0400 Subject: [PATCH 2/4] fix(messenger): pay the regional fee when a person's fee is unknown startChattingFee already falls back to the regional default, so a null fee only means the presets have not loaded yet. The profile's pinned button now waits for it instead of pushing the chat's keypad, and the OpenSendCash fallback and its Start Chatting label are gone. --- .../core/src/main/res/values/strings.xml | 2 -- .../screens/profile/ChatProfileScreen.kt | 5 +---- .../screens/profile/ChatProfileViewModel.kt | 20 ++++-------------- .../screens/profile/ProfilePinnedAction.kt | 17 ++++++++------- .../profile/ChatProfileViewModelTest.kt | 8 ++++--- .../screens/profile/ProfileActionsTest.kt | 21 ++++++++++++------- 6 files changed, 32 insertions(+), 41 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index b8e131dfc7..5f844740e2 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1287,8 +1287,6 @@ Unblock Blocked - - Start Chatting Open Chat More 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/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(), - ) } } From e70265bd3831bdfd387db0e4ba1560390c397a58 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 18:25:38 -0400 Subject: [PATCH 3/4] refactor(chat): remove the paid-DM gate from the conversation The conversation no longer takes the fee that opens a tip DM; the profile does. Drops ChatViewModel's init-payment flow (fee state, minimum-amount hint, OnInitPaymentConfirmed, the ChatInitPayment step), the open-with-Send-Cash hand-off and the openSendCash route flag. A saved Chat route that still carries a ByUser identifier opens the profile instead of a gated chat. Sends from the conversation are all plain cash sends. The profile-card deeplink Maestro flow now waits on the profile rather than chat_screen. --- .../ui/navigation/AppScreenContent.kt | 12 +- .../kotlin/com/flipcash/app/core/AppRoute.kt | 3 - .../flipcash/app/core/chat/ChatIdentifier.kt | 16 +- .../com/flipcash/app/core/chat/ChatStep.kt | 12 - .../core/src/main/res/values/strings.xml | 1 - .../flipcash/app/messenger/ChatFlowScreen.kt | 59 +---- .../app/messenger/internal/ChatViewModel.kt | 209 ++---------------- .../app/messenger/internal/SendCashOnOpen.kt | 62 ------ .../screens/components/SendCashButton.kt | 27 +-- .../internal/screens/profile/ProfileShare.kt | 9 - .../internal/ChatGroupCashLinkTest.kt | 1 - .../internal/ChatInitFeeReducerTest.kt | 45 ---- .../internal/ChatSendFailureAnalyticsTest.kt | 8 +- .../internal/ChatViewModelStateTest.kt | 43 ---- .../internal/StartSendCashOnceReadyTest.kt | 73 ------ maestro/tip_deeplink.yaml | 11 +- 16 files changed, 52 insertions(+), 539 deletions(-) delete mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/SendCashOnOpen.kt delete mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatInitFeeReducerTest.kt delete mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/StartSendCashOnceReadyTest.kt 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 26175a7481..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,7 +124,14 @@ 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, key.origin) } annotatedEntry { NewChatScreen() } 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 358cd351c2..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 @@ -360,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) 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 cc9e89f10c..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. diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 5f844740e2..afcbbfd9e3 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1261,7 +1261,6 @@ 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 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/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index f1a916d819..0bbfb94838 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(), ) } @@ -1920,21 +1825,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 +1920,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 +2509,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 +2522,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 +2551,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 +2575,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 +2588,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 +2607,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) } } @@ -3063,8 +2902,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 +2944,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/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/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/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/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" From 6aebb4a80913ea5d9ed1a7eae5f4ef724c4691af Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 18:44:34 -0400 Subject: [PATCH 4/4] refactor(chat): drop the by-user chat path and cover the routing callers ChatViewModel never receives ChatIdentifier.ByUser now that the app redirects it to the profile, so its three branches become an error. TipChat carries a ChatId, since the router only ever builds it from one. Adds tests for the typing gate (tip DM open, contact DM waits for Cash), the transaction details counterpart action following DmDestinationResolver, the username lookup's two destinations, and ProfileOrigin.resetsToChatsAfterBlock. FindByUsernameViewModel takes a DispatcherProvider so its events are testable. Removes the tip-minimum strings this branch orphaned, and fixes a stale comment and the tip_deeplink entry in the Maestro README. --- .../app/core/navigation/DeeplinkType.kt | 2 +- .../core/src/main/res/values/strings.xml | 3 - .../app/core/chat/ProfileOriginTest.kt | 26 +++ .../core/navigation/DeeplinkScanPolicyTest.kt | 2 +- .../app/messenger/internal/ChatViewModel.kt | 27 +-- .../internal/ChatOpenTranscriptTest.kt | 21 ++- .../messenger/internal/ChatTypingGateTest.kt | 158 ++++++++++++++++++ .../flipcash/app/scanner/internal/Scanner.kt | 2 +- .../internal/FindByUsernameViewModel.kt | 3 + .../internal/FindByUsernameViewModelTest.kt | 92 ++++++++++ .../TransactionDetailsViewModelTest.kt | 127 ++++++++++++++ .../flipcash/app/analytics/ConversionsTest.kt | 2 +- .../flipcash/app/router/internal/AppRouter.kt | 4 +- .../app/router/internal/AppRouterTest.kt | 4 +- .../internal/TransactionDetailsMapper.kt | 4 +- maestro/README.md | 2 +- 16 files changed, 432 insertions(+), 47 deletions(-) create mode 100644 apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/chat/ProfileOriginTest.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt create mode 100644 apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/FindByUsernameViewModelTest.kt create mode 100644 apps/flipcash/features/transactions/src/test/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModelTest.kt 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 afcbbfd9e3..61461e0833 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1263,9 +1263,6 @@ Show My Tip Card Send %1$s to Start Chatting Swipe to Tip - %1$s minimum - %1$s Minimum Tip - Please enter a higher amount via Tip Card