From de72102275ce9089aea721c5e0d20f39ca7eb058 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 13:51:44 -0400 Subject: [PATCH 1/5] refactor(chat): drive the reaction sheets from ChatViewModel state The reactors sheet, emoji picker and quick-reaction strip read the view model through public methods that returned flows or values, bypassing State and Event. The screen collected those flows itself, and the quick strip was built in the screen and passed back into the selection event. Each surface now has a slice of State: `reactors` (pills, rows with names and avatars resolved, loading, hasMore), `emojiPicker` (query, sections, loaded) and `quickReactionInputs`. Opening, paging, searching and dismissing go through Events, and a reactor tap sends OpenReactorProfile, answered by the ShowReactorProfile event. Message rows carry the viewer's own reactions, so selecting a bubble builds the strip in the same reduction as the selection. Picker sections for a query the user has already typed past are dropped. OpenReactors carries the long-pressed pills, so the sheet has a title before the live reaction data arrives. --- .../flipcash/app/messenger/ChatFlowScreen.kt | 68 ++- .../app/messenger/internal/ChatViewModel.kt | 511 ++++++++++++------ .../internal/screens/MessengerScreen.kt | 9 +- .../internal/screens/ReactionPickerSheet.kt | 49 +- .../internal/screens/ReactorsSheet.kt | 32 +- .../internal/screens/components/MessageRow.kt | 2 +- .../internal/ChatReactionsReducerTest.kt | 180 ++++++ .../flipcash/shared/chat/models/ChatAction.kt | 5 +- .../shared/chat/models/ChatListItem.kt | 7 + 9 files changed, 591 insertions(+), 272 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt 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 856629e16e..4908c40f3a 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 @@ -2,11 +2,11 @@ package com.flipcash.app.messenger import android.os.Parcelable import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -57,7 +57,6 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch @Composable fun ChatFlowScreen( @@ -161,6 +160,12 @@ private fun FlowConversationScreen( } } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .collect { navigator.push(ChatStep.Profile(contact = it.participant)) } + } + LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() @@ -359,9 +364,18 @@ private fun FlowEditGroupPictureScreen() { private fun FlowReactionPickerScreen(messageId: Long) { val viewModel = flowSharedViewModel() val dismissSheet = LocalBottomSheetDismissDispatcher.current + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + // OpenReactionPicker opened it; this entry leaving the back stack is what closes it. + DisposableEffect(viewModel, messageId) { + onDispose { viewModel.dispatchEvent(ChatViewModel.Event.EmojiPickerDismissed(messageId)) } + } + val picker = state.emojiPicker?.takeIf { it.messageId == messageId } ReactionPickerSheet( - loadSections = { query -> viewModel.emojiPickerSections(query) }, + query = picker?.query.orEmpty(), + sections = picker?.sections.orEmpty(), + loaded = picker?.loaded == true, + onQueryChanged = { viewModel.dispatchEvent(ChatViewModel.Event.EmojiPickerQueryChanged(it)) }, onSelected = { emoji -> viewModel.dispatchEvent( ChatViewModel.Event.ToggleReaction(messageId, emoji, clearsSelection = true) @@ -373,50 +387,30 @@ private fun FlowReactionPickerScreen(messageId: Long) { } /** - * Who reacted to [messageId], and with what. Tapping a row dismisses this sheet first, then opens - * the reactor's profile — [ChatViewModel.reactorParticipant] resolves a [ChatParticipant] even for - * a reactor absent from `senderProfiles`. + * Who reacted to [messageId], and with what. Tapping a row dismisses this sheet first; the profile + * opens from [FlowConversationScreen] once [ChatViewModel.Event.ShowReactorProfile] names the + * participant, since this entry is gone by then. */ @Composable private fun FlowReactorsScreen(messageId: Long) { val viewModel = flowSharedViewModel() - val navigator = LocalCodeNavigator.current val dismissSheet = LocalBottomSheetDismissDispatcher.current - val coroutineScope = rememberCoroutineScope() - - val pills by remember(viewModel, messageId) { viewModel.reactionPills(messageId) } - .collectAsStateWithLifecycle(initialValue = emptyList()) - val rows by remember(viewModel, messageId) { viewModel.reactorsRows(messageId) } - .collectAsStateWithLifecycle(initialValue = emptyList()) - val loading by remember(viewModel, messageId) { viewModel.reactorsLoading(messageId) } - .collectAsStateWithLifecycle(initialValue = true) - - // OpenReactors already started the fetch; this catches reactions that change while the sheet - // is up. Collected here rather than off `pills` above, whose empty initial value would read as - // "every reaction removed". The first emission is a no-op unless the pills moved since the - // long-press. - LaunchedEffect(viewModel, messageId) { - viewModel.reactionPills(messageId).collect { viewModel.syncReactors(messageId, it) } + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + // OpenReactors opened it; this entry leaving the back stack is what closes it. + DisposableEffect(viewModel, messageId) { + onDispose { viewModel.dispatchEvent(ChatViewModel.Event.ReactorsDismissed(messageId)) } } + val reactors = state.reactors?.takeIf { it.messageId == messageId } ReactorsSheet( - pills = pills, - rows = rows, - loading = loading, - hasMore = { viewModel.reactorsHasMore(messageId) }, - resolveDisplay = { userId -> viewModel.reactorDisplay(userId) }, - onLoadMore = { viewModel.loadMoreReactors(messageId) }, + pills = reactors?.pills.orEmpty(), + rows = reactors?.rows.orEmpty(), + loading = reactors?.loading ?: true, + hasMore = reactors?.hasMore == true, + onLoadMore = { viewModel.dispatchEvent(ChatViewModel.Event.LoadMoreReactors) }, onOpenProfile = { userId -> - // Dismiss first (decision: profile opens after the sheet closes), then resolve the - // participant — a row is only tappable once reactorDisplay has already resolved it, so - // this is a re-read of state already on screen rather than a fresh network round trip - // in the common case; reactorParticipant only reaches further (member roster, cached - // profile store) for a reactor absent from senderProfiles. dismissSheet() - coroutineScope.launch { - val participant = viewModel.reactorParticipant(userId) ?: return@launch - navigator.push(ChatStep.Profile(contact = participant)) - } + viewModel.dispatchEvent(ChatViewModel.Event.OpenReactorProfile(userId)) }, onDismiss = dismissSheet, ) 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 6ee74941d2..538c8859dd 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 @@ -19,8 +19,8 @@ import com.flipcash.analytics.CashLinkChoice import com.flipcash.analytics.GroupAccess as AnalyticsGroupAccess import com.flipcash.analytics.GroupGateFunding import com.flipcash.analytics.GroupInviteMethod -import com.flipcash.analytics.GroupInviteSource import com.flipcash.analytics.GroupInviteSheetSource +import com.flipcash.analytics.GroupInviteSource import com.flipcash.analytics.State as AnalyticsState import com.flipcash.analytics.events.AddMoneyEvents import com.flipcash.analytics.events.ChatEvents @@ -33,32 +33,32 @@ import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatParticipant -import com.flipcash.app.core.toast.SystemToastController import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.core.extensions.setText +import com.flipcash.app.core.toast.SystemToastController import com.flipcash.app.core.tokens.brandedName import com.flipcash.app.core.tokens.isReserve import com.flipcash.app.core.ui.ConfirmationStyle import com.flipcash.app.core.util.Linkify import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.messenger.internal.link.CashCardTap -import com.flipcash.app.persistence.sources.UserProfileDataSource -import com.flipcash.libs.coroutines.DispatcherProvider -import com.flipcash.shared.chat.UnreadBoundary import com.flipcash.app.messenger.internal.link.ClaimReplyTargets import com.flipcash.app.messenger.internal.link.LinkCardClassifier import com.flipcash.app.messenger.internal.link.LinkCardResolver +import com.flipcash.app.persistence.sources.UserProfileDataSource import com.flipcash.app.session.CashLinkClaims import com.flipcash.app.session.ChatCashLinks import com.flipcash.app.session.SettledClaim import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.features.messenger.R +import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.models.JoinChatError import com.flipcash.services.models.TipAction import com.flipcash.services.models.TipOrigin 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 import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.DeliveryStatus @@ -79,7 +79,10 @@ import com.flipcash.shared.chat.ChatMembership import com.flipcash.shared.chat.GroupAccess import com.flipcash.shared.chat.MessageCapability import com.flipcash.shared.chat.MessagePolicy +import com.flipcash.shared.chat.MessageReactions +import com.flipcash.shared.chat.UnreadBoundary import com.flipcash.shared.chat.applying +import com.flipcash.shared.chat.canReact import com.flipcash.shared.chat.chatDraftOf import com.flipcash.shared.chat.groupAccess import com.flipcash.shared.chat.models.ChatListItem @@ -88,30 +91,29 @@ import com.flipcash.shared.chat.models.ChatQuoteSnippet import com.flipcash.shared.chat.models.LinkCard import com.flipcash.shared.chat.models.LinkCardResolution import com.flipcash.shared.chat.models.ReceiptStatus -import com.flipcash.shared.chat.models.splitAroundLinkCard import com.flipcash.shared.chat.models.SenderIdentity import com.flipcash.shared.chat.models.SeparatorConfig -import com.flipcash.shared.chat.readOnly -import com.flipcash.shared.chat.MessageReactions +import com.flipcash.shared.chat.models.splitAroundLinkCard import com.flipcash.shared.chat.reactions.ReactionError +import com.flipcash.shared.chat.reactions.ReactionPill import com.flipcash.shared.chat.reactions.ReactionStrip import com.flipcash.shared.chat.reactions.ReactionStripComposer import com.flipcash.shared.chat.reactions.ReactorNameResolver import com.flipcash.shared.chat.reactions.ReactorsListModel import com.flipcash.shared.chat.reactions.ReactorsPrefetchCache import com.flipcash.shared.chat.reactions.SelfReaction -import com.getcode.libs.emojis.reactions.EmojiCatalogLoader -import com.getcode.libs.emojis.reactions.EmojiDrawability -import com.getcode.libs.emojis.reactions.EmojiPickerModel -import com.getcode.libs.emojis.reactions.RecentReactions -import com.getcode.libs.emojis.reactions.RecentReactionsStore -import com.flipcash.shared.chat.canReact +import com.flipcash.shared.chat.readOnly import com.flipcash.shared.chat.resolveCapabilities import com.flipcash.shared.chat.ui.detectUrls import com.flipcash.shared.chat.ui.linkableText import com.flipcash.shared.chat.withinWindows import com.flipcash.shared.payments.ContactPaymentDelegate import com.flipcash.shared.payments.TipPaymentDelegate +import com.getcode.libs.emojis.reactions.EmojiCatalogLoader +import com.getcode.libs.emojis.reactions.EmojiDrawability +import com.getcode.libs.emojis.reactions.EmojiPickerModel +import com.getcode.libs.emojis.reactions.RecentReactions +import com.getcode.libs.emojis.reactions.RecentReactionsStore import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.TransactionController @@ -141,13 +143,13 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNot @@ -158,8 +160,10 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.transformLatest @@ -354,6 +358,16 @@ internal class ChatViewModel @Inject constructor( * to, or before the catalog/recents have loaded for the first selection of this session. */ val quickReactionStrip: List = emptyList(), + /** + * What [quickReactionStrip] is built from besides the message's own reactions, or null + * before the first load. Held here so the selection reducer can build the strip in the same + * update that lifts the bubble. + */ + val quickReactionInputs: QuickReactionInputs? = null, + /** The full emoji picker, while it's open. */ + val emojiPicker: EmojiPickerState? = null, + /** The reactors sheet, while it's open. */ + val reactors: ReactorsSheetState? = null, ) { /** * The DM counterparty, or `null` for a group. @@ -597,12 +611,7 @@ internal class ChatViewModel @Inject constructor( data class MessagePolicyChanged(val policy: MessagePolicy) : Event /** Selects [bubble], or leaves selection mode if it is already the selected one. */ - /** [quickReactionStrip] is built ahead by [ChatViewModel.quickReactionStripFor], so the - * strip lands in the same update as the selection; empty leaves it to the strip flow. */ - data class ToggleMessageSelection( - val bubble: ChatListItem.ContentBubble, - val quickReactionStrip: List = emptyList(), - ) : Event + data class ToggleMessageSelection(val bubble: ChatListItem.ContentBubble) : Event data object ClearMessageSelection : Event // The message actions carry what they act on rather than reading it back off the selection: @@ -646,11 +655,54 @@ internal class ChatViewModel @Inject constructor( /** Opens the full emoji picker for [messageId], from a pill row's or the strip's "+". */ data class OpenReactionPicker(val messageId: Long) : Event - /** Opens who-reacted for [messageId], from a long-press on a pill. */ - data class OpenReactors(val messageId: Long) : Event + /** The picker's search text changed, already debounced by the sheet. */ + data class EmojiPickerQueryChanged(val query: String) : Event + + /** Internal: [sections] for [query] finished loading for the open picker. */ + data class EmojiPickerSectionsLoaded( + val messageId: Long, + val query: String, + val sections: List, + ) : Event + + /** The picker for [messageId] left the screen. */ + data class EmojiPickerDismissed(val messageId: Long) : Event + + /** + * Opens who-reacted for [messageId], from a long-press on a pill. [pills] are the ones the + * reader pressed, so the sheet has a title before the live overlay is read. + */ + data class OpenReactors(val messageId: Long, val pills: List) : Event + + /** Internal: the open reactors sheet's pills, rows or paging moved. */ + data class ReactorsUpdated( + val messageId: Long, + val pills: List, + val rows: List, + val loading: Boolean, + val hasMore: Boolean, + ) : Event + + /** The reactors sheet scrolled its last row into view. */ + data object LoadMoreReactors : Event + + /** A reactors-sheet row was tapped. */ + data class OpenReactorProfile(val userId: ID) : Event + + /** Internal: [participant] is who [OpenReactorProfile] named; the screen pushes the profile. */ + data class ShowReactorProfile(val participant: ChatParticipant.TipUser) : Event - /** Internal: the quick strip's entries for the current selection have (re)composed. */ - data class QuickReactionStripComposed(val entries: List) : Event + /** The reactors sheet for [messageId] left the screen. */ + data class ReactorsDismissed(val messageId: Long) : Event + + /** Internal: the quick strip's catalog and recents (re)loaded. */ + data class QuickReactionInputsLoaded(val inputs: QuickReactionInputs) : Event + + /** Internal: the viewer's own reactions on the selected message moved. */ + data class SelectionReactionsChanged( + val messageId: Long, + val selfReactions: List, + ) : Event /** * The transcript's own refresh-on-paging hook has ids it wants reaction state refreshed @@ -734,9 +786,6 @@ internal class ChatViewModel @Inject constructor( .flatMapLatest { chatCoordinator.observeChatReactions(it) } .stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap()) - /** Declared before `init`, which starts filling it. See [StripInputs]. */ - private val stripInputs = MutableStateFlow(null) - /** * 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 @@ -772,12 +821,12 @@ internal class ChatViewModel @Inject constructor( ?.let { ChatParticipant.TipUser(userId, it) } /** - * One [ReactorsListModel] per messageId, started on [Event.OpenReactors] (see - * [initMessageActionHandlers]) rather than on the reactors sheet's own composition — loading - * has to begin on the pill long-press itself, and the sheet is a fresh Compose tree on every - * push. `getReactorsPage` is captured against whatever [ChatId] is current at call time - * rather than the chat this view model was built for; a chat never changes under one instance - * in practice, but reading [stateFlow] here keeps the cache correct if that ever changes. + * One [ReactorsListModel] per messageId, started when [State.reactors] names a message (see + * [initReactorsSheet]) rather than on the reactors sheet's own composition — loading has to + * begin on the pill long-press itself, and the sheet is a fresh Compose tree on every push. + * `getReactorsPage` is captured against whatever [ChatId] is current at call time rather than + * the chat this view model was built for; a chat never changes under one instance in practice, + * but reading [stateFlow] here keeps the cache correct if that ever changes. */ private val reactorsPrefetchCache = ReactorsPrefetchCache(scope = viewModelScope) { messageId, emoji, token -> val chatId = stateFlow.value.chatId ?: return@ReactorsPrefetchCache Result.failure( @@ -786,34 +835,13 @@ internal class ChatViewModel @Inject constructor( chatCoordinator.getReactorsPage(chatId, messageId, emoji, token) } - fun reactorsRows(messageId: Long): Flow> = reactorsPrefetchCache.rows(messageId) - - fun reactorsLoading(messageId: Long): Flow = reactorsPrefetchCache.loading(messageId) - - fun reactorsHasMore(messageId: Long): Boolean = reactorsPrefetchCache.hasMore(messageId) - - fun loadMoreReactors(messageId: Long) = reactorsPrefetchCache.loadMoreIfNeeded(messageId) - - /** - * Refetches [messageId]'s reactors when [pills] no longer match what the cache fetched for — - * called by the open reactors sheet each time its live pills change, so a reaction added or - * removed while it's up (or since it was last opened) shows in the rows, not just the title. - */ - fun syncReactors(messageId: Long, pills: List) = - reactorsPrefetchCache.start(messageId, pills) - - /** [messageId]'s pills, live — the reactors sheet's summary row (decision 4: title + pills). */ - fun reactionPills(messageId: Long): Flow> = - reactionOverlay.map { it[messageId]?.pills.orEmpty() }.distinctUntilChanged() - /** * The full picker's sections for [query] (recents row + one section per catalog category, or a - * single search-results section — see [EmojiPickerModel.sections]), recomposed each time - * [query] changes. Loads the catalog once per call rather than caching it on the view model, - * matching how [emojiCatalogLoader] is already used for the quick strip above — the loader - * itself caches the parsed file, so this is cheap after the first call. + * single search-results section — see [EmojiPickerModel.sections]). Loads the catalog on each + * call rather than caching it on the view model; the loader itself caches the parsed file, so + * this is cheap after the first call. */ - suspend fun emojiPickerSections(query: String): List { + private suspend fun emojiPickerSections(query: String): List { val catalog = emojiCatalogLoader.load() val undrawable = catalog.entries .map { it.emoji } @@ -823,58 +851,89 @@ internal class ChatViewModel @Inject constructor( return EmojiPickerModel.sections(catalog = catalog, undrawable = undrawable, recents = recents, query = query) } + /** + * What the quick strip is built from, other than the message's own reactions. Held in [State] + * ahead of a long-press so the selection reducer can build the strip in the same update that + * lifts the bubble; built on demand, the strip trailed the lift by the catalog's glyph check and + * a DataStore read. Reloaded at init and after each toggle, which is what moves recents. + */ + data class QuickReactionInputs( + val recentStats: Map, + val catalogFill: List, + val undrawable: Set, + ) + /** A reactors-sheet row's resolved identity: the name to show (decision 4's precedence, * "You" for the viewer), and the profile to draw an avatar from when one is known. */ data class ReactorDisplay(val name: String, val profile: UserProfile?) + /** One person in the reactors sheet: every emoji they used, and who they are once resolved. */ + data class ReactorRow( + val userId: ID, + val emojis: List, + /** Null until a source names them; the row draws a placeholder and isn't tappable. */ + val display: ReactorDisplay?, + ) + + /** The reactors sheet for [messageId], while it's open. */ + data class ReactorsSheetState( + val messageId: Long, + /** [messageId]'s pills, live — the sheet's title total and summary row (decision 4). */ + val pills: List = emptyList(), + val rows: List = emptyList(), + /** True from the open until the first round lands, and while any later page is in flight. */ + val loading: Boolean = true, + /** Whether any of the message's emojis still has an unfetched page. */ + val hasMore: Boolean = false, + ) + + /** The full emoji picker for [messageId], while it's open. */ + data class EmojiPickerState( + val messageId: Long, + /** The search text the sheet last reported, already debounced. */ + val query: String = "", + /** The sections for [query]; stale for a moment while a new query's load is in flight. */ + val sections: List = emptyList(), + /** False until the first load lands, which is what the sheet's spinner waits on. */ + val loaded: Boolean = false, + ) + /** - * The reactors sheet's display name and avatar source for [userId] (decision 4), re-emitting - * as later sources — the member roster, then a server fetch — resolve. Kicks off - * [ChatCoordinator.requestSenderProfile] itself when nothing has an answer yet, mirroring - * [resolveSenderName]'s fallback for the transcript. The self case shows the viewer's own - * picture (so their avatar isn't blank) under the "You" label rather than their own profile - * name. + * [userId]'s name and avatar source for the reactors sheet (decision 4), from what the cached + * profile store, the member roster and the transcript's sender profiles hold right now, or null + * when none of them names this person. The viewer shows as "You" over their own picture, so + * their avatar isn't blank, rather than under their own profile name. */ - fun reactorDisplay(userId: ID): Flow { - val chatId = stateFlow.value.chatId - val selfLabel = resources.getString(R.string.title_you) + private fun reactorDisplay( + userId: ID, + cachedProfiles: Map, + members: List, + sentProfiles: Map?, + ): ReactorDisplay? { val selfUserId = userManager.accountId - val members = chatId?.let { chatCoordinator.observeMembers(it) } ?: flowOf(emptyList()) - var requested = false - return combine( - userProfileDataSource.observeProfiles(), - members, - senderProfiles, - ) { cachedProfiles, memberList, sentProfiles -> - val cached = cachedProfiles[userId.hexEncodedString()] - val name = ReactorNameResolver.resolve( - userId = userId, - selfUserId = selfUserId, - selfLabel = selfLabel, - cachedProfile = cached, - members = memberList, - senderProfiles = sentProfiles.orEmpty(), - ) ?: return@combine null - val profile = if (selfUserId != null && userId == selfUserId) { - userManager.profile - } else { - cached - ?: memberList.firstOrNull { it.userId == userId }?.userProfile - ?: sentProfiles?.get(userId.hexEncodedString()) - } - ReactorDisplay(name = name, profile = profile) - }.onEach { display -> - if (display == null && !requested) { - requested = true - chatCoordinator.requestSenderProfile(userId) - } + val cached = cachedProfiles[userId.hexEncodedString()] + val name = ReactorNameResolver.resolve( + userId = userId, + selfUserId = selfUserId, + selfLabel = resources.getString(R.string.title_you), + cachedProfile = cached, + members = members, + senderProfiles = sentProfiles.orEmpty(), + ) ?: return null + val profile = if (selfUserId != null && userId == selfUserId) { + userManager.profile + } else { + cached + ?: members.firstOrNull { it.userId == userId }?.userProfile + ?: sentProfiles?.get(userId.hexEncodedString()) } + return ReactorDisplay(name = name, profile = profile) } /** [ChatParticipant] for [userId] to open a profile from the reactors sheet, unlike * [memberParticipant] this also covers a reactor absent from [senderProfiles] by falling back * to whatever the cached profile store or member roster already has. */ - suspend fun reactorParticipant(userId: ID): ChatParticipant.TipUser? { + private suspend fun reactorParticipant(userId: ID): ChatParticipant.TipUser? { memberParticipant(userId)?.let { return it } val chatId = stateFlow.value.chatId val fromMembers = chatId?.let { id -> @@ -884,6 +943,57 @@ internal class ChatViewModel @Inject constructor( return profile?.let { ChatParticipant.TipUser(userId, it) } } + /** + * Drives [State.reactors] while the sheet is open. The pills are the overlay's, live, so a + * reaction added or removed under the open sheet changes them; each change goes to + * [reactorsPrefetchCache], which refetches the rows only when a count moved. The fetch starts + * on [Event.OpenReactors] itself, before the navigator pushes the sheet, so loading begins on + * the long-press rather than on the sheet's own composition. + * + * A reactor none of the sources names yet is asked for once, the same fallback + * [resolveSenderName] uses for the transcript; the row fills in when the profile lands. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private fun initReactorsSheet() { + stateFlow.map { it.reactors?.messageId } + .distinctUntilChanged() + .flatMapLatest { messageId -> + val opened = stateFlow.value.reactors + if (messageId == null || opened == null) return@flatMapLatest emptyFlow() + val pills = reactionOverlay.map { it[messageId]?.pills ?: opened.pills } + .distinctUntilChanged() + .onEach { reactorsPrefetchCache.start(messageId, it) } + val members = stateFlow.mapNotNull { it.chatId } + .distinctUntilChanged() + .flatMapLatest { chatCoordinator.observeMembers(it) } + .onStart { emit(emptyList()) } + val requested = mutableSetOf() + combine( + pills, + reactorsPrefetchCache.rows(messageId), + reactorsPrefetchCache.loading(messageId), + combine(userProfileDataSource.observeProfiles(), members, senderProfiles, ::Triple), + ) { currentPills, rows, loading, (cachedProfiles, memberList, sentProfiles) -> + val resolved = rows.map { row -> + val display = reactorDisplay(row.userId, cachedProfiles, memberList, sentProfiles) + if (display == null && requested.add(row.userId)) { + chatCoordinator.requestSenderProfile(row.userId) + } + ReactorRow(userId = row.userId, emojis = row.emojis, display = display) + } + Event.ReactorsUpdated( + messageId = messageId, + pills = currentPills, + rows = resolved, + loading = loading, + hasMore = reactorsPrefetchCache.hasMore(messageId), + ) + } + } + .onEach { dispatchEvent(it) } + .launchIn(viewModelScope) + } + /** * How a drawn card reaches the lookup. Provided to the transcript, read by `LinkCardView`. * @@ -963,6 +1073,10 @@ internal class ChatViewModel @Inject constructor( } } else null + // Stored fallback only — see the reactionOverlay pass below, which fills in a + // live override where one exists. + val storedReactions = MessageReactions.from(message.reactions) + // 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 @@ -1006,10 +1120,9 @@ internal class ChatViewModel @Inject constructor( // after the first page does. senderId = message.senderId?.takeIf { !message.isFromSelf }, linkCard = linkCard, - // Stored fallback only — see the reactionOverlay pass below, which fills - // in a live override for whichever row ends up as `isLastRow` once - // `splitAroundLinkCard` has run. - reactionPills = MessageReactions.from(message.reactions).pills, + // `splitAroundLinkCard` keeps these pills on the last row only. + reactionPills = storedReactions.pills, + selfReactions = storedReactions.selfReactions, canReact = canReact(message), ) // A carded link takes a row of its own, with the prose either side of it @@ -1030,10 +1143,13 @@ internal class ChatViewModel @Inject constructor( combine(pages, reactionOverlay) { data, overlay -> if (overlay.isEmpty()) return@combine data data.map { bubble -> - // Only the row the "Edited" marker also lands on carries the pill row, so a - // split message shows one set of pills, not one per row. - val live = if (bubble.isLastRow) overlay[bubble.messageId] else null - if (live == null) bubble else bubble.copy(reactionPills = live.pills) + val live = overlay[bubble.messageId] ?: return@map bubble + bubble.copy( + // Only the row the "Edited" marker also lands on carries the pill row, so a + // split message shows one set of pills, not one per row. + reactionPills = if (bubble.isLastRow) live.pills else bubble.reactionPills, + selfReactions = live.selfReactions, + ) } } }.cachedIn(viewModelScope) @@ -1874,100 +1990,74 @@ internal class ChatViewModel @Inject constructor( .launchIn(viewModelScope) } - /** - * What the quick strip is built from, other than the message's own reactions. Kept ready ahead - * of a long-press so the selection reducer can build the strip in the same state update that - * lifts the bubble; built on demand, the strip trailed the lift by the catalog's glyph check - * and a DataStore read. Refreshed at init and after each toggle, which is what moves recents. - */ - private class StripInputs( - val recentStats: Map, - val catalogFill: List, - val undrawable: Set, - ) - - private suspend fun refreshStripInputs(): StripInputs { + private suspend fun loadQuickReactionInputs(): QuickReactionInputs { val catalog = emojiCatalogLoader.load() val fill = catalog.firstCategoryEntries.map { it.emoji } val undrawable = withContext(Dispatchers.Default) { fill.filterNot { EmojiDrawability.isDrawable(it) }.toSet() } - return StripInputs( + return QuickReactionInputs( recentStats = recentReactionsStore.stats(), catalogFill = fill, undrawable = undrawable, - ).also { stripInputs.value = it } - } - - /** - * The quick strip for [bubble], from the inputs already in hand, or empty if they have not - * loaded yet (the strip flow fills it in then). Called as the bubble is selected, so the strip - * enters with the lift instead of a round trip behind it. - */ - fun quickReactionStripFor(bubble: ChatListItem.ContentBubble): List { - if (!bubble.canReact) return emptyList() - val inputs = stripInputs.value ?: return emptyList() - return composeStrip(inputs, reactionOverlay.value[bubble.messageId]?.selfReactions.orEmpty()) - } - - private fun composeStrip(inputs: StripInputs, selfReactions: List) = - ReactionStripComposer.compose( - recentStats = inputs.recentStats, - selfReactions = selfReactions, - catalogFillSource = inputs.catalogFill, - undrawable = inputs.undrawable, ) + } private fun initMessageActionHandlers() { - viewModelScope.launch { refreshStripInputs() } + viewModelScope.launch { dispatchEvent(Event.QuickReactionInputsLoaded(loadQuickReactionInputs())) } eventFlow.filterIsInstance() .onEach { event -> val chatId = stateFlow.value.chatId ?: return@onEach viewModelScope.launch { chatCoordinator.toggleReaction(chatId, event.messageId, event.emoji) - refreshStripInputs() + // A toggle is what moves recents, so the next strip reflects it. + dispatchEvent(Event.QuickReactionInputsLoaded(loadQuickReactionInputs())) } } .launchIn(viewModelScope) - // The quick strip above a selected, reactable bubble — recomposed each time the selection - // moves to a different message. Reads the live overlay for self-reactions rather than the - // selected bubble's own (possibly stale) snapshot, the same source [mappedMessages] treats - // as authoritative. + // The selection holds the bubble as it was when pressed; a reaction that lands while it is + // up (the viewer's own from another device, or a toggle's reconcile) reaches the strip here. + // Reads the live overlay, the same source [mappedMessages] treats as authoritative. stateFlow.map { it.selection?.takeIf { bubble -> bubble.canReact }?.messageId } .distinctUntilChanged() .flatMapLatest { messageId -> if (messageId == null) { - flowOf(emptyList()) + emptyFlow() } else { - reactionOverlay.map { overlay -> - overlay[messageId]?.selfReactions.orEmpty() - }.distinctUntilChanged() + reactionOverlay.mapNotNull { overlay -> overlay[messageId]?.selfReactions } + .distinctUntilChanged() + .map { Event.SelectionReactionsChanged(messageId, it) } } } - .onEach { selfReactions -> - val entries = if (selfReactions.isEmpty() && stateFlow.value.selection == null) { - emptyList() - } else { - composeStrip(stripInputs.value ?: refreshStripInputs(), selfReactions) - } - dispatchEvent(Event.QuickReactionStripComposed(entries)) + .onEach { dispatchEvent(it) } + .launchIn(viewModelScope) + + // The picker's sections follow its query. mapLatest drops a load the reader has already + // typed past. + stateFlow.map { state -> state.emojiPicker?.let { it.messageId to it.query } } + .distinctUntilChanged() + .filterNotNull() + .mapLatest { (messageId, query) -> + Event.EmojiPickerSectionsLoaded(messageId, query, emojiPickerSections(query)) } + .onEach { dispatchEvent(it) } .launchIn(viewModelScope) - // OpenReactionPicker carries no side effect here — the screen's action handler pushes the - // ChatStep, the same as every other transcript-triggered navigation (see MessengerScreen's - // chatActionHandler). The reducer above is a no-op for both too; they exist as events at - // all so a picker pick can reach ToggleReaction through the same dispatch path everything - // else in the transcript uses. - // - // OpenReactors does have one: it starts the reactors fetch (see reactorsPrefetchCache) - // before the navigator pushes ChatStep.Reactors, so loading begins on the long-press - // rather than on the sheet's own composition. - eventFlow.filterIsInstance() + initReactorsSheet() + + eventFlow.filterIsInstance() + .onEach { + val messageId = stateFlow.value.reactors?.messageId ?: return@onEach + reactorsPrefetchCache.loadMoreIfNeeded(messageId) + } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() .onEach { event -> - syncReactors(event.messageId, reactionOverlay.value[event.messageId]?.pills.orEmpty()) + val participant = reactorParticipant(event.userId) ?: return@onEach + dispatchEvent(Event.ShowReactorProfile(participant)) } .launchIn(viewModelScope) @@ -2820,14 +2910,10 @@ internal class ChatViewModel @Inject constructor( capabilities = if (state.isOutsideGroup) open.readOnly() else open, ) } - state.copy( - selection = selected, - quickReactionStrip = if (selected != null) event.quickReactionStrip else emptyList(), - confirmingDelete = false, - ) + state.copy(selection = selected, confirmingDelete = false).withQuickReactionStrip() } Event.ClearMessageSelection -> { state -> - state.copy(selection = null, confirmingDelete = false) + state.copy(selection = null, confirmingDelete = false).withQuickReactionStrip() } is Event.CopyMessage -> { state -> state.copy(selection = null, confirmingDelete = false) @@ -2877,9 +2963,60 @@ internal class ChatViewModel @Inject constructor( is Event.ToggleReaction -> { state -> if (event.clearsSelection) state.copy(selection = null) else state } - is Event.OpenReactionPicker -> { state -> state } - is Event.OpenReactors -> { state -> state } - is Event.QuickReactionStripComposed -> { state -> state.copy(quickReactionStrip = event.entries) } + is Event.OpenReactionPicker -> { state -> + state.copy(emojiPicker = EmojiPickerState(messageId = event.messageId)) + } + is Event.EmojiPickerQueryChanged -> { state -> + state.copy(emojiPicker = state.emojiPicker?.copy(query = event.query)) + } + is Event.EmojiPickerSectionsLoaded -> { state -> + val picker = state.emojiPicker + // A load for a query already typed past, or a picker since closed, is dropped. + if (picker?.messageId != event.messageId || picker.query != event.query) { + state + } else { + state.copy(emojiPicker = picker.copy(sections = event.sections, loaded = true)) + } + } + is Event.EmojiPickerDismissed -> { state -> + if (state.emojiPicker?.messageId == event.messageId) state.copy(emojiPicker = null) else state + } + is Event.OpenReactors -> { state -> + state.copy(reactors = ReactorsSheetState(messageId = event.messageId, pills = event.pills)) + } + is Event.ReactorsUpdated -> { state -> + val reactors = state.reactors + if (reactors?.messageId != event.messageId) { + state + } else { + state.copy( + reactors = reactors.copy( + pills = event.pills, + rows = event.rows, + loading = event.loading, + hasMore = event.hasMore, + ) + ) + } + } + Event.LoadMoreReactors -> { state -> state } + is Event.OpenReactorProfile -> { state -> state } + is Event.ShowReactorProfile -> { state -> state } + is Event.ReactorsDismissed -> { state -> + if (state.reactors?.messageId == event.messageId) state.copy(reactors = null) else state + } + is Event.QuickReactionInputsLoaded -> { state -> + state.copy(quickReactionInputs = event.inputs).withQuickReactionStrip() + } + is Event.SelectionReactionsChanged -> { state -> + val selection = state.selection + if (selection?.messageId != event.messageId) { + state + } else { + state.copy(selection = selection.copy(selfReactions = event.selfReactions)) + .withQuickReactionStrip() + } + } is Event.RefreshReactionIds -> { state -> state } // Nothing on screen moves when a voucher is tapped -- the link leaves, the card // keeps saying what it said, and the claim comes back as its own signal. @@ -2898,3 +3035,23 @@ internal class ChatViewModel @Inject constructor( } } } + +/** + * [ChatViewModel.State.quickReactionStrip] rebuilt for the current selection: empty while nothing + * is selected, while the selected message can't be reacted to, or before the inputs have loaded. + */ +private fun ChatViewModel.State.withQuickReactionStrip(): ChatViewModel.State { + val bubble = selection?.takeIf { it.canReact } + val inputs = quickReactionInputs + val strip = if (bubble == null || inputs == null) { + emptyList() + } else { + ReactionStripComposer.compose( + recentStats = inputs.recentStats, + selfReactions = bubble.selfReactions, + catalogFillSource = inputs.catalogFill, + undrawable = inputs.undrawable, + ) + } + return copy(quickReactionStrip = strip) +} 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 3bc1d3c932..ed04e4b52c 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 @@ -151,12 +151,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } is ChatAction.ToggleSelection -> { - viewModel.dispatchEvent( - ChatViewModel.Event.ToggleMessageSelection( - bubble = action.bubble, - quickReactionStrip = viewModel.quickReactionStripFor(action.bubble), - ) - ) + viewModel.dispatchEvent(ChatViewModel.Event.ToggleMessageSelection(action.bubble)) } ChatAction.ClearSelection -> { @@ -241,7 +236,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } is ChatAction.OpenReactors -> { - viewModel.dispatchEvent(ChatViewModel.Event.OpenReactors(action.messageId)) + viewModel.dispatchEvent(ChatViewModel.Event.OpenReactors(action.messageId, action.pills)) navigator.push(ChatStep.Reactors(action.messageId)) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt index a7ee08a9fb..bcd292c7c1 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt @@ -3,22 +3,11 @@ package com.flipcash.app.messenger.internal.screens import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.snap import androidx.compose.animation.core.spring +import androidx.compose.foundation.background import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.offset -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.semantics.onClick -import androidx.compose.ui.semantics.selected -import androidx.compose.ui.unit.IntOffset -import kotlin.math.roundToInt -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.PaddingValues import androidx.compose.foundation.layout.Row @@ -28,6 +17,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -50,22 +40,30 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.flipcash.features.messenger.R @@ -74,8 +72,8 @@ import com.getcode.libs.emojis.reactions.EmojiPickerModel import com.getcode.theme.CodeTheme import com.getcode.ui.core.unboundedClickable import com.getcode.ui.theme.CodeCircularProgressIndicator -import com.getcode.ui.utils.LocalSheetOverhang import com.getcode.ui.utils.AllowSheetExpansionWhenScrollable +import com.getcode.ui.utils.LocalSheetOverhang import dev.chrisbanes.haze.HazeInput import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.HazeBlurStyle @@ -83,6 +81,7 @@ import dev.chrisbanes.haze.blur.HazeColorEffect import dev.chrisbanes.haze.blur.hazeBlur import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState +import kotlin.math.roundToInt import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch @@ -97,7 +96,10 @@ import kotlinx.coroutines.launch */ @Composable internal fun ReactionPickerSheet( - loadSections: suspend (query: String) -> List, + query: String, + sections: List, + loaded: Boolean, + onQueryChanged: (String) -> Unit, onSelected: (String) -> Unit, onDismiss: () -> Unit, ) { @@ -115,21 +117,16 @@ internal fun ReactionPickerSheet( .padding(top = 16.dp, bottom = 12.dp), ) - val query by produceState(initialValue = "") { + val currentOnQueryChanged by rememberUpdatedState(onQueryChanged) + LaunchedEffect(searchState) { snapshotFlow { searchState.text.toString() } .distinctUntilChanged() .debounce(150) - .collect { value = it } - } - - var hasLoadedOnce by remember { mutableStateOf(false) } - val sections by produceState(initialValue = emptyList(), query, loadSections) { - value = loadSections(query) - hasLoadedOnce = true + .collect { currentOnQueryChanged(it) } } when { - !hasLoadedOnce -> { + !loaded -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CodeCircularProgressIndicator() } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsSheet.kt index 878c247cb3..25e7cbf83d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsSheet.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -51,12 +52,10 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.features.messenger.R import com.flipcash.services.models.handle import com.flipcash.shared.chat.reactions.ReactionPill -import com.flipcash.shared.chat.reactions.ReactorsListModel import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.opencode.model.core.ID import com.getcode.theme.CodeTheme @@ -64,26 +63,18 @@ import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.utils.AllowSheetExpansionWhenScrollable import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged /** * Who reacted, and with what (decision 4): one row per person, every emoji they used, no filters. * Opens unscrolled with nothing focused — there is no search here, unlike the picker. - * - * @param resolveDisplay per-row identity, re-subscribed for the row's lifetime in composition — - * see [ChatViewModel.reactorDisplay] for the source precedence and its live re-resolution as a - * member roster or a server fetch lands. - * @param hasMore read live rather than passed as a snapshot: the last row's visibility effect below - * asks it fresh each time it fires. */ @Composable internal fun ReactorsSheet( pills: List, - rows: List, + rows: List, loading: Boolean, - hasMore: () -> Boolean, - resolveDisplay: (ID) -> Flow, + hasMore: Boolean, onLoadMore: () -> Unit, onOpenProfile: (ID) -> Unit, onDismiss: () -> Unit, @@ -136,9 +127,8 @@ internal fun ReactorsSheet( } else { items(rows, key = { it.userId.joinToString(",") }) { row -> ReactorRow( - userId = row.userId, emojis = row.emojis, - resolveDisplay = resolveDisplay, + display = row.display, onClick = { onOpenProfile(row.userId) }, ) } @@ -150,16 +140,18 @@ internal fun ReactorsSheet( @Composable private fun LoadMoreOnLastRowVisible( listState: LazyListState, - rows: List, - hasMore: () -> Boolean, + rows: List, + hasMore: Boolean, onLoadMore: () -> Unit, ) { + // Read inside the effect rather than keyed on: a page landing changes rows, which restarts it. + val currentHasMore by rememberUpdatedState(hasMore) LaunchedEffect(listState, rows) { if (rows.isEmpty()) return@LaunchedEffect snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index } .distinctUntilChanged() .collect { lastVisibleIndex -> - if (lastVisibleIndex != null && lastVisibleIndex >= rows.lastIndex && hasMore()) { + if (lastVisibleIndex != null && lastVisibleIndex >= rows.lastIndex && currentHasMore) { onLoadMore() } } @@ -204,14 +196,10 @@ private fun SummaryPillRow(pills: List) { */ @Composable private fun ReactorRow( - userId: ID, emojis: List, - resolveDisplay: (ID) -> Flow, + display: ChatViewModel.ReactorDisplay?, onClick: () -> Unit, ) { - val displayFlow = remember(userId, resolveDisplay) { resolveDisplay(userId) } - val display by displayFlow.collectAsStateWithLifecycle(initialValue = null) - BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { // The person keeps priority over the emoji, which always get at least MinEmojiWidth. val personMaxWidth = (maxWidth - RowLeadingInset - MinEmojiWidth).coerceAtLeast(0.dp) 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 78d731440d..4efc718c4d 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 @@ -427,7 +427,7 @@ internal fun MessageRow( onAction(ChatAction.ToggleReaction(item.messageId, emoji)) }, onPillLongClick = { - onAction(ChatAction.OpenReactors(item.messageId)) + onAction(ChatAction.OpenReactors(item.messageId, item.reactionPills)) }, onOpenPicker = { onAction(ChatAction.OpenReactionPicker(item.messageId)) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt new file mode 100644 index 0000000000..0056b9f91f --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt @@ -0,0 +1,180 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.MessagePolicy +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.reactions.ReactionPill +import com.flipcash.shared.chat.reactions.ReactionStrip +import com.flipcash.shared.chat.reactions.SelfReaction +import com.getcode.libs.emojis.reactions.EmojiPickerModel +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * The quick strip, the emoji picker and the reactors sheet are plain state: the strip is built in + * the same update that selects the bubble, and each sheet is opened by its own event and closed by + * its dismissal, dropping results that arrive for a sheet or query no longer showing. + */ +class ChatReactionsReducerTest { + + private val unbounded = MessagePolicy(editWindow = null, deleteWindow = null) + + private val inputs = ChatViewModel.QuickReactionInputs( + recentStats = emptyMap(), + catalogFill = emptyList(), + undrawable = emptySet(), + ) + + private fun bubble( + messageId: Long, + canReact: Boolean = true, + selfReactions: List = emptyList(), + ) = ChatListItem.ContentBubble( + messageId = messageId, + contentIndex = 0, + content = MessageContent.Text("hello"), + isFromSelf = false, + timestamp = Instant.fromEpochSeconds(1_000), + selfReactions = selfReactions, + canReact = canReact, + ) + + private fun reduce( + state: ChatViewModel.State, + event: ChatViewModel.Event, + ): ChatViewModel.State = ChatViewModel.updateStateForEvent(event)(state) + + private fun pill(emoji: String, count: Long) = + ReactionPill(emoji = emoji, count = count, selfReacted = false, pending = false) + + @Test + fun `selecting a bubble builds the strip in the same update, highlighting the viewer's reactions`() { + val squid = SelfReaction(emoji = "🦑", reactedAt = Instant.fromEpochSeconds(2_000)) + + val state = reduce( + ChatViewModel.State(messagePolicy = unbounded, quickReactionInputs = inputs), + ChatViewModel.Event.ToggleMessageSelection(bubble(1, selfReactions = listOf(squid))), + ) + + assertTrue(ReactionStrip.Entry(emoji = "🦑", highlighted = true) in state.quickReactionStrip) + } + + @Test + fun `no strip before the inputs load, then it fills in for the selection`() { + val selected = reduce( + ChatViewModel.State(messagePolicy = unbounded), + ChatViewModel.Event.ToggleMessageSelection(bubble(1)), + ) + assertEquals(emptyList(), selected.quickReactionStrip) + + val loaded = reduce(selected, ChatViewModel.Event.QuickReactionInputsLoaded(inputs)) + + assertTrue(loaded.quickReactionStrip.isNotEmpty()) + } + + @Test + fun `no strip for a message that can't be reacted to`() { + val state = reduce( + ChatViewModel.State(messagePolicy = unbounded, quickReactionInputs = inputs), + ChatViewModel.Event.ToggleMessageSelection(bubble(1, canReact = false)), + ) + + assertEquals(emptyList(), state.quickReactionStrip) + } + + @Test + fun `clearing the selection clears the strip`() { + val selected = reduce( + ChatViewModel.State(messagePolicy = unbounded, quickReactionInputs = inputs), + ChatViewModel.Event.ToggleMessageSelection(bubble(1)), + ) + + val cleared = reduce(selected, ChatViewModel.Event.ClearMessageSelection) + + assertEquals(emptyList(), cleared.quickReactionStrip) + } + + @Test + fun `a reaction landing on the selected message reaches the strip`() { + val selected = reduce( + ChatViewModel.State(messagePolicy = unbounded, quickReactionInputs = inputs), + ChatViewModel.Event.ToggleMessageSelection(bubble(1)), + ) + val squid = SelfReaction(emoji = "🦑", reactedAt = Instant.fromEpochSeconds(2_000)) + + val state = reduce(selected, ChatViewModel.Event.SelectionReactionsChanged(1, listOf(squid))) + + assertTrue(ReactionStrip.Entry(emoji = "🦑", highlighted = true) in state.quickReactionStrip) + } + + @Test + fun `a reaction on another message leaves the selection alone`() { + val selected = reduce( + ChatViewModel.State(messagePolicy = unbounded, quickReactionInputs = inputs), + ChatViewModel.Event.ToggleMessageSelection(bubble(1)), + ) + val squid = SelfReaction(emoji = "🦑", reactedAt = Instant.fromEpochSeconds(2_000)) + + val state = reduce(selected, ChatViewModel.Event.SelectionReactionsChanged(2, listOf(squid))) + + assertEquals(selected, state) + } + + @Test + fun `picker sections for a query already typed past are dropped`() { + val opened = reduce(ChatViewModel.State(), ChatViewModel.Event.OpenReactionPicker(1)) + val typed = reduce(opened, ChatViewModel.Event.EmojiPickerQueryChanged("cat")) + val sections = listOf(EmojiPickerModel.Section(id = "s", title = "S", entries = emptyList())) + + val stale = reduce(typed, ChatViewModel.Event.EmojiPickerSectionsLoaded(1, "", sections)) + val current = reduce(typed, ChatViewModel.Event.EmojiPickerSectionsLoaded(1, "cat", sections)) + + assertEquals(false, stale.emojiPicker?.loaded) + assertEquals(sections, current.emojiPicker?.sections) + assertEquals(true, current.emojiPicker?.loaded) + } + + @Test + fun `opening the reactors sheet seeds its pills from the ones pressed`() { + val pills = listOf(pill("👍", 3), pill("🔥", 1)) + + val state = reduce(ChatViewModel.State(), ChatViewModel.Event.OpenReactors(1, pills)) + + val reactors = assertNotNull(state.reactors) + assertEquals(pills, reactors.pills) + assertTrue(reactors.loading) + } + + @Test + fun `reactors updates for another message are dropped`() { + val opened = reduce(ChatViewModel.State(), ChatViewModel.Event.OpenReactors(1, emptyList())) + + val state = reduce( + opened, + ChatViewModel.Event.ReactorsUpdated( + messageId = 2, + pills = listOf(pill("👍", 1)), + rows = emptyList(), + loading = false, + hasMore = false, + ), + ) + + assertEquals(opened, state) + } + + @Test + fun `a late dismissal of an earlier reactors sheet leaves the open one`() { + val opened = reduce(ChatViewModel.State(), ChatViewModel.Event.OpenReactors(2, emptyList())) + + val stale = reduce(opened, ChatViewModel.Event.ReactorsDismissed(1)) + val closed = reduce(opened, ChatViewModel.Event.ReactorsDismissed(2)) + + assertNotNull(stale.reactors) + assertNull(closed.reactors) + } +} 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 eb2136951e..6209da5559 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 @@ -3,6 +3,7 @@ package com.flipcash.shared.chat.models import androidx.compose.runtime.staticCompositionLocalOf import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.reactions.ReactionPill import com.getcode.opencode.model.core.ID import com.getcode.solana.keys.Mint @@ -108,8 +109,8 @@ sealed interface ChatAction { /** Opens the full emoji picker for [messageId], from the pill row's or the quick strip's "+". */ data class OpenReactionPicker(val messageId: Long) : ChatAction - /** Opens who-reacted for [messageId], from a long-press on a pill. */ - data class OpenReactors(val messageId: Long) : ChatAction + /** Opens who-reacted for [messageId], from a long-press on one of its [pills]. */ + data class OpenReactors(val messageId: Long, val pills: List) : ChatAction /** * Asks the coordinator to refresh reaction state for [messageIds] — the transcript's own 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 3d5b54bdd4..3c56637ab9 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 @@ -3,6 +3,7 @@ package com.flipcash.shared.chat.models import com.flipcash.services.models.chat.MessageContent import com.flipcash.shared.chat.MessageCapability import com.flipcash.shared.chat.reactions.ReactionPill +import com.flipcash.shared.chat.reactions.SelfReaction import com.getcode.opencode.model.core.ID import kotlin.time.Instant @@ -103,6 +104,12 @@ sealed interface ChatListItem { * for a message with no reactions. */ val reactionPills: List = emptyList(), + /** + * The emoji the viewer reacted to this message with, from the same source as + * [reactionPills]. Unlike the pills, every row of a split message carries it: the quick + * strip is built from it for whichever row was pressed. + */ + val selfReactions: List = emptyList(), /** * Whether the viewer may react to this message — system messages and messages still * pending send are excluded. See `com.flipcash.shared.chat.canReact`. From 4ec77d71222feadffbaca6a5669d3c8095e221f8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 14:34:15 -0400 Subject: [PATCH 2/5] refactor(chat): give the emoji picker its own view model The picker's search and sections don't belong to the chat, so they move off ChatViewModel into EmojiPickerViewModel, scoped to the picker's nav entry. It owns the search field's TextFieldState, debounces it and loads sections with mapLatest, which drops a load the reader has typed past. The sheet reads the field directly, so there is no separate query in state and no open or dismiss events to pair up. Choosing an emoji still dispatches ChatViewModel.Event.ToggleReaction. --- .../flipcash/app/messenger/ChatFlowScreen.kt | 23 +++-- .../app/messenger/internal/ChatViewModel.kt | 75 ----------------- .../internal/screens/EmojiPickerViewModel.kt | 83 +++++++++++++++++++ .../internal/screens/MessengerScreen.kt | 1 - .../internal/screens/ReactionPickerSheet.kt | 17 +--- .../internal/ChatReactionsReducerTest.kt | 21 +---- .../screens/EmojiPickerReducerTest.kt | 34 ++++++++ 7 files changed, 133 insertions(+), 121 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerViewModel.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerReducerTest.kt 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 4908c40f3a..1d8d00a864 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 @@ -28,6 +28,7 @@ import com.flipcash.app.messenger.internal.GroupInviteViewModel import com.flipcash.app.messenger.internal.StartSendCashOnceReady import com.flipcash.app.messenger.internal.screens.GroupInviteSheet import com.flipcash.app.messenger.internal.screens.MessengerScreen +import com.flipcash.app.messenger.internal.screens.EmojiPickerViewModel import com.flipcash.app.messenger.internal.screens.ReactionPickerSheet import com.flipcash.app.messenger.internal.screens.ReactorsSheet import com.flipcash.app.messenger.internal.screens.cash.ChatAmountEntryContent @@ -359,25 +360,23 @@ private fun FlowEditGroupPictureScreen() { EditGroupPictureScreen(flowSharedViewModel()) } -/** The full emoji picker for [messageId] — a tap on any emoji toggles it and dismisses. */ +/** + * The full emoji picker for [messageId] — a tap on any emoji toggles it and dismisses. The search + * lives in the entry's own [EmojiPickerViewModel]; only the toggle goes to the chat. + */ @Composable private fun FlowReactionPickerScreen(messageId: Long) { - val viewModel = flowSharedViewModel() + val chatViewModel = flowSharedViewModel() + val viewModel = hiltViewModel() val dismissSheet = LocalBottomSheetDismissDispatcher.current val state by viewModel.stateFlow.collectAsStateWithLifecycle() - // OpenReactionPicker opened it; this entry leaving the back stack is what closes it. - DisposableEffect(viewModel, messageId) { - onDispose { viewModel.dispatchEvent(ChatViewModel.Event.EmojiPickerDismissed(messageId)) } - } - val picker = state.emojiPicker?.takeIf { it.messageId == messageId } ReactionPickerSheet( - query = picker?.query.orEmpty(), - sections = picker?.sections.orEmpty(), - loaded = picker?.loaded == true, - onQueryChanged = { viewModel.dispatchEvent(ChatViewModel.Event.EmojiPickerQueryChanged(it)) }, + searchState = state.searchFieldState, + sections = state.sections, + loaded = state.loaded, onSelected = { emoji -> - viewModel.dispatchEvent( + chatViewModel.dispatchEvent( ChatViewModel.Event.ToggleReaction(messageId, emoji, clearsSelection = true) ) dismissSheet() 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 538c8859dd..2d3da9fd25 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 @@ -111,7 +111,6 @@ import com.flipcash.shared.payments.ContactPaymentDelegate import com.flipcash.shared.payments.TipPaymentDelegate import com.getcode.libs.emojis.reactions.EmojiCatalogLoader import com.getcode.libs.emojis.reactions.EmojiDrawability -import com.getcode.libs.emojis.reactions.EmojiPickerModel import com.getcode.libs.emojis.reactions.RecentReactions import com.getcode.libs.emojis.reactions.RecentReactionsStore import com.getcode.manager.BottomBarAction @@ -160,7 +159,6 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart @@ -365,7 +363,6 @@ internal class ChatViewModel @Inject constructor( */ val quickReactionInputs: QuickReactionInputs? = null, /** The full emoji picker, while it's open. */ - val emojiPicker: EmojiPickerState? = null, /** The reactors sheet, while it's open. */ val reactors: ReactorsSheetState? = null, ) { @@ -652,22 +649,6 @@ internal class ChatViewModel @Inject constructor( val clearsSelection: Boolean = false, ) : Event - /** Opens the full emoji picker for [messageId], from a pill row's or the strip's "+". */ - data class OpenReactionPicker(val messageId: Long) : Event - - /** The picker's search text changed, already debounced by the sheet. */ - data class EmojiPickerQueryChanged(val query: String) : Event - - /** Internal: [sections] for [query] finished loading for the open picker. */ - data class EmojiPickerSectionsLoaded( - val messageId: Long, - val query: String, - val sections: List, - ) : Event - - /** The picker for [messageId] left the screen. */ - data class EmojiPickerDismissed(val messageId: Long) : Event - /** * Opens who-reacted for [messageId], from a long-press on a pill. [pills] are the ones the * reader pressed, so the sheet has a title before the live overlay is read. @@ -835,22 +816,6 @@ internal class ChatViewModel @Inject constructor( chatCoordinator.getReactorsPage(chatId, messageId, emoji, token) } - /** - * The full picker's sections for [query] (recents row + one section per catalog category, or a - * single search-results section — see [EmojiPickerModel.sections]). Loads the catalog on each - * call rather than caching it on the view model; the loader itself caches the parsed file, so - * this is cheap after the first call. - */ - private suspend fun emojiPickerSections(query: String): List { - val catalog = emojiCatalogLoader.load() - val undrawable = catalog.entries - .map { it.emoji } - .filterNot { EmojiDrawability.isDrawable(it) } - .toSet() - val recents = recentReactionsStore.rank(undrawable = undrawable) - return EmojiPickerModel.sections(catalog = catalog, undrawable = undrawable, recents = recents, query = query) - } - /** * What the quick strip is built from, other than the message's own reactions. Held in [State] * ahead of a long-press so the selection reducer can build the strip in the same update that @@ -887,17 +852,6 @@ internal class ChatViewModel @Inject constructor( val hasMore: Boolean = false, ) - /** The full emoji picker for [messageId], while it's open. */ - data class EmojiPickerState( - val messageId: Long, - /** The search text the sheet last reported, already debounced. */ - val query: String = "", - /** The sections for [query]; stale for a moment while a new query's load is in flight. */ - val sections: List = emptyList(), - /** False until the first load lands, which is what the sheet's spinner waits on. */ - val loaded: Boolean = false, - ) - /** * [userId]'s name and avatar source for the reactors sheet (decision 4), from what the cached * profile store, the member roster and the transcript's sender profiles hold right now, or null @@ -2034,17 +1988,6 @@ internal class ChatViewModel @Inject constructor( .onEach { dispatchEvent(it) } .launchIn(viewModelScope) - // The picker's sections follow its query. mapLatest drops a load the reader has already - // typed past. - stateFlow.map { state -> state.emojiPicker?.let { it.messageId to it.query } } - .distinctUntilChanged() - .filterNotNull() - .mapLatest { (messageId, query) -> - Event.EmojiPickerSectionsLoaded(messageId, query, emojiPickerSections(query)) - } - .onEach { dispatchEvent(it) } - .launchIn(viewModelScope) - initReactorsSheet() eventFlow.filterIsInstance() @@ -2963,24 +2906,6 @@ internal class ChatViewModel @Inject constructor( is Event.ToggleReaction -> { state -> if (event.clearsSelection) state.copy(selection = null) else state } - is Event.OpenReactionPicker -> { state -> - state.copy(emojiPicker = EmojiPickerState(messageId = event.messageId)) - } - is Event.EmojiPickerQueryChanged -> { state -> - state.copy(emojiPicker = state.emojiPicker?.copy(query = event.query)) - } - is Event.EmojiPickerSectionsLoaded -> { state -> - val picker = state.emojiPicker - // A load for a query already typed past, or a picker since closed, is dropped. - if (picker?.messageId != event.messageId || picker.query != event.query) { - state - } else { - state.copy(emojiPicker = picker.copy(sections = event.sections, loaded = true)) - } - } - is Event.EmojiPickerDismissed -> { state -> - if (state.emojiPicker?.messageId == event.messageId) state.copy(emojiPicker = null) else state - } is Event.OpenReactors -> { state -> state.copy(reactors = ReactorsSheetState(messageId = event.messageId, pills = event.pills)) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerViewModel.kt new file mode 100644 index 0000000000..e86f5192da --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerViewModel.kt @@ -0,0 +1,83 @@ +package com.flipcash.app.messenger.internal.screens + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.viewModelScope +import com.flipcash.libs.coroutines.DispatcherProvider +import com.getcode.libs.emojis.reactions.EmojiDrawability +import com.getcode.libs.emojis.reactions.EmojiCatalogLoader +import com.getcode.libs.emojis.reactions.EmojiPickerModel +import com.getcode.libs.emojis.reactions.RecentReactionsStore +import com.getcode.view.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onEach + +/** + * The full emoji picker's search and sections. Scoped to the picker's own nav entry + * (`ChatStep.ReactionPicker`), so it lives exactly as long as the sheet and starts empty each time + * it opens. Choosing an emoji is a chat action and stays on `ChatViewModel.Event.ToggleReaction`. + */ +@HiltViewModel +class EmojiPickerViewModel @Inject constructor( + dispatchers: DispatcherProvider, + private val emojiCatalogLoader: EmojiCatalogLoader, + private val recentReactionsStore: RecentReactionsStore, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, + defaultDispatcher = dispatchers.Default, +) { + data class State( + val searchFieldState: TextFieldState = TextFieldState(), + /** The sections for the search text; stale for a moment while a new search's load is in flight. */ + val sections: List = emptyList(), + /** False until the first load lands, which is what the sheet's spinner waits on. */ + val loaded: Boolean = false, + ) + + sealed interface Event { + /** Internal: the sections for the settled search text finished loading. */ + data class SectionsLoaded(val sections: List) : Event + } + + init { + // mapLatest drops a load the reader has already typed past. + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + snapshotFlow { stateFlow.value.searchFieldState.text.toString() } + .distinctUntilChanged() + .debounce(150) + .mapLatest { query -> Event.SectionsLoaded(sections(query)) } + .onEach { dispatchEvent(it) } + .launchIn(viewModelScope) + } + + /** + * The sections for [query] (recents row + one section per catalog category, or a single + * search-results section — see [EmojiPickerModel.sections]). The loader caches the parsed + * catalog, so reloading it per query is cheap after the first. + */ + private suspend fun sections(query: String): List { + val catalog = emojiCatalogLoader.load() + val undrawable = catalog.entries + .map { it.emoji } + .filterNot { EmojiDrawability.isDrawable(it) } + .toSet() + val recents = recentReactionsStore.rank(undrawable = undrawable) + return EmojiPickerModel.sections(catalog = catalog, undrawable = undrawable, recents = recents, query = query) + } + + companion object { + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + when (event) { + is Event.SectionsLoaded -> { state -> state.copy(sections = event.sections, loaded = true) } + } + } + } +} 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 ed04e4b52c..4549df158e 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 @@ -231,7 +231,6 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } is ChatAction.OpenReactionPicker -> { - viewModel.dispatchEvent(ChatViewModel.Event.OpenReactionPicker(action.messageId)) navigator.push(ChatStep.ReactionPicker(action.messageId)) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt index bcd292c7c1..5dedfa85f7 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt @@ -42,9 +42,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -82,8 +80,6 @@ import dev.chrisbanes.haze.blur.hazeBlur import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState import kotlin.math.roundToInt -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch /** @@ -96,17 +92,16 @@ import kotlinx.coroutines.launch */ @Composable internal fun ReactionPickerSheet( - query: String, + searchState: TextFieldState, sections: List, loaded: Boolean, - onQueryChanged: (String) -> Unit, onSelected: (String) -> Unit, onDismiss: () -> Unit, ) { val gridState = rememberLazyListState() AllowSheetExpansionWhenScrollable(gridState) - val searchState = remember { TextFieldState() } + val query = searchState.text.toString() Column(modifier = Modifier.fillMaxWidth()) { SearchField( @@ -117,14 +112,6 @@ internal fun ReactionPickerSheet( .padding(top = 16.dp, bottom = 12.dp), ) - val currentOnQueryChanged by rememberUpdatedState(onQueryChanged) - LaunchedEffect(searchState) { - snapshotFlow { searchState.text.toString() } - .distinctUntilChanged() - .debounce(150) - .collect { currentOnQueryChanged(it) } - } - when { !loaded -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt index 0056b9f91f..0ef78a0cce 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt @@ -6,7 +6,6 @@ import com.flipcash.shared.chat.models.ChatListItem import com.flipcash.shared.chat.reactions.ReactionPill import com.flipcash.shared.chat.reactions.ReactionStrip import com.flipcash.shared.chat.reactions.SelfReaction -import com.getcode.libs.emojis.reactions.EmojiPickerModel import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -15,9 +14,9 @@ import kotlin.test.assertTrue import kotlin.time.Instant /** - * The quick strip, the emoji picker and the reactors sheet are plain state: the strip is built in - * the same update that selects the bubble, and each sheet is opened by its own event and closed by - * its dismissal, dropping results that arrive for a sheet or query no longer showing. + * The quick strip and the reactors sheet are plain state: the strip is built in the same update + * that selects the bubble, and the sheet is opened by its own event and closed by its dismissal, + * dropping results that arrive for a sheet no longer showing. */ class ChatReactionsReducerTest { @@ -124,20 +123,6 @@ class ChatReactionsReducerTest { assertEquals(selected, state) } - @Test - fun `picker sections for a query already typed past are dropped`() { - val opened = reduce(ChatViewModel.State(), ChatViewModel.Event.OpenReactionPicker(1)) - val typed = reduce(opened, ChatViewModel.Event.EmojiPickerQueryChanged("cat")) - val sections = listOf(EmojiPickerModel.Section(id = "s", title = "S", entries = emptyList())) - - val stale = reduce(typed, ChatViewModel.Event.EmojiPickerSectionsLoaded(1, "", sections)) - val current = reduce(typed, ChatViewModel.Event.EmojiPickerSectionsLoaded(1, "cat", sections)) - - assertEquals(false, stale.emojiPicker?.loaded) - assertEquals(sections, current.emojiPicker?.sections) - assertEquals(true, current.emojiPicker?.loaded) - } - @Test fun `opening the reactors sheet seeds its pills from the ones pressed`() { val pills = listOf(pill("👍", 3), pill("🔥", 1)) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerReducerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerReducerTest.kt new file mode 100644 index 0000000000..afc3f1e603 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerReducerTest.kt @@ -0,0 +1,34 @@ +package com.flipcash.app.messenger.internal.screens + +import com.getcode.libs.emojis.reactions.EmojiPickerModel +import kotlin.test.Test +import kotlin.test.assertEquals + +class EmojiPickerReducerTest { + + private fun reduce( + state: EmojiPickerViewModel.State, + event: EmojiPickerViewModel.Event, + ): EmojiPickerViewModel.State = EmojiPickerViewModel.updateStateForEvent(event)(state) + + private fun sections(id: String) = listOf(EmojiPickerModel.Section(id = id, title = id, entries = emptyList())) + + @Test + fun `the picker is loading until its first sections land`() { + val state = EmojiPickerViewModel.State() + + assertEquals(false, state.loaded) + assertEquals(emptyList(), state.sections) + } + + @Test + fun `loaded sections replace the ones showing and keep the search field`() { + val first = reduce(EmojiPickerViewModel.State(), EmojiPickerViewModel.Event.SectionsLoaded(sections("all"))) + + val second = reduce(first, EmojiPickerViewModel.Event.SectionsLoaded(sections("search"))) + + assertEquals(sections("search"), second.sections) + assertEquals(true, second.loaded) + assertEquals(first.searchFieldState, second.searchFieldState) + } +} From fa494b808963740f800ec80638a69ad68480e6e7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 14:41:04 -0400 Subject: [PATCH 3/5] test(chat): cover the emoji picker dropping a search typed past EmojiPickerViewModelTest holds each section load open, finishes the "dog" search before the earlier "cat" one, and checks the late "cat" results don't replace "dog". Swapping mapLatest for flatMapMerge makes it fail with [cat face]. Robolectric's Paint reports no emoji glyphs, which empties every section, so the view model takes a GlyphProbe through an internal constructor. The @Inject constructor still passes PaintGlyphProbe. --- .../internal/screens/EmojiPickerViewModel.kt | 13 ++- .../screens/EmojiPickerViewModelTest.kt | 104 ++++++++++++++++++ 2 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerViewModelTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerViewModel.kt index e86f5192da..dd71fa4e8b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerViewModel.kt @@ -25,15 +25,24 @@ import kotlinx.coroutines.flow.onEach * it opens. Choosing an emoji is a chat action and stays on `ChatViewModel.Event.ToggleReaction`. */ @HiltViewModel -class EmojiPickerViewModel @Inject constructor( +class EmojiPickerViewModel internal constructor( dispatchers: DispatcherProvider, private val emojiCatalogLoader: EmojiCatalogLoader, private val recentReactionsStore: RecentReactionsStore, + // A seam for tests: Robolectric's Paint reports no emoji glyphs, which would empty every section. + private val glyphProbe: EmojiDrawability.GlyphProbe, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, defaultDispatcher = dispatchers.Default, ) { + @Inject + constructor( + dispatchers: DispatcherProvider, + emojiCatalogLoader: EmojiCatalogLoader, + recentReactionsStore: RecentReactionsStore, + ) : this(dispatchers, emojiCatalogLoader, recentReactionsStore, EmojiDrawability.PaintGlyphProbe) + data class State( val searchFieldState: TextFieldState = TextFieldState(), /** The sections for the search text; stale for a moment while a new search's load is in flight. */ @@ -67,7 +76,7 @@ class EmojiPickerViewModel @Inject constructor( val catalog = emojiCatalogLoader.load() val undrawable = catalog.entries .map { it.emoji } - .filterNot { EmojiDrawability.isDrawable(it) } + .filterNot { EmojiDrawability.isDrawable(it, glyphProbe) } .toSet() val recents = recentReactionsStore.rank(undrawable = undrawable) return EmojiPickerModel.sections(catalog = catalog, undrawable = undrawable, recents = recents, query = query) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerViewModelTest.kt new file mode 100644 index 0000000000..c72ee77416 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerViewModelTest.kt @@ -0,0 +1,104 @@ +package com.flipcash.app.messenger.internal.screens + +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.snapshots.Snapshot +import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.getcode.libs.emojis.reactions.EmojiCatalogLoader +import com.getcode.libs.emojis.reactions.EmojiPickerModel +import com.getcode.libs.emojis.reactions.RecentReactions +import com.getcode.libs.emojis.reactions.RecentReactionsStore +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals + +/** + * A search's sections load off the main thread, so a slow load for text the reader has since + * typed past can finish after the load for what they typed next. The picker must keep showing the + * newer search's results rather than let the late one overwrite them. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class EmojiPickerViewModelTest { + + private val scheduler = TestCoroutineScheduler() + private val dispatcher = UnconfinedTestDispatcher(scheduler) + + private val catalog = EmojiCatalogLoader(dispatcher = dispatcher) { + """ + { + "categories": ["Animals"], + "emoji": [ + {"emoji": "🐱", "name": "cat face", "category": "Animals", "version": "1.0", "skinTone": false}, + {"emoji": "🐶", "name": "dog face", "category": "Animals", "version": "1.0", "skinTone": false} + ] + } + """.trimIndent() + } + + /** Holds each section load at its recents read until the test releases it, in call order. */ + private val recents = object : RecentReactionsStore { + val pending = mutableListOf>() + + override suspend fun record(emoji: String) = Unit + override suspend fun stats(): Map = emptyMap() + override suspend fun rank(undrawable: Set, limit: Int): List { + CompletableDeferred().also { pending += it }.await() + return emptyList() + } + } + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun TestScope.search(model: EmojiPickerViewModel, text: String) { + model.stateFlow.value.searchFieldState.setTextAndPlaceCursorAtEnd(text) + Snapshot.sendApplyNotifications() + advanceUntilIdle() + } + + private fun EmojiPickerViewModel.resultNames(): List = + stateFlow.value.sections.single { it.id == EmojiPickerModel.SEARCH_RESULTS_ID }.entries.map { it.name } + + @Test + fun `a search typed past that finishes late does not replace the newer results`() = runTest(scheduler) { + val model = EmojiPickerViewModel( + dispatchers = TestDispatcherProvider(dispatcher), + emojiCatalogLoader = catalog, + recentReactionsStore = recents, + glyphProbe = { true }, + ) + advanceUntilIdle() + recents.pending[0].complete(Unit) // the opening, unsearched load + advanceUntilIdle() + + search(model, "cat") + val cat = recents.pending[1] + search(model, "dog") + val dog = recents.pending[2] + + dog.complete(Unit) + advanceUntilIdle() + assertEquals(listOf("dog face"), model.resultNames()) + + cat.complete(Unit) + advanceUntilIdle() + assertEquals(listOf("dog face"), model.resultNames()) + } +} From 4019e4439ee812180a26d1ba5625bcea88fd20d0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 15:23:59 -0400 Subject: [PATCH 4/5] refactor(messenger): give the reactors sheet its own view model The reactors sheet's rows, paging and pills lived on ChatViewModel, which fed them to the sheet through OpenReactors, LoadMoreReactors, ReactorsDismissed and a ShowReactorProfile round trip. ReactorsViewModel is scoped to the ChatStep.Reactors entry instead: it opens on the chat and message from the nav key, follows that message's live pills, owns the ReactorsPrefetchCache, and resolves names and avatars. The sheet pushes a reactor's profile itself. ChatStep.Reactors now carries the chat id, and OpenReactors drops the pills it carried for the old sheet title. --- .../com/flipcash/app/core/chat/ChatStep.kt | 2 +- .../flipcash/app/messenger/ChatFlowScreen.kt | 40 ++-- .../app/messenger/internal/ChatViewModel.kt | 208 ----------------- .../internal/screens/MessengerScreen.kt | 4 +- .../internal/screens/ReactorsSheet.kt | 7 +- .../internal/screens/ReactorsViewModel.kt | 218 ++++++++++++++++++ .../internal/screens/components/MessageRow.kt | 2 +- .../internal/ChatReactionsReducerTest.kt | 50 +--- .../internal/screens/ReactorsReducerTest.kt | 68 ++++++ .../flipcash/shared/chat/models/ChatAction.kt | 5 +- .../chat/reactions/ReactorsPrefetchCache.kt | 15 +- .../reactions/ReactorsPrefetchCacheTest.kt | 7 +- 12 files changed, 320 insertions(+), 306 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsViewModel.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsReducerTest.kt 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 ae32609a1b..a2bb6c3c6e 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 @@ -136,5 +136,5 @@ sealed interface ChatStep : FlowStep, Parcelable { */ @Parcelize @Serializable - data class Reactors(val messageId: Long) : ChatStep, Sheet, HalfSheet + data class Reactors(val chatId: ChatId, val messageId: Long) : ChatStep, Sheet, HalfSheet } 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 1d8d00a864..94b0bf6d97 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 @@ -2,7 +2,6 @@ package com.flipcash.app.messenger import android.os.Parcelable import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -31,6 +30,7 @@ import com.flipcash.app.messenger.internal.screens.MessengerScreen import com.flipcash.app.messenger.internal.screens.EmojiPickerViewModel import com.flipcash.app.messenger.internal.screens.ReactionPickerSheet import com.flipcash.app.messenger.internal.screens.ReactorsSheet +import com.flipcash.app.messenger.internal.screens.ReactorsViewModel 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 @@ -40,6 +40,7 @@ import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupNameScr import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupPictureScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupScreen import com.getcode.navigation.annotatedEntry +import com.flipcash.services.models.chat.ChatId import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.FlowHost import com.getcode.navigation.flow.flowSharedViewModel @@ -128,7 +129,7 @@ private fun chatEntryProvider( FlowReactionPickerScreen(step.messageId) } annotatedEntry { step -> - FlowReactorsScreen(step.messageId) + FlowReactorsScreen(step.chatId, step.messageId) } } @@ -161,12 +162,6 @@ private fun FlowConversationScreen( } } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .collect { navigator.push(ChatStep.Profile(contact = it.participant)) } - } - LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() @@ -386,30 +381,29 @@ private fun FlowReactionPickerScreen(messageId: Long) { } /** - * Who reacted to [messageId], and with what. Tapping a row dismisses this sheet first; the profile - * opens from [FlowConversationScreen] once [ChatViewModel.Event.ShowReactorProfile] names the - * participant, since this entry is gone by then. + * Who reacted to [messageId], and with what, from the entry's own [ReactorsViewModel]. Tapping a + * named row dismisses this sheet, then opens that person's profile on the chat. */ @Composable -private fun FlowReactorsScreen(messageId: Long) { - val viewModel = flowSharedViewModel() +private fun FlowReactorsScreen(chatId: ChatId, messageId: Long) { + val viewModel = hiltViewModel() + val navigator = LocalCodeNavigator.current val dismissSheet = LocalBottomSheetDismissDispatcher.current val state by viewModel.stateFlow.collectAsStateWithLifecycle() - // OpenReactors opened it; this entry leaving the back stack is what closes it. - DisposableEffect(viewModel, messageId) { - onDispose { viewModel.dispatchEvent(ChatViewModel.Event.ReactorsDismissed(messageId)) } + LaunchedEffect(viewModel, chatId, messageId) { + viewModel.dispatchEvent(ReactorsViewModel.Event.Open(chatId, messageId)) } - val reactors = state.reactors?.takeIf { it.messageId == messageId } ReactorsSheet( - pills = reactors?.pills.orEmpty(), - rows = reactors?.rows.orEmpty(), - loading = reactors?.loading ?: true, - hasMore = reactors?.hasMore == true, - onLoadMore = { viewModel.dispatchEvent(ChatViewModel.Event.LoadMoreReactors) }, + pills = state.pills, + rows = state.rows, + loading = state.loading, + hasMore = state.hasMore, + onLoadMore = { viewModel.dispatchEvent(ReactorsViewModel.Event.LoadMore) }, onOpenProfile = { userId -> + val profile = state.rows.firstOrNull { it.userId == userId }?.display?.profile dismissSheet() - viewModel.dispatchEvent(ChatViewModel.Event.OpenReactorProfile(userId)) + if (profile != null) navigator.push(ChatStep.Profile(contact = ChatParticipant.TipUser(userId, profile))) }, onDismiss = dismissSheet, ) 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 2d3da9fd25..12c72be038 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 @@ -98,9 +98,6 @@ import com.flipcash.shared.chat.reactions.ReactionError import com.flipcash.shared.chat.reactions.ReactionPill import com.flipcash.shared.chat.reactions.ReactionStrip import com.flipcash.shared.chat.reactions.ReactionStripComposer -import com.flipcash.shared.chat.reactions.ReactorNameResolver -import com.flipcash.shared.chat.reactions.ReactorsListModel -import com.flipcash.shared.chat.reactions.ReactorsPrefetchCache import com.flipcash.shared.chat.reactions.SelfReaction import com.flipcash.shared.chat.readOnly import com.flipcash.shared.chat.resolveCapabilities @@ -362,9 +359,6 @@ internal class ChatViewModel @Inject constructor( * update that lifts the bubble. */ val quickReactionInputs: QuickReactionInputs? = null, - /** The full emoji picker, while it's open. */ - /** The reactors sheet, while it's open. */ - val reactors: ReactorsSheetState? = null, ) { /** * The DM counterparty, or `null` for a group. @@ -649,33 +643,6 @@ internal class ChatViewModel @Inject constructor( val clearsSelection: Boolean = false, ) : Event - /** - * Opens who-reacted for [messageId], from a long-press on a pill. [pills] are the ones the - * reader pressed, so the sheet has a title before the live overlay is read. - */ - data class OpenReactors(val messageId: Long, val pills: List) : Event - - /** Internal: the open reactors sheet's pills, rows or paging moved. */ - data class ReactorsUpdated( - val messageId: Long, - val pills: List, - val rows: List, - val loading: Boolean, - val hasMore: Boolean, - ) : Event - - /** The reactors sheet scrolled its last row into view. */ - data object LoadMoreReactors : Event - - /** A reactors-sheet row was tapped. */ - data class OpenReactorProfile(val userId: ID) : Event - - /** Internal: [participant] is who [OpenReactorProfile] named; the screen pushes the profile. */ - data class ShowReactorProfile(val participant: ChatParticipant.TipUser) : Event - - /** The reactors sheet for [messageId] left the screen. */ - data class ReactorsDismissed(val messageId: Long) : Event - /** Internal: the quick strip's catalog and recents (re)loaded. */ data class QuickReactionInputsLoaded(val inputs: QuickReactionInputs) : Event @@ -801,21 +768,6 @@ internal class ChatViewModel @Inject constructor( senderProfiles.value?.get(userId.hexEncodedString()) ?.let { ChatParticipant.TipUser(userId, it) } - /** - * One [ReactorsListModel] per messageId, started when [State.reactors] names a message (see - * [initReactorsSheet]) rather than on the reactors sheet's own composition — loading has to - * begin on the pill long-press itself, and the sheet is a fresh Compose tree on every push. - * `getReactorsPage` is captured against whatever [ChatId] is current at call time rather than - * the chat this view model was built for; a chat never changes under one instance in practice, - * but reading [stateFlow] here keeps the cache correct if that ever changes. - */ - private val reactorsPrefetchCache = ReactorsPrefetchCache(scope = viewModelScope) { messageId, emoji, token -> - val chatId = stateFlow.value.chatId ?: return@ReactorsPrefetchCache Result.failure( - IllegalStateException("No active chat to fetch reactors for") - ) - chatCoordinator.getReactorsPage(chatId, messageId, emoji, token) - } - /** * What the quick strip is built from, other than the message's own reactions. Held in [State] * ahead of a long-press so the selection reducer can build the strip in the same update that @@ -828,126 +780,6 @@ internal class ChatViewModel @Inject constructor( val undrawable: Set, ) - /** A reactors-sheet row's resolved identity: the name to show (decision 4's precedence, - * "You" for the viewer), and the profile to draw an avatar from when one is known. */ - data class ReactorDisplay(val name: String, val profile: UserProfile?) - - /** One person in the reactors sheet: every emoji they used, and who they are once resolved. */ - data class ReactorRow( - val userId: ID, - val emojis: List, - /** Null until a source names them; the row draws a placeholder and isn't tappable. */ - val display: ReactorDisplay?, - ) - - /** The reactors sheet for [messageId], while it's open. */ - data class ReactorsSheetState( - val messageId: Long, - /** [messageId]'s pills, live — the sheet's title total and summary row (decision 4). */ - val pills: List = emptyList(), - val rows: List = emptyList(), - /** True from the open until the first round lands, and while any later page is in flight. */ - val loading: Boolean = true, - /** Whether any of the message's emojis still has an unfetched page. */ - val hasMore: Boolean = false, - ) - - /** - * [userId]'s name and avatar source for the reactors sheet (decision 4), from what the cached - * profile store, the member roster and the transcript's sender profiles hold right now, or null - * when none of them names this person. The viewer shows as "You" over their own picture, so - * their avatar isn't blank, rather than under their own profile name. - */ - private fun reactorDisplay( - userId: ID, - cachedProfiles: Map, - members: List, - sentProfiles: Map?, - ): ReactorDisplay? { - val selfUserId = userManager.accountId - val cached = cachedProfiles[userId.hexEncodedString()] - val name = ReactorNameResolver.resolve( - userId = userId, - selfUserId = selfUserId, - selfLabel = resources.getString(R.string.title_you), - cachedProfile = cached, - members = members, - senderProfiles = sentProfiles.orEmpty(), - ) ?: return null - val profile = if (selfUserId != null && userId == selfUserId) { - userManager.profile - } else { - cached - ?: members.firstOrNull { it.userId == userId }?.userProfile - ?: sentProfiles?.get(userId.hexEncodedString()) - } - return ReactorDisplay(name = name, profile = profile) - } - - /** [ChatParticipant] for [userId] to open a profile from the reactors sheet, unlike - * [memberParticipant] this also covers a reactor absent from [senderProfiles] by falling back - * to whatever the cached profile store or member roster already has. */ - private suspend fun reactorParticipant(userId: ID): ChatParticipant.TipUser? { - memberParticipant(userId)?.let { return it } - val chatId = stateFlow.value.chatId - val fromMembers = chatId?.let { id -> - chatCoordinator.observeMembers(id).first().firstOrNull { it.userId == userId }?.userProfile - } - val profile = fromMembers ?: userProfileDataSource.getCachedProfile(userId) - return profile?.let { ChatParticipant.TipUser(userId, it) } - } - - /** - * Drives [State.reactors] while the sheet is open. The pills are the overlay's, live, so a - * reaction added or removed under the open sheet changes them; each change goes to - * [reactorsPrefetchCache], which refetches the rows only when a count moved. The fetch starts - * on [Event.OpenReactors] itself, before the navigator pushes the sheet, so loading begins on - * the long-press rather than on the sheet's own composition. - * - * A reactor none of the sources names yet is asked for once, the same fallback - * [resolveSenderName] uses for the transcript; the row fills in when the profile lands. - */ - @OptIn(ExperimentalCoroutinesApi::class) - private fun initReactorsSheet() { - stateFlow.map { it.reactors?.messageId } - .distinctUntilChanged() - .flatMapLatest { messageId -> - val opened = stateFlow.value.reactors - if (messageId == null || opened == null) return@flatMapLatest emptyFlow() - val pills = reactionOverlay.map { it[messageId]?.pills ?: opened.pills } - .distinctUntilChanged() - .onEach { reactorsPrefetchCache.start(messageId, it) } - val members = stateFlow.mapNotNull { it.chatId } - .distinctUntilChanged() - .flatMapLatest { chatCoordinator.observeMembers(it) } - .onStart { emit(emptyList()) } - val requested = mutableSetOf() - combine( - pills, - reactorsPrefetchCache.rows(messageId), - reactorsPrefetchCache.loading(messageId), - combine(userProfileDataSource.observeProfiles(), members, senderProfiles, ::Triple), - ) { currentPills, rows, loading, (cachedProfiles, memberList, sentProfiles) -> - val resolved = rows.map { row -> - val display = reactorDisplay(row.userId, cachedProfiles, memberList, sentProfiles) - if (display == null && requested.add(row.userId)) { - chatCoordinator.requestSenderProfile(row.userId) - } - ReactorRow(userId = row.userId, emojis = row.emojis, display = display) - } - Event.ReactorsUpdated( - messageId = messageId, - pills = currentPills, - rows = resolved, - loading = loading, - hasMore = reactorsPrefetchCache.hasMore(messageId), - ) - } - } - .onEach { dispatchEvent(it) } - .launchIn(viewModelScope) - } - /** * How a drawn card reaches the lookup. Provided to the transcript, read by `LinkCardView`. * @@ -1988,22 +1820,6 @@ internal class ChatViewModel @Inject constructor( .onEach { dispatchEvent(it) } .launchIn(viewModelScope) - initReactorsSheet() - - eventFlow.filterIsInstance() - .onEach { - val messageId = stateFlow.value.reactors?.messageId ?: return@onEach - reactorsPrefetchCache.loadMoreIfNeeded(messageId) - } - .launchIn(viewModelScope) - - eventFlow.filterIsInstance() - .onEach { event -> - val participant = reactorParticipant(event.userId) ?: return@onEach - dispatchEvent(Event.ShowReactorProfile(participant)) - } - .launchIn(viewModelScope) - // See ReactionRefreshPlanner and MessageList's reaction-refresh effects: the transcript // computes which ids need a refresh (the newest window on open/resume, Room-sourced pages // as they page in) and hands the batch down here — this just forwards it to the @@ -2906,30 +2722,6 @@ internal class ChatViewModel @Inject constructor( is Event.ToggleReaction -> { state -> if (event.clearsSelection) state.copy(selection = null) else state } - is Event.OpenReactors -> { state -> - state.copy(reactors = ReactorsSheetState(messageId = event.messageId, pills = event.pills)) - } - is Event.ReactorsUpdated -> { state -> - val reactors = state.reactors - if (reactors?.messageId != event.messageId) { - state - } else { - state.copy( - reactors = reactors.copy( - pills = event.pills, - rows = event.rows, - loading = event.loading, - hasMore = event.hasMore, - ) - ) - } - } - Event.LoadMoreReactors -> { state -> state } - is Event.OpenReactorProfile -> { state -> state } - is Event.ShowReactorProfile -> { state -> state } - is Event.ReactorsDismissed -> { state -> - if (state.reactors?.messageId == event.messageId) state.copy(reactors = null) else state - } is Event.QuickReactionInputsLoaded -> { state -> state.copy(quickReactionInputs = event.inputs).withQuickReactionStrip() } 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 4549df158e..756d7cccd3 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 @@ -235,8 +235,8 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } is ChatAction.OpenReactors -> { - viewModel.dispatchEvent(ChatViewModel.Event.OpenReactors(action.messageId, action.pills)) - navigator.push(ChatStep.Reactors(action.messageId)) + val chatId = state.chatId + if (chatId != null) navigator.push(ChatStep.Reactors(chatId, action.messageId)) } is ChatAction.RefreshReactionIds -> { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsSheet.kt index 25e7cbf83d..15c593c251 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsSheet.kt @@ -52,7 +52,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.features.messenger.R import com.flipcash.services.models.handle import com.flipcash.shared.chat.reactions.ReactionPill @@ -72,7 +71,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged @Composable internal fun ReactorsSheet( pills: List, - rows: List, + rows: List, loading: Boolean, hasMore: Boolean, onLoadMore: () -> Unit, @@ -140,7 +139,7 @@ internal fun ReactorsSheet( @Composable private fun LoadMoreOnLastRowVisible( listState: LazyListState, - rows: List, + rows: List, hasMore: Boolean, onLoadMore: () -> Unit, ) { @@ -197,7 +196,7 @@ private fun SummaryPillRow(pills: List) { @Composable private fun ReactorRow( emojis: List, - display: ChatViewModel.ReactorDisplay?, + display: ReactorsViewModel.ReactorDisplay?, onClick: () -> Unit, ) { BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsViewModel.kt new file mode 100644 index 0000000000..96a0d8681a --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsViewModel.kt @@ -0,0 +1,218 @@ +package com.flipcash.app.messenger.internal.screens + +import androidx.lifecycle.viewModelScope +import com.flipcash.app.persistence.sources.UserProfileDataSource +import com.flipcash.features.messenger.R +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.MessageReactions +import com.flipcash.shared.chat.reactions.ReactionPill +import com.flipcash.shared.chat.reactions.ReactorNameResolver +import com.flipcash.shared.chat.reactions.ReactorsPrefetchCache +import com.getcode.opencode.model.core.ID +import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.hexEncodedString +import com.getcode.view.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart + +/** + * Who reacted to one message, and with what. Scoped to the reactors sheet's own nav entry + * (`ChatStep.Reactors`), so the rows are fetched when the sheet opens and dropped when it closes. + * Opening a reactor's profile is navigation, which the sheet does itself from a row's profile. + */ +@HiltViewModel +class ReactorsViewModel @Inject constructor( + private val chatCoordinator: ChatCoordinator, + private val userManager: UserManager, + private val resources: ResourceHelper, + private val userProfileDataSource: UserProfileDataSource, + dispatchers: DispatcherProvider, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, + defaultDispatcher = dispatchers.Default, +) { + + /** A reactors-sheet row's resolved identity: the name to show (decision 4's precedence, + * "You" for the viewer), and the profile to draw an avatar from when one is known. */ + data class ReactorDisplay(val name: String, val profile: UserProfile?) + + /** One person in the reactors sheet: every emoji they used, and who they are once resolved. */ + data class ReactorRow( + val userId: ID, + val emojis: List, + /** Null until a source names them; the row draws a placeholder and isn't tappable. */ + val display: ReactorDisplay?, + ) + + data class State( + val chatId: ChatId? = null, + val messageId: Long? = null, + /** The message's pills, live — the sheet's title total and summary row (decision 4). */ + val pills: List = emptyList(), + val rows: List = emptyList(), + /** True from the open until the first round lands, and while any later page is in flight. */ + val loading: Boolean = true, + /** Whether any of the message's emojis still has an unfetched page. */ + val hasMore: Boolean = false, + ) + + sealed interface Event { + /** The sheet opened on [messageId] in [chatId]. */ + data class Open(val chatId: ChatId, val messageId: Long) : Event + + /** Internal: the pills, rows or paging moved. */ + data class Updated( + val pills: List, + val rows: List, + val loading: Boolean, + val hasMore: Boolean, + ) : Event + + /** The sheet scrolled its last row into view. */ + data object LoadMore : Event + } + + private val cache = ReactorsPrefetchCache(scope = viewModelScope) { messageId, emoji, token -> + val chatId = stateFlow.value.chatId ?: return@ReactorsPrefetchCache Result.failure( + IllegalStateException("No chat to fetch reactors for") + ) + chatCoordinator.getReactorsPage(chatId, messageId, emoji, token) + } + + init { + initRows() + + eventFlow.filterIsInstance() + .onEach { stateFlow.value.messageId?.let(cache::loadMoreIfNeeded) } + .launchIn(viewModelScope) + } + + /** + * The pills follow the chat's live reactions, so a reaction added or removed under the open + * sheet changes them; a message the live overlay doesn't hold yet shows its stored reactions, + * the same fallback the transcript draws its pills from. Each change goes to [cache], which + * refetches the rows only when a count moved. + * + * A reactor none of the sources names yet is asked for once, the same fallback the transcript + * uses for a sender; the row fills in when the profile lands. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private fun initRows() { + stateFlow.map { state -> state.chatId?.let { it to state.messageId } } + .distinctUntilChanged() + .flatMapLatest { target -> + val (chatId, messageId) = target ?: return@flatMapLatest emptyFlow() + if (messageId == null) return@flatMapLatest emptyFlow() + val stored = flow { + emit(MessageReactions.from(chatCoordinator.getMessage(chatId, messageId)?.reactions).pills) + } + val pills = combine(stored, chatCoordinator.observeChatReactions(chatId)) { fallback, live -> + live[messageId]?.pills ?: fallback + } + .distinctUntilChanged() + .onEach { cache.start(messageId, it) } + val members = chatCoordinator.observeMembers(chatId).onStart { emit(emptyList()) } + val sources = combine( + userProfileDataSource.observeProfiles(), + members, + chatCoordinator.observeSenderProfiles().onStart { emit(emptyMap()) }, + ::Triple, + ) + val requested = mutableSetOf() + combine( + pills, + cache.rows(messageId), + cache.loading(messageId), + sources, + ) { currentPills, rows, loading, (cachedProfiles, memberList, senderProfiles) -> + val resolved = rows.map { row -> + val display = display(row.userId, cachedProfiles, memberList, senderProfiles) + if (display == null && requested.add(row.userId)) { + chatCoordinator.requestSenderProfile(row.userId) + } + ReactorRow(userId = row.userId, emojis = row.emojis, display = display) + } + Event.Updated( + pills = currentPills, + rows = resolved, + loading = loading, + hasMore = cache.hasMore(messageId), + ) + } + } + .onEach { dispatchEvent(it) } + .launchIn(viewModelScope) + } + + /** + * [userId]'s name and avatar source (decision 4), from what the cached profile store, the + * member roster and the transcript's sender profiles hold right now, or null when none of them + * names this person. The viewer shows as "You" over their own picture, so their avatar isn't + * blank, rather than under their own profile name. + */ + private fun display( + userId: ID, + cachedProfiles: Map, + members: List, + senderProfiles: Map, + ): ReactorDisplay? { + val selfUserId = userManager.accountId + val cached = cachedProfiles[userId.hexEncodedString()] + val name = ReactorNameResolver.resolve( + userId = userId, + selfUserId = selfUserId, + selfLabel = resources.getString(R.string.title_you), + cachedProfile = cached, + members = members, + senderProfiles = senderProfiles, + ) ?: return null + val profile = if (selfUserId != null && userId == selfUserId) { + userManager.profile + } else { + cached + ?: members.firstOrNull { it.userId == userId }?.userProfile + ?: senderProfiles[userId.hexEncodedString()] + } + return ReactorDisplay(name = name, profile = profile) + } + + companion object { + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + when (event) { + is Event.Open -> { state -> + if (state.chatId == event.chatId && state.messageId == event.messageId) { + state + } else { + State(chatId = event.chatId, messageId = event.messageId) + } + } + is Event.Updated -> { state -> + state.copy( + pills = event.pills, + rows = event.rows, + loading = event.loading, + hasMore = event.hasMore, + ) + } + Event.LoadMore -> { state -> state } + } + } + } +} 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 4efc718c4d..78d731440d 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 @@ -427,7 +427,7 @@ internal fun MessageRow( onAction(ChatAction.ToggleReaction(item.messageId, emoji)) }, onPillLongClick = { - onAction(ChatAction.OpenReactors(item.messageId, item.reactionPills)) + onAction(ChatAction.OpenReactors(item.messageId)) }, onOpenPicker = { onAction(ChatAction.OpenReactionPicker(item.messageId)) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt index 0ef78a0cce..7a84afc789 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt @@ -3,20 +3,15 @@ package com.flipcash.app.messenger.internal import com.flipcash.services.models.chat.MessageContent import com.flipcash.shared.chat.MessagePolicy import com.flipcash.shared.chat.models.ChatListItem -import com.flipcash.shared.chat.reactions.ReactionPill import com.flipcash.shared.chat.reactions.ReactionStrip import com.flipcash.shared.chat.reactions.SelfReaction import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Instant /** - * The quick strip and the reactors sheet are plain state: the strip is built in the same update - * that selects the bubble, and the sheet is opened by its own event and closed by its dismissal, - * dropping results that arrive for a sheet no longer showing. + * The quick strip is plain state, built in the same update that selects the bubble. */ class ChatReactionsReducerTest { @@ -47,9 +42,6 @@ class ChatReactionsReducerTest { event: ChatViewModel.Event, ): ChatViewModel.State = ChatViewModel.updateStateForEvent(event)(state) - private fun pill(emoji: String, count: Long) = - ReactionPill(emoji = emoji, count = count, selfReacted = false, pending = false) - @Test fun `selecting a bubble builds the strip in the same update, highlighting the viewer's reactions`() { val squid = SelfReaction(emoji = "🦑", reactedAt = Instant.fromEpochSeconds(2_000)) @@ -122,44 +114,4 @@ class ChatReactionsReducerTest { assertEquals(selected, state) } - - @Test - fun `opening the reactors sheet seeds its pills from the ones pressed`() { - val pills = listOf(pill("👍", 3), pill("🔥", 1)) - - val state = reduce(ChatViewModel.State(), ChatViewModel.Event.OpenReactors(1, pills)) - - val reactors = assertNotNull(state.reactors) - assertEquals(pills, reactors.pills) - assertTrue(reactors.loading) - } - - @Test - fun `reactors updates for another message are dropped`() { - val opened = reduce(ChatViewModel.State(), ChatViewModel.Event.OpenReactors(1, emptyList())) - - val state = reduce( - opened, - ChatViewModel.Event.ReactorsUpdated( - messageId = 2, - pills = listOf(pill("👍", 1)), - rows = emptyList(), - loading = false, - hasMore = false, - ), - ) - - assertEquals(opened, state) - } - - @Test - fun `a late dismissal of an earlier reactors sheet leaves the open one`() { - val opened = reduce(ChatViewModel.State(), ChatViewModel.Event.OpenReactors(2, emptyList())) - - val stale = reduce(opened, ChatViewModel.Event.ReactorsDismissed(1)) - val closed = reduce(opened, ChatViewModel.Event.ReactorsDismissed(2)) - - assertNotNull(stale.reactors) - assertNull(closed.reactors) - } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsReducerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsReducerTest.kt new file mode 100644 index 0000000000..929d67e502 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsReducerTest.kt @@ -0,0 +1,68 @@ +package com.flipcash.app.messenger.internal.screens + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.reactions.ReactionPill +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ReactorsReducerTest { + + private val chat = ChatId(ByteArray(32) { 1 }) + + private fun reduce( + state: ReactorsViewModel.State, + event: ReactorsViewModel.Event, + ): ReactorsViewModel.State = ReactorsViewModel.updateStateForEvent(event)(state) + + private fun pill(emoji: String, count: Long) = + ReactionPill(emoji = emoji, count = count, selfReacted = false, pending = false) + + private fun updated(vararg pills: ReactionPill) = ReactorsViewModel.Event.Updated( + pills = pills.toList(), + rows = emptyList(), + loading = false, + hasMore = false, + ) + + @Test + fun `opening names the message and starts loading`() { + val state = reduce(ReactorsViewModel.State(), ReactorsViewModel.Event.Open(chat, 1)) + + assertEquals(chat, state.chatId) + assertEquals(1L, state.messageId) + assertTrue(state.loading) + } + + @Test + fun `an update replaces the pills, rows and paging`() { + val opened = reduce(ReactorsViewModel.State(), ReactorsViewModel.Event.Open(chat, 1)) + + val state = reduce(opened, updated(pill("👍", 3))) + + assertEquals(listOf(pill("👍", 3)), state.pills) + assertEquals(false, state.loading) + } + + @Test + fun `reopening the same message keeps what it has loaded`() { + val loaded = reduce( + reduce(ReactorsViewModel.State(), ReactorsViewModel.Event.Open(chat, 1)), + updated(pill("👍", 3)), + ) + + assertEquals(loaded, reduce(loaded, ReactorsViewModel.Event.Open(chat, 1))) + } + + @Test + fun `opening another message starts over`() { + val loaded = reduce( + reduce(ReactorsViewModel.State(), ReactorsViewModel.Event.Open(chat, 1)), + updated(pill("👍", 3)), + ) + + val state = reduce(loaded, ReactorsViewModel.Event.Open(chat, 2)) + + assertEquals(ReactorsViewModel.State(chatId = chat, messageId = 2), state) + } +} 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 6209da5559..a461f7dc55 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 @@ -3,7 +3,6 @@ package com.flipcash.shared.chat.models import androidx.compose.runtime.staticCompositionLocalOf import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId -import com.flipcash.shared.chat.reactions.ReactionPill import com.getcode.opencode.model.core.ID import com.getcode.solana.keys.Mint @@ -109,8 +108,8 @@ sealed interface ChatAction { /** Opens the full emoji picker for [messageId], from the pill row's or the quick strip's "+". */ data class OpenReactionPicker(val messageId: Long) : ChatAction - /** Opens who-reacted for [messageId], from a long-press on one of its [pills]. */ - data class OpenReactors(val messageId: Long, val pills: List) : ChatAction + /** Opens who-reacted for [messageId], from a long-press on one of its pills. */ + data class OpenReactors(val messageId: Long) : ChatAction /** * Asks the coordinator to refresh reaction state for [messageIds] — the transcript's own diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactorsPrefetchCache.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactorsPrefetchCache.kt index 93a8547a5b..5ad6632a98 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactorsPrefetchCache.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactorsPrefetchCache.kt @@ -15,15 +15,10 @@ import kotlinx.coroutines.launch * Owns one [ReactorsListModel] per messageId, keyed so the reactors sheet and whatever started the * fetch read the same rows/loading state rather than each building their own. * - * Loading has to start on the pill long-press (decision: `OpenReactors` fires the fetch), not on - * the reactors sheet's first composition — the sheet is a fresh Compose tree on every push, and a - * fetch gated on its `LaunchedEffect` would race the same tap that opened it. [start] is called - * from `ChatViewModel`'s `OpenReactors` handler, one step before the navigator pushes - * `ChatStep.Reactors`; [rows]/[loading] are backed by one shared `StateFlow` each (keyed by - * messageId), rather than a per-messageId flow instance created inside [start] — a composition that - * calls [rows] *before* [start] has run still sees the update once it does, because it collects a - * `.map` over the same always-live backing flow rather than a separate object [start] would - * otherwise swap in. + * [rows]/[loading] are backed by one shared `StateFlow` each (keyed by messageId), rather than a + * per-messageId flow instance created inside [start] — a collector that calls [rows] *before* + * [start] has run still sees the update once it does, because it collects a `.map` over the same + * always-live backing flow rather than a separate object [start] would otherwise swap in. */ class ReactorsPrefetchCache( private val scope: CoroutineScope, @@ -53,7 +48,7 @@ class ReactorsPrefetchCache( * Starts the first round of pages for [messageId]'s [pills] unless the cache already holds a * fetch for exactly these pills. An empty [pills] list (nothing reacted, or the last reaction * was just removed) drops whatever was cached instead of fetching. No-ops for an unchanged - * emoji → count set — a second `OpenReactors` for the same + * emoji → count set — a second [start] for the same * reactions re-opens the existing fetch/rows rather than starting over. A changed set (someone * reacted, un-reacted, or used a new emoji) refetches from the first page: the pages already * held can't be patched, since the pills say how many reacted but not who. The previous rows diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactorsPrefetchCacheTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactorsPrefetchCacheTest.kt index 45a791a93d..a688798465 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactorsPrefetchCacheTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactorsPrefetchCacheTest.kt @@ -29,9 +29,7 @@ class ReactorsPrefetchCacheTest { Result.success(ReactorsPage(reactors = emptyList(), hasMore = false)) } - // OpenReactors calls start() synchronously, one step before the navigator pushes the sheet - // (see ChatViewModel's OpenReactors handler) — so by the time a sheet would compose and - // read rows(), the fetch is already in flight. + // start() alone puts the fetch in flight, before anything reads rows(). cache.start(messageId = 1L, pills = listOf(pill("👍"), pill("🔥"))) advanceUntilIdle() @@ -61,8 +59,7 @@ class ReactorsPrefetchCacheTest { Result.success(ReactorsPage(reactors = listOf(reactor(alice, reactedAt)), hasMore = false)) } - // The sheet's own composition can read rows()/loading() before ChatViewModel's OpenReactors - // handler has run start() — both derive from the same backing StateFlow, so a flow obtained + // A collector can read rows()/loading() before start() has run — both derive from the same backing StateFlow, so a flow obtained // now still reflects the fetch once it lands, rather than being a stale placeholder. val rowsFlow = cache.rows(1L) From 745b682c00c7535c2a56531ac898893eb9f17f63 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 15:32:42 -0400 Subject: [PATCH 5/5] test(messenger): cover the reactors view model following live reactions One test adds a reaction under the open sheet and checks the new reactor's row appears. The other checks an unnamed reactor's profile is requested once and the row picks up the name when it lands. --- .../internal/screens/ReactorsViewModelTest.kt | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsViewModelTest.kt diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsViewModelTest.kt new file mode 100644 index 0000000000..865a01949a --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/ReactorsViewModelTest.kt @@ -0,0 +1,140 @@ +package com.flipcash.app.messenger.internal.screens + +import com.flipcash.app.persistence.sources.UserProfileDataSource +import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.Reactor +import com.flipcash.services.repository.ReactorsPage +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.MessageReactions +import com.flipcash.shared.chat.reactions.ReactionPill +import com.getcode.opencode.model.core.ID +import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.hexEncodedString +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * The open sheet follows the message's live reactions and fills in reactors' names as their + * profiles arrive, rather than showing whatever it held when it opened. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ReactorsViewModelTest { + + private val scheduler = TestCoroutineScheduler() + private val dispatcher = UnconfinedTestDispatcher(scheduler) + + private val chat = ChatId(ByteArray(32) { 1 }) + private val messageId = 7L + private val alice: ID = List(32) { 2 } + private val bob: ID = List(32) { 3 } + + /** Who has reacted with 👍, as the server would answer a reactors fetch right now. */ + private var thumbsUp = listOf(alice) + + private val liveReactions = MutableStateFlow>(emptyMap()) + private val senderProfiles = MutableStateFlow>(emptyMap()) + + private val chatCoordinator = mockk(relaxed = true) { + every { observeChatReactions(chat) } answers { liveReactions } + every { observeMembers(chat) } returns emptyFlow() + every { observeSenderProfiles() } answers { senderProfiles } + coEvery { getMessage(chat, messageId) } returns null + coEvery { getReactorsPage(chat, messageId, "👍", any(), any()) } answers { + Result.success( + ReactorsPage( + reactors = thumbsUp.mapIndexed { i, id -> Reactor(id, Instant.fromEpochSeconds(i.toLong())) }, + hasMore = false, + ), + ) + } + } + + private val userProfileDataSource = mockk { + every { observeProfiles() } returns MutableStateFlow(emptyMap()) + } + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun viewModel() = ReactorsViewModel( + chatCoordinator = chatCoordinator, + userManager = mockk(relaxed = true), + resources = mockk(relaxed = true), + userProfileDataSource = userProfileDataSource, + dispatchers = TestDispatcherProvider(dispatcher), + ).apply { dispatchEvent(ReactorsViewModel.Event.Open(chat, messageId)) } + + private fun reacted(count: Long) = mapOf( + messageId to MessageReactions( + pills = listOf(ReactionPill(emoji = "👍", count = count, selfReacted = false, pending = false)), + selfReactions = emptyList(), + ), + ) + + private fun profile(name: String) = UserProfile( + displayName = name, + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + ) + + private fun ReactorsViewModel.rowIds() = stateFlow.value.rows.map { it.userId } + + @Test + fun `a reaction added while the sheet is open adds its row`() = runTest(scheduler) { + liveReactions.value = reacted(count = 1) + val model = viewModel() + advanceUntilIdle() + assertEquals(listOf(alice), model.rowIds()) + + thumbsUp = listOf(alice, bob) + liveReactions.value = reacted(count = 2) + advanceUntilIdle() + + assertEquals(2L, model.stateFlow.value.pills.single().count) + assertEquals(setOf(alice, bob), model.rowIds().toSet()) + } + + @Test + fun `an unnamed reactor is asked for once and named when the profile lands`() = runTest(scheduler) { + liveReactions.value = reacted(count = 1) + val model = viewModel() + advanceUntilIdle() + + assertEquals(null, model.stateFlow.value.rows.single().display) + verify(exactly = 1) { chatCoordinator.requestSenderProfile(alice) } + + senderProfiles.value = mapOf(alice.hexEncodedString() to profile("Alice")) + advanceUntilIdle() + + assertEquals("Alice", model.stateFlow.value.rows.single().display?.name) + verify(exactly = 1) { chatCoordinator.requestSenderProfile(alice) } + } +}