Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ import com.flipcash.app.deposit.DepositFlowScreen
import com.flipcash.app.directsend.SendFlowScreen
import com.flipcash.app.invite.InviteContactScreen
import com.flipcash.app.messenger.ChatFlowScreen
import com.flipcash.app.messenger.ChatAmountEntryScreen
import com.flipcash.app.discovery.TokenDiscoveryScreen
import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEntryDecorator
import com.flipcash.app.lab.LabsScreen
import com.flipcash.app.lab.NavBarSettingsScreen
import com.flipcash.app.login.OnboardingFlowScreen
import com.flipcash.app.menu.MenuScreen
import com.flipcash.app.myaccount.BlocklistScreen
import com.flipcash.app.myaccount.UserProfileScreen
import com.flipcash.app.myaccount.MyAccountScreen
import com.flipcash.app.scanner.ScannerScreen
Expand Down Expand Up @@ -107,9 +107,6 @@ fun appEntryProvider(
annotatedEntry<AppRoute.Messaging.Chat> { key ->
ChatFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
}
annotatedEntry<AppRoute.Messaging.AmountEntry> { key ->
ChatAmountEntryScreen(key.identifier)
}

// Tokens
annotatedEntry<AppRoute.Token.Info> { key ->
Expand Down Expand Up @@ -141,6 +138,7 @@ fun appEntryProvider(
annotatedEntry<AppRoute.Menu.NavBarSettings> { NavBarSettingsScreen() }
annotatedEntry<AppRoute.Menu.UserProfile> { UserProfileScreen() }
annotatedEntry<AppRoute.Menu.MyAccount> { MyAccountScreen() }
annotatedEntry<AppRoute.Menu.Blocklist> { BlocklistScreen() }
annotatedEntry<AppRoute.Menu.BackupKey> { BackupKeyScreen() }
annotatedEntry<AppRoute.Menu.AdvancedFeatures> { AdvancedFeaturesScreen() }
annotatedEntry<AppRoute.Menu.DeviceLogs> { DeviceLogsScreen() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,9 @@ sealed interface AppRoute : NavKey, Parcelable {
data object MyAccount : Menu
@Serializable
data object BackupKey : Menu

@Serializable
data object Blocklist: Menu
@Serializable
data object AppSettings : Menu
@Serializable
Expand Down Expand Up @@ -302,9 +305,6 @@ sealed interface AppRoute : NavKey, Parcelable {
override val initialStack: List<NavKey>
get() = listOf(ChatStep.Conversation)
}

@Serializable
data class AmountEntry(val identifier: ChatIdentifier) : Messaging
}

@Serializable
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.flipcash.app.core.blocklist

import com.flipcash.services.models.chat.MediaItem
import com.getcode.opencode.model.core.ID
import kotlin.time.Instant

/**
* A blocklist entry enriched with the display profile needed to render it — the server's blocklist
* entry only carries the user id + when they were blocked, so the name/avatar are resolved
* separately (via the profile service) and carried alongside for the blocklist UI.
*
* @param userId The blocked user
* @param displayName Resolved display name, or empty if the profile could not be resolved
* @param profilePicture Resolved avatar, or null if unset/unresolved
* @param blockedAt When the user was blocked
*/
data class BlockedUserProfile(
val userId: ID,
val displayName: String,
val profilePicture: MediaItem?,
val blockedAt: Instant,
)
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
package com.flipcash.app.messenger.internal
package com.flipcash.app.core.chat

import android.os.Parcelable
import com.flipcash.app.core.contacts.DeviceContact
import com.flipcash.services.models.UserProfile
import com.getcode.opencode.model.core.ID
import kotlinx.parcelize.Parcelize

/**
* The counterparty a DM header and info card renders.
*
* A conversation is backed by one of two identity sources depending on its
* [com.flipcash.services.models.chat.ChatType]:
*
* - [Contact] — a `CONTACT_DM`. Identity comes from a device [DeviceContact]: it has a phone
* - [Contact] — a `CONTACT_DM`. Identity comes from a device [com.flipcash.app.core.contacts.DeviceContact]: it has a phone
* number and supports the "add to contacts" action.
* - [TipUser] — a `TIP_DM`. The counterparty has no device contact; identity comes from their
* server [UserProfile] (display name + profile picture), the same source the tips list uses.
* server [com.flipcash.services.models.UserProfile] (display name + profile picture), the same source the tips list uses.
*/
internal sealed interface ChatParticipant {
@Parcelize
sealed interface ChatParticipant: Parcelable {
val displayName: String

data class Contact(val contact: DeviceContact) : ChatParticipant {
Expand All @@ -25,4 +28,4 @@ internal sealed interface ChatParticipant {
data class TipUser(val userId: ID, val profile: UserProfile) : ChatParticipant {
override val displayName: String get() = profile.displayName
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,8 @@ sealed interface ChatStep : FlowStep, Parcelable {
@Parcelize
@Serializable
data object AmountEntry : ChatStep, NavigationRetVal<ChatSendResult>

@Parcelize
@Serializable
data class Profile(val contact: ChatParticipant): ChatStep
}
18 changes: 18 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -904,4 +904,22 @@
<string name="error_description_tipMinimum">Enter a larger amount to send this tip</string>
<string name="label_viaTipCard">via Tip Card</string>

<string name="title_block">Block</string>
<string name="title_blocklist">Blocked</string>
<string name="action_block">Block</string>
<string name="prompt_title_blockUser">Block %1$s?</string>
<string name="prompt_description_blockUser">You won’t see messages from them, but you will still receive cash they send you. Flipcash won’t tell them you blocked them</string>
<string name="action_unblock">Unblock</string>
<string name="prompt_title_unblockUser">Unblock %1$s?</string>
<string name="prompt_description_unblockUser">The conversation with them will reappear in Tips</string>
<string name="title_blocklistEmpty">No One Blocked</string>
<string name="description_blocklistEmpty">Block people from sending you messages by tapping their profile and selecting block</string>
<string name="subtitle_joinedDate">Joined %1$s</string>

<string name="error_title_failedToBlock">Something Went Wrong</string>
<string name="error_description_failedToBlock">We were unable to block the user. Please try again</string>
<string name="error_title_failedToUnblock">Something Went Wrong</string>
<string name="error_description_failedToUnblock">We were unable to unblock the user. Please try again</string>


</resources>
5 changes: 5 additions & 0 deletions apps/flipcash/features/messenger/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ android {

dependencies {
implementation(project(":apps:flipcash:shared:analytics"))
implementation(project(":apps:flipcash:shared:blocklist"))
implementation(project(":apps:flipcash:shared:chat"))
implementation(project(":apps:flipcash:shared:chat-ui"))
implementation(project(":apps:flipcash:shared:amount-entry"))
implementation(project(":apps:flipcash:shared:contacts"))
implementation(project(":apps:flipcash:shared:featureflags"))
implementation(project(":apps:flipcash:shared:funding"))
implementation(project(":apps:flipcash:shared:menu"))
implementation(project(":apps:flipcash:shared:payments"))
implementation(project(":apps:flipcash:shared:tokens"))
implementation(project(":libs:vibrator:bindings"))
Expand All @@ -23,4 +25,7 @@ dependencies {
implementation(project(":libs:datetime"))
implementation(libs.compose.paging)
implementation(libs.bundles.haze)

testImplementation(libs.bundles.unit.testing)
testImplementation(libs.mockito.kotlin)
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,20 @@ import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.app.core.chat.ChatParticipant
import com.flipcash.app.core.chat.ChatSendResult
import com.flipcash.app.core.chat.ChatStep
import com.flipcash.app.core.extensions.openAsSheet
import com.flipcash.app.messenger.internal.ChatViewModel
import com.flipcash.app.messenger.internal.screens.MessengerScreen
import com.flipcash.app.messenger.internal.screens.cash.ChatAmountEntryContent
import com.flipcash.app.messenger.internal.screens.profile.ChatProfileScreen
import com.flipcash.app.messenger.internal.screens.profile.ChatProfileViewModel
import com.getcode.navigation.annotatedEntry
import com.getcode.navigation.core.LocalCodeNavigator
import com.getcode.navigation.flow.FlowHost
import com.getcode.navigation.flow.flowSharedViewModel
import com.getcode.navigation.flow.rememberFlowNavigator
import com.getcode.navigation.flow.rememberInitialStack
import com.getcode.navigation.results.NavResultOrCanceled
import com.getcode.navigation.results.NavResultStateRegistry
Expand All @@ -30,6 +35,8 @@ import com.getcode.navigation.results.resultBackNavigator
import com.getcode.navigation.scenes.LocalSheetNavigator
import com.getcode.ui.utils.rememberKeyboardController
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach

@Composable
fun ChatFlowScreen(
Expand Down Expand Up @@ -57,6 +64,9 @@ private fun chatEntryProvider(
annotatedEntry<ChatStep.AmountEntry> {
FlowAmountEntryScreen()
}
annotatedEntry<ChatStep.Profile> { step ->
FlowChatProfileScreen(step.contact)
}
}

@Composable
Expand Down Expand Up @@ -141,3 +151,25 @@ private fun FlowAmountEntryScreen() {
onExit = { navigator.navigateBack() }, // pop the AmountEntry step
)
}

@Composable
private fun FlowChatProfileScreen(participant: ChatParticipant) {
val viewModel = flowSharedViewModel<ChatProfileViewModel>()
val flowNavigator = rememberFlowNavigator<ChatStep, Parcelable>()

LaunchedEffect(viewModel, participant) {
viewModel.dispatchEvent(ChatProfileViewModel.Event.OnParticipantSet(participant))
}

ChatProfileScreen(viewModel)

LaunchedEffect(viewModel) {
viewModel.eventFlow
.filterIsInstance<ChatProfileViewModel.Event.BlockSuccessful>()
.onEach {
// Blocking removes the DM, so exit the whole chat flow (FlowHost.onExit pops the
// Chat route) and land back on the Tips list the chat was opened from.
flowNavigator.exitCanceled()
}.launchIn(this)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import com.flipcash.app.analytics.FlipcashAnalyticsService
import com.flipcash.app.contacts.ContactCoordinator
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.app.core.chat.ChatParticipant
import com.flipcash.app.core.contacts.DeviceContact
import com.flipcash.app.core.ui.ConfirmationStyle
import com.flipcash.app.featureflags.FeatureFlag
Expand Down Expand Up @@ -145,7 +146,15 @@ internal class ChatViewModel @Inject constructor(
// open would be missed by the bottom bar before it subscribes, whereas state is durable
// until the input is actually composed and can consume it.
val messageInputRequested: Boolean = false,
)
// Whether the Blocklist beta flag is enabled. Backs canViewProfile; observed in init.
val blocklistEnabled: Boolean = false,
) {
// Opening the participant's profile (the entry point to blocking) is only available for tip
// DMs, and only when the Blocklist beta flag is on. Derived so it stays correct regardless
// of whether the flag or the chat type resolves first.
val canViewProfile: Boolean
get() = blocklistEnabled && chatType == ChatType.TIP_DM
}

sealed interface Event {
data class OnChatOpened(val identifier: ChatIdentifier) : Event
Expand Down Expand Up @@ -189,6 +198,7 @@ internal class ChatViewModel @Inject constructor(
data class LimitsChanged(val limits: Limits?) : Event
data class AdvanceReadPointer(val messageId: Long) : Event
data class ChatDeactivated(val isReadOnly: Boolean) : Event
data class BlocklistEnabledChanged(val enabled: Boolean) : Event
}

@OptIn(ExperimentalCoroutinesApi::class)
Expand Down Expand Up @@ -433,6 +443,10 @@ internal class ChatViewModel @Inject constructor(
.onEach { dispatchEvent(Event.ChatDeactivated(isReadOnly = it)) }
.launchIn(viewModelScope)

featureFlags.observe(FeatureFlag.Blocklist)
.onEach { dispatchEvent(Event.BlocklistEnabledChanged(it)) }
.launchIn(viewModelScope)

// Advance read pointer when user scrolls to messages
eventFlow
.filterIsInstance<Event.AdvanceReadPointer>()
Expand Down Expand Up @@ -905,6 +919,7 @@ internal class ChatViewModel @Inject constructor(
is Event.LimitsChanged -> { state -> state.copy(limits = event.limits) }
is Event.AdvanceReadPointer -> { state -> state }
is Event.ChatDeactivated -> { state -> state.copy(isAnonymous = event.isReadOnly) }
is Event.BlocklistEnabledChanged -> { state -> state.copy(blocklistEnabled = event.enabled) }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import androidx.compose.ui.platform.testTag
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.compose.collectAsLazyPagingItems
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.chat.ChatStep
import com.flipcash.app.messenger.internal.ChatViewModel
import com.flipcash.app.messenger.internal.screens.components.ChatTopBar
import com.flipcash.app.messenger.internal.screens.components.MessageList
Expand All @@ -31,8 +32,51 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
val hazeState = rememberHazeState()
val keyboard = rememberKeyboardController()

val chatActionHandler = { action: ChatAction ->
when (action) {
is ChatAction.AdvanceReadPointer -> {
viewModel.dispatchEvent(ChatViewModel.Event.AdvanceReadPointer(action.messageId))
}

ChatAction.RefreshContact -> {
viewModel.dispatchEvent(ChatViewModel.Event.RefreshContact)
}

is ChatAction.RetryMessage -> {
keyboard.hideIfVisible {
viewModel.dispatchEvent(
ChatViewModel.Event.RetryMessage(
action.bubble.pendingClientIdHex,
action.bubble.content
)
)
}
}

is ChatAction.ViewToken -> {
keyboard.hideIfVisible {
viewModel.dispatchEvent(
ChatViewModel.Event.OpenScreen(AppRoute.Token.Info(action.mint))
)
}
}

is ChatAction.ViewProfile -> {
// The triggers (top-bar tap, contact-card chevron) are only clickable when the
// Blocklist beta flag is on, so no gating is needed here.
state.participant?.let {
keyboard.hideIfVisible {
navigator.push(ChatStep.Profile(it))
}
}
}
}

Unit
}

ChatInputScaffold(
topBar = { ChatTopBar(navigator, state.participant) },
topBar = { ChatTopBar(navigator, state, chatActionHandler) },
bottomBar = {
UserControlBottomBar(
state = state,
Expand All @@ -51,36 +95,8 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
messages = messages,
separatorConfig = state.separatorConfig,
otherReadPointer = otherReadPointer,
onAction = { action ->
when (action) {
is ChatAction.AdvanceReadPointer -> {
viewModel.dispatchEvent(ChatViewModel.Event.AdvanceReadPointer(action.messageId))
}

ChatAction.RefreshContact -> {
viewModel.dispatchEvent(ChatViewModel.Event.RefreshContact)
}

is ChatAction.RetryMessage -> {
keyboard.hideIfVisible {
viewModel.dispatchEvent(
ChatViewModel.Event.RetryMessage(
action.bubble.pendingClientIdHex,
action.bubble.content
)
)
}
}

is ChatAction.ViewToken -> {
keyboard.hideIfVisible {
viewModel.dispatchEvent(
ChatViewModel.Event.OpenScreen(AppRoute.Token.Info(action.mint))
)
}
}
}
},
onAction = chatActionHandler,
canViewProfile = state.canViewProfile,
)
}
}
Expand Down
Loading
Loading