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 @@ -151,6 +151,12 @@ enum class ProfileOrigin {
*/
Mention,

/**
* A portrait in a public group's Chatting grid. Like [Mention], the person is not who the chat
* is with, so Mute would act on the wrong chat and opening a chat has somewhere new to go.
*/
GroupMember,

/** A `flipcash.com/...` link. The default for `AppRoute.Messaging.Profile`. Blocking pops back. */
Link,

Expand Down
18 changes: 12 additions & 6 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -915,13 +915,19 @@
<string name="subtitle_chatGate_speakerCreatorOnly">Only the creator can send messages</string>
<string name="subtitle_chatGate_speakerUnsupported">Update Flipcash to send messages</string>
<string name="action_buyMoreToken">Buy More %1$s</string>
<string name="action_join">Join</string>
<string name="action_joinChat">Join Chat</string>
<string name="title_leaveChat">Leave Chat</string>
<string name="action_leaveChat">Leave</string>
<string name="title_encryption">Encryption</string>
<string name="label_groupRequiredToJoin">%1$s Required to Join</string>
<string name="label_groupRequiredToChat">%1$s Required to Chat</string>
<string name="action_buyToJoin">Buy %1$s to Join</string>
<string name="action_buyToChat">Buy %1$s to Chat</string>
<string name="action_leaveChat">Leave Chat</string>
<string name="prompt_title_leaveChat">Leave %1$s?</string>
<string name="prompt_description_leaveChat">You won’t receive new messages from this chat. You can join it again if it still lets you in</string>
<string name="prompt_description_leaveChat">You won’t receive messages from this group any more. You can join again with an invite link</string>
<string name="error_title_failedToLeave">Something Went Wrong</string>
<string name="error_description_failedToLeave">We were unable to leave the chat. Please try again</string>
<string name="error_description_failedToLeave">We were unable to leave this group. Please try again</string>

<!-- Muting a chat. The contract has two mute shapes, a deadline and forever, so the sheet
offers durations plus an indefinite option; unmuting is its own request, which is why
Expand Down Expand Up @@ -953,9 +959,9 @@
<string name="error_description_failedToMute">We were unable to mute the chat. Please try again</string>
<string name="error_title_failedToUnmute">Something Went Wrong</string>
<string name="error_description_failedToUnmute">We were unable to unmute the chat. Please try again</string>
<string name="error_title_failedToJoin">Couldn\'t Join</string>
<string name="error_description_failedToJoin">We were unable to join the chat. Please try again</string>
<string name="error_description_joinChat_rulesNotSatisfied">Your balance doesn\'t meet this chat\'s requirement yet</string>
<string name="error_title_failedToJoin">Couldn\'t Join Chat</string>
<string name="error_description_failedToJoin">Something went wrong. Please try again.</string>
<string name="error_description_joinChat_rulesNotSatisfied">You don\'t meet this chat\'s requirements yet.</string>
<string name="error_description_joinChat_denied">This chat isn\'t open to you</string>
<string name="error_description_joinChat_notFound">This chat no longer exists</string>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class ProfileOriginTest {
val pops = ProfileOrigin.entries - setOf(ProfileOrigin.Scan, ProfileOrigin.UsernameLookup)

assertEquals(
setOf(ProfileOrigin.Chat, ProfileOrigin.Mention, ProfileOrigin.Link, ProfileOrigin.Transaction),
setOf(ProfileOrigin.Chat, ProfileOrigin.Mention, ProfileOrigin.GroupMember, ProfileOrigin.Link, ProfileOrigin.Transaction),
pops.toSet(),
)
pops.forEach { assertFalse(it.resetsToChatsAfterBlock, "$it") }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,14 +248,14 @@ private fun FlowGroupInviteSheet() {
}

GroupInviteSheet(
inviteUrl = state.groupInviteUrl,
inviteUrl = state.shareableGroupInviteUrl,
subject = ShareToChatsSubject.Group(state.subject as? ChatSubject.Group),
state = inviteState,
onShare = { viewModel.dispatchEvent(ChatViewModel.Event.InviteLinkShared) },
onCopy = { viewModel.dispatchEvent(ChatViewModel.Event.CopyInviteLink) },
onToggle = inviteViewModel::toggle,
onMessageChanged = inviteViewModel::onMessageChanged,
onInvite = { state.groupInviteUrl?.let(inviteViewModel::invite) },
onInvite = { state.shareableGroupInviteUrl?.let(inviteViewModel::invite) },
onDismiss = dismissSheet,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ sealed interface ChatSubject {
val isMember: Boolean?,
/** The group's creator (`ChatMetadata.creator`); null when the metadata did not carry one. */
val creator: ID? = null,
/** The group's description (`ChatMetadata.description`); null when unset. */
val description: String? = null,
/**
* The banner above the group's profile. A feed row may carry none even when the group has
* one; the profile asks `GetChat` once when it opens on a group without one.
*/
val coverPicture: MediaItem? = null,
/** A private group's roster is not sampled for the profile, which the server denies. */
val isPrivate: Boolean = false,
) : ChatSubject {
override val title: String get() = groupTitle.orEmpty()
override val subtitle: String? get() = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ import com.flipcash.features.messenger.R
import com.flipcash.libs.coroutines.DispatcherProvider
import com.flipcash.services.chat.E2eePolicy
import com.flipcash.services.models.JoinChatError
import com.flipcash.services.models.chat.SampledChatter
import com.flipcash.app.messenger.internal.screens.profile.GroupBalanceRequirements
import com.flipcash.app.messenger.internal.screens.profile.GroupProfileStanding
import com.flipcash.services.models.TipAction
import com.flipcash.services.models.TipOrigin
import com.flipcash.services.models.UserProfile
Expand Down Expand Up @@ -183,6 +186,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
Expand Down Expand Up @@ -398,6 +402,24 @@ internal class ChatViewModel @Inject constructor(
* the whole round trip and read as dead — which is what it looked like before it had one.
*/
val joinProgress: LoadingSuccessState = LoadingSuccessState(),
/** A leave is out, so the profile's Leave Chat shows a spinner and cannot be tapped again. */
val leaving: Boolean = false,
/**
* What the group's profile pins and states about the viewer's balance, decided with the
* live balance and rates. Null until they arrive, and for anything that is not a group.
*/
val profileStanding: GroupProfileStanding? = null,
/**
* The name of each token a balance rule on the profile names, by the mint's bytes: the join
* and chat rows can name different ones, so the single [ruleCurrency] is not enough. A mint
* the cache has not resolved is absent.
*/
val ruleTokens: Map<List<Byte>, RuleCurrency> = emptyMap(),
/**
* Who has been chatting in a public group, as of the last time its profile opened. Empty for
* a private group and when the sample could not be had.
*/
val chatters: List<SampledChatter> = emptyList(),
/**
* The quick strip's entries for [selection], recomposed whenever the selected message
* changes — empty while nothing is selected, while the selected message can't be reacted
Expand Down Expand Up @@ -580,6 +602,15 @@ internal class ChatViewModel @Inject constructor(
*/
val offersGroupInvite: Boolean
get() = groupInviteUrl != null

/**
* The same link without the membership check, for the profile's Share, which is offered to
* anyone looking at the group: the link is the chat's id and gives a recipient nothing the
* chat's own page would not. [groupInviteUrl] stays member-only, since it decides whether the
* transcript offers its own invite.
*/
val shareableGroupInviteUrl: String?
get() = (subject as? ChatSubject.Group)?.let { Linkify.groupChatInvite(it.chatId) }
}

/**
Expand Down Expand Up @@ -650,6 +681,15 @@ internal class ChatViewModel @Inject constructor(
/** The leave went through, so whatever is showing the group's profile should close. */
data object LeftChat : Event

/** The leave did not go through; the profile stays up and the button comes back. */
data object LeaveFailed : Event

/** The group's profile came on screen: sample its chatters and fill in a missing cover. */
data object GroupProfileOpened : Event
data class OnChattersLoaded(val chatters: List<SampledChatter>) : Event
data class OnProfileStandingResolved(val standing: GroupProfileStanding?) : Event
data class OnRuleTokensResolved(val tokens: Map<List<Byte>, RuleCurrency>) : Event

/** This chat's viewer state moved, from the stream or from the viewer's own request. */
data class OnViewerStateResolved(val viewerState: ViewerState?) : Event

Expand Down Expand Up @@ -1704,6 +1744,98 @@ internal class ChatViewModel @Inject constructor(
.onEach { dispatchEvent(Event.OnRuleCurrencyResolved(it)) }
.launchIn(viewModelScope)

// The profile names the token behind both the join and the chat row, which may differ.
// Each is fetched like the gate's, and a mint that never resolves does not hold the others
// back: it simply stays unnamed.
stateFlow.map { state ->
GroupBalanceRequirements.from((state.subject as? ChatSubject.Group)?.rules)
?.let { listOfNotNull(it.join, it.chat) }.orEmpty()
.mapNotNull { it.mints.firstOrNull()?.let { mint -> Mint(mint.bytes) } }
.distinct()
}
.distinctUntilChanged()
.flatMapLatest { mints ->
if (mints.isEmpty()) {
flowOf(emptyMap())
} else {
combine(
mints.map { mint ->
tokenCoordinator.observeRuleToken(mint)
.map<Token, Pair<List<Byte>, RuleCurrency>?> {
mint.bytes to RuleCurrency(it.brandedName(resources), it.isReserve)
}
.onStart { emit(null) }
}
) { named -> named.filterNotNull().toMap() }
}
}
.distinctUntilChanged()
.onEach { dispatchEvent(Event.OnRuleTokensResolved(it)) }
.launchIn(viewModelScope)

// What the profile's pinned button does and says, re-decided as the balance and rates move.
stateFlow.map { it.subject as? ChatSubject.Group }
.distinctUntilChanged()
.flatMapLatest { group ->
if (group == null) {
flowOf(null)
} else {
combine(
tokenCoordinator.tokenBalances,
userFlags.resolvedFlags.map { it.isStaff.effectiveValue },
exchange.observeRates(),
) { balances, staff, rates ->
GroupProfileStanding.of(
isMember = group.isMember == true,
rules = group.rules,
balances = balances,
isStaff = staff,
rates = rates,
viewerId = userManager.accountId,
creatorId = group.creator,
)
}
}
}
.distinctUntilChanged()
.onEach { dispatchEvent(Event.OnProfileStandingResolved(it)) }
.launchIn(viewModelScope)

// Sampled as soon as a public group resolves, so the profile has its grid on the way in
// rather than popping it in after the push, and again each time the profile opens: this
// view model outlives the screen, and who is chatting is a thing that moves. The list on
// screen stays until the new one lands. A private group's sampling is denied, so it is
// not asked; a failure reads as nobody to show.
merge(
stateFlow.map { (it.subject as? ChatSubject.Group)?.takeUnless { group -> group.isPrivate }?.chatId }
.filterNotNull()
.distinctUntilChanged(),
eventFlow.filterIsInstance<Event.GroupProfileOpened>().mapNotNull { stateFlow.value.chatId },
).onEach { chatId ->
val group = stateFlow.value.subject as? ChatSubject.Group ?: return@onEach
if (group.isPrivate) {
dispatchEvent(Event.OnChattersLoaded(emptyList()))
return@onEach
}
val chatters = chatCoordinator.sampleChatters(chatId)
.onFailure { trace("failed to sample chatters - ${it.localizedMessage}") }
.getOrNull()?.chatters
// A failed refresh keeps what is already showing; only a first failure hides the grid.
if (chatters != null || stateFlow.value.chatters.isEmpty()) {
dispatchEvent(Event.OnChattersLoaded(chatters.orEmpty()))
}
}.launchIn(viewModelScope)

// A row that came from the feed can lack the cover the profile shows; GetChat has it. The
// stored metadata updates and OnGroupResolved carries it into the subject.
eventFlow.filterIsInstance<Event.GroupProfileOpened>()
.onEach {
val chatId = stateFlow.value.chatId ?: return@onEach
val group = stateFlow.value.subject as? ChatSubject.Group ?: return@onEach
if (group.coverPicture == null) chatCoordinator.refreshCover(chatId)
}
.launchIn(viewModelScope)

// Re-resolved whenever membership or the rules move, and internally whenever the balance
// does. flatMapLatest rather than combine because the balance flow is the inner one: a new
// subject must cancel the gate it was deciding, not race it.
Expand Down Expand Up @@ -2070,9 +2202,9 @@ internal class ChatViewModel @Inject constructor(
.launchIn(viewModelScope)

// Read off the state rather than carried on the event, so the row that copies the link and
// the row that shares it are handing out the one url [State.groupInviteUrl] builds.
// the row that shares it are handing out the one url [State.shareableGroupInviteUrl] builds.
eventFlow.filterIsInstance<Event.CopyInviteLink>()
.mapNotNull { stateFlow.value.groupInviteUrl }
.mapNotNull { stateFlow.value.shareableGroupInviteUrl }
.onEach { url ->
clipboardManager.setText(
text = url,
Expand Down Expand Up @@ -2211,7 +2343,7 @@ internal class ChatViewModel @Inject constructor(
eventFlow.filterIsInstance<Event.LeaveChat>()
.mapNotNull { stateFlow.value.subject as? ChatSubject.Group }
.onEach { group ->
BottomBarManager.showAlert(
BottomBarManager.showInfo(
title = resources.getString(
R.string.prompt_title_leaveChat,
group.title,
Expand All @@ -2238,6 +2370,7 @@ internal class ChatViewModel @Inject constructor(
.onSuccess { dispatchEvent(Event.LeftChat) }
.onFailure {
trace("failed to leave chat - ${it.localizedMessage}")
dispatchEvent(Event.LeaveFailed)
BottomBarManager.showError(
title = resources.getString(R.string.error_title_failedToLeave),
message = resources.getString(R.string.error_description_failedToLeave),
Expand Down Expand Up @@ -2834,6 +2967,9 @@ internal class ChatViewModel @Inject constructor(
rules = metadata.rules,
isMember = event.membership.isMember,
creator = metadata.creator,
description = metadata.description,
coverPicture = metadata.coverPicture,
isPrivate = metadata.isPrivate,
),
chatType = ChatType.GROUP,
resolveState = ResolveState.Resolved,
Expand Down Expand Up @@ -2868,9 +3004,12 @@ internal class ChatViewModel @Inject constructor(
is Event.GateFundingTapped,
Event.GroupInfoOpened,
Event.InviteCardFollowed,
Event.LeaveChat,
Event.LeaveConfirmed,
Event.LeftChat -> { state -> state }
Event.LeaveChat, Event.GroupProfileOpened -> { state -> state }
is Event.OnChattersLoaded -> { state -> state.copy(chatters = event.chatters) }
is Event.OnProfileStandingResolved -> { state -> state.copy(profileStanding = event.standing) }
is Event.OnRuleTokensResolved -> { state -> state.copy(ruleTokens = event.tokens) }
Event.LeaveConfirmed -> { state -> state.copy(leaving = true) }
Event.LeftChat, Event.LeaveFailed -> { state -> state.copy(leaving = false) }
is Event.OnTipUserResolved -> { state ->
// A device contact, once matched, wins over the server profile (it carries the
// phone number and the user's own naming). Otherwise this is a tip DM: adopt the
Expand Down
Loading
Loading