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 856629e16e..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 @@ -6,7 +6,6 @@ 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 @@ -28,8 +27,10 @@ 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.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 @@ -39,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 @@ -57,7 +59,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( @@ -128,7 +129,7 @@ private fun chatEntryProvider( FlowReactionPickerScreen(step.messageId) } annotatedEntry { step -> - FlowReactorsScreen(step.messageId) + FlowReactorsScreen(step.chatId, step.messageId) } } @@ -354,16 +355,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() ReactionPickerSheet( - loadSections = { query -> viewModel.emojiPickerSections(query) }, + searchState = state.searchFieldState, + sections = state.sections, + loaded = state.loaded, onSelected = { emoji -> - viewModel.dispatchEvent( + chatViewModel.dispatchEvent( ChatViewModel.Event.ToggleReaction(messageId, emoji, clearsSelection = true) ) dismissSheet() @@ -373,50 +381,29 @@ 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, 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 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() + LaunchedEffect(viewModel, chatId, messageId) { + viewModel.dispatchEvent(ReactorsViewModel.Event.Open(chatId, messageId)) } ReactorsSheet( - pills = pills, - rows = rows, - loading = loading, - hasMore = { viewModel.reactorsHasMore(messageId) }, - resolveDisplay = { userId -> viewModel.reactorDisplay(userId) }, - onLoadMore = { viewModel.loadMoreReactors(messageId) }, + pills = state.pills, + rows = state.rows, + loading = state.loading, + hasMore = state.hasMore, + onLoadMore = { viewModel.dispatchEvent(ReactorsViewModel.Event.LoadMore) }, 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. + val profile = state.rows.firstOrNull { it.userId == userId }?.display?.profile dismissSheet() - coroutineScope.launch { - val participant = viewModel.reactorParticipant(userId) ?: return@launch - navigator.push(ChatStep.Profile(contact = participant)) - } + 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 6ee74941d2..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 @@ -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,25 @@ 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.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 +139,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 @@ -160,6 +158,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map 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 +353,12 @@ 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 DM counterparty, or `null` for a group. @@ -597,12 +602,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: @@ -643,14 +643,14 @@ 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 - - /** Opens who-reacted for [messageId], from a long-press on a pill. */ - data class OpenReactors(val messageId: Long) : Event + /** Internal: the quick strip's catalog and recents (re)loaded. */ + data class QuickReactionInputsLoaded(val inputs: QuickReactionInputs) : Event - /** Internal: the quick strip's entries for the current selection have (re)composed. */ - data class QuickReactionStripComposed(val entries: List) : 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 +734,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,117 +769,16 @@ 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. - */ - 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) - } - - 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. + * 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. */ - 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. - */ - 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) - } - - /** 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?) - - /** - * 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. - */ - fun reactorDisplay(userId: ID): Flow { - val chatId = stateFlow.value.chatId - val selfLabel = resources.getString(R.string.title_you) - 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) - } - } - } - - /** [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? { - 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) } - } + data class QuickReactionInputs( + val recentStats: Map, + val catalogFill: List, + val undrawable: Set, + ) /** * How a drawn card reaches the lookup. Provided to the transcript, read by `LinkCardView`. @@ -963,6 +859,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 +906,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 +929,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,101 +1776,48 @@ 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)) - } - .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() - .onEach { event -> - syncReactors(event.messageId, reactionOverlay.value[event.messageId]?.pills.orEmpty()) - } + .onEach { dispatchEvent(it) } .launchIn(viewModelScope) // See ReactionRefreshPlanner and MessageList's reaction-refresh effects: the transcript @@ -2820,14 +2669,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 +2722,18 @@ 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.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 +2752,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/EmojiPickerViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerViewModel.kt new file mode 100644 index 0000000000..dd71fa4e8b --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerViewModel.kt @@ -0,0 +1,92 @@ +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 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. */ + 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, glyphProbe) } + .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 3bc1d3c932..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 @@ -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 -> { @@ -236,13 +231,12 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } is ChatAction.OpenReactionPicker -> { - viewModel.dispatchEvent(ChatViewModel.Event.OpenReactionPicker(action.messageId)) navigator.push(ChatStep.ReactionPicker(action.messageId)) } is ChatAction.OpenReactors -> { - viewModel.dispatchEvent(ChatViewModel.Event.OpenReactors(action.messageId)) - 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/ReactionPickerSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReactionPickerSheet.kt index a7ee08a9fb..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 @@ -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,28 @@ 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.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 +70,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,8 +79,7 @@ import dev.chrisbanes.haze.blur.HazeColorEffect import dev.chrisbanes.haze.blur.hazeBlur import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged +import kotlin.math.roundToInt import kotlinx.coroutines.launch /** @@ -97,14 +92,16 @@ import kotlinx.coroutines.launch */ @Composable internal fun ReactionPickerSheet( - loadSections: suspend (query: String) -> List, + searchState: TextFieldState, + sections: List, + loaded: Boolean, onSelected: (String) -> Unit, onDismiss: () -> Unit, ) { val gridState = rememberLazyListState() AllowSheetExpansionWhenScrollable(gridState) - val searchState = remember { TextFieldState() } + val query = searchState.text.toString() Column(modifier = Modifier.fillMaxWidth()) { SearchField( @@ -115,21 +112,8 @@ internal fun ReactionPickerSheet( .padding(top = 16.dp, bottom = 12.dp), ) - val query by produceState(initialValue = "") { - 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 - } - 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..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 @@ -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,9 @@ 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 +62,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 +126,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 +139,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 +195,10 @@ private fun SummaryPillRow(pills: List) { */ @Composable private fun ReactorRow( - userId: ID, emojis: List, - resolveDisplay: (ID) -> Flow, + display: ReactorsViewModel.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/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/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..7a84afc789 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt @@ -0,0 +1,117 @@ +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.ReactionStrip +import com.flipcash.shared.chat.reactions.SelfReaction +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * The quick strip is plain state, built in the same update that selects the bubble. + */ +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) + + @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) + } +} 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) + } +} 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()) + } +} 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/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) } + } +} 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..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 @@ -108,7 +108,7 @@ 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. */ + /** Opens who-reacted for [messageId], from a long-press on one of its pills. */ data class OpenReactors(val messageId: Long) : ChatAction /** 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`. 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)