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 af9adcc605..ca747ce83b 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
@@ -1,6 +1,7 @@
package com.flipcash.app.core.chat
import android.os.Parcelable
+import com.getcode.navigation.HalfSheet
import com.getcode.navigation.Sheet
import com.getcode.navigation.WrapContentSheet
import com.getcode.navigation.flow.FlowStep
@@ -96,4 +97,26 @@ sealed interface ChatStep : FlowStep, Parcelable {
@Parcelize
@Serializable
data object EditGroupPicture : ChatStep
+
+ /**
+ * The full emoji picker for a message's reaction, opened from the pill row's "+" or the
+ * quick strip's "+". A plain [Sheet] rather than [WrapContentSheet]: the picker's catalog
+ * needs the screen's height, unlike the short, fixed-content sheets above. [HalfSheet]
+ * matches iOS's `EmojiPickerSheet.presentationDetents([.medium, .large])` — it rests at half
+ * height and only grows to the expanded detent once the grid reports it has more than that to
+ * show (see `AllowSheetExpansionWhenScrollable`).
+ */
+ @Parcelize
+ @Serializable
+ data class ReactionPicker(val messageId: Long) : ChatStep, Sheet, HalfSheet
+
+ /**
+ * Who reacted, and with what — opened by long-pressing a pill. A plain [Sheet] for the same
+ * reason as [ReactionPicker]: a per-emoji reactor list can run long enough to need the full
+ * sheet height rather than wrapping its content. [HalfSheet] matches iOS's
+ * `ReactorsSheet.presentationDetents([.medium, .large])`.
+ */
+ @Parcelize
+ @Serializable
+ data class Reactors(val messageId: Long) : ChatStep, Sheet, HalfSheet
}
diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index c981d0eff9..d02127ecdd 100644
--- a/apps/flipcash/core/src/main/res/values/strings.xml
+++ b/apps/flipcash/core/src/main/res/values/strings.xml
@@ -1004,6 +1004,13 @@
Message Not Deleted
The message couldn\'t be deleted, so it is still visible to everyone.
Message
+ Couldn\'t add reaction
+ This message has the maximum number of reactions
+ %1$d Reactions
+ Reactions
+ Search
+ Clear search
+ Add reaction
Unknown Contact
Allow Full Contact Access
diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts
index 4c59fcef43..043a468609 100644
--- a/apps/flipcash/features/messenger/build.gradle.kts
+++ b/apps/flipcash/features/messenger/build.gradle.kts
@@ -18,8 +18,11 @@ dependencies {
implementation(project(":apps:flipcash:shared:contacts"))
implementation(project(":apps:flipcash:shared:featureflags"))
implementation(project(":apps:flipcash:shared:funding"))
+ implementation(project(":libs:emojis"))
implementation(project(":apps:flipcash:shared:menu"))
implementation(project(":apps:flipcash:shared:payments"))
+ // UserProfileDataSource, for the reactors sheet's cached-profile name resolution (decision 4).
+ implementation(project(":apps:flipcash:shared:persistence:sources"))
implementation(project(":apps:flipcash:shared:router"))
implementation(project(":apps:flipcash:shared:session"))
implementation(project(":apps:flipcash:shared:shareable"))
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 621c64f6af..87f878fa87 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,6 +6,7 @@ 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.lifecycle.compose.collectAsStateWithLifecycle
@@ -24,6 +25,8 @@ import com.flipcash.app.messenger.internal.ChatViewModel
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.ReactionPickerSheet
+import com.flipcash.app.messenger.internal.screens.ReactorsSheet
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
@@ -51,6 +54,7 @@ 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(
@@ -116,6 +120,13 @@ private fun chatEntryProvider(
annotatedEntry {
FlowEditGroupPictureScreen()
}
+
+ annotatedEntry { step ->
+ FlowReactionPickerScreen(step.messageId)
+ }
+ annotatedEntry { step ->
+ FlowReactorsScreen(step.messageId)
+ }
}
@Composable
@@ -318,3 +329,63 @@ private fun FlowEditGroupNameScreen() {
private fun FlowEditGroupPictureScreen() {
EditGroupPictureScreen(flowSharedViewModel())
}
+
+/** The full emoji picker for [messageId] — a tap on any emoji toggles it and dismisses. */
+@Composable
+private fun FlowReactionPickerScreen(messageId: Long) {
+ val viewModel = flowSharedViewModel()
+ val dismissSheet = LocalBottomSheetDismissDispatcher.current
+
+ ReactionPickerSheet(
+ loadSections = { query -> viewModel.emojiPickerSections(query) },
+ onSelected = { emoji ->
+ viewModel.dispatchEvent(
+ ChatViewModel.Event.ToggleReaction(messageId, emoji, clearsSelection = true)
+ )
+ dismissSheet()
+ },
+ onDismiss = dismissSheet,
+ )
+}
+
+/**
+ * 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`.
+ */
+@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)
+
+ ReactorsSheet(
+ pills = pills,
+ rows = rows,
+ loading = loading,
+ hasMore = { viewModel.reactorsHasMore(messageId) },
+ resolveDisplay = { userId -> viewModel.reactorDisplay(userId) },
+ onLoadMore = { viewModel.loadMoreReactors(messageId) },
+ 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))
+ }
+ },
+ 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 22e41d114b..b20e508889 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
@@ -13,6 +13,7 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.flatMap
+import androidx.paging.map
import com.flipcash.analytics.AddMoneySource
import com.flipcash.analytics.GroupAccess as AnalyticsGroupAccess
import com.flipcash.analytics.GroupGateFunding
@@ -31,6 +32,7 @@ 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.tokens.brandedName
@@ -39,6 +41,7 @@ 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
@@ -86,6 +89,20 @@ 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.reactions.ReactionError
+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.resolveCapabilities
import com.flipcash.shared.chat.ui.detectUrls
import com.flipcash.shared.chat.ui.linkableText
@@ -178,6 +195,10 @@ internal class ChatViewModel @Inject constructor(
private val linkCardResolver: LinkCardResolver,
private val cashLinkClaims: CashLinkClaims,
private val chatDraftStore: ChatDraftStore,
+ private val recentReactionsStore: RecentReactionsStore,
+ private val emojiCatalogLoader: EmojiCatalogLoader,
+ private val toastController: SystemToastController,
+ private val userProfileDataSource: UserProfileDataSource,
dispatchers: DispatcherProvider,
) : BaseViewModel(
initialState = State(),
@@ -323,6 +344,12 @@ internal class ChatViewModel @Inject constructor(
* the whole round trip and read as dead — which is what it looked like before it had one.
*/
val joinProgress: LoadingSuccessState = LoadingSuccessState(),
+ /**
+ * The quick strip's entries for [selection], recomposed whenever the selected message
+ * changes — empty while nothing is selected, while the selected message can't be reacted
+ * to, or before the catalog/recents have loaded for the first selection of this session.
+ */
+ val quickReactionStrip: List = emptyList(),
) {
/**
* The DM counterparty, or `null` for a group.
@@ -558,7 +585,12 @@ 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. */
- data class ToggleMessageSelection(val bubble: ChatListItem.ContentBubble) : Event
+ /** [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 object ClearMessageSelection : Event
// The message actions carry what they act on rather than reading it back off the selection:
@@ -587,6 +619,34 @@ internal class ChatViewModel @Inject constructor(
data class ReplyToMessage(val quote: ChatQuote) : Event
data object CancelReply : Event
+ /**
+ * Toggles the viewer's reaction with [emoji] on [messageId] — a tap on a pill, on the
+ * quick strip above a selected bubble, or a pick from the full picker. When it came from
+ * the strip, [clearsSelection] takes the bubble out of selection mode the same tap it acts
+ * on; a tap on the pill row under a bubble that isn't selected leaves selection untouched.
+ */
+ data class ToggleReaction(
+ val messageId: Long,
+ val emoji: String,
+ 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 entries for the current selection have (re)composed. */
+ data class QuickReactionStripComposed(val entries: List) : Event
+
+ /**
+ * The transcript's own refresh-on-paging hook has ids it wants reaction state refreshed
+ * for — see [ReactionRefreshPlanner] and the [ChatAction.RefreshReactionIds] that carries
+ * this down from `MessageList`. Not a reader gesture, so it changes no visible state.
+ */
+ data class RefreshReactionIds(val messageIds: List) : Event
+
/**
* The reader tapped a cash voucher in this transcript, naming the link.
*
@@ -646,6 +706,25 @@ internal class ChatViewModel @Inject constructor(
*/
private val viewerCanPost = stateFlow.map { !it.isOutsideGroup }.distinctUntilChanged()
+ /**
+ * Live reaction overrides for the open chat — see [ReactionOperations.observeChatReactions].
+ * A message missing here falls back to `MessageReactions.from(message.reactions)` in
+ * [mappedMessages], per that function's contract.
+ *
+ * Held as a [StateFlow] (Eagerly, like [senderProfiles]) rather than left cold: the toggle
+ * handler needs a synchronous read of "is this already self-reacted" to decide whether a tap
+ * is an add worth ranking for the quick strip, and a cold flow would mean starting a second
+ * collection just to answer that one question.
+ */
+ @OptIn(ExperimentalCoroutinesApi::class)
+ private val reactionOverlay: StateFlow