diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index f68858a255..8746b801c0 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -45,4 +45,16 @@ sealed interface ChatStep : FlowStep, Parcelable { @Parcelize @Serializable data class Profile(val contact: ChatParticipant): ChatStep + + /** + * The group's own profile — the counterpart of [Profile] for a chat that has no counterparty. + * + * Carries nothing, because there is nothing a group can be identified by that the flow does + * not already hold: the screen reads the group off the conversation's view model, the same + * way the transcript does. [Profile] has to carry its participant because a member's profile + * is opened from a bubble rather than from the chat the flow was opened on. + */ + @Parcelize + @Serializable + data object GroupProfile : ChatStep } diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 99ff557a2d..640e4e339f 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -250,6 +250,12 @@ %1$s members + + 0 people + 1 person + %1$s people + + Add Cash Select Method Amount to Add @@ -885,6 +891,19 @@ From Your Contacts Add Contact + Balance Requirement: %1$s of $%2$s + Balance Requirement: %1$s + Minimum Balance: %1$s of $%2$s + Minimum Balance: %1$s + This chat is open to Flipcash staff only. + Buy More $%1$s + Join Chat + Leave Chat + Leave + Leave %1$s? + You won’t receive new messages from this chat. You can join it again if it still lets you in + Something Went Wrong + We were unable to leave the chat. Please try again Are You Sure? Anyone you sent the link to won\'t be able to collect the cash @@ -898,6 +917,9 @@ You tipped %1$s Someone tipped you %1$s You: %1$s + %1$s: %2$s + %1$s sent %2$s + %1$s tipped %2$s Is Typing… Edited 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 878d9bcf45..8ff43e177e 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 @@ -24,6 +24,7 @@ import com.flipcash.app.messenger.internal.screens.cash.ChatAmountEntryContent import com.flipcash.app.messenger.internal.screens.cash.ChatInitPaymentSheet import com.flipcash.app.messenger.internal.screens.profile.ChatProfileScreen import com.flipcash.app.messenger.internal.screens.profile.ChatProfileViewModel +import com.flipcash.app.messenger.internal.screens.profile.GroupProfileScreen import com.getcode.navigation.annotatedEntry import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.FlowHost @@ -89,6 +90,9 @@ private fun chatEntryProvider( annotatedEntry { step -> FlowChatProfileScreen(step.contact) } + annotatedEntry { + FlowGroupProfileScreen() + } } @Composable @@ -230,3 +234,14 @@ private fun FlowChatProfileScreen(participant: ChatParticipant) { }.launchIn(this) } } + +/** + * The group's profile, on the conversation's own view model. + * + * No [LaunchedEffect] seeding it the way [FlowChatProfileScreen] seeds a participant: the shared + * view model is already open on this group, so there is nothing to hand it. + */ +@Composable +private fun FlowGroupProfileScreen() { + GroupProfileScreen(flowSharedViewModel()) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt new file mode 100644 index 0000000000..2aa6dc6c5f --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt @@ -0,0 +1,121 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules +import com.flipcash.services.models.chat.MediaItem + +/** + * What the messenger screen is a conversation *with*. + * + * [ChatParticipant] answers this for a DM and is unchanged — [Contact] and [TipUser] wrap it + * rather than restating it, so DM rendering keeps reading the same fields off the same type. What + * the subject adds is a [Group] arm and, with it, exhaustiveness: the title bar, the info card and + * the profile route each become a `when` over three arms, so a fourth chat type is a compile error + * at every site that has to decide something rather than a blank render at one of them. + * + * Deliberately not `Parcelable`. Nothing bundles a subject — + * [com.flipcash.app.messenger.internal.screens.ChatStep.Profile] takes a [ChatParticipant], which + * is why [asParticipant] exists — and parcelling one would mean parcelling [ChatRules]. + */ +sealed interface ChatSubject { + + /** What to call this conversation. Never null; a nameless subject reads as empty, not absent. */ + val title: String + + /** + * The line under the title, or `null` when there is none. + * + * [Group] answers `null` even though it has one to show: "3 people" is a plural resource and + * resolving it needs a `Context` this type has no business holding. The title bar renders it + * from [Group.memberCount] instead. + */ + val subtitle: String? + + /** Whether tapping the title or the info card's chevron opens a profile. */ + val canViewProfile: Boolean + + /** + * The [ChatParticipant] behind this subject, or `null` for a [Group]. + * + * The bridge to code that predates the subject: the profile route, the tip recipient flow and + * the payment paths all still speak `ChatParticipant`, and none of them can be reached from a + * group. + */ + fun asParticipant(): ChatParticipant? + + /** + * A `CONTACT_DM`. Carried, not extended: contact DMs are end-of-life, and this arm exists so + * their phone line and add-to-contacts pill keep rendering exactly as they did. + */ + data class Contact(val participant: ChatParticipant.Contact) : ChatSubject { + override val title: String get() = participant.name.orEmpty() + override val subtitle: String? get() = null + override val canViewProfile: Boolean get() = false + override fun asParticipant(): ChatParticipant = participant + } + + /** A `TIP_DM`. Blocking is reached through its profile. */ + data class TipUser(val participant: ChatParticipant.TipUser) : ChatSubject { + override val title: String get() = participant.name.orEmpty() + // `handle` already carries the leading `@` (see `String.asHandle`), so this is the raw + // value rather than one this type prefixes again. + override val subtitle: String? get() = participant.handle + override val canViewProfile: Boolean get() = true + override fun asParticipant(): ChatParticipant = participant + } + + /** + * A `GROUP`. Everything here comes off the chat's own row rather than off a counterparty, + * because a group has none. + * + * [isMember] rides along because the access gate reads it in the same breath as [rules], and + * splitting them would let the screen blur a chat it had just joined for one frame. + */ + data class Group( + val chatId: ChatId, + val groupTitle: String?, + val picture: MediaItem?, + val memberCount: Long, + val rules: ChatRules?, + val isMember: Boolean, + ) : ChatSubject { + override val title: String get() = groupTitle.orEmpty() + override val subtitle: String? get() = null + // The group's own profile, not a counterparty's — which is why this is true while + // [asParticipant] stays null. The route the tap takes is decided per arm, in + // MessengerScreen's ChatAction.ViewProfile handler. + override val canViewProfile: Boolean get() = true + override fun asParticipant(): ChatParticipant? = null + } +} + +/** + * The subject for a DM counterparty. + * + * The bridge in the other direction from [ChatSubject.asParticipant]: the profile route still + * carries a [ChatParticipant] through navigation, and its header renders the same avatar the + * conversation bar does. A group has no participant to arrive here with, so `null` in is `null` out. + */ +internal fun ChatParticipant?.asSubject(): ChatSubject? = when (this) { + is ChatParticipant.Contact -> ChatSubject.Contact(this) + is ChatParticipant.TipUser -> ChatSubject.TipUser(this) + null -> null +} + +/** + * The balance requirement to show the user, or `null` when there is nothing actionable to show. + * + * Listener rules gate reading and joining, speaker rules gate sending, and speaker rules apply on + * top of listener rules — so the first thing standing between the viewer and the chat is the + * listener requirement when there is one. + * + * [ChatRuleRequirement.Staff] deliberately produces nothing: it is not a bar a user can clear by + * doing something, and rendering it as a requirement would read as an instruction. + */ +internal fun ChatRules?.balanceRequirement(): ChatRuleRequirement.MinimumBalance? { + val rules = this ?: return null + return rules.listener.filterIsInstance().firstOrNull() + ?: rules.speaker.filterIsInstance().firstOrNull() +} 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 7ae8a5fb54..11ae602285 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 @@ -24,6 +24,7 @@ import com.flipcash.shared.chat.withinWindows import com.flipcash.shared.chat.applying import com.flipcash.shared.chat.resolveCapabilities import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.SenderIdentity import com.flipcash.shared.chat.models.ChatQuote import com.flipcash.shared.chat.models.ChatQuoteSnippet import com.flipcash.shared.chat.models.ReceiptStatus @@ -48,8 +49,11 @@ import com.flipcash.shared.amountentry.AmountEntryLabel import com.flipcash.shared.amountentry.AmountEntryStyle import com.flipcash.shared.chat.ActiveTypist import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatMembership import com.flipcash.shared.payments.ContactPaymentDelegate import com.flipcash.shared.payments.TipPaymentDelegate +import com.getcode.solana.keys.Mint +import com.getcode.utils.hexEncodedString import com.getcode.opencode.model.core.ID import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager @@ -72,6 +76,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop @@ -137,7 +142,7 @@ internal class ChatViewModel @Inject constructor( data class State( val separatorConfig: SeparatorConfig = SeparatorConfig.Continuous(), val chatId: ChatId? = null, - val participant: ChatParticipant? = null, + val subject: ChatSubject? = null, // The kind of DM this conversation is, resolved from the fast local contact lookup ahead of // the participant's server profile (which resolves over the network for tip DMs). Starts // UNKNOWN and settles to CONTACT_DM / TIP_DM as soon as the chat opens; the send button and @@ -218,10 +223,31 @@ internal class ChatViewModel @Inject constructor( * the reducer is where the selection is set. */ val messagePolicy: MessagePolicy = MessagePolicy.Default, + /** + * The symbol of the token a group's balance requirement names — "BadBoys", not its mint. + * Null until the token cache has it, and null for any chat without such a rule. + */ + val ruleTicker: String? = null, + /** + * What this viewer may do here. Null for anything that is not a group — a DM has no gate, + * and rendering one from a default would blur every contact conversation in the app. + */ + val groupAccess: GroupAccess? = null, ) { - // Opening the participant's profile (the entry point to blocking) is only available for tip DMs. + /** + * The DM counterparty, or `null` for a group. + * + * Kept as a derived property so the paths that predate [ChatSubject] — the quote accent, + * the tip recipient flow and the payment branches — keep reading what they always read. + * None of them is reachable from a group, which is exactly what a `null` says here. + */ + val participant: ChatParticipant? + get() = subject?.asParticipant() + + // Opening the participant's profile (the entry point to blocking) is the tip arm's answer + // alone. Asking the subject rather than comparing chat types means a new arm has to say. val canViewProfile: Boolean - get() = chatType == ChatType.TIP_DM + get() = subject?.canViewProfile == true /** What the selection bar may offer, straight from what the transcript already resolved. */ val selectionCapabilities: Set @@ -246,6 +272,27 @@ internal class ChatViewModel @Inject constructor( data class OnContactFound(val contact: DeviceContact): Event data class OnTipUserResolved(val userId: ID, val profile: UserProfile): Event data object OnTipDmDetected : Event + + /** The chat this screen is showing turned out to be a group, with this metadata. */ + data class OnGroupResolved(val membership: ChatMembership) : Event + + /** The token cache learned the symbol behind the group's balance requirement. */ + data class OnRuleTickerResolved(val ticker: String?) : Event + + /** The gate re-decided, because membership, the rules, or the balance moved. */ + data class OnGroupAccessResolved(val access: GroupAccess) : Event + + /** The gate's "Join Chat" button. */ + data object JoinChat : Event + + /** The group profile's "Leave Chat" row, which puts the confirmation up. */ + data object LeaveChat : Event + + /** The user confirmed the leave. */ + data object LeaveConfirmed : Event + + /** The leave went through, so whatever is showing the group's profile should close. */ + data object LeftChat : Event data class OnCurrencySymbolUpdated(val symbol: String): Event data class OnChatInitFeeUpdated(val formatted: String?) : Event data object RefreshContact : Event @@ -352,9 +399,48 @@ internal class ChatViewModel @Inject constructor( */ private val messagePolicy = stateFlow.map { it.messagePolicy }.distinctUntilChanged() + /** + * Null for a DM, a map for a group — the distinction the transcript needs, because a DM's + * counterparty is already named in the title bar and attributing each of their bubbles would be + * noise. `null` rather than an empty map so the difference survives: an empty map in a group is + * a real state (nothing resolved yet) and has to keep asking for profiles, where a DM must not + * ask at all. + * + * Two sources behind it, because the roster is a truncated subset — members come from it, and + * anyone it omits (a sender who left, a member past the cap) is asked for one profile at a time. + * See SenderResolver. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private val senderProfiles: StateFlow?> = + stateFlow.map { it.chatType == ChatType.GROUP } + .distinctUntilChanged() + .flatMapLatest { isGroup -> + if (isGroup) chatCoordinator.observeSenderProfiles() else flowOf(null) + } + // Held rather than cold so a tap on a sender's picture can read the profile that drew + // it. The transcript is the only thing that asks for these, so the map the paging + // stream is using is the one to answer from — looking the sender up again would be a + // second source for an identity already on screen. + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + /** + * The member behind [userId], as a participant a profile screen can be opened on. + * + * Null until the transcript has resolved them, which it has by the time their picture is + * drawn — the picture is what this is reached from. + */ + fun memberParticipant(userId: ID): ChatParticipant.TipUser? = + senderProfiles.value?.get(userId.hexEncodedString()) + ?.let { ChatParticipant.TipUser(userId, it) } + @OptIn(ExperimentalCoroutinesApi::class) val messages: Flow> = - combine(messageStream, pendingMutations, messagePolicy) { pagingData, mutations, policy -> + combine( + messageStream, + pendingMutations, + messagePolicy, + senderProfiles, + ) { pagingData, mutations, policy, profiles -> pagingData.flatMap { stored -> val message = stored.applying(mutations[stored.messageId]) message.content.mapIndexed { index, content -> @@ -383,6 +469,26 @@ internal class ChatViewModel @Inject constructor( } } else null + // A message the viewer sent needs no attribution, and neither does a DM — + // `profiles` is null for one. Asking for a profile that is missing is what keeps + // a sender from showing as a blank name for the rest of the session; the + // resolver dedupes, so asking once per page is asking once. + val sender = profiles?.let { resolved -> + message.senderId?.takeIf { !message.isFromSelf }?.let { senderId -> + val profile = resolved[senderId.hexEncodedString()] + if (profile == null) { + chatCoordinator.requestSenderProfile(senderId) + null + } else { + SenderIdentity( + userId = senderId, + displayName = profile.displayName, + picture = profile.profilePicture, + ) + } + } + } + ChatListItem.ContentBubble( messageId = message.messageId, contentIndex = index, @@ -400,6 +506,7 @@ internal class ChatViewModel @Inject constructor( // each action site. capabilities = resolveCapabilities(message, policy), quote = quote, + sender = sender, ) } }.insertSeparators { before: ChatListItem.ContentBubble?, after: ChatListItem.ContentBubble? -> @@ -417,16 +524,32 @@ internal class ChatViewModel @Inject constructor( * [ChatParticipant.Contact] wraps a device contact and carries no user id, so the participant * is not a usable source for a counterparty's colour. */ + /** + * Who the citation says said it. + * + * A DM's counterparty is the chat's, so the participant answers for every message they sent. + * A group has no counterparty and a different name per message, so the answer is the cited + * sender's own profile — the same map the transcript draws their bubble from. Missing means + * the device has never seen them, which a request fixes for the next emission; the strip + * showing a blank name is what it looked like before. + */ + private suspend fun ChatMessage.quoteAuthorName(): String { + if (isFromSelf) return resources.getString(R.string.title_you) + + val profiles = senderProfiles.value ?: return stateFlow.value.participant?.name.orEmpty() + val senderId = senderId ?: return "" + val profile = profiles[senderId.hexEncodedString()] + ?: return "".also { chatCoordinator.requestSenderProfile(senderId) } + + return profile.displayName + } + private suspend fun ChatMessage.toQuote(): ChatQuote { val body = content.firstOrNull() val palette = senderId?.let { generateComplementaryColorPalette(it) } return ChatQuote( messageId = messageId, - authorName = if (isFromSelf) { - resources.getString(R.string.title_you) - } else { - stateFlow.value.participant?.name.orEmpty() - }, + authorName = quoteAuthorName(), snippet = when (body) { is MessageContent.Cash -> ChatQuoteSnippet.Cash( amount = body.amount, @@ -696,6 +819,48 @@ internal class ChatViewModel @Inject constructor( .onEach { member -> dispatchEvent(Event.OnTipUserResolved(member.userId, member.userProfile)) } .launchIn(viewModelScope) + // A group's identity is its own row, not a counterparty. Observed rather than read once + // because every field the chrome shows moves under the user: a roster event changes the + // member count, a join flips membership, a rules change moves the gate. + stateFlow.mapNotNull { it.chatId } + .distinctUntilChanged() + .flatMapLatest { chatCoordinator.observeMetadata(it) } + .filterNotNull() + .filter { it.metadata.type == ChatType.GROUP } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnGroupResolved(it)) } + .launchIn(viewModelScope) + + // The cache starts empty and fills in, so this is observed rather than read once — a + // requirement resolved against an empty cache would render its amount with no token beside + // it and never correct itself. + combine( + stateFlow.map { (it.subject as? ChatSubject.Group)?.rules.balanceRequirement() } + .distinctUntilChanged(), + tokenCoordinator.observeTokenCache(), + ) { requirement, tokens -> + requirement?.mints?.firstOrNull()?.let { tokens[Mint(it.bytes)]?.symbol } + } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnRuleTickerResolved(it)) } + .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. + stateFlow.map { it.subject as? ChatSubject.Group } + .distinctUntilChanged() + .flatMapLatest { group -> + if (group == null) { + flowOf(null) + } else { + tokenCoordinator.groupAccess(isMember = group.isMember, rules = group.rules) + } + } + .filterNotNull() + .onEach { dispatchEvent(Event.OnGroupAccessResolved(it)) } + .launchIn(viewModelScope) + // Observe member identity — if the other member loses identity (e.g. unlinked // their phone), mark the chat as read-only. Gated by chat type through the same rule the // feed filters on, so a tip DM — addressed by user id, named by handle — is never @@ -829,16 +994,27 @@ internal class ChatViewModel @Inject constructor( .onEach { typists -> dispatchEvent(Event.TypistsUpdated(typists)) } .launchIn(viewModelScope) - // Enable typing notifications once a payment has been exchanged - stateFlow.mapNotNull { it.chatId } - .distinctUntilChanged() - .flatMapLatest { chatId -> - chatCoordinator.observeMessages(chatId) - .map { messages -> - messages.any { msg -> msg.content.any { it is MessageContent.Cash } } - } - .distinctUntilChanged() + // A DM opens its composer once a payment has been exchanged. A group has no such + // exchange to wait for: its own rules say who may post, and [GroupAccess] has already + // applied them — a group that reaches the composer at all is [GroupAccess.Membered], and + // one that has not is showing the gate bar instead. Letting a group fall through to the + // DM rule left it unable to type until someone tipped into it. + combine( + stateFlow.mapNotNull { it.chatId }.distinctUntilChanged(), + stateFlow.map { it.subject is ChatSubject.Group }.distinctUntilChanged(), + ::Pair, + ) + .flatMapLatest { (chatId, isGroup) -> + if (isGroup) { + flowOf(true) + } else { + chatCoordinator.observeMessages(chatId) + .map { messages -> + messages.any { msg -> msg.content.any { it is MessageContent.Cash } } + } + } } + .distinctUntilChanged() .onEach { dispatchEvent(Event.TypingEnabled(it)) } .launchIn(viewModelScope) } @@ -921,6 +1097,53 @@ internal class ChatViewModel @Inject constructor( } .launchIn(viewModelScope) + eventFlow.filterIsInstance() + .onEach { + val chatId = stateFlow.value.chatId ?: return@onEach + // No optimistic flip. `join` caches the chat and the membership flag comes back + // through observeMetadata, which is the same path a join from another device takes — + // one source for the gate rather than two that can disagree. + chatCoordinator.join(chatId) + .onFailure { trace("failed to join chat - ${it.localizedMessage}") } + } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .mapNotNull { stateFlow.value.subject as? ChatSubject.Group } + .onEach { group -> + BottomBarManager.showAlert( + title = resources.getString( + R.string.prompt_title_leaveChat, + group.title, + ), + message = resources.getString(R.string.prompt_description_leaveChat), + actions = listOf( + BottomBarAction(text = resources.getString(R.string.action_leaveChat)) { + dispatchEvent(Event.LeaveConfirmed) + } + ), + showCancel = true, + ) + } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { + val chatId = stateFlow.value.chatId ?: return@onEach + // `leave` clears the membership locally before the call, so the gate is back in + // place by the time the profile closes — the same single source the join reads. + chatCoordinator.leave(chatId) + .onSuccess { dispatchEvent(Event.LeftChat) } + .onFailure { + trace("failed to leave chat - ${it.localizedMessage}") + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToLeave), + message = resources.getString(R.string.error_description_failedToLeave), + ) + } + } + .launchIn(viewModelScope) + // A bubble is what the UI has; a citation is what the composer needs, and building one // reads the stored message. A message this device never stored drops the request rather // than opening a strip with nothing in it. @@ -1290,7 +1513,7 @@ internal class ChatViewModel @Inject constructor( when (val id = event.identifier) { is ChatIdentifier.ByContact -> state.copy( - participant = ChatParticipant.Contact(id.contact), + subject = ChatSubject.Contact(ChatParticipant.Contact(id.contact)), chatType = ChatType.CONTACT_DM, ) is ChatIdentifier.ByChatId -> state @@ -1300,7 +1523,9 @@ internal class ChatViewModel @Inject constructor( // profile to observe. is ChatIdentifier.ByUser -> state.copy( - participant = ChatParticipant.TipUser(id.userId, id.profile), + subject = ChatSubject.TipUser( + ChatParticipant.TipUser(id.userId, id.profile) + ), chatType = ChatType.TIP_DM, resolveState = ResolveState.Resolved, ) @@ -1308,19 +1533,42 @@ internal class ChatViewModel @Inject constructor( } is Event.OnContactFound -> { state -> state.copy( - participant = ChatParticipant.Contact(event.contact), + subject = ChatSubject.Contact(ChatParticipant.Contact(event.contact)), chatType = ChatType.CONTACT_DM, ) } Event.OnTipDmDetected -> { state -> state.copy(chatType = ChatType.TIP_DM) } + is Event.OnGroupResolved -> { state -> + val metadata = event.membership.metadata + state.copy( + subject = ChatSubject.Group( + chatId = metadata.chatId, + groupTitle = metadata.title, + picture = metadata.picture, + memberCount = metadata.rosterSummary.memberCount, + rules = metadata.rules, + isMember = event.membership.isMember, + ), + chatType = ChatType.GROUP, + resolveState = ResolveState.Resolved, + ) + } + is Event.OnRuleTickerResolved -> { state -> state.copy(ruleTicker = event.ticker) } + is Event.OnGroupAccessResolved -> { state -> state.copy(groupAccess = event.access) } + Event.JoinChat, + Event.LeaveChat, + Event.LeaveConfirmed, + Event.LeftChat -> { state -> state } 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 // profile identity and mark the recipient resolved so the send can proceed (the // tip user is known to exist; the tip send resolves their address at send time). - if (state.participant is ChatParticipant.Contact) state + if (state.subject is ChatSubject.Contact) state else state.copy( - participant = ChatParticipant.TipUser(event.userId, event.profile), + subject = ChatSubject.TipUser( + ChatParticipant.TipUser(event.userId, event.profile) + ), chatType = ChatType.TIP_DM, resolveState = ResolveState.Resolved, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupAccess.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupAccess.kt new file mode 100644 index 0000000000..9c3327effe --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupAccess.kt @@ -0,0 +1,97 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.TokenWithBalance +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +/** + * What this viewer may do in this group. + * + * One value for the whole screen, because the transcript's blur, the composer's replacement and the + * button's label are three views of the same answer and must never disagree. Resolved in the feature + * rather than in `shared/chat`: the chat layer stores the rules and the membership flag, and what + * they *mean* depends on a token balance that lives on the other side of that module boundary. + */ +internal sealed interface GroupAccess { + + /** The viewer is in the chat. Everything is open. */ + data object Membered : GroupAccess + + /** Not in the chat, but nothing is standing in the way of joining it. */ + data object Eligible : GroupAccess + + /** Not in the chat, and [unmet] is what is standing in the way. */ + data class Blocked(val unmet: ChatRuleRequirement) : GroupAccess + + companion object { + /** + * The gate, as a pure function of the three things that decide it. + * + * Membership wins outright. Re-checking the rules for a member would blur the transcript of + * a chat they are already in the moment their balance dipped below the bar they joined + * over — the server does not remove them for that, so neither does the client. + * + * Only *listener* rules are checked. Speaker rules gate sending inside a chat the viewer can + * already read, which is a different screen state and not this one. + * + * A [ChatRuleRequirement.MinimumBalance] is satisfied by the largest single balance among + * the mints it names, not by their sum: the requirement is "hold $100 of this", and two + * unrelated $60 positions are not that. An empty `mints` names no token in particular, so + * any one of them can satisfy it. + */ + fun evaluate( + isMember: Boolean, + rules: ChatRules?, + balances: List, + ): GroupAccess { + if (isMember) return Membered + val listener = rules?.listener.orEmpty() + if (listener.isEmpty()) return Eligible + + // Keyed by bytes, not by the key object: `class Mint(bytes) : PublicKey(bytes)` + // (libs/encryption/keys/.../Mint.kt), so the `PublicKey`s in `mints` are not `Mint`s and + // a map keyed by `Mint` would miss every one of them. + val byMint: Map, Fiat> = + balances.associate { it.token.address.bytes to it.balance } + + val unmet = listener.firstOrNull { requirement -> + when (requirement) { + is ChatRuleRequirement.MinimumBalance -> { + val candidates = if (requirement.mints.isEmpty()) { + byMint.values + } else { + requirement.mints.mapNotNull { byMint[it.bytes] } + } + val best = candidates.maxOrNull() + best == null || best < requirement.amount + } + // Nothing the client can check and nothing the user can go and acquire. A + // non-member facing a staff chat is blocked, and Task 9 gives that arm a + // disabled button rather than a purchase the user cannot make. + ChatRuleRequirement.Staff -> true + } + } + + return if (unmet == null) Eligible else Blocked(unmet) + } + } +} + +/** + * [GroupAccess.evaluate] over the live balance. + * + * `distinctUntilChanged` because `tokenBalances` re-emits on every price tick, and the gate only + * ever has three answers — without it the transcript's blur would recompose several times a second + * to arrive at the value it already had. + */ +internal fun TokenCoordinator.groupAccess( + isMember: Boolean, + rules: ChatRules?, +): Flow = tokenBalances + .map { GroupAccess.evaluate(isMember = isMember, rules = rules, balances = it) } + .distinctUntilChanged() diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 2fefbf828b..c6871f8e21 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -15,12 +15,15 @@ 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.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.GroupAccess import com.flipcash.app.messenger.internal.screens.components.ChatTopBar import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge.softTopEdge import com.flipcash.app.messenger.internal.screens.components.MessageList import com.flipcash.app.messenger.internal.screens.components.UserControlBottomBar +import com.getcode.ui.components.BlurredContent import com.flipcash.shared.chat.models.ChatAction import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.theme.CodeScaffold @@ -104,15 +107,28 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { viewModel.dispatchEvent(ChatViewModel.Event.JumpToMessage(action.messageId)) } + ChatAction.JoinChat -> viewModel.dispatchEvent(ChatViewModel.Event.JoinChat) + is ChatAction.ViewProfile -> { - // The triggers (top-bar tap, contact-card chevron) are only clickable for tip DMs - // (see State.canViewProfile), so no gating is needed here. - state.participant?.let { - keyboard.hideIfVisible { - navigator.push(ChatStep.Profile(it)) + // The triggers (top-bar tap, info-card chevron) are only clickable for subjects + // that have a profile (see State.canViewProfile), so no gating is needed here. + // Which profile depends on the subject: a DM's is its counterparty's, and a group + // is its own — it has no participant to open one on. + keyboard.hideIfVisible { + when (state.subject) { + is ChatSubject.Group -> navigator.push(ChatStep.GroupProfile) + else -> state.participant?.let { navigator.push(ChatStep.Profile(it)) } } } } + + is ChatAction.ViewMemberProfile -> { + // Resolved in the view model: the profile that drew the picture in the gutter is + // the one to open, and the transcript already holds it. + viewModel.memberParticipant(action.userId)?.let { + keyboard.hideIfVisible { navigator.push(ChatStep.Profile(it)) } + } + } } Unit @@ -151,20 +167,28 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { ) }, ) { overlapPadding -> - MessageList( - modifier = Modifier - .fillMaxSize() - .testTag("chat_message_list") - .softTopEdge(ChatTopEdge.blurHold(barHeight)) - .hazeSource(hazeState), - state = state, - contentPadding = overlapPadding, - messages = messages, - separatorConfig = state.separatorConfig, - otherReadPointer = otherReadPointer, - onAction = chatActionHandler, - canViewProfile = state.canViewProfile, - onJumpConsumed = { viewModel.dispatchEvent(ChatViewModel.Event.JumpConsumed) }, - ) + // The transcript and the info card go behind a blur together, because they are one surface + // to the reader: legible metadata over an unreadable conversation would be the gate half + // open. The title bar stays sharp — it is how a non-member knows what they are looking at. + BlurredContent( + modifier = Modifier.fillMaxSize(), + enabled = state.groupAccess != null && state.groupAccess != GroupAccess.Membered, + ) { + MessageList( + modifier = Modifier + .fillMaxSize() + .testTag("chat_message_list") + .softTopEdge(ChatTopEdge.blurHold(barHeight)) + .hazeSource(hazeState), + state = state, + contentPadding = overlapPadding, + messages = messages, + separatorConfig = state.separatorConfig, + otherReadPointer = otherReadPointer, + onAction = chatActionHandler, + canViewProfile = state.canViewProfile, + onJumpConsumed = { viewModel.dispatchEvent(ChatViewModel.Event.JumpConsumed) }, + ) + } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index c257161739..a02b95716c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.GroupAccess import com.flipcash.app.messenger.internal.screens.ChatAnimations import com.flipcash.services.models.chat.ChatType import com.flipcash.features.messenger.R @@ -72,6 +73,15 @@ internal fun UserControlBottomBar( return } + // The same shape as the block above it, and for the same reason: there is no composer to + // configure when the viewer cannot post. Membered is not a gate, so it falls through to the + // composer like any DM. + val access = state.groupAccess + if (access != null && access != GroupAccess.Membered) { + GroupGateBar(access = access, ticker = state.ruleTicker) + return + } + val keyboard = rememberKeyboardController() val focusRequester = remember { FocusRequester() } var buttonHeight by remember { mutableStateOf(0.dp) } @@ -210,7 +220,9 @@ internal fun UserControlBottomBar( CancelEditButton( onClick = { dispatch(ChatViewModel.Event.CancelEdit) }, ) - } else { + } else if (state.chatType != ChatType.GROUP) { + // Sending cash into a group is not wired up yet, so the button is left + // out rather than shown and dead. The composer takes the row on its own. SendCashButton( state = state, hazeState = hazeState, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatInfoCard.kt similarity index 74% rename from apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt rename to apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatInfoCard.kt index 3a5285b3df..93fee31276 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatInfoCard.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -33,23 +34,46 @@ import androidx.compose.ui.tooling.preview.PreviewWrapper import com.flipcash.app.core.android.IntentUtils import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.messenger.internal.ChatSubject +import com.flipcash.app.messenger.internal.balanceRequirement import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.features.messenger.R import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.Mint import com.getcode.theme.CodeTheme import com.getcode.ui.core.addIf +/** + * The identity card at the head of a transcript. + * + * Was `ContactInfoContainer`, which opened with `participant as? ChatParticipant.Contact` and + * rendered a bare avatar and name for anything else. Driving it from [ChatSubject] makes each arm + * say what it shows: the contact arm keeps its phone line and add-to-contacts pill, the tip arm + * keeps its handle, and the group arm gets the rules line from node 10125:19201. + */ @Composable -internal fun ContactInfoContainer( - participant: ChatParticipant?, +internal fun ChatInfoCard( + subject: ChatSubject?, modifier: Modifier = Modifier, includeBorder: Boolean = true, onOpenProfile: (() -> Unit)? = null, onRefreshContact: () -> Unit = {}, + /** + * The symbol of the token a group's balance rule names, once the token cache has it. + * + * Resolved in the view model rather than read here: `observeTokenCache()` starts empty and + * fills in, so a snapshot read would render the rule with no token and never correct itself. + */ + ticker: String? = null, ) { + val participant = subject?.asParticipant() // Phone number and the add-to-contacts pill only apply to a device contact; a tip DM's // counterparty (a server profile) has neither. - val contact = (participant as? ChatParticipant.Contact)?.contact + val contact = (subject as? ChatSubject.Contact)?.participant?.contact Column( modifier = modifier .addIf(includeBorder) { @@ -67,8 +91,8 @@ internal fun ContactInfoContainer( }, horizontalAlignment = Alignment.CenterHorizontally, ) { - ParticipantAvatar( - participant = participant, + ChatSubjectAvatar( + subject = subject, modifier = Modifier .size(CodeTheme.dimens.staticGrid.x17) .clip(CircleShape), @@ -81,7 +105,7 @@ internal fun ContactInfoContainer( ) { Text( modifier = if (onOpenProfile != null) Modifier.weight(1f, fill = false) else Modifier, - text = participant?.name.orEmpty(), + text = subject?.title.orEmpty(), autoSize = TextAutoSize.StepBased( minFontSize = CodeTheme.typography.textSmall.fontSize, maxFontSize = CodeTheme.typography.textLarge.fontSize, @@ -122,6 +146,31 @@ internal fun ContactInfoContainer( ) } + // Node 10125:19201. Rendered for a member as well as a non-member: it is the chat's + // standing requirement, not a gate message, and it is the one thing the card can say + // about a group that it cannot say about a person. + val requirement = (subject as? ChatSubject.Group)?.rules.balanceRequirement() + if (requirement != null) { + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + text = if (ticker != null) { + stringResource( + R.string.label_chat_balanceRequirement, + requirement.amount.formatted(), + ticker, + ) + } else { + stringResource( + R.string.label_chat_balanceRequirement_anyToken, + requirement.amount.formatted(), + ) + }, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult() ) { onRefreshContact() } @@ -248,7 +297,7 @@ private fun TipPill( @Preview @PreviewWrapper(FlipcashThemeWrapper::class) @Composable -private fun Preview_AllStates() { +private fun Preview_ChatInfoCard() { // A saved device contact: name + number + the "From your contacts" pill. val knownContact = ChatParticipant.Contact( DeviceContact( @@ -285,16 +334,35 @@ private fun Preview_AllStates() { profile = UserProfile.Empty.copy(username = "sally_streamer"), ) + // The group arm: title, member count, and the balance rule from node 10125:19201. + val group = ChatSubject.Group( + chatId = ChatId(byteArrayOf(1)), + groupTitle = "Bad Boys", + picture = null, + memberCount = 412L, + rules = ChatRules( + listener = listOf( + ChatRuleRequirement.MinimumBalance( + amount = Fiat(100.0), + mints = listOf(Mint(List(32) { 7.toByte() })), + ), + ), + speaker = emptyList(), + ), + isMember = false, + ) + // Fixed width so every state renders at the same size regardless of name/number length. val cardWidth = Modifier.width(300.dp) Column( modifier = Modifier.padding(CodeTheme.dimens.grid.x4), verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x4), ) { - ContactInfoContainer(participant = knownContact, modifier = cardWidth) - ContactInfoContainer(participant = unknownContact, modifier = cardWidth) - ContactInfoContainer(participant = tipUser, modifier = cardWidth) - ContactInfoContainer(participant = handleOnlyUser, modifier = cardWidth) + ChatInfoCard(subject = ChatSubject.Contact(knownContact), modifier = cardWidth) + ChatInfoCard(subject = ChatSubject.Contact(unknownContact), modifier = cardWidth) + ChatInfoCard(subject = ChatSubject.TipUser(tipUser), modifier = cardWidth) + ChatInfoCard(subject = ChatSubject.TipUser(handleOnlyUser), modifier = cardWidth) + ChatInfoCard(subject = group, modifier = cardWidth, ticker = "BADBOYS") } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatSubjectAvatar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatSubjectAvatar.kt new file mode 100644 index 0000000000..cb45413c65 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatSubjectAvatar.kt @@ -0,0 +1,45 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.flipcash.app.messenger.internal.ChatSubject +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.shared.common.ui.ContactAvatar + +/** + * The picture for whatever the screen is a conversation with. + * + * Replaces `ParticipantAvatar`: a group's picture is a + * [com.flipcash.services.models.chat.MediaItem] on the chat's own row, read through + * [BlobAccessContext.ChatProfile] rather than through a profile id, so there was no participant + * arm it could have been added to. + */ +@Composable +internal fun ChatSubjectAvatar(subject: ChatSubject?, modifier: Modifier = Modifier) { + when (subject) { + is ChatSubject.Contact -> + ContactAvatar(contact = subject.participant.contact, modifier = modifier) + + // The member's own id, not profile.userId: the server sets the id on the chat member and + // leaves it unset inside the nested profile, and without it the picture's expired download + // URL cannot be re-minted. + is ChatSubject.TipUser -> + ContactAvatar( + userProfile = subject.participant.profile, + modifier = modifier, + userId = subject.participant.userId, + ) + + is ChatSubject.Group -> + ContactAvatar( + image = subject.picture, + displayName = subject.title, + access = BlobAccessContext.ChatProfile(subject.chatId), + modifier = modifier, + ) + + // The frame before the subject resolves. The gradient-and-initials fallback renders with + // no contact, which is the same thing a DM has always shown here. + null -> ContactAvatar(contact = null, modifier = modifier) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index 8e9c787501..23688fd983 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues @@ -37,10 +38,12 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp +import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.features.messenger.R import com.flipcash.shared.chat.MessageCapability @@ -141,7 +144,7 @@ private fun ConversationTitleBar( }, title = { Row( - // Profile open is only available for tip DMs (see State.canViewProfile). + // Profile open is the tip arm's answer alone (see State.canViewProfile). modifier = Modifier .fillMaxWidth() .then( @@ -156,21 +159,38 @@ private fun ConversationTitleBar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { - ParticipantAvatar( - participant = state.participant, + ChatSubjectAvatar( + subject = state.subject, modifier = Modifier .requiredSize(CodeTheme.dimens.staticGrid.x8) .clip(CircleShape), ) - Text( - modifier = Modifier.weight(1f), - // Name-or-handle: the bar is one line (node 9443:9094), and the handle is - // the only identity a name-less tip DM counterparty has. - text = state.participant?.name.orEmpty(), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, - ) + Column(modifier = Modifier.weight(1f)) { + Text( + // Name-or-handle for a DM, the group's title for a group. A DM bar stays + // one line (node 9443:9094): the handle is the only identity a name-less + // tip counterparty has, so it is the title there rather than a second row. + text = state.subject?.title.orEmpty(), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + // A group always shows its size, whether or not the viewer is in it — the + // count is what a join changes, and node 10125:19153 -> 10125:19201 is the + // same bar at two values of it. A DM has nothing to count. + val group = state.subject as? ChatSubject.Group + if (group != null) { + Text( + text = pluralStringResource( + R.plurals.subtitle_chatMemberCount, + group.memberCount.toInt(), + group.memberCount.toString(), + ), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + } } } ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupGateBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupGateBar.kt new file mode 100644 index 0000000000..87ae037981 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupGateBar.kt @@ -0,0 +1,99 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import com.flipcash.app.messenger.internal.GroupAccess +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.shared.chat.models.ChatAction +import com.flipcash.shared.chat.models.LocalChatActionHandler +import com.getcode.solana.keys.Mint +import com.getcode.theme.CodeTheme +import com.getcode.ui.theme.CodeButton + +/** + * What stands where the composer does for someone who is not in the group. + * + * Node 10125:19153. The caption states the bar and the button is the one thing that clears it, so + * both come from the same [GroupAccess.Blocked.unmet] the transcript's blur is decided by — there is + * no second evaluation here that could disagree with it. + */ +@Composable +internal fun GroupGateBar( + access: GroupAccess, + ticker: String?, + modifier: Modifier = Modifier, +) { + val onAction = LocalChatActionHandler.current + val unmet = (access as? GroupAccess.Blocked)?.unmet + val balance = unmet as? ChatRuleRequirement.MinimumBalance + + Column( + modifier = modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(vertical = CodeTheme.dimens.grid.x3), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + val caption = when { + balance != null && ticker != null -> stringResource( + R.string.subtitle_chatGate_minimumBalance, + balance.amount.formatted(), + ticker, + ) + balance != null -> stringResource( + R.string.subtitle_chatGate_minimumBalance_anyToken, + balance.amount.formatted(), + ) + unmet == ChatRuleRequirement.Staff -> + stringResource(R.string.subtitle_chatGate_staffOnly) + // Eligible, and Membered for the frame between a successful join and the membership + // flag arriving back through observeMetadata. + else -> null + } + + if (caption != null) { + Text( + text = caption, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + + if (balance != null) { + CodeButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.action_buyMoreToken, ticker.orEmpty()), + // Nothing to buy until the symbol resolves: the label would read "Buy More $" and + // the token screen has no mint to open. + enabled = ticker != null, + onClick = { + balance.mints.firstOrNull() + ?.let { onAction(ChatAction.ViewToken(Mint(it.bytes))) } + }, + ) + } else { + CodeButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.action_joinChat), + // A staff rule is not something the user can go and satisfy, so the button says + // what it would do and refuses to do it, rather than sending a join the server + // will reject. + enabled = unmet == null, + onClick = { onAction(ChatAction.JoinChat) }, + ) + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 9b46611a60..d89a8d5866 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -322,8 +322,8 @@ internal fun MessageList( .fillParentMaxWidth(), contentAlignment = Alignment.Center, ) { - ContactInfoContainer( - participant = state.participant, + ChatInfoCard( + subject = state.subject, modifier = Modifier .fillMaxWidth(0.63f), onRefreshContact = { onAction(ChatAction.RefreshContact) }, @@ -334,6 +334,7 @@ internal fun MessageList( } else { null }, + ticker = state.ruleTicker, ) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index 3befb5439c..baf28b438a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -5,11 +5,16 @@ import androidx.compose.animation.EnterTransition import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset @@ -19,6 +24,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Reply 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 @@ -37,6 +43,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.paging.compose.LazyPagingItems import com.flipcash.app.messenger.internal.screens.ChatAnimations +import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.MessagePointer import com.flipcash.shared.chat.MessageCapability import com.flipcash.shared.chat.models.ChatAction @@ -45,6 +52,7 @@ import com.flipcash.shared.chat.models.LocalChatActionHandler import com.flipcash.shared.chat.models.ReceiptStatus import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.shared.chat.ui.ContentBubble +import com.flipcash.shared.common.ui.ContactAvatar import com.flipcash.shared.chat.ui.bubblePositionOf import com.getcode.theme.CodeTheme import com.getcode.ui.core.addIf @@ -202,47 +210,98 @@ internal fun MessageRow( if (item.receiptStatus == ReceiptStatus.SENDING) { wasSending = true } - Column( + // Bound to a local: `sender` is a property of another module's public API, so + // Kotlin will not smart-cast it to non-null inside the branches below. + val sender = item.sender + val runStart = startsSenderRun( + current = item, + // Bounded: `peek` throws off the end of the snapshot, and the oldest loaded + // message has no item above it — a one-message transcript crashed here. + older = if (index + 1 < messages.itemCount) messages.peek(index + 1) else null, + ) + Row( modifier = Modifier.fillMaxWidth(), - horizontalAlignment = if (item.isFromSelf) Alignment.End else Alignment.Start, + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), ) { - Box(insertionModifier) { - ContentBubble( - item = item, - // The bubble's own targets go with the row's: a cash - // bubble behind the backdrop would otherwise open token - // info from under the bar. - interactive = !selecting, - // A bubble with a tap target of its own consumes the press, - // so the row's long-press never reaches it. Handing it the - // same gesture is what makes a cash bubble selectable. - onLongClick = select, - position = bubblePositionOf( - index, - item, - messages, - separatorConfig - ), - attention = attention, - ) + // The gutter is reserved on every row of a sender run, not only the labelled + // one, so the bubbles above and below line up on the same left edge instead of + // stepping in and out as the run starts. + if (sender != null) { + Box(modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x6)) { + if (runStart) { + ContactAvatar( + image = sender.picture, + displayName = sender.displayName, + access = BlobAccessContext.profile(sender.userId), + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) + // The picture is the only handle the transcript gives on + // the person behind a bubble. Inert while the backdrop is + // up, like every other target on the row. + .addIf(!selecting) { + Modifier.clickable { + onAction(ChatAction.ViewMemberProfile(sender.userId)) + } + }, + ) + } + } } - val showReceipt = - shouldShowReceiptLabel(index, item, messages, otherReadPointer) - AnimatedVisibility( - visible = showReceipt && effectiveStatus != null, - enter = EnterTransition.None, - exit = ChatAnimations.receiptExit, + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = if (item.isFromSelf) Alignment.End else Alignment.Start, ) { - if (effectiveStatus != null) { - ReceiptLabel( - status = effectiveStatus, - readPointer = otherReadPointer, - animateEntrance = wasSending, - onRetryFailed = if (effectiveStatus == ReceiptStatus.FAILED) { - { onAction(ChatAction.RetryMessage(item)) } - } else null, + if (sender != null && runStart) { + Text( + modifier = Modifier.padding( + start = CodeTheme.dimens.grid.x1, + bottom = CodeTheme.dimens.grid.x1, + ), + text = sender.displayName, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + Box(insertionModifier) { + ContentBubble( + item = item, + // The bubble's own targets go with the row's: a cash + // bubble behind the backdrop would otherwise open token + // info from under the bar. + interactive = !selecting, + // A bubble with a tap target of its own consumes the press, + // so the row's long-press never reaches it. Handing it the + // same gesture is what makes a cash bubble selectable. + onLongClick = select, + position = bubblePositionOf( + index, + item, + messages, + separatorConfig + ), + attention = attention, ) } + val showReceipt = + shouldShowReceiptLabel(index, item, messages, otherReadPointer) + AnimatedVisibility( + visible = showReceipt && effectiveStatus != null, + enter = EnterTransition.None, + exit = ChatAnimations.receiptExit, + ) { + if (effectiveStatus != null) { + ReceiptLabel( + status = effectiveStatus, + readPointer = otherReadPointer, + animateEntrance = wasSending, + onRetryFailed = if (effectiveStatus == ReceiptStatus.FAILED) { + { onAction(ChatAction.RetryMessage(item)) } + } else null, + ) + } + } } } } @@ -332,3 +391,19 @@ private fun bottomSpacingFor( else -> tight } } + +/** + * Whether [current] is the bubble that wears its sender's name and picture. + * + * `older` is the item drawn *above* [current] — under `reverseLayout` that is `peek(index + 1)`, + * the mirror of [bottomSpacingFor]'s `index - 1`. A run is labelled at its top, so the bubble that + * starts one is the one whose upper neighbour came from someone else. + * + * Pure so it can be tested without a `PagingData`: the messenger module has no `paging-testing` + * dependency, and the peek belongs to the caller anyway. + */ +internal fun startsSenderRun(current: ChatListItem.ContentBubble, older: ChatListItem?): Boolean { + val sender = current.sender ?: return false + val olderBubble = older as? ChatListItem.ContentBubble ?: return true + return olderBubble.sender?.userId != sender.userId +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ParticipantAvatar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ParticipantAvatar.kt deleted file mode 100644 index 065e99a21f..0000000000 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ParticipantAvatar.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.flipcash.app.messenger.internal.screens.components - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.flipcash.app.core.chat.ChatParticipant -import com.flipcash.shared.common.ui.ContactAvatar - -/** - * Renders a [ChatParticipant]'s avatar, selecting the identity source by chat type: a device - * contact's photo/initials for a [ChatParticipant.Contact], the server profile picture for a - * [ChatParticipant.TipUser]. A null participant falls through to the unknown-contact avatar. - * - * ContactAvatar sizes the profile-picture rendition to the avatar's measured bounds, so both the - * small top-bar avatar and the large info card get an appropriately crisp image. - */ -@Composable -internal fun ParticipantAvatar( - participant: ChatParticipant?, - modifier: Modifier = Modifier, -) { - when (participant) { - is ChatParticipant.Contact -> ContactAvatar(contact = participant.contact, modifier = modifier) - // The member's own id, not profile.userId: the server sets the id on the chat member and - // leaves it unset inside the nested profile, and without it the picture's expired download - // URL cannot be re-minted. - is ChatParticipant.TipUser -> ContactAvatar( - userProfile = participant.profile, - modifier = modifier, - userId = participant.userId, - ) - null -> ContactAvatar(contact = null, modifier = modifier) - } -} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt index 4e9350c65a..b0de4a28ef 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt @@ -63,7 +63,11 @@ internal fun RowScope.SendCashButton( // is the entire bar. Condensing it there would leave a full-width transparent "$"; what the // chat actually needs is its one call to action, so it stays white and says what it does. val isCallToAction = isTipChat && !canType - val isTyping = !isCallToAction && (isTipChat || state.chatInputState.text.isNotEmpty()) + // A group is condensed like a tip chat: the group is not one counterparty to "Send $" to, and + // the expanded pill would take the row from a composer that is always open there. + val isGroupChat = state.chatType == ChatType.GROUP + val isTyping = !isCallToAction && + (isTipChat || isGroupChat || state.chatInputState.text.isNotEmpty()) // Colors ease slowly and independently of the width/label so the fill change reads as one calm // transition instead of snapping with the resize — but NOT on the first settle. A tip chat opens 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 c433de17f9..1ee7127da7 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 @@ -22,7 +22,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.menu.MenuList -import com.flipcash.app.messenger.internal.screens.components.ParticipantAvatar +import com.flipcash.app.messenger.internal.asSubject +import com.flipcash.app.messenger.internal.screens.components.ChatSubjectAvatar import com.flipcash.features.messenger.R import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme @@ -91,8 +92,8 @@ internal fun ProfileHeader( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, ) { - ParticipantAvatar( - participant = participant, + ChatSubjectAvatar( + subject = participant.asSubject(), modifier = Modifier .size(CodeTheme.dimens.staticGrid.x17) .clip(CircleShape), diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt new file mode 100644 index 0000000000..ab90b97082 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt @@ -0,0 +1,28 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Logout +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.stringResource +import com.flipcash.app.menu.FullMenuItem +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.features.messenger.R + +/** + * The one thing a group's profile can act on. + * + * Its action is a [ChatViewModel.Event] rather than an event of the profile's own, because the + * group is the conversation's — the leave has to clear the membership the transcript is reading, + * and there is one view model holding it. + */ +internal data object LeaveChat : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.AutoMirrored.Outlined.Logout) + + override val name: String + @Composable get() = stringResource(R.string.title_leaveChat) + + override val action: ChatViewModel.Event = ChatViewModel.Event.LeaveChat +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt new file mode 100644 index 0000000000..7a52d3db86 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt @@ -0,0 +1,134 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import android.os.Parcelable +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.layout.Column +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.menu.MenuItem +import com.flipcash.app.menu.MenuList +import com.flipcash.app.messenger.internal.ChatSubject +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.screens.components.ChatSubjectAvatar +import com.flipcash.features.messenger.R +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeScaffold +import kotlinx.coroutines.flow.filterIsInstance + +/** + * Whether the group's profile offers a way out of the group. + * + * Off while the rest of group membership settles: a leave is not something to hand someone before + * the join it undoes is finished. Everything behind it — the confirmation, the call, the close — + * is in place, so this is the only line to change when it ships. + */ +private const val SHOW_LEAVE_ACTION = false + +/** + * The group's own profile, reached from the info card at the head of its transcript. + * + * Driven by the conversation's [ChatViewModel] rather than one of its own: everything on this + * screen — the title, the picture, the member count, the membership the leave clears — is state the + * transcript is already holding and keeping current from the roster. A second view model would + * mean a second copy going stale behind this one. + */ +@Composable +internal fun GroupProfileScreen(viewModel: ChatViewModel) { + val flowNavigator = rememberFlowNavigator() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val group = state.subject as? ChatSubject.Group + + // A group you have left has nothing left on this screen to act on, so it closes back to the + // transcript — which is now showing the gate in place of the composer. + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .collect { flowNavigator.back() } + } + + CodeScaffold( + topBar = { + AppBarWithTitle(onBackIconClicked = { flowNavigator.back() }) + }, + ) { innerPadding -> + MenuList( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + // Leaving is the only thing on offer, and only to someone who is in the group — but + // it is held back for now, so this is the identity header on its own. The leave + // itself is wired end to end behind [SHOW_LEAVE_ACTION]; flipping it puts the row back. + items = if (SHOW_LEAVE_ACTION && group?.isMember == true) { + listOf>(LeaveChat) + } else { + emptyList() + }, + header = { + GroupProfileHeader( + group = group, + modifier = Modifier + .fillMaxWidth() + .padding(top = CodeTheme.dimens.grid.x7), + ) + }, + onItemClick = { viewModel.dispatchEvent(it.action) }, + ) + } +} + +@VisibleForTesting +@Composable +internal fun GroupProfileHeader( + group: ChatSubject.Group?, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ChatSubjectAvatar( + subject = group, + modifier = Modifier + .size(CodeTheme.dimens.staticGrid.x17) + .clip(CircleShape), + ) + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + text = group?.title.orEmpty(), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // The size stands where a person's handle does: it is the one thing a group is identified + // by beyond its name, and it is what a join or a leave changes. + if (group != null) { + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1), + text = pluralStringResource( + R.plurals.subtitle_chatMemberCount, + group.memberCount.toInt(), + group.memberCount.toString(), + ), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt index 561ae58212..035c8278c0 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.unit.dp import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.messenger.internal.screens.components.ChatTopBar -import com.flipcash.app.messenger.internal.screens.components.ContactInfoContainer +import com.flipcash.app.messenger.internal.screens.components.ChatInfoCard import com.flipcash.app.messenger.internal.screens.profile.ProfileHeader import com.flipcash.app.theme.FlipcashPreview import com.flipcash.services.models.UserProfile @@ -83,9 +83,12 @@ class ChatIdentityScreenshotTest { verticalArrangement = Arrangement.spacedBy(16.dp), ) { val cardWidth = Modifier.width(300.dp) - ContactInfoContainer(participant = knownContact, modifier = cardWidth) - ContactInfoContainer(participant = namedTipUser, modifier = cardWidth) - ContactInfoContainer(participant = handleOnlyTipUser, modifier = cardWidth) + ChatInfoCard(subject = ChatSubject.Contact(knownContact), modifier = cardWidth) + ChatInfoCard(subject = ChatSubject.TipUser(namedTipUser), modifier = cardWidth) + ChatInfoCard( + subject = ChatSubject.TipUser(handleOnlyTipUser), + modifier = cardWidth, + ) } } } @@ -108,7 +111,10 @@ class ChatIdentityScreenshotTest { ChatTopBar( navigator = navigator, state = ChatViewModel.State( - participant = participant, + subject = when (participant) { + is ChatParticipant.TipUser -> ChatSubject.TipUser(participant) + is ChatParticipant.Contact -> ChatSubject.Contact(participant) + }, chatType = if (participant is ChatParticipant.TipUser) { ChatType.TIP_DM } else { diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatRulesSummaryTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatRulesSummaryTest.kt new file mode 100644 index 0000000000..4d46575536 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatRulesSummaryTest.kt @@ -0,0 +1,65 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.PublicKey +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The info card's rules line. Only a minimum balance has anything to say on it — staff membership + * is not something a user can go and acquire, so showing it as a requirement would be an + * instruction with no action behind it. + */ +class ChatRulesSummaryTest { + + private val mint = PublicKey(ByteArray(32) { 5 }.toList()) + + @Test + fun `a minimum balance names the amount and the ticker`() { + val rules = ChatRules( + listener = listOf(ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(mint))), + speaker = emptyList(), + ) + + assertEquals( + ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(mint)), + rules.balanceRequirement(), + ) + } + + @Test + fun `a speaker balance rule counts when there is no listener rule`() { + val rules = ChatRules( + listener = emptyList(), + speaker = listOf(ChatRuleRequirement.MinimumBalance(Fiat(50.0), emptyList())), + ) + + assertEquals(Fiat(50.0), (rules.balanceRequirement())?.amount) + } + + @Test + fun `a listener rule wins over a speaker rule`() { + val rules = ChatRules( + listener = listOf(ChatRuleRequirement.MinimumBalance(Fiat(100.0), emptyList())), + speaker = listOf(ChatRuleRequirement.MinimumBalance(Fiat(500.0), emptyList())), + ) + + assertEquals(Fiat(100.0), rules.balanceRequirement()?.amount) + } + + @Test + fun `staff-only rules have no line to render`() { + val rules = ChatRules(listener = listOf(ChatRuleRequirement.Staff), speaker = emptyList()) + + assertNull(rules.balanceRequirement()) + } + + @Test + fun `no rules at all have no line to render`() { + assertNull(ChatRules(emptyList(), emptyList()).balanceRequirement()) + assertNull((null as ChatRules?).balanceRequirement()) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSubjectTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSubjectTest.kt new file mode 100644 index 0000000000..aed53e1e35 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSubjectTest.kt @@ -0,0 +1,103 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules +import com.getcode.opencode.model.financial.Fiat +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The subject exists so that identity questions are exhaustive `when`s rather than chat-type + * comparisons. These assert the three answers every renderer asks for, arm by arm, and that the + * legacy contact arm still hands back the `ChatParticipant` the profile route needs. + */ +class ChatSubjectTest { + + private val contact = DeviceContact( + e164 = "+15551234567", + androidContactId = 1L, + displayName = "Ada Lovelace", + photoUri = null, + displayNumber = "(555) 123-4567", + ) + private val profile = UserProfile.Empty.copy(displayName = "Grace", username = "grace") + private val userId = ByteArray(16) { 1 }.toList() + private val chatId = ChatId(ByteArray(32) { 9 }.toList()) + + @Test + fun `a contact subject titles by the device contact and stays a participant`() { + val subject = ChatSubject.Contact(ChatParticipant.Contact(contact)) + + assertEquals("Ada Lovelace", subject.title) + assertNull(subject.subtitle) + assertEquals(ChatParticipant.Contact(contact), subject.asParticipant()) + } + + @Test + fun `a tip subject titles by the profile and subtitles by the handle`() { + val subject = ChatSubject.TipUser(ChatParticipant.TipUser(userId, profile)) + + assertEquals("Grace", subject.title) + assertEquals("@grace", subject.subtitle) + assertEquals(ChatParticipant.TipUser(userId, profile), subject.asParticipant()) + } + + @Test + fun `a group subject titles by the group and has no participant to open a profile for`() { + val subject = ChatSubject.Group( + chatId = chatId, + groupTitle = "Flipcash Staff", + picture = null, + memberCount = 3L, + rules = ChatRules( + listener = listOf( + ChatRuleRequirement.MinimumBalance(Fiat(100.0), emptyList()) + ), + speaker = emptyList(), + ), + isMember = true, + ) + + assertEquals("Flipcash Staff", subject.title) + // The member count is a plural resource, so the chrome renders it off `memberCount`. + assertNull(subject.subtitle) + assertNull(subject.asParticipant()) + assertEquals(3L, subject.memberCount) + } + + @Test + fun `a group with no server title falls back rather than rendering blank`() { + val subject = ChatSubject.Group( + chatId = chatId, + groupTitle = null, + picture = null, + memberCount = 0L, + rules = null, + isMember = false, + ) + + assertEquals("", subject.title) + assertFalse(subject.isMember) + } + + /** + * A contact DM is the only subject with no profile behind it: its counterparty is a phone + * number in the address book, not an account. A group's profile is its own — which is why it + * says yes here while [ChatSubject.asParticipant] still says there is no one to open one on. + */ + @Test + fun `every subject but a contact DM opens a profile`() { + assertTrue(ChatSubject.TipUser(ChatParticipant.TipUser(userId, profile)).canViewProfile) + assertTrue( + ChatSubject.Group(chatId, "Flipcash Staff", null, 3L, null, true).canViewProfile + ) + assertFalse(ChatSubject.Contact(ChatParticipant.Contact(contact)).canViewProfile) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupAccessTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupAccessTest.kt new file mode 100644 index 0000000000..16cbb29351 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupAccessTest.kt @@ -0,0 +1,187 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.HolderMetrics +import com.getcode.opencode.model.financial.MintMetadata +import com.getcode.opencode.model.financial.TokenWithBalance +import com.getcode.opencode.model.financial.VmMetadata +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey +import org.junit.Test +import kotlin.test.assertEquals + +/** + * The gate, as a table. + * + * Membership short-circuits everything: a member is past the rules by definition, and re-evaluating + * them would blur the transcript of a chat they are already in the moment their balance dipped. + */ +class GroupAccessTest { + + private fun mint(seed: Byte) = Mint(ByteArray(32) { seed }.toList()) + + private fun token(seed: Byte, symbol: String) = MintMetadata( + address = mint(seed), + decimals = 6, + name = symbol, + symbol = symbol, + createdAt = null, + description = "", + imageUrl = "", + vmMetadata = VmMetadata( + vm = PublicKey.fromBase58("11111111111111111111111111111111"), + authority = PublicKey.fromBase58("11111111111111111111111111111111"), + lockDurationInDays = 21, + ), + launchpadMetadata = null, + billCustomizations = null, + socialLinks = emptyList(), + holderMetrics = HolderMetrics.None, + ) + + private fun held(seed: Byte, symbol: String, dollars: Double) = + TokenWithBalance(token = token(seed, symbol), balance = Fiat(dollars)) + + private val badBoys = mint(1) + private val other = mint(2) + + private fun rules(vararg listener: ChatRuleRequirement) = + ChatRules(listener = listener.toList(), speaker = emptyList()) + + @Test + fun `a member is in, whatever the rules say`() { + val access = GroupAccess.evaluate( + isMember = true, + rules = rules(ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(badBoys))), + balances = emptyList(), + ) + + assertEquals(GroupAccess.Membered, access) + } + + @Test + fun `no rules means anyone may join`() { + assertEquals( + GroupAccess.Eligible, + GroupAccess.evaluate(isMember = false, rules = null, balances = emptyList()), + ) + assertEquals( + GroupAccess.Eligible, + GroupAccess.evaluate(isMember = false, rules = rules(), balances = emptyList()), + ) + } + + @Test + fun `enough of the named token clears the bar`() { + val access = GroupAccess.evaluate( + isMember = false, + rules = rules(ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(badBoys))), + balances = listOf(held(1, "BadBoys", 100.0)), + ) + + assertEquals(GroupAccess.Eligible, access) + } + + @Test + fun `too little of the named token is blocked by that requirement`() { + val requirement = ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(badBoys)) + + val access = GroupAccess.evaluate( + isMember = false, + rules = rules(requirement), + balances = listOf(held(1, "BadBoys", 99.99)), + ) + + assertEquals(GroupAccess.Blocked(requirement), access) + } + + @Test + fun `a balance in the wrong token does not count`() { + val requirement = ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(badBoys)) + + val access = GroupAccess.evaluate( + isMember = false, + rules = rules(requirement), + balances = listOf(held(2, "Other", 1_000.0)), + ) + + assertEquals(GroupAccess.Blocked(requirement), access) + } + + @Test + fun `no named mints accepts any single token`() { + val access = GroupAccess.evaluate( + isMember = false, + rules = rules(ChatRuleRequirement.MinimumBalance(Fiat(100.0), emptyList())), + balances = listOf(held(2, "Other", 150.0)), + ) + + assertEquals(GroupAccess.Eligible, access) + } + + @Test + fun `several named mints are satisfied by the largest, not the sum`() { + val requirement = + ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(badBoys, other)) + + assertEquals( + GroupAccess.Blocked(requirement), + GroupAccess.evaluate( + isMember = false, + rules = rules(requirement), + balances = listOf(held(1, "BadBoys", 60.0), held(2, "Other", 60.0)), + ), + ) + assertEquals( + GroupAccess.Eligible, + GroupAccess.evaluate( + isMember = false, + rules = rules(requirement), + balances = listOf(held(1, "BadBoys", 40.0), held(2, "Other", 120.0)), + ), + ) + } + + @Test + fun `a staff rule blocks with nothing to buy`() { + val access = GroupAccess.evaluate( + isMember = false, + rules = rules(ChatRuleRequirement.Staff), + balances = listOf(held(1, "BadBoys", 1_000.0)), + ) + + assertEquals(GroupAccess.Blocked(ChatRuleRequirement.Staff), access) + } + + @Test + fun `the first unmet requirement is the one reported`() { + val balance = ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(badBoys)) + + val access = GroupAccess.evaluate( + isMember = false, + rules = ChatRules( + listener = listOf(balance, ChatRuleRequirement.Staff), + speaker = emptyList(), + ), + balances = emptyList(), + ) + + assertEquals(GroupAccess.Blocked(balance), access) + } + + @Test + fun `speaker rules do not gate reading`() { + val access = GroupAccess.evaluate( + isMember = false, + rules = ChatRules( + listener = emptyList(), + speaker = listOf(ChatRuleRequirement.MinimumBalance(Fiat(500.0), listOf(badBoys))), + ), + balances = emptyList(), + ) + + assertEquals(GroupAccess.Eligible, access) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupChromeScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupChromeScreenshotTest.kt new file mode 100644 index 0000000000..dabe7a950b --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupChromeScreenshotTest.kt @@ -0,0 +1,212 @@ +package com.flipcash.app.messenger.internal + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import com.flipcash.app.messenger.internal.screens.components.ChatInfoCard +import com.flipcash.app.messenger.internal.screens.components.ChatTopBar +import com.flipcash.app.messenger.internal.screens.components.GroupGateBar +import com.flipcash.app.theme.FlipcashPreview +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules +import com.flipcash.services.models.chat.ChatType +import com.getcode.navigation.core.CodeNavigator +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.Mint +import io.mockk.mockk +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File + +/** + * Renders the group chrome — title bar, info card, gate bar — to PNGs in `build/screenshots/` so + * the layout can be eyeballed without an emulator. Not an assertion test. + * + * Same mechanics as [ChatIdentityScreenshotTest]: pause the clock, pump a fixed number of frames, + * and draw the Android view directly, so a composable that keeps scheduling frames can't hang + * `captureToImage()`'s implicit `waitForIdle`. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w400dp-h1100dp-xhdpi") +class GroupChromeScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private val mint = Mint(List(32) { 7.toByte() }) + + private val balanceRule = ChatRules( + listener = listOf( + ChatRuleRequirement.MinimumBalance( + mints = listOf(mint), + amount = Fiat(100.0), + ), + ), + speaker = emptyList(), + ) + + // The member's view: no rules line worth gating on, a real member count. + private val memberedGroup = ChatSubject.Group( + chatId = ChatId(byteArrayOf(1)), + groupTitle = "Bad Boys", + picture = null, + memberCount = 412L, + rules = null, + isMember = true, + ) + + // The outsider's view: same group, carrying the balance rule the gate reports. + private val gatedGroup = memberedGroup.copy(rules = balanceRule, isMember = false) + + // The degenerate roster: the plural string's "1 person" arm and an untitled group. + private val soloGroup = memberedGroup.copy(groupTitle = null, memberCount = 1) + + @Test + fun rendersGroupTitleBar() { + val navigator = mockk(relaxed = true) + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Column( + modifier = Modifier.width(360.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + listOf(memberedGroup, gatedGroup, soloGroup).forEach { group -> + ChatTopBar( + navigator = navigator, + state = ChatViewModel.State( + subject = group, + chatType = ChatType.GROUP, + ), + onBarHeightChange = {}, + chatActionHandler = {}, + dispatch = {}, + ) + } + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("group_top_bar.png") + } + + @Test + fun rendersGroupInfoCard() { + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + val cardWidth = Modifier.width(300.dp) + ChatInfoCard(subject = memberedGroup, modifier = cardWidth) + // The ticker resolved, as it is once the token cache fills in. + ChatInfoCard(subject = gatedGroup, modifier = cardWidth, ticker = "BADBOYS") + // The ticker not yet resolved — the frame the card renders first. + ChatInfoCard(subject = gatedGroup, modifier = cardWidth) + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("group_info_card.png") + } + + @Test + fun rendersGroupGateBar() { + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Column( + modifier = Modifier.width(360.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + // Blocked on a balance, ticker resolved: "Buy More $BADBOYS", enabled. + GroupGateBar( + access = GroupAccess.Blocked( + unmet = ChatRuleRequirement.MinimumBalance( + mints = listOf(mint), + amount = Fiat(100.0), + ), + ), + ticker = "BADBOYS", + ) + // The same state before the ticker resolves: the button is disabled. + GroupGateBar( + access = GroupAccess.Blocked( + unmet = ChatRuleRequirement.MinimumBalance( + mints = emptyList(), + amount = Fiat(100.0), + ), + ), + ticker = null, + ) + // Blocked on staff: no action the viewer can take. + GroupGateBar( + access = GroupAccess.Blocked(unmet = ChatRuleRequirement.Staff), + ticker = null, + ) + // Eligible: one button, "Join Chat". + GroupGateBar(access = GroupAccess.Eligible, ticker = null) + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("group_gate_bar.png") + } + + private fun capture(name: String) { + val root: View = composeRule.activity.findViewById(android.R.id.content) + val width = root.width.takeIf { it > 0 } ?: 1080 + val height = root.height.takeIf { it > 0 } ?: 1920 + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + val cropped = bitmap.trimmedToDrawnArea() + + val outDir = File("build/screenshots").apply { mkdirs() } + val file = File(outDir, name) + file.outputStream().use { cropped.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath} (${cropped.width}x${cropped.height})") + } + + /** + * The content view is the full device, but the previews wrap their content — crop away the + * untouched (fully transparent) margin so the PNG is just what was composed. + */ + private fun Bitmap.trimmedToDrawnArea(): Bitmap { + val pixels = IntArray(width * height) + getPixels(pixels, 0, width, 0, 0, width, height) + var left = width + var top = height + var right = -1 + var bottom = -1 + for (y in 0 until height) { + for (x in 0 until width) { + if (pixels[y * width + x] ushr 24 == 0) continue + if (x < left) left = x + if (x > right) right = x + if (y < top) top = y + if (y > bottom) bottom = y + } + } + if (right < left || bottom < top) return this + return Bitmap.createBitmap(this, left, top, right - left + 1, bottom - top + 1) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SenderRunTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SenderRunTest.kt new file mode 100644 index 0000000000..4f1f5bc3d6 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SenderRunTest.kt @@ -0,0 +1,65 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.app.messenger.internal.screens.components.startsSenderRun +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.SenderIdentity +import com.flipcash.services.models.chat.MessageContent +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Which bubble in a run wears the name and the picture. + * + * The list is `reverseLayout`, so index 0 is the newest message at the bottom and index + 1 is the + * message drawn *above*. A run therefore starts at its oldest message — the topmost one — which is + * the one whose upper neighbour is someone else, a date separator, or nothing. + */ +class SenderRunTest { + + private val alice = SenderIdentity(userId = listOf(1), displayName = "Alice", picture = null) + private val bob = SenderIdentity(userId = listOf(2), displayName = "Bob", picture = null) + + private fun bubble(sender: SenderIdentity?, isFromSelf: Boolean = false) = + ChatListItem.ContentBubble( + messageId = 1L, + contentIndex = 0, + content = MessageContent.Text("hi"), + isFromSelf = isFromSelf, + timestamp = Instant.fromEpochMilliseconds(1_000), + sender = sender, + ) + + @Test + fun `a bubble with no sender never starts a run`() { + assertFalse(startsSenderRun(bubble(sender = null), older = null)) + assertFalse(startsSenderRun(bubble(sender = null, isFromSelf = true), older = bubble(alice))) + } + + @Test + fun `the oldest loaded bubble starts a run`() { + assertTrue(startsSenderRun(bubble(alice), older = null)) + } + + @Test + fun `a bubble under a different sender starts a run`() { + assertTrue(startsSenderRun(bubble(alice), older = bubble(bob))) + } + + @Test + fun `a bubble under the same sender continues the run`() { + assertFalse(startsSenderRun(bubble(alice), older = bubble(alice))) + } + + @Test + fun `a bubble under one of the viewer's own starts a run`() { + assertTrue(startsSenderRun(bubble(alice), older = bubble(sender = null, isFromSelf = true))) + } + + @Test + fun `a bubble under a date separator starts a run`() { + val separator = ChatListItem.DateSeparator(Instant.fromEpochMilliseconds(1_000)) + assertTrue(startsSenderRun(bubble(alice), older = separator)) + } +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt index d1c53e0ea7..57257f7932 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt @@ -4,6 +4,8 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.data.Loadable +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.core.extensions.onResult import com.flipcash.app.core.tipping.TipStep import com.flipcash.app.shareable.ShareSheetController @@ -19,15 +21,18 @@ import com.flipcash.shared.chat.ui.toConversationReference import com.flipcash.shared.tipping.TippingCoordinator import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import javax.inject.Inject +@OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel internal class TipFlowViewModel @Inject constructor( chatCoordinator: ChatCoordinator, @@ -36,6 +41,7 @@ internal class TipFlowViewModel @Inject constructor( tokenCoordinator: TokenCoordinator, shareable: ShareSheetController, tipCodePreviewCache: TipCodePreviewCache, + featureFlags: FeatureFlagController, private val resources: ResourceHelper, ) : BaseViewModel( initialState = State(), @@ -87,7 +93,14 @@ internal class TipFlowViewModel @Inject constructor( .launchIn(viewModelScope) combine( - chatCoordinator.feed(ChatType.TIP_DM), + featureFlags.observe(FeatureFlag.GroupChats) + .flatMapLatest { groupsOn -> + if (groupsOn) { + chatCoordinator.feed(ChatType.TIP_DM, ChatType.GROUP) + } else { + chatCoordinator.feed(ChatType.TIP_DM) + } + }, tokenCoordinator.tokens, ) { summaries, tokens -> val selfId = userManager.accountId diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt index c98008eed8..74ecfd4314 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt @@ -6,7 +6,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.shared.chat.ui.ChatListRow import com.flipcash.shared.chat.ui.ChatRowSubtitle import com.flipcash.shared.chat.ui.ChatRowTrailing @@ -29,7 +28,7 @@ internal fun TipChatRow( ContactAvatar( image = chat.image, displayName = chat.name.orEmpty(), - access = BlobAccessContext.profile(chat.userId), + access = chat.avatarAccess, modifier = Modifier .requiredSize(CodeTheme.dimens.staticGrid.x8) .clip(CircleShape), diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index 506062d1bb..31945dd602 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -1,6 +1,7 @@ package com.flipcash.shared.chat.models import androidx.compose.runtime.staticCompositionLocalOf +import com.getcode.opencode.model.core.ID import com.getcode.solana.keys.Mint sealed interface ChatAction { @@ -10,6 +11,19 @@ sealed interface ChatAction { data class ViewToken(val mint: Mint) : ChatAction data object ViewProfile : ChatAction + /** + * Opens the profile of the member who sent a bubble, from their picture in the gutter. + * + * Separate from [ViewProfile], which opens whoever the chat itself is with. A group is + * with no one, so the only profile reachable from its transcript is a sender's, and the + * id is how the handler finds them — the profile it needs is the one the transcript + * already resolved to draw this picture. + */ + data class ViewMemberProfile(val userId: ID) : ChatAction + + /** Joins the group this screen is showing. */ + data object JoinChat : ChatAction + /** * Adds [bubble] to the selection, or removes it if it is already selected. * diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index 8a4eadba77..010090e869 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -43,6 +43,11 @@ sealed interface ChatListItem { * never synced still reads as a message. */ val quote: ChatQuote? = null, + /** + * Who sent this, when that is not implied. Null for the viewer's own messages and for every + * message in a DM; set only for another member's message in a group. + */ + val sender: SenderIdentity? = null, ) : ChatListItem { /** The body a Copy or an Edit acts on, or `null` for a bubble that carries no text. */ val plainText: String? diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/SenderIdentity.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/SenderIdentity.kt new file mode 100644 index 0000000000..0051d336cc --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/SenderIdentity.kt @@ -0,0 +1,18 @@ +package com.flipcash.shared.chat.models + +import com.flipcash.services.models.chat.MediaItem +import com.getcode.opencode.model.core.ID + +/** + * Who sent a message, as the transcript needs to show it. + * + * Deliberately not a `UserProfile`: the row renders a name and a picture and nothing else, and a + * profile would drag social accounts, contact methods and a DM fee through `PagingData`'s equality + * on every page. It is also nullable on [ChatListItem.ContentBubble] — a DM has one counterparty + * named in the title bar, so attributing each bubble there would be noise. + */ +data class SenderIdentity( + val userId: ID, + val displayName: String, + val picture: MediaItem?, +) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt index 89dfd60676..ef28929149 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt @@ -1,6 +1,7 @@ package com.flipcash.shared.chat.ui import com.flipcash.core.R +import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.handle import com.flipcash.shared.chat.ChatSummary @@ -27,13 +28,18 @@ fun ChatSummary.toConversationReference( tokensByMint: Map, resources: ResourceHelper, ): ConversationReference { + val isGroup = metadata.type == ChatType.GROUP val other = metadata.members.firstOrNull { it.userId != selfId } return ConversationReference( chatId = metadata.chatId, - userId = other?.userId, - displayName = other?.userProfile?.displayName, - handle = other?.userProfile?.handle, - image = other?.userProfile?.profilePicture, + // A group has no single counterparty, and passing one would name the row after whichever + // member happened to be first in a truncated roster. + userId = other?.userId.takeUnless { isGroup }, + displayName = other?.userProfile?.displayName.takeUnless { isGroup }, + handle = other?.userProfile?.handle.takeUnless { isGroup }, + image = if (isGroup) metadata.picture else other?.userProfile?.profilePicture, + title = metadata.title, + isGroup = isGroup, lastMessagePreview = formatPreview(selfId, tokensByMint, resources), lastActivity = metadata.lastActivity, unreadCount = unreadCount, @@ -47,7 +53,16 @@ private fun ChatSummary.formatPreview( ): String? { val lastMsg = metadata.lastMessage ?: return null val sentBySelf = lastMsg.senderId != null && lastMsg.senderId == selfId - return lastMsg.content.firstOrNull()?.previewText(sentBySelf, tokensByMint, resources) + // Only a group attributes, and only someone else's message: "You:" already covers the viewer's, + // and a DM's counterparty is the row's own name. Null when the roster subset does not have the + // sender — the list does not fetch profiles, so an unattributed body is the honest fallback. + val senderName = if (metadata.type == ChatType.GROUP && !sentBySelf) { + metadata.members.firstOrNull { it.userId == lastMsg.senderId }?.userProfile?.displayName + } else { + null + } + return lastMsg.content.firstOrNull() + ?.previewText(sentBySelf, senderName, tokensByMint, resources) } /** @@ -58,6 +73,7 @@ private fun ChatSummary.formatPreview( */ private fun MessageContent.previewText( sentBySelf: Boolean, + senderName: String?, tokensByMint: Map, resources: ResourceHelper, depth: Int = 0, @@ -67,6 +83,8 @@ private fun MessageContent.previewText( when { message == null -> null sentBySelf -> resources.getString(R.string.label_chat_preview_sentMessage, message) + senderName != null -> + resources.getString(R.string.label_chat_preview_senderMessage, senderName, message) else -> message } } @@ -85,13 +103,23 @@ private fun MessageContent.previewText( } else { formatted } - val previewRes = when (action) { - MessageContent.Cash.Action.TIPPED -> - if (sentBySelf) R.string.label_chat_preview_tippedCash else R.string.label_chat_preview_receivedCash - MessageContent.Cash.Action.SENT -> - if (sentBySelf) R.string.label_chat_preview_sentCash else R.string.label_chat_preview_receivedCash + // "You received" does not carry over: in a DM the cash came to the viewer, in a group it + // went to the group and the viewer may have got none of it. + if (senderName != null) { + val previewRes = when (action) { + MessageContent.Cash.Action.TIPPED -> R.string.label_chat_preview_tippedCash_bySender + MessageContent.Cash.Action.SENT -> R.string.label_chat_preview_sentCash_bySender + } + resources.getString(previewRes, senderName, label) + } else { + val previewRes = when (action) { + MessageContent.Cash.Action.TIPPED -> + if (sentBySelf) R.string.label_chat_preview_tippedCash else R.string.label_chat_preview_receivedCash + MessageContent.Cash.Action.SENT -> + if (sentBySelf) R.string.label_chat_preview_sentCash else R.string.label_chat_preview_receivedCash + } + resources.getString(previewRes, label) } - resources.getString(previewRes, label) } // A reply wraps what the sender actually typed, so it previews as that content would have @@ -101,7 +129,7 @@ private fun MessageContent.previewText( is MessageContent.Reply -> if (depth >= MAX_REPLY_UNWRAP_DEPTH) { null } else { - content.firstOrNull()?.previewText(sentBySelf, tokensByMint, resources, depth + 1) + content.firstOrNull()?.previewText(sentBySelf, senderName, tokensByMint, resources, depth + 1) } // The feed carries the newest message that still has content, so a tombstone only diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt index 4bf9cf1cc9..d7a916e11c 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt @@ -1,6 +1,7 @@ package com.flipcash.shared.chat.ui import com.getcode.opencode.model.core.ID +import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.models.nameOrHandle @@ -24,6 +25,10 @@ data class ConversationReference( val handle: String? = null, /** Counterparty avatar media; resolve a URL via [MediaItem.url]. */ val image: MediaItem? = null, + /** The chat's own title. Only groups have one; a DM is named by its counterparty. */ + val title: String? = null, + /** Whether this row is a group. Decides where the name and the avatar's authority come from. */ + val isGroup: Boolean = false, val lastMessagePreview: String? = null, /** The chat's last-activity timestamp; drives recency sorting and the row's trailing timestamp. */ val lastActivity: Instant? = null, @@ -31,10 +36,25 @@ data class ConversationReference( val isTyping: Boolean = false, ) { /** - * What to call the counterparty: [displayName] when they have one, [handle] when they don't. + * What to call this row: the chat's [title] for a group, otherwise the counterparty's + * [displayName], or their [handle] when they have no name. * * The same rule [com.flipcash.app.core.chat.ChatParticipant.name] applies in the messenger, so a * tip DM reads the same in the list as it does once opened. */ - val name: String? get() = nameOrHandle(displayName, handle) + val name: String? get() = if (isGroup) title else nameOrHandle(displayName, handle) + + /** + * What authorizes re-minting [image]'s download URL. + * + * A group's picture hangs off the chat's public profile, not a member's — passing a user id for + * it resolves nothing and the row falls back to initials. Answered here rather than at the call + * site so a row cannot get it wrong. + */ + val avatarAccess: BlobAccessContext + get() = if (isGroup) { + BlobAccessContext.ChatProfile(chatId) + } else { + BlobAccessContext.profile(userId) + } } \ No newline at end of file diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt index 28d6760a22..370de9c58b 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt @@ -2,6 +2,7 @@ package com.flipcash.shared.chat.ui import com.flipcash.core.R import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage @@ -16,6 +17,7 @@ import io.mockk.mockk import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlin.test.assertTrue import kotlin.time.Instant /** @@ -105,4 +107,106 @@ class ChatSummaryPreviewTest { assertNull(preview(listOf(nested))) } + + private fun groupSummary( + content: List, + senderId: ID?, + ) = ChatSummary( + metadata = ChatMetadata( + chatId = ChatId(byteArrayOf(9)), + type = ChatType.GROUP, + members = listOf( + ChatMember(userId = self, userProfile = profile("Me"), pointers = emptyList()), + ChatMember(userId = other, userProfile = profile("Alice"), pointers = emptyList()), + ), + lastMessage = ChatMessage( + messageId = 42, + senderId = senderId, + content = content, + timestamp = sentAt, + unreadSeq = 0, + ), + lastActivity = sentAt, + title = "Bad Boys", + ), + unreadCount = 0, + ) + + private fun groupReference(content: List, senderId: ID? = other) = + groupSummary(content, senderId) + .toConversationReference(selfId = self, tokensByMint = emptyMap(), resources = resources) + + @Test + fun `a group is named by its title`() { + // Building the reference formats the preview eagerly, so these identity tests have to + // stub it even though they do not assert on it. + every { + resources.getString(R.string.label_chat_preview_senderMessage, "Alice", "gm") + } returns "Alice: gm" + + val reference = groupReference(listOf(MessageContent.Text("gm"))) + + assertEquals("Bad Boys", reference.name) + assertTrue(reference.isGroup) + } + + @Test + fun `a group's avatar authorizes off the chat's own profile`() { + // Building the reference formats the preview eagerly, so these identity tests have to + // stub it even though they do not assert on it. + every { + resources.getString(R.string.label_chat_preview_senderMessage, "Alice", "gm") + } returns "Alice: gm" + + val reference = groupReference(listOf(MessageContent.Text("gm"))) + + assertEquals( + BlobAccessContext.ChatProfile(ChatId(byteArrayOf(9))), + reference.avatarAccess, + ) + } + + @Test + fun `a DM's avatar still reads off the counterparty's profile`() { + val reference = summary(listOf(MessageContent.Text("gm")), senderId = other) + .toConversationReference(selfId = self, tokensByMint = emptyMap(), resources = resources) + + assertEquals(BlobAccessContext.Profile(other), reference.avatarAccess) + } + + @Test + fun `another member's message is prefixed with their name`() { + every { + resources.getString(R.string.label_chat_preview_senderMessage, "Alice", "gm") + } returns "Alice: gm" + + assertEquals("Alice: gm", groupReference(listOf(MessageContent.Text("gm"))).lastMessagePreview) + } + + @Test + fun `the viewer's own message keeps the You prefix in a group`() { + every { + resources.getString(R.string.label_chat_preview_sentMessage, "gm") + } returns "You: gm" + + assertEquals( + "You: gm", + groupReference(listOf(MessageContent.Text("gm")), senderId = self).lastMessagePreview, + ) + } + + @Test + fun `a message from someone the roster subset omits is not prefixed`() { + val stranger: ID = listOf(7) + + assertEquals( + "gm", + groupReference(listOf(MessageContent.Text("gm")), senderId = stranger).lastMessagePreview, + ) + } + + @Test + fun `a DM is not prefixed with the counterparty's name`() { + assertEquals("gm", preview(listOf(MessageContent.Text("gm")))) + } } diff --git a/apps/flipcash/shared/chat/build.gradle.kts b/apps/flipcash/shared/chat/build.gradle.kts index 6004b3e160..1dedaba8c6 100644 --- a/apps/flipcash/shared/chat/build.gradle.kts +++ b/apps/flipcash/shared/chat/build.gradle.kts @@ -19,7 +19,6 @@ dependencies { implementation(project(":apps:flipcash:shared:persistence:sources")) implementation(project(":apps:flipcash:shared:persistence:db")) implementation(project(":apps:flipcash:shared:contacts")) - implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:tokens")) implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":services:flipcash")) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index 11b6d5abcd..043ba6fd0a 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -5,6 +5,7 @@ package com.flipcash.shared.chat import androidx.paging.ExperimentalPagingApi import androidx.paging.PagingData import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage @@ -169,6 +170,30 @@ interface MessagingOperations { /** Observes the member list for [chatId]. */ fun observeMembers(chatId: ChatId): Flow> + /** + * One chat's metadata and this device's membership of it, re-emitted on change, or `null` + * while the chat is not stored locally. + * + * Rebuilt per emission the way the feed builds a summary: the row supplies title, picture, + * roster summary and rules, the member table supplies the roster subset, and the message table + * supplies the last message. + */ + fun observeMetadata(chatId: ChatId): Flow + + /** + * Every profile this device holds, keyed by user-id hex. + * + * The group transcript indexes into this by a bubble's `senderId` to draw a name and avatar. + * A DM never reads it: its only other participant is already the screen's subject. + */ + fun observeSenderProfiles(): Flow> + + /** + * Asks for [userId]'s profile if nothing has asked already, for a sender the roster subset + * does not cover. Returns immediately; the answer arrives through [observeSenderProfiles]. + */ + fun requestSenderProfile(userId: ID) + /** Observes the other member's read pointer in [chatId] (for read receipts). */ fun observeOtherReadPointer(chatId: ChatId): Flow diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatMembership.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatMembership.kt new file mode 100644 index 0000000000..2df27aa7b7 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatMembership.kt @@ -0,0 +1,16 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatMetadata + +/** + * A chat's metadata together with whether this device is a member of it. + * + * [ChatMetadata] is server truth about the chat; membership is a fact about the viewer, stored on + * the same row and written by a join, a leave or a feed sync. The access gate needs both to decide + * what it renders, and a caller that had to ask twice could observe them a frame apart and blur a + * chat it had just joined. + */ +data class ChatMembership( + val metadata: ChatMetadata, + val isMember: Boolean, +) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt index 8a1f3a4544..740a0bd1c1 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt @@ -5,8 +5,6 @@ package com.flipcash.shared.chat.internal import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.UserManager @@ -80,7 +78,6 @@ class RealChatCoordinator @Inject constructor( private val stateHolder: ChatStateHolder, private val userManager: UserManager, private val networkObserver: NetworkConnectivityListener, - private val featureFlags: FeatureFlagController, private val dispatchers: DispatcherProvider, ) : ChatCoordinator, SessionListener, @@ -126,11 +123,12 @@ class RealChatCoordinator @Inject constructor( feedDelegate.observeFeedFromDb() feedDelegate.syncFeed() groupFeedDelegate.initialize(scope) - // The only gate on the branch. Everything behind it is built and tested; what it decides - // is whether the device asks the server for groups at all. - if (featureFlags.get(FeatureFlag.GroupChats)) { - groupFeedDelegate.syncGroupFeed() - } + // Synced unconditionally. The GroupChats flag filters the chat list, and every read of + // this cache is already scoped to the chat types its caller asked for, so a group sitting + // in the database with the flag off is invisible rather than latent. Gating the fetch + // instead made the toggle do nothing until the process restarted, and contradicted the + // roster-change routing below, which was never gated. + groupFeedDelegate.syncGroupFeed() eventStreamDelegate.open() eventStreamDelegate.startHeartbeat { feedDelegate.syncFeed() } } @@ -186,6 +184,8 @@ class RealChatCoordinator @Inject constructor( when (event) { is GroupFeedDelegate.Event.LoadMessages -> messagingDelegate.loadMessages(event.chatId) + is GroupFeedDelegate.Event.DeltaSyncNeeded -> + eventStreamDelegate.performDeltaSync(event.chatId) } }.launchIn(scope) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/SenderResolver.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/SenderResolver.kt new file mode 100644 index 0000000000..105d34ec3e --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/SenderResolver.kt @@ -0,0 +1,91 @@ +package com.flipcash.shared.chat.internal + +import com.flipcash.app.persistence.sources.UserProfileDataSource +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.UserProfile +import com.getcode.opencode.model.core.ID +import com.getcode.utils.TraceType +import com.getcode.utils.hexEncodedString +import com.getcode.utils.trace +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Resolves the profiles of message senders the roster does not cover. + * + * `ChatMetadata.members` is a subset for a large group, so a transcript bubble can carry a + * `senderId` no local table has a name or picture for. This fetches that profile once and writes + * it to `user_profiles` — the table every other avatar in the app already reads — rather than to + * `chat_members`, because the fact is about the user, not about their membership of this chat. + * `ChatMemberWithProfile.profile` is already nullable, so nothing needs a migration. + * + * [request] never blocks and never returns the answer. A bubble whose fetch is outstanding renders + * a neutral avatar with no name line; when the write lands, [profiles] re-emits and the bubble + * fills in. That is why there is no suspend `resolve(userId)` here: the transcript is a Paging + * stream, and a per-item suspend call would stall page composition on the network. + * + * Owns its own scope because [com.flipcash.shared.chat.internal.delegates.MessagingDelegate] — its + * only caller — has no `initialize(scope)` and giving it one would churn `RealChatCoordinator` and + * every construction of it in tests. + */ +@Singleton +class SenderResolver @Inject constructor( + private val profileController: ProfileController, + private val userProfileDataSource: UserProfileDataSource, + private val dispatchers: DispatcherProvider, +) { + private val scope = CoroutineScope(dispatchers.IO + SupervisorJob()) + + /** + * User-id hexes with a fetch in flight. Marked *before* the launch, so two requests arriving + * in the same frame collapse on the calling thread instead of racing to the network. + */ + private val inFlight = mutableSetOf() + + /** Every profile this device holds, keyed by user-id hex. The transcript indexes into it. */ + val profiles: Flow> = userProfileDataSource.observeProfiles() + + /** + * Asks for [userId]'s profile if nothing has asked already. Returns immediately. + * + * A failure releases the id rather than caching the miss: the common cause is being offline, + * and a permanently poisoned id would leave the sender nameless for the life of the process. + */ + fun request(userId: ID) { + val hex = userId.hexEncodedString() + synchronized(inFlight) { + if (!inFlight.add(hex)) return + } + scope.launch { + profileController.getProfileForUser(userId) + .onSuccess { profile -> + userProfileDataSource.store(userId, profile) + // Kept marked on success: the profile is in `user_profiles` now, so the + // transcript reads it from [profiles] and never asks again. + } + .onFailure { error -> + synchronized(inFlight) { inFlight.remove(hex) } + trace( + tag = TAG, + message = "failed to resolve sender $hex", + error = error, + type = TraceType.Error, + ) + } + } + } + + /** Drops what this process has asked for, so a re-login re-resolves against the new account. */ + fun clear() { + synchronized(inFlight) { inFlight.clear() } + } + + private companion object { + const val TAG = "SenderResolver" + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt index 21e6289784..abb125ffcf 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt @@ -50,6 +50,13 @@ class GroupFeedDelegate @Inject constructor( sealed interface Event { /** A group whose transcript the device does not have yet. */ data class LoadMessages(val chatId: ChatId) : Event + + /** + * The group's applied cursor is behind the feed's head, so the device is missing a window + * of its transcript. Same contract as + * [FeedSyncDelegate.Event.DeltaSyncNeeded][FeedSyncDelegate.Event.DeltaSyncNeeded]. + */ + data class DeltaSyncNeeded(val chatId: ChatId) : Event } private val _events = Channel(Channel.UNLIMITED) @@ -87,6 +94,18 @@ class GroupFeedDelegate @Inject constructor( return } persist(page.chats) + + // Caching the feed is not the same as having the transcript: [persist] writes each group's + // last-message preview and nothing else, so without this a synced group opens to a single + // bubble. The applied cursor is what says whether the transcript was ever pulled — a feed + // sync never writes it. Same rule the DM feed applies in [FeedSyncDelegate]. + for (chat in page.chats) { + val cursor = metadataDataSource.getLatestEventSequence(chat.chatId) + when { + cursor <= 0L -> _events.send(Event.LoadMessages(chat.chatId)) + chat.latestEventSequence > cursor -> _events.send(Event.DeltaSyncNeeded(chat.chatId)) + } + } } /** diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index 779d01cb00..6680931158 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -25,7 +25,10 @@ import com.flipcash.services.models.chat.PointerType import com.flipcash.services.models.chat.TypingState import com.flipcash.services.models.DeleteMessageError import com.flipcash.services.models.EditMessageError +import com.flipcash.services.models.UserProfile import com.flipcash.shared.chat.ChatHydrationState +import com.flipcash.shared.chat.internal.SenderResolver +import com.flipcash.shared.chat.ChatMembership import com.flipcash.shared.chat.MessagingOperations import com.flipcash.shared.chat.PendingMutation import com.flipcash.shared.chat.internal.ChatStateHolder @@ -37,6 +40,7 @@ import com.getcode.utils.trace import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map @@ -72,6 +76,7 @@ class MessagingDelegate @Inject constructor( private val userManager: UserManager, private val stateHolder: ChatStateHolder, private val analytics: FlipcashAnalyticsService, + private val senderResolver: SenderResolver, ) : MessagingOperations { /** @@ -162,6 +167,24 @@ class MessagingDelegate @Inject constructor( return memberDataSource.observeMembers(chatId) } + override fun observeMetadata(chatId: ChatId): Flow = + combine( + metadataDataSource.observeById(chatId), + memberDataSource.observeMembers(chatId), + ) { entity, members -> + entity ?: return@combine null + val lastMessage = entity.lastMessageId + ?.let { messageDataSource.getLatestVisible(entity.chatIdHex) } + ChatMembership( + metadata = metadataDataSource.toMetadata(entity, members, lastMessage), + isMember = entity.isMember, + ) + }.distinctUntilChanged() + + override fun observeSenderProfiles(): Flow> = senderResolver.profiles + + override fun requestSenderProfile(userId: ID) = senderResolver.request(userId) + override fun observeOtherReadPointer(chatId: ChatId): Flow { val selfId = userManager.accountId return memberDataSource.observeMembers(chatId) @@ -457,6 +480,7 @@ class MessagingDelegate @Inject constructor( metadataDataSource.clear() messageDataSource.clear() memberDataSource.clear() + senderResolver.clear() } // endregion diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt index c029b1621c..670ecce947 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt @@ -113,6 +113,7 @@ class ChatCoordinatorEagerBalanceTest { userManager = userManager, stateHolder = stateHolder, analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), ) val dmChatResolverDelegate = DmChatResolverDelegate( @@ -132,7 +133,6 @@ class ChatCoordinatorEagerBalanceTest { networkObserver = mockk(relaxed = true), dispatchers = testDispatchers, groupFeedDelegate = mockk(relaxed = true), - featureFlags = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt index 82846e894b..ebb63ca3e7 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt @@ -116,6 +116,7 @@ class ChatCoordinatorEventsTest { userManager = userManager, stateHolder = stateHolder, analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), ) val dmChatResolverDelegate = DmChatResolverDelegate( @@ -135,7 +136,6 @@ class ChatCoordinatorEventsTest { networkObserver = mockk(relaxed = true), dispatchers = testDispatchers, groupFeedDelegate = mockk(relaxed = true), - featureFlags = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt index 61d109e430..f53cee6e6c 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt @@ -107,13 +107,13 @@ class ChatCoordinatorTeardownTest { userManager = userManager, stateHolder = stateHolder, analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), ), stateHolder = stateHolder, userManager = userManager, networkObserver = mockk(relaxed = true), dispatchers = TestDispatchers(TestCoroutineScheduler()), groupFeedDelegate = mockk(relaxed = true), - featureFlags = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt index f5c469108a..a3e14a066f 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt @@ -139,13 +139,13 @@ class ChatHistoryHydrationTest { userManager = userManager, stateHolder = stateHolder, analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), ), stateHolder = stateHolder, userManager = userManager, networkObserver = mockk(relaxed = true), dispatchers = TestDispatchers(testScheduler), groupFeedDelegate = mockk(relaxed = true), - featureFlags = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt index 4fa156c2c7..042100d7ae 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt @@ -1,8 +1,6 @@ package com.flipcash.shared.chat import com.flipcash.app.core.dispatchers.TestDispatchers -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.persistence.sources.ChatMemberDataSource import com.flipcash.app.persistence.sources.ChatMessageDataSource import com.flipcash.app.persistence.sources.ChatMetadataDataSource @@ -60,7 +58,6 @@ class GroupChatRoutingTest { private val chatUpdatesChannel = Channel(capacity = Channel.UNLIMITED) private val groupFeedDelegate = mockk(relaxed = true) - private val featureFlags = mockk(relaxed = true) private val testDispatchers = TestDispatchers(TestCoroutineScheduler()) @@ -118,12 +115,12 @@ class GroupChatRoutingTest { userManager = userManager, stateHolder = stateHolder, analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), ), groupFeedDelegate = groupFeedDelegate, stateHolder = stateHolder, userManager = userManager, networkObserver = mockk(relaxed = true), - featureFlags = featureFlags, dispatchers = testDispatchers, ) } @@ -140,7 +137,6 @@ class GroupChatRoutingTest { @Test fun `a roster change on the stream reaches the group delegate`() = runTest(testDispatchers.dispatcher) { - coEvery { featureFlags.get(FeatureFlag.GroupChats) } returns true val subject = coordinator() subject.onUserLoggedIn(mockk(relaxed = true)) runCurrent() @@ -154,7 +150,6 @@ class GroupChatRoutingTest { @Test fun `an update with no roster change does not reach the group delegate`() = runTest(testDispatchers.dispatcher) { - coEvery { featureFlags.get(FeatureFlag.GroupChats) } returns true val subject = coordinator() subject.onUserLoggedIn(mockk(relaxed = true)) runCurrent() @@ -167,32 +162,19 @@ class GroupChatRoutingTest { } @Test - fun `logging in syncs the group feed when the flag is on`() = runTest(testDispatchers.dispatcher) { - coEvery { featureFlags.get(FeatureFlag.GroupChats) } returns true - + fun `logging in syncs the group feed`() = runTest(testDispatchers.dispatcher) { val subject = coordinator() subject.onUserLoggedIn(mockk(relaxed = true)) runCurrent() + // Unconditional: the GroupChats flag filters the list, it does not decide whether the + // cache is populated. Gating the fetch here made the toggle a no-op until the next launch. coVerify { groupFeedDelegate.syncGroupFeed() } subject.teardown() } - @Test - fun `logging in does not sync the group feed when the flag is off`() = runTest(testDispatchers.dispatcher) { - coEvery { featureFlags.get(FeatureFlag.GroupChats) } returns false - - val subject = coordinator() - subject.onUserLoggedIn(mockk(relaxed = true)) - runCurrent() - - coVerify(exactly = 0) { groupFeedDelegate.syncGroupFeed() } - subject.teardown() - } - @Test fun `teardown cancels the group delegate's jobs`() = runTest(testDispatchers.dispatcher) { - coEvery { featureFlags.get(FeatureFlag.GroupChats) } returns true val subject = coordinator() subject.onUserLoggedIn(mockk(relaxed = true)) runCurrent() diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupFeedDelegateTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupFeedDelegateTest.kt index 7fe77fe449..6c8757ce28 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupFeedDelegateTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupFeedDelegateTest.kt @@ -22,8 +22,10 @@ import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeoutOrNull import org.junit.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Instant @@ -62,7 +64,7 @@ class GroupFeedDelegateTest { pointers = emptyList(), ) - private fun group(version: Long = 4) = ChatMetadata( + private fun group(version: Long = 4, latestEventSequence: Long = 0) = ChatMetadata( chatId = chatId, type = ChatType.GROUP, members = listOf(member(selfId)), @@ -70,8 +72,15 @@ class GroupFeedDelegateTest { lastActivity = Instant.fromEpochSeconds(1_000), title = "Flipcash Staff", rosterSummary = RosterSummary(memberCount = 12, version = version), + latestEventSequence = latestEventSequence, ) + private fun feedReturns(vararg chats: ChatMetadata) { + coEvery { controller.getGroupChatFeed(any()) } returns Result.success( + ChatFeedPage(chats = chats.toList(), pagingToken = null, hasMore = false) + ) + } + @Test fun `a sync persists the groups it fetched`() = runTest { coEvery { controller.getGroupChatFeed(any()) } returns Result.success( @@ -108,6 +117,41 @@ class GroupFeedDelegateTest { coVerify(exactly = 0) { metadataDataSource.upsert(any>()) } } + /** + * A feed sync caches each group's last-message preview and nothing else, so a group the device + * has never fetched would otherwise open to a single bubble. The applied cursor, not the + * presence of a message row, is what says the transcript was pulled. + */ + @Test + fun `a synced group the device never fetched asks for its messages`() = runTest { + feedReturns(group(latestEventSequence = 9)) + coEvery { metadataDataSource.getLatestEventSequence(chatId) } returns 0 + + subject.performGroupFeedSync() + + assertEquals(GroupFeedDelegate.Event.LoadMessages(chatId), subject.events.first()) + } + + @Test + fun `a synced group whose cursor is behind the feed head streams the gap`() = runTest { + feedReturns(group(latestEventSequence = 9)) + coEvery { metadataDataSource.getLatestEventSequence(chatId) } returns 5 + + subject.performGroupFeedSync() + + assertEquals(GroupFeedDelegate.Event.DeltaSyncNeeded(chatId), subject.events.first()) + } + + @Test + fun `a synced group that is already caught up asks for nothing`() = runTest { + feedReturns(group(latestEventSequence = 9)) + coEvery { metadataDataSource.getLatestEventSequence(chatId) } returns 9 + + subject.performGroupFeedSync() + + assertNull(withTimeoutOrNull(1_000) { subject.events.first() }) + } + @Test fun `joining persists the chat and asks for its messages`() = runTest { coEvery { controller.joinChat(chatId) } returns Result.success(group()) diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingLoadCursorTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingLoadCursorTest.kt index a5473ab98f..28066303a5 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingLoadCursorTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingLoadCursorTest.kt @@ -44,6 +44,7 @@ class MessagingLoadCursorTest { userManager = mockk(relaxed = true), stateHolder = mockk(relaxed = true), analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), ) @Test diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMetadataObservationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMetadataObservationTest.kt new file mode 100644 index 0000000000..6244ae7225 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMetadataObservationTest.kt @@ -0,0 +1,125 @@ +package com.flipcash.shared.chat + +import app.cash.turbine.test +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.persistence.entities.ChatMetadataEntity +import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.RosterSummary +import com.flipcash.shared.chat.internal.delegates.MessagingDelegate +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * `observeMetadata` is the one read the chrome and the access gate share. It carries membership + * alongside the metadata because [ChatMetadata] has no place for it — membership is a column on + * the row, and the gate is the first thing that needs to ask. + */ +class MessagingMetadataObservationTest { + + private val scheduler = TestCoroutineScheduler() + private val dispatchers = TestDispatchers(scheduler) + + private val chatId = ChatId(ByteArray(32) { 7 }.toList()) + private val chatIdHex = "07".repeat(32) + + private fun entity(memberCount: Long, isMember: Boolean) = ChatMetadataEntity( + chatIdHex = chatIdHex, + chatType = ChatType.GROUP.name, + lastActivityEpochMs = 1_000, + lastMessageId = null, + title = "Flipcash Staff", + memberCount = memberCount, + rosterVersion = 1, + isMember = isMember, + ) + + private fun metadata(memberCount: Long) = ChatMetadata( + chatId = chatId, + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochMilliseconds(1_000), + title = "Flipcash Staff", + rosterSummary = RosterSummary(memberCount = memberCount, version = 1), + ) + + @Test + fun `observeMetadata carries the row's membership and re-emits on change`() = + runTest(dispatchers.dispatcher) { + val rows = MutableStateFlow(entity(memberCount = 2, isMember = false)) + + val metadataDataSource = mockk(relaxed = true) { + every { observeById(chatId) } returns rows + every { toMetadata(entity(2, false), any(), any()) } returns metadata(2) + every { toMetadata(entity(3, true), any(), any()) } returns metadata(3) + } + val memberDataSource = mockk(relaxed = true) { + every { observeMembers(chatId) } returns flowOf(emptyList()) + } + val messageDataSource = mockk(relaxed = true) + + val subject = delegate(metadataDataSource, memberDataSource, messageDataSource) + + subject.observeMetadata(chatId).test { + val first = awaitItem()!! + assertEquals(2L, first.metadata.rosterSummary.memberCount) + assertFalse(first.isMember) + + rows.value = entity(memberCount = 3, isMember = true) + val second = awaitItem()!! + assertEquals(3L, second.metadata.rosterSummary.memberCount) + assertTrue(second.isMember) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `observeMetadata emits null while the chat is not stored`() = + runTest(dispatchers.dispatcher) { + val metadataDataSource = mockk(relaxed = true) { + every { observeById(chatId) } returns flowOf(null) + } + val memberDataSource = mockk(relaxed = true) { + every { observeMembers(chatId) } returns flowOf(emptyList()) + } + + val subject = delegate(metadataDataSource, memberDataSource, mockk(relaxed = true)) + + subject.observeMetadata(chatId).test { + assertEquals(null, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + private fun delegate( + metadataDataSource: ChatMetadataDataSource, + memberDataSource: ChatMemberDataSource, + messageDataSource: ChatMessageDataSource, + ) = MessagingDelegate( + chatController = mockk(relaxed = true), + messagingController = mockk(relaxed = true), + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + notificationManager = mockk(relaxed = true), + userManager = mockk(relaxed = true), + stateHolder = mockk(relaxed = true), + analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), + ) +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMutationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMutationTest.kt index 0c3a6a775e..5ae5ab56f9 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMutationTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMutationTest.kt @@ -70,6 +70,7 @@ class MessagingMutationTest { userManager = userManager, stateHolder = mockk(relaxed = true), analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushedMessageTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushedMessageTest.kt index 9eb95bb982..252f9661d8 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushedMessageTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushedMessageTest.kt @@ -49,6 +49,7 @@ class MessagingPushedMessageTest { userManager = mockk(relaxed = true), stateHolder = mockk(relaxed = true), analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), ) @Test diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt index a349a99aad..2f12aa4294 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt @@ -134,6 +134,7 @@ class ReceivedCounterTest { userManager = userManager, stateHolder = stateHolder, analytics = analytics, + senderResolver = mockk(relaxed = true), ) val dmChatResolverDelegate = DmChatResolverDelegate( @@ -153,7 +154,6 @@ class ReceivedCounterTest { networkObserver = mockk(relaxed = true), dispatchers = testDispatchers, groupFeedDelegate = mockk(relaxed = true), - featureFlags = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt index 1b75fda32f..3c5633d12b 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt @@ -137,6 +137,7 @@ class ReceivedEventTest { userManager = userManager, stateHolder = stateHolder, analytics = analytics, + senderResolver = mockk(relaxed = true), ) val dmChatResolverDelegate = DmChatResolverDelegate( @@ -156,7 +157,6 @@ class ReceivedEventTest { networkObserver = mockk(relaxed = true), dispatchers = testDispatchers, groupFeedDelegate = mockk(relaxed = true), - featureFlags = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/SenderResolverTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/SenderResolverTest.kt new file mode 100644 index 0000000000..9423f75a02 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/SenderResolverTest.kt @@ -0,0 +1,110 @@ +package com.flipcash.shared.chat + +import app.cash.turbine.test +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.persistence.sources.UserProfileDataSource +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.UserProfile +import com.flipcash.shared.chat.internal.SenderResolver +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals + +/** + * `GetProfile` is single-user, so every distinct unknown sender in a group costs one call. The + * guarantee this test exists for is that it costs no more than one: the transcript asks for a + * sender on every recomposition that sees a bubble from them, and an unguarded resolver would + * fan that out into a call per frame. + * + * The dedupe marks a user id as in-flight *before* launching the fetch, so the second request + * is dropped on the calling thread. That makes the behaviour deterministic under + * `StandardTestDispatcher` rather than dependent on when the coroutine happens to be scheduled. + */ +class SenderResolverTest { + + private val scheduler = TestCoroutineScheduler() + private val dispatchers = TestDispatchers(scheduler) + + private val userId = ByteArray(16) { 3 }.toList() + private val userIdHex = "03".repeat(16) + private val profile = UserProfile.Empty.copy(displayName = "Ada", userId = userId) + + private val profileController = mockk() + private val userProfileDataSource = mockk(relaxed = true) { + every { observeProfiles() } returns MutableStateFlow(emptyMap()) + } + + private fun subject() = SenderResolver( + profileController = profileController, + userProfileDataSource = userProfileDataSource, + dispatchers = dispatchers, + ) + + @Test + fun `a miss triggers one fetch and is written to user_profiles`() = runTest(dispatchers.dispatcher) { + coEvery { profileController.getProfileForUser(userId) } returns Result.success(profile) + val resolver = subject() + + resolver.request(userId) + advanceUntilIdle() + + coVerify(exactly = 1) { profileController.getProfileForUser(userId) } + coVerify(exactly = 1) { userProfileDataSource.store(userId, profile) } + resolver.clear() + } + + @Test + fun `concurrent requests for the same sender are deduped`() = runTest(dispatchers.dispatcher) { + coEvery { profileController.getProfileForUser(userId) } returns Result.success(profile) + val resolver = subject() + + resolver.request(userId) + resolver.request(userId) + resolver.request(userId) + advanceUntilIdle() + + coVerify(exactly = 1) { profileController.getProfileForUser(userId) } + resolver.clear() + } + + @Test + fun `a failed fetch releases the id so a later request retries`() = runTest(dispatchers.dispatcher) { + coEvery { profileController.getProfileForUser(userId) } returns + Result.failure(RuntimeException("offline")) + val resolver = subject() + + resolver.request(userId) + advanceUntilIdle() + resolver.request(userId) + advanceUntilIdle() + + coVerify(exactly = 2) { profileController.getProfileForUser(userId) } + coVerify(exactly = 0) { userProfileDataSource.store(any(), any()) } + resolver.clear() + } + + @Test + fun `resolved profiles are observed off the shared user_profiles table`() = + runTest(dispatchers.dispatcher) { + val table = MutableStateFlow>(emptyMap()) + every { userProfileDataSource.observeProfiles() } returns table + val resolver = subject() + + resolver.profiles.test { + assertEquals(emptyMap(), awaitItem()) + + table.value = mapOf(userIdHex to profile) + assertEquals("Ada", awaitItem()[userIdHex]?.displayName) + + cancelAndIgnoreRemainingEvents() + } + resolver.clear() + } +} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt index e8e2cc3278..683078121d 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt @@ -54,6 +54,20 @@ interface ChatMetadataDao { @Query("SELECT * FROM chat_metadata WHERE chat_id_hex = :chatIdHex") suspend fun getById(chatIdHex: String): ChatMetadataEntity? + /** + * One chat's row, re-emitted whenever it changes. + * + * The chrome and the access gate read title, picture, member count, rules and membership off + * this row, and every one of them moves under the user — a roster event changes the count, a + * join flips membership. Room re-runs the query on any write to `chat_metadata`, so a screen + * observing this needs nothing that polls. + * + * Emits `null` for a chat this device has not stored, which is the state a chat opened from a + * link sits in until its sync lands. + */ + @Query("SELECT * FROM chat_metadata WHERE chat_id_hex = :chatIdHex") + fun observeById(chatIdHex: String): Flow + @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertIfAbsent(entity: ChatMetadataEntity): Long diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt index 1545ad1919..120bdf7559 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.paging.PagingSource import androidx.room.Room import androidx.test.core.app.ApplicationProvider +import app.cash.turbine.test import com.flipcash.app.persistence.FlipcashDatabase import com.flipcash.app.persistence.converters.ChatRulesSerialized import com.flipcash.app.persistence.entities.ChatMetadataEntity @@ -15,6 +16,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import kotlin.test.assertEquals +import kotlin.test.assertNull /** * Covers what a feed sync is allowed to overwrite. `latest_event_sequence` (the applied @@ -280,6 +282,30 @@ class ChatMetadataDaoTest { assertEquals(null, dao.getRosterVersion(OTHER_HEX)) } + @Test + fun `observeById re-emits when the row changes`() = runTest { + dao.upsert(entity(title = "Flipcash Staff", memberCount = 2, rosterVersion = 1)) + + dao.observeById(CHAT_HEX).test { + assertEquals(2L, awaitItem()?.memberCount) + + // Through `updateRosterIfNewer`, not `upsert`: the roster columns are versioned and + // an upsert deliberately leaves them alone, so this is the write a roster event makes. + dao.updateRosterIfNewer(CHAT_HEX, memberCount = 3, rosterVersion = 2) + assertEquals(3L, awaitItem()?.memberCount) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `observeById emits null for a chat this device has never stored`() = runTest { + dao.observeById(CHAT_HEX).test { + assertNull(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + private companion object { const val CHAT_HEX = "aabbccdd" const val OTHER_HEX = "eeff0011" diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt index e6b3f57b41..262b53de12 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt @@ -26,6 +26,10 @@ class ChatMetadataDataSource @Inject constructor( fun observeAll(): Flow> = db?.chatMetadataDao()?.observeAll() ?: emptyFlow() + /** One chat's row by [chatId], re-emitted on change; `null` while it is not stored. */ + fun observeById(chatId: ChatId): Flow = + db?.chatMetadataDao()?.observeById(mapper.chatIdHex(chatId)) ?: emptyFlow() + /** * The merged feed of [chatTypes] as a Paging source. *