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