Skip to content

Commit 96bfa54

Browse files
authored
feat: chat profile screen, block/unblock & blocklist (#1174)
1 parent dfc391f commit 96bfa54

68 files changed

Lines changed: 2221 additions & 115 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,13 @@ import com.flipcash.app.deposit.DepositFlowScreen
3535
import com.flipcash.app.directsend.SendFlowScreen
3636
import com.flipcash.app.invite.InviteContactScreen
3737
import com.flipcash.app.messenger.ChatFlowScreen
38-
import com.flipcash.app.messenger.ChatAmountEntryScreen
3938
import com.flipcash.app.discovery.TokenDiscoveryScreen
4039
import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEntryDecorator
4140
import com.flipcash.app.lab.LabsScreen
4241
import com.flipcash.app.lab.NavBarSettingsScreen
4342
import com.flipcash.app.login.OnboardingFlowScreen
4443
import com.flipcash.app.menu.MenuScreen
44+
import com.flipcash.app.myaccount.BlocklistScreen
4545
import com.flipcash.app.myaccount.UserProfileScreen
4646
import com.flipcash.app.myaccount.MyAccountScreen
4747
import com.flipcash.app.scanner.ScannerScreen
@@ -107,9 +107,6 @@ fun appEntryProvider(
107107
annotatedEntry<AppRoute.Messaging.Chat> { key ->
108108
ChatFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
109109
}
110-
annotatedEntry<AppRoute.Messaging.AmountEntry> { key ->
111-
ChatAmountEntryScreen(key.identifier)
112-
}
113110

114111
// Tokens
115112
annotatedEntry<AppRoute.Token.Info> { key ->
@@ -141,6 +138,7 @@ fun appEntryProvider(
141138
annotatedEntry<AppRoute.Menu.NavBarSettings> { NavBarSettingsScreen() }
142139
annotatedEntry<AppRoute.Menu.UserProfile> { UserProfileScreen() }
143140
annotatedEntry<AppRoute.Menu.MyAccount> { MyAccountScreen() }
141+
annotatedEntry<AppRoute.Menu.Blocklist> { BlocklistScreen() }
144142
annotatedEntry<AppRoute.Menu.BackupKey> { BackupKeyScreen() }
145143
annotatedEntry<AppRoute.Menu.AdvancedFeatures> { AdvancedFeaturesScreen() }
146144
annotatedEntry<AppRoute.Menu.DeviceLogs> { DeviceLogsScreen() }

apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,9 @@ sealed interface AppRoute : NavKey, Parcelable {
275275
data object MyAccount : Menu
276276
@Serializable
277277
data object BackupKey : Menu
278+
279+
@Serializable
280+
data object Blocklist: Menu
278281
@Serializable
279282
data object AppSettings : Menu
280283
@Serializable
@@ -302,9 +305,6 @@ sealed interface AppRoute : NavKey, Parcelable {
302305
override val initialStack: List<NavKey>
303306
get() = listOf(ChatStep.Conversation)
304307
}
305-
306-
@Serializable
307-
data class AmountEntry(val identifier: ChatIdentifier) : Messaging
308308
}
309309

310310
@Serializable
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.flipcash.app.core.blocklist
2+
3+
import com.flipcash.services.models.chat.MediaItem
4+
import com.getcode.opencode.model.core.ID
5+
import kotlin.time.Instant
6+
7+
/**
8+
* A blocklist entry enriched with the display profile needed to render it — the server's blocklist
9+
* entry only carries the user id + when they were blocked, so the name/avatar are resolved
10+
* separately (via the profile service) and carried alongside for the blocklist UI.
11+
*
12+
* @param userId The blocked user
13+
* @param displayName Resolved display name, or empty if the profile could not be resolved
14+
* @param profilePicture Resolved avatar, or null if unset/unresolved
15+
* @param blockedAt When the user was blocked
16+
*/
17+
data class BlockedUserProfile(
18+
val userId: ID,
19+
val displayName: String,
20+
val profilePicture: MediaItem?,
21+
val blockedAt: Instant,
22+
)

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatParticipant.kt renamed to apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatParticipant.kt

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
1-
package com.flipcash.app.messenger.internal
1+
package com.flipcash.app.core.chat
22

3+
import android.os.Parcelable
34
import com.flipcash.app.core.contacts.DeviceContact
45
import com.flipcash.services.models.UserProfile
56
import com.getcode.opencode.model.core.ID
7+
import kotlinx.parcelize.Parcelize
68

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

2124
data class Contact(val contact: DeviceContact) : ChatParticipant {
@@ -25,4 +28,4 @@ internal sealed interface ChatParticipant {
2528
data class TipUser(val userId: ID, val profile: UserProfile) : ChatParticipant {
2629
override val displayName: String get() = profile.displayName
2730
}
28-
}
31+
}

apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,8 @@ sealed interface ChatStep : FlowStep, Parcelable {
1919
@Parcelize
2020
@Serializable
2121
data object AmountEntry : ChatStep, NavigationRetVal<ChatSendResult>
22+
23+
@Parcelize
24+
@Serializable
25+
data class Profile(val contact: ChatParticipant): ChatStep
2226
}

apps/flipcash/core/src/main/res/values/strings.xml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -904,4 +904,22 @@
904904
<string name="error_description_tipMinimum">Enter a larger amount to send this tip</string>
905905
<string name="label_viaTipCard">via Tip Card</string>
906906

907+
<string name="title_block">Block</string>
908+
<string name="title_blocklist">Blocked</string>
909+
<string name="action_block">Block</string>
910+
<string name="prompt_title_blockUser">Block %1$s?</string>
911+
<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>
912+
<string name="action_unblock">Unblock</string>
913+
<string name="prompt_title_unblockUser">Unblock %1$s?</string>
914+
<string name="prompt_description_unblockUser">The conversation with them will reappear in Tips</string>
915+
<string name="title_blocklistEmpty">No One Blocked</string>
916+
<string name="description_blocklistEmpty">Block people from sending you messages by tapping their profile and selecting block</string>
917+
<string name="subtitle_joinedDate">Joined %1$s</string>
918+
919+
<string name="error_title_failedToBlock">Something Went Wrong</string>
920+
<string name="error_description_failedToBlock">We were unable to block the user. Please try again</string>
921+
<string name="error_title_failedToUnblock">Something Went Wrong</string>
922+
<string name="error_description_failedToUnblock">We were unable to unblock the user. Please try again</string>
923+
924+
907925
</resources>

apps/flipcash/features/messenger/build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ android {
88

99
dependencies {
1010
implementation(project(":apps:flipcash:shared:analytics"))
11+
implementation(project(":apps:flipcash:shared:blocklist"))
1112
implementation(project(":apps:flipcash:shared:chat"))
1213
implementation(project(":apps:flipcash:shared:chat-ui"))
1314
implementation(project(":apps:flipcash:shared:amount-entry"))
1415
implementation(project(":apps:flipcash:shared:contacts"))
1516
implementation(project(":apps:flipcash:shared:featureflags"))
1617
implementation(project(":apps:flipcash:shared:funding"))
18+
implementation(project(":apps:flipcash:shared:menu"))
1719
implementation(project(":apps:flipcash:shared:payments"))
1820
implementation(project(":apps:flipcash:shared:tokens"))
1921
implementation(project(":libs:vibrator:bindings"))
@@ -23,4 +25,7 @@ dependencies {
2325
implementation(project(":libs:datetime"))
2426
implementation(libs.compose.paging)
2527
implementation(libs.bundles.haze)
28+
29+
testImplementation(libs.bundles.unit.testing)
30+
testImplementation(libs.mockito.kotlin)
2631
}

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,20 @@ import androidx.navigation3.runtime.NavKey
1313
import androidx.navigation3.runtime.entryProvider
1414
import com.flipcash.app.core.AppRoute
1515
import com.flipcash.app.core.chat.ChatIdentifier
16+
import com.flipcash.app.core.chat.ChatParticipant
1617
import com.flipcash.app.core.chat.ChatSendResult
1718
import com.flipcash.app.core.chat.ChatStep
1819
import com.flipcash.app.core.extensions.openAsSheet
1920
import com.flipcash.app.messenger.internal.ChatViewModel
2021
import com.flipcash.app.messenger.internal.screens.MessengerScreen
22+
import com.flipcash.app.messenger.internal.screens.cash.ChatAmountEntryContent
23+
import com.flipcash.app.messenger.internal.screens.profile.ChatProfileScreen
24+
import com.flipcash.app.messenger.internal.screens.profile.ChatProfileViewModel
2125
import com.getcode.navigation.annotatedEntry
2226
import com.getcode.navigation.core.LocalCodeNavigator
2327
import com.getcode.navigation.flow.FlowHost
2428
import com.getcode.navigation.flow.flowSharedViewModel
29+
import com.getcode.navigation.flow.rememberFlowNavigator
2530
import com.getcode.navigation.flow.rememberInitialStack
2631
import com.getcode.navigation.results.NavResultOrCanceled
2732
import com.getcode.navigation.results.NavResultStateRegistry
@@ -30,6 +35,8 @@ import com.getcode.navigation.results.resultBackNavigator
3035
import com.getcode.navigation.scenes.LocalSheetNavigator
3136
import com.getcode.ui.utils.rememberKeyboardController
3237
import kotlinx.coroutines.flow.filterIsInstance
38+
import kotlinx.coroutines.flow.launchIn
39+
import kotlinx.coroutines.flow.onEach
3340

3441
@Composable
3542
fun ChatFlowScreen(
@@ -57,6 +64,9 @@ private fun chatEntryProvider(
5764
annotatedEntry<ChatStep.AmountEntry> {
5865
FlowAmountEntryScreen()
5966
}
67+
annotatedEntry<ChatStep.Profile> { step ->
68+
FlowChatProfileScreen(step.contact)
69+
}
6070
}
6171

6272
@Composable
@@ -141,3 +151,25 @@ private fun FlowAmountEntryScreen() {
141151
onExit = { navigator.navigateBack() }, // pop the AmountEntry step
142152
)
143153
}
154+
155+
@Composable
156+
private fun FlowChatProfileScreen(participant: ChatParticipant) {
157+
val viewModel = flowSharedViewModel<ChatProfileViewModel>()
158+
val flowNavigator = rememberFlowNavigator<ChatStep, Parcelable>()
159+
160+
LaunchedEffect(viewModel, participant) {
161+
viewModel.dispatchEvent(ChatProfileViewModel.Event.OnParticipantSet(participant))
162+
}
163+
164+
ChatProfileScreen(viewModel)
165+
166+
LaunchedEffect(viewModel) {
167+
viewModel.eventFlow
168+
.filterIsInstance<ChatProfileViewModel.Event.BlockSuccessful>()
169+
.onEach {
170+
// Blocking removes the DM, so exit the whole chat flow (FlowHost.onExit pops the
171+
// Chat route) and land back on the Tips list the chat was opened from.
172+
flowNavigator.exitCanceled()
173+
}.launchIn(this)
174+
}
175+
}

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import com.flipcash.app.analytics.FlipcashAnalyticsService
1313
import com.flipcash.app.contacts.ContactCoordinator
1414
import com.flipcash.app.core.AppRoute
1515
import com.flipcash.app.core.chat.ChatIdentifier
16+
import com.flipcash.app.core.chat.ChatParticipant
1617
import com.flipcash.app.core.contacts.DeviceContact
1718
import com.flipcash.app.core.ui.ConfirmationStyle
1819
import com.flipcash.app.featureflags.FeatureFlag
@@ -145,7 +146,15 @@ internal class ChatViewModel @Inject constructor(
145146
// open would be missed by the bottom bar before it subscribes, whereas state is durable
146147
// until the input is actually composed and can consume it.
147148
val messageInputRequested: Boolean = false,
148-
)
149+
// Whether the Blocklist beta flag is enabled. Backs canViewProfile; observed in init.
150+
val blocklistEnabled: Boolean = false,
151+
) {
152+
// Opening the participant's profile (the entry point to blocking) is only available for tip
153+
// DMs, and only when the Blocklist beta flag is on. Derived so it stays correct regardless
154+
// of whether the flag or the chat type resolves first.
155+
val canViewProfile: Boolean
156+
get() = blocklistEnabled && chatType == ChatType.TIP_DM
157+
}
149158

150159
sealed interface Event {
151160
data class OnChatOpened(val identifier: ChatIdentifier) : Event
@@ -189,6 +198,7 @@ internal class ChatViewModel @Inject constructor(
189198
data class LimitsChanged(val limits: Limits?) : Event
190199
data class AdvanceReadPointer(val messageId: Long) : Event
191200
data class ChatDeactivated(val isReadOnly: Boolean) : Event
201+
data class BlocklistEnabledChanged(val enabled: Boolean) : Event
192202
}
193203

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

446+
featureFlags.observe(FeatureFlag.Blocklist)
447+
.onEach { dispatchEvent(Event.BlocklistEnabledChanged(it)) }
448+
.launchIn(viewModelScope)
449+
436450
// Advance read pointer when user scrolls to messages
437451
eventFlow
438452
.filterIsInstance<Event.AdvanceReadPointer>()
@@ -905,6 +919,7 @@ internal class ChatViewModel @Inject constructor(
905919
is Event.LimitsChanged -> { state -> state.copy(limits = event.limits) }
906920
is Event.AdvanceReadPointer -> { state -> state }
907921
is Event.ChatDeactivated -> { state -> state.copy(isAnonymous = event.isReadOnly) }
922+
is Event.BlocklistEnabledChanged -> { state -> state.copy(blocklistEnabled = event.enabled) }
908923
}
909924
}
910925
}

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt

Lines changed: 47 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import androidx.compose.ui.platform.testTag
1111
import androidx.lifecycle.compose.collectAsStateWithLifecycle
1212
import androidx.paging.compose.collectAsLazyPagingItems
1313
import com.flipcash.app.core.AppRoute
14+
import com.flipcash.app.core.chat.ChatStep
1415
import com.flipcash.app.messenger.internal.ChatViewModel
1516
import com.flipcash.app.messenger.internal.screens.components.ChatTopBar
1617
import com.flipcash.app.messenger.internal.screens.components.MessageList
@@ -31,8 +32,51 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
3132
val hazeState = rememberHazeState()
3233
val keyboard = rememberKeyboardController()
3334

35+
val chatActionHandler = { action: ChatAction ->
36+
when (action) {
37+
is ChatAction.AdvanceReadPointer -> {
38+
viewModel.dispatchEvent(ChatViewModel.Event.AdvanceReadPointer(action.messageId))
39+
}
40+
41+
ChatAction.RefreshContact -> {
42+
viewModel.dispatchEvent(ChatViewModel.Event.RefreshContact)
43+
}
44+
45+
is ChatAction.RetryMessage -> {
46+
keyboard.hideIfVisible {
47+
viewModel.dispatchEvent(
48+
ChatViewModel.Event.RetryMessage(
49+
action.bubble.pendingClientIdHex,
50+
action.bubble.content
51+
)
52+
)
53+
}
54+
}
55+
56+
is ChatAction.ViewToken -> {
57+
keyboard.hideIfVisible {
58+
viewModel.dispatchEvent(
59+
ChatViewModel.Event.OpenScreen(AppRoute.Token.Info(action.mint))
60+
)
61+
}
62+
}
63+
64+
is ChatAction.ViewProfile -> {
65+
// The triggers (top-bar tap, contact-card chevron) are only clickable when the
66+
// Blocklist beta flag is on, so no gating is needed here.
67+
state.participant?.let {
68+
keyboard.hideIfVisible {
69+
navigator.push(ChatStep.Profile(it))
70+
}
71+
}
72+
}
73+
}
74+
75+
Unit
76+
}
77+
3478
ChatInputScaffold(
35-
topBar = { ChatTopBar(navigator, state.participant) },
79+
topBar = { ChatTopBar(navigator, state, chatActionHandler) },
3680
bottomBar = {
3781
UserControlBottomBar(
3882
state = state,
@@ -51,36 +95,8 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
5195
messages = messages,
5296
separatorConfig = state.separatorConfig,
5397
otherReadPointer = otherReadPointer,
54-
onAction = { action ->
55-
when (action) {
56-
is ChatAction.AdvanceReadPointer -> {
57-
viewModel.dispatchEvent(ChatViewModel.Event.AdvanceReadPointer(action.messageId))
58-
}
59-
60-
ChatAction.RefreshContact -> {
61-
viewModel.dispatchEvent(ChatViewModel.Event.RefreshContact)
62-
}
63-
64-
is ChatAction.RetryMessage -> {
65-
keyboard.hideIfVisible {
66-
viewModel.dispatchEvent(
67-
ChatViewModel.Event.RetryMessage(
68-
action.bubble.pendingClientIdHex,
69-
action.bubble.content
70-
)
71-
)
72-
}
73-
}
74-
75-
is ChatAction.ViewToken -> {
76-
keyboard.hideIfVisible {
77-
viewModel.dispatchEvent(
78-
ChatViewModel.Event.OpenScreen(AppRoute.Token.Info(action.mint))
79-
)
80-
}
81-
}
82-
}
83-
},
98+
onAction = chatActionHandler,
99+
canViewProfile = state.canViewProfile,
84100
)
85101
}
86102
}

0 commit comments

Comments
 (0)