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 @@ -48,4 +48,9 @@ sealed interface UpdateProfileStep : FlowStep, Parcelable {
@Parcelize
@Serializable
object Bio : UpdateProfileStep

/** The public groups the profile features, picked from the ones the user has joined. */
@Parcelize
@Serializable
object FeaturedGroups : UpdateProfileStep
}
19 changes: 18 additions & 1 deletion apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1270,7 +1270,7 @@
starting a conversation. Distinct from the send-side strings above, which are about a
tip in flight. The floor is explained in prose under the keypad and enforced by keeping
the save button inert, so there is no below-minimum error to show. -->
<string name="title_minimumToChat">Minimum to Chat</string>
<string name="title_minimumToChat">Minimum To Chat</string>
<string name="description_minimumToChat">Choose how much someone must send you to start chatting. You can send it back if the conversation is worthwhile.</string>
<string name="error_title_minimumTipFailed">Something Went Wrong</string>
<string name="error_description_minimumTipFailed">We were unable to save your minimum tip. Please try again</string>
Expand Down Expand Up @@ -1359,6 +1359,23 @@
<!-- Bio editor. The count under the field is code points left of 160. -->
<string name="title_bio">Bio</string>
<string name="placeholder_bio">Add a bio</string>

<string name="title_featuredGroups">Favorite Public Groups</string>
<string name="placeholder_featuredGroups">Add groups</string>
<plurals name="value_featuredGroupCount">
<item quantity="one">1 group</item>
<item quantity="other">%1$s groups</item>
</plurals>
<string name="title_featuredGroupsPicker">Favorite Groups</string>
<string name="hint_searchPublicGroups">Search your public groups</string>
<string name="label_featuredGroupsSelected">%1$s of %2$s selected</string>
<string name="footer_featuredGroups">These public groups will appear on your public profile</string>
<string name="text_featuredGroupsLoadFailed">Couldn\'t load your groups</string>
<string name="text_featuredGroupsEmpty">Join a public group to feature it on your profile</string>
<string name="error_title_featuredGroupsPrivate">Only Public Groups Can Be Featured</string>
<string name="error_description_featuredGroupsPrivate">One of these groups is private. Remove it and try again</string>
<string name="error_title_featuredGroupsSaveFailed">Couldn\'t Save Your Groups</string>
<string name="error_description_featuredGroupsSaveFailed">Try again</string>
<string name="error_bioNotAllowed">This bio isn\'t allowed. Try different wording</string>
<string name="error_bioInvalid">This bio isn\'t valid</string>
<string name="error_title_bioSaveFailed">Something Went Wrong</string>
Expand Down
1 change: 1 addition & 0 deletions apps/flipcash/features/menu/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ dependencies {
implementation(project(":apps:flipcash:shared:analytics"))
implementation(project(":apps:flipcash:shared:authentication"))
implementation(project(":apps:flipcash:shared:bills"))
implementation(project(":apps:flipcash:shared:chat"))
implementation(project(":apps:flipcash:shared:common-ui"))
implementation(project(":apps:flipcash:shared:funding"))
implementation(project(":apps:flipcash:shared:payments"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import com.flipcash.app.bills.components.cards.LocalTipCardColor
import com.flipcash.app.bills.components.cards.TipCardFlattened
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.bill.Scannable
import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.app.core.navigation.LocalTabBarPadding
import com.flipcash.app.menu.internal.MenuScreenViewModel.Event
import com.flipcash.app.menu.internal.MenuScreenViewModel.ProfileState
Expand All @@ -59,9 +60,11 @@ import com.flipcash.features.menu.R
import com.flipcash.services.models.UserProfile
import com.flipcash.services.models.chat.BlobAccessContext
import com.flipcash.shared.common.ui.ContactAvatar
import com.flipcash.shared.common.ui.profile.FeaturedGroupsSection
import com.flipcash.shared.common.ui.profile.ProfileActionButton
import com.flipcash.shared.common.ui.profile.ProfileHeader
import com.flipcash.shared.common.ui.profile.ProfileStatsCard
import com.flipcash.shared.common.ui.profile.rememberFeaturedGroupItems
import com.getcode.theme.CodeTheme
import com.getcode.theme.White
import com.getcode.theme.White08
Expand Down Expand Up @@ -154,6 +157,21 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) {
joined = profileState.joined,
)
}
item(key = "featured_groups") {
FeaturedGroupsSection(
modifier = Modifier
.padding(horizontal = CodeTheme.dimens.inset)
.padding(top = CodeTheme.dimens.staticGrid.x4),
groups = rememberFeaturedGroupItems(state.featuredGroups),
onOpen = { chatId ->
viewModel.dispatchEvent(
Event.OpenScreen(
AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId))
)
)
},
)
}
item(key = "bottom_spacer") { Spacer(Modifier.height(CodeTheme.dimens.grid.x4)) }
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ import com.flipcash.app.userflags.usernameGate
import com.flipcash.features.menu.R
import com.flipcash.services.user.AuthState
import com.flipcash.services.models.UserProfile
import com.flipcash.services.models.chat.ChatMetadata
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.chat.FeaturedGroupsStore
import com.flipcash.shared.common.ui.profile.joinedLabel
import com.getcode.manager.BottomBarAction
import com.getcode.manager.BottomBarManager
Expand All @@ -33,6 +35,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
Expand All @@ -58,6 +61,7 @@ internal class MenuScreenViewModel @Inject constructor(
analytics: FlipcashAnalytics,
private val tippingCoordinator: TippingCoordinator,
private val tipCodePreviewCache: TipCodePreviewCache,
private val featuredGroupsStore: FeaturedGroupsStore,
tipPayments: TipPaymentDelegate,
private val resources: ResourceHelper,
) :
Expand All @@ -78,6 +82,8 @@ internal class MenuScreenViewModel @Inject constructor(
// What another user must send to open a chat with the viewer, formatted. Null until it
// resolves, which the stats card draws as a dash.
val minimumToChat: String? = null,
// The viewer's own featured groups, in profile order. Empty until the first read lands.
val featuredGroups: List<ChatMetadata> = emptyList(),
)

/**
Expand Down Expand Up @@ -122,6 +128,7 @@ internal class MenuScreenViewModel @Inject constructor(
val minimumBalance: String,
) : Event
data class OnMinimumToChatChanged(val minimumToChat: String?) : Event
data class OnFeaturedGroupsChanged(val groups: List<ChatMetadata>) : Event

/** The progress card's tap — claim a handle, or explain why it can't be claimed yet. */
data object ClaimUsername : Event
Expand Down Expand Up @@ -186,6 +193,21 @@ internal class MenuScreenViewModel @Inject constructor(
.onEach { dispatchEvent(Event.OnMinimumToChatChanged(it)) }
.launchIn(viewModelScope)

// The featured groups are held for the session, so a save in the picker reaches this tab
// without a read. The read here is for the first look and for a handle that changes.
featuredGroupsStore.groups
.onEach { dispatchEvent(Event.OnFeaturedGroupsChanged(it)) }
.launchIn(viewModelScope)

userManager.state
.filter { it.authState is AuthState.Ready }
.map { it.userProfile?.username }
.filterNotNull()
.filter { it.isNotEmpty() }
.distinctUntilChanged()
.onEach { featuredGroupsStore.load(it) }
.launchIn(viewModelScope)

// The username nudge. Gated on Ready for the same reason as the tip card: a named account
// restores its cached profile before auth completes, so the card would otherwise flash for
// someone who already holds a handle.
Expand Down Expand Up @@ -298,6 +320,10 @@ internal class MenuScreenViewModel @Inject constructor(
state.copy(minimumToChat = event.minimumToChat)
}

is Event.OnFeaturedGroupsChanged -> { state ->
state.copy(featuredGroups = event.groups)
}

is Event.PresentDepositOptions,
Event.ClaimTipCard,
Event.ClaimUsername,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ 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.ChatIdentifier
import com.flipcash.app.core.chat.ChatParticipant
import com.flipcash.app.core.chat.ReportSubject
import com.flipcash.app.messenger.internal.rememberMutedLabel
Expand All @@ -42,12 +43,14 @@ import com.flipcash.services.models.chat.BlobAccessContext
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatType
import com.flipcash.shared.common.ui.ContactAvatar
import com.flipcash.shared.common.ui.profile.FeaturedGroupsSection
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.flipcash.shared.common.ui.profile.rememberFeaturedGroupItems
import com.getcode.navigation.core.LocalCodeNavigator
import com.getcode.navigation.flow.rememberFlowNavigator
import com.getcode.theme.CodeTheme
Expand Down Expand Up @@ -206,6 +209,15 @@ internal fun PersonProfileScreen(
minimumToChat = state.fee?.takeIf { state.profileSettled }?.formatted(),
joined = joinedLabel(state.joinDate),
)
FeaturedGroupsSection(
modifier = Modifier
.padding(horizontal = CodeTheme.dimens.inset)
.padding(top = CodeTheme.dimens.staticGrid.x4),
groups = rememberFeaturedGroupItems(state.featuredGroups),
onOpen = { chatId ->
navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId)))
},
)
}

AppBarWithTitle(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ 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.ChatController
import com.flipcash.services.controllers.ProfileController
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatMetadata
import com.flipcash.services.models.chat.ViewerState
import com.flipcash.services.models.chat.isMutedAt
import com.flipcash.services.user.UserManager
Expand Down Expand Up @@ -63,6 +65,7 @@ internal class ChatProfileViewModel @Inject constructor(
private val tipPaymentDelegate: TipPaymentDelegate,
private val e2eePolicy: E2eePolicy,
private val startChattingPayer: StartChattingPayer,
private val chatController: ChatController,
) : BaseViewModel<ChatProfileViewModel.State, ChatProfileViewModel.Event>(
initialState = State(selfId = userManager.accountId),
updateStateForEvent = updateStateForEvent,
Expand Down Expand Up @@ -113,6 +116,11 @@ internal class ChatProfileViewModel @Inject constructor(
* and cannot be dismissed until the chat is ready, or the payment fails.
*/
val paymentInProgress: Boolean = false,
/**
* The public groups this person features. Empty until the read lands, and after one that
* fails: the section hides rather than say the read went wrong.
*/
val featuredGroups: List<ChatMetadata> = emptyList(),
) {
/** Null for your own profile, for anyone but a tip user, and until [profileSettled]. */
val pinnedAction: ProfilePinnedAction?
Expand Down Expand Up @@ -161,6 +169,7 @@ internal class ChatProfileViewModel @Inject constructor(
val viewerState: ViewerState? = null,
) : Event
data class FeeLoaded(val fee: Fiat?) : Event
data class FeaturedGroupsLoaded(val groups: List<ChatMetadata>) : Event
data class TokenUpdated(val token: Token) : Event

/** The pinned button was tapped; what it does is [State.pinnedAction]'s. */
Expand Down Expand Up @@ -290,6 +299,19 @@ internal class ChatProfileViewModel @Inject constructor(
.onEach { dispatchEvent(Event.BlockedChanged(it)) }
.launchIn(viewModelScope)

// Keyed on the handle because that is all the read takes, and a cached participant may not
// carry one until the full profile replaces it. Latest: groups fetched for someone the
// screen has moved on from are dropped.
stateFlow
.map { (it.participant as? ChatParticipant.TipUser)?.profile?.username?.takeIf { name -> name.isNotBlank() } }
.filterNotNull()
.distinctUntilChanged()
.collectLatestIn(viewModelScope) { username ->
dispatchEvent(Event.FeaturedGroupsLoaded(emptyList()))
chatController.getFeaturedGroups(username)
.onSuccess { dispatchEvent(Event.FeaturedGroupsLoaded(it)) }
}

// 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()
Expand Down Expand Up @@ -456,6 +478,7 @@ internal class ChatProfileViewModel @Inject constructor(
val updateStateForEvent: (Event) -> ((State) -> State) = { event ->
when (event) {
is Event.OnParticipantSet -> { state -> state.withParticipantSet(event) }
is Event.FeaturedGroupsLoaded -> { state -> state.copy(featuredGroups = event.groups) }
is Event.JoinDateLoaded -> { state -> state.copy(joinDate = event.joinDate) }
is Event.ProfileLoaded -> { state ->
// A fetch that lands after the screen moved on is someone else's.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ 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.ChatController
import com.flipcash.services.controllers.ProfileController
import com.flipcash.services.models.chat.ChatMetadata
import com.flipcash.services.models.chat.ChatType
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatMember
import com.flipcash.shared.chat.ChatCoordinator
Expand Down Expand Up @@ -53,6 +56,9 @@ class ChatProfileViewModelTest {
private val serverJoin = Instant.fromEpochSeconds(1_710_000_000)

private val profiles = mockk<ProfileController>()
private val chatController = mockk<ChatController> {
coEvery { getFeaturedGroups(any()) } returns Result.success(emptyList())
}
private val userManager = mockk<UserManager> { every { accountId } returns List(16) { 1 } }

private val dmChatId = ChatId(ByteArray(32) { 7 })
Expand Down Expand Up @@ -88,6 +94,7 @@ class ChatProfileViewModelTest {
tipPaymentDelegate = tipPaymentDelegate,
e2eePolicy = mockk<E2eePolicy>(relaxed = true),
startChattingPayer = payer,
chatController = chatController,
)

private fun openFull(model: ChatProfileViewModel) =
Expand Down Expand Up @@ -135,6 +142,39 @@ class ChatProfileViewModelTest {
assertEquals("Sally", model.stateFlow.value.participant?.name)
}

private fun featured(hex: String) = ChatMetadata(
chatId = ChatId(hex),
type = ChatType.GROUP,
members = emptyList(),
lastMessage = null,
lastActivity = Instant.fromEpochSeconds(0),
)

@Test
fun `a person's featured groups are read by their handle`() = runTest {
coEvery { profiles.getProfileForUser(theirId) } returns
Result.success(participant(serverJoin).profile)
val groups = listOf(featured("aa"), featured("bb"))
coEvery { chatController.getFeaturedGroups("sally_streamer") } returns Result.success(groups)
val model = viewModel()

model.dispatchEvent(ChatProfileViewModel.Event.OnParticipantSet(participant(cachedJoin)))

assertEquals(groups, model.stateFlow.value.featuredGroups)
}

@Test
fun `a failed featured groups read leaves the section hidden`() = runTest {
coEvery { profiles.getProfileForUser(theirId) } returns
Result.success(participant(serverJoin).profile)
coEvery { chatController.getFeaturedGroups(any()) } returns Result.failure(Exception("offline"))
val model = viewModel()

model.dispatchEvent(ChatProfileViewModel.Event.OnParticipantSet(participant(cachedJoin)))

assertEquals(emptyList(), model.stateFlow.value.featuredGroups)
}

@Test
fun `a failed fetch keeps the cached participant`() = runTest {
coEvery { profiles.getProfileForUser(theirId) } returns Result.failure(Exception("offline"))
Expand Down
1 change: 1 addition & 0 deletions apps/flipcash/features/myaccount/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ dependencies {
implementation(project(":apps:flipcash:shared:authentication"))
implementation(project(":apps:flipcash:shared:bill-customization"))
implementation(project(":apps:flipcash:shared:blocklist"))
implementation(project(":apps:flipcash:shared:chat"))
implementation(project(":apps:flipcash:shared:common-ui"))
implementation(project(":apps:flipcash:shared:contacts"))
implementation(project(":apps:flipcash:shared:featureflags"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ 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.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
Expand Down Expand Up @@ -110,6 +111,14 @@ internal fun EditProfileScreenContent(
placeholder = stringResource(R.string.placeholder_minimumToChat),
onClick = { openStep(UpdateProfileStep.MinimumTip) },
)
FieldCard(
title = stringResource(R.string.title_featuredGroups),
value = state.featuredGroupCount.takeIf { it > 0 }?.let {
pluralStringResource(R.plurals.value_featuredGroupCount, it, it.toString())
},
placeholder = stringResource(R.string.placeholder_featuredGroups),
onClick = { openStep(UpdateProfileStep.FeaturedGroups) },
)
}
}
}
Expand Down
Loading
Loading