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