diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts
index 42b2d1e676..d32b4ec51f 100644
--- a/apps/flipcash/app/build.gradle.kts
+++ b/apps/flipcash/app/build.gradle.kts
@@ -235,6 +235,7 @@ dependencies {
implementation(project(":apps:flipcash:shared:notifications"))
implementation(project(":apps:flipcash:shared:chat"))
implementation(project(":apps:flipcash:shared:chat-ui"))
+ debugImplementation(project(":apps:flipcash:shared:persistence:sources"))
implementation(project(":apps:flipcash:shared:onramp:coinbase"))
implementation(project(":apps:flipcash:shared:onramp:deeplinks"))
implementation(libs.phantom.connect) {
diff --git a/apps/flipcash/app/src/debug/AndroidManifest.xml b/apps/flipcash/app/src/debug/AndroidManifest.xml
index 0775aa4c45..32c99fccdf 100644
--- a/apps/flipcash/app/src/debug/AndroidManifest.xml
+++ b/apps/flipcash/app/src/debug/AndroidManifest.xml
@@ -2,7 +2,13 @@
-
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/flipcash/app/src/debug/kotlin/com/flipcash/app/debug/ReactionAlertDebugReceiver.kt b/apps/flipcash/app/src/debug/kotlin/com/flipcash/app/debug/ReactionAlertDebugReceiver.kt
new file mode 100644
index 0000000000..ede84497e9
--- /dev/null
+++ b/apps/flipcash/app/src/debug/kotlin/com/flipcash/app/debug/ReactionAlertDebugReceiver.kt
@@ -0,0 +1,108 @@
+package com.flipcash.app.debug
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.flipcash.app.persistence.sources.ChatMemberDataSource
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.chat.ReactionUpdate
+import com.flipcash.shared.chat.alerts.ReactionActorProfiles
+import com.flipcash.shared.chat.alerts.ReactionAlertController
+import com.flipcash.shared.chat.alerts.ReactionAlertEvent
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+
+/**
+ * Debug builds only: fires canned in-app reaction alerts so the banner can be driven from adb.
+ *
+ * adb shell am broadcast -a com.flipcash.debug.REACTION_ALERT --es kind reaction
+ * adb shell am broadcast -a com.flipcash.debug.REACTION_ALERT --es kind boost
+ * adb shell am broadcast -a com.flipcash.debug.REACTION_ALERT --es kind stack
+ *
+ * The reactor is the first known chat member whose name is `--es name` (default "XCTests"), and the
+ * photo comes through the same lookup the real stream uses. `stack` sends four reactors one after
+ * another so the card grows from 1 to 4 people.
+ */
+class ReactionAlertDebugReceiver : BroadcastReceiver() {
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface Deps {
+ fun controller(): ReactionAlertController
+ fun members(): ChatMemberDataSource
+ fun profiles(): ReactionActorProfiles
+ }
+
+ override fun onReceive(context: Context, intent: Intent) {
+ val deps = EntryPointAccessors.fromApplication(context.applicationContext, Deps::class.java)
+ val controller = deps.controller()
+ val members = deps.members()
+ val profiles = deps.profiles()
+ val kind = intent.getStringExtra("kind") ?: "reaction"
+ val name = intent.getStringExtra("name") ?: "XCTests"
+ val pending = goAsync()
+ scope.launch {
+ try {
+ fire(kind, name, controller, members, profiles)
+ } finally {
+ pending.finish()
+ }
+ }
+ }
+
+ private suspend fun fire(
+ kind: String,
+ name: String,
+ controller: ReactionAlertController,
+ members: ChatMemberDataSource,
+ profiles: ReactionActorProfiles,
+ ) {
+ val all = members.observeAll().first()
+ val (chatHex, member) = all.entries.firstNotNullOfOrNull { (hex, list) ->
+ list.firstOrNull { it.userProfile.displayName == name }?.let { hex to it }
+ } ?: all.entries.firstNotNullOf { (hex, list) -> list.firstOrNull()?.let { hex to it } }
+ val chat = ChatId(chatHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray())
+ val resolved = profiles.resolve(member.userId)
+ val actorName = resolved?.displayName?.takeIf { it.isNotBlank() } ?: member.userProfile.displayName
+ val photo = member.userProfile.profilePicture ?: resolved?.profilePicture
+
+ fun event(who: List, whoName: String, whoPhoto: Boolean, emoji: String, action: ReactionUpdate.Action, cents: Long? = null) =
+ ReactionAlertEvent(
+ chatId = chat,
+ messageId = 1,
+ emoji = emoji,
+ actor = who,
+ action = action,
+ boostDeltaCents = cents,
+ actorName = whoName,
+ chatName = "Moony",
+ quote = "Me! I'll send my \$40 tonight",
+ actorPhoto = if (whoPhoto) photo else null,
+ )
+
+ when (kind) {
+ "boost" -> controller.offer(event(member.userId, actorName, true, "🔥", ReactionUpdate.Action.BOOSTED, 500))
+ "stack" -> {
+ val crowd = listOf("Chloe" to "❤️", "Sam" to "😂", "Jo" to "👏")
+ controller.offer(event(member.userId, actorName, true, "🔥", ReactionUpdate.Action.ADDED))
+ crowd.forEachIndexed { i, (n, e) ->
+ delay(900)
+ controller.offer(event(listOf(i.toByte(), 9), n, false, e, ReactionUpdate.Action.ADDED))
+ }
+ }
+ else -> controller.offer(event(member.userId, actorName, true, "🔥", ReactionUpdate.Action.ADDED))
+ }
+ }
+
+ private companion object {
+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ }
+}
diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt
index fdef96c4e2..b64d22218a 100644
--- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt
+++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt
@@ -201,6 +201,7 @@ internal fun App(
TransactionNotificationPromptHost(viewModel.notificationPrompt)
CoinbaseOnRampHandler {
AppContent(
+ reactionAlerts = viewModel.reactionAlerts,
codeNavigator = codeNavigator,
resultStateRegistry = resultStateRegistry,
barManager = barManager,
diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/HomeViewModel.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/HomeViewModel.kt
index 1caeaa0b46..42fd6449ca 100644
--- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/HomeViewModel.kt
+++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/HomeViewModel.kt
@@ -33,6 +33,7 @@ internal class HomeViewModel @Inject constructor(
featureFlagController: FeatureFlagController,
userFlags: UserFlagsCoordinator,
val notificationPrompt: com.flipcash.app.tokens.TransactionNotificationPrompt,
+ val reactionAlerts: com.flipcash.shared.chat.alerts.ReactionAlertController,
) : ViewModel() {
/**
diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt
index 9ca4a55b69..0c6a64b87a 100644
--- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt
+++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt
@@ -43,6 +43,12 @@ import androidx.navigation3.scene.SinglePaneSceneStrategy
import com.flipcash.app.cardexpand.CardExpansionController
import com.flipcash.app.cardexpand.LocalCardExpansion
import com.flipcash.app.core.AppRoute
+import com.flipcash.app.core.chat.ChatIdentifier
+import com.flipcash.shared.chat.alerts.LocalReactionAlerts
+import com.flipcash.shared.chat.alerts.ReactionAlertAction
+import com.flipcash.shared.chat.alerts.ReactionAlertController
+import com.flipcash.shared.chat.ui.ReactionAlertHost
+import kotlinx.coroutines.flow.filterIsInstance
import com.flipcash.app.core.navigation.DeeplinkAction
import com.flipcash.app.core.navigation.LocalTabBarVisibility
import com.flipcash.app.core.navigation.NavBarButton
@@ -108,6 +114,7 @@ internal fun AppContent(
resultStateRegistry: NavResultStateRegistry,
barManager: BarManager,
cardExpansion: CardExpansionController,
+ reactionAlerts: ReactionAlertController,
deepLink: () -> DeepLink?,
onPendingAction: (DeeplinkAction) -> Unit = {},
canSwitchAccounts: Boolean = false,
@@ -141,6 +148,21 @@ internal fun AppContent(
}
UpdateAvailableToast(navigator = codeNavigator, barVisibility = barVisibility, toasts = toasts)
+ // A reaction pill tapped outside its chat opens the chat at the message. One tapped inside the
+ // open chat is handled by the chat screen, which scrolls and pulses instead.
+ LaunchedEffect(reactionAlerts, codeNavigator) {
+ reactionAlerts.actions
+ .filterIsInstance()
+ .collect { action ->
+ codeNavigator.push(
+ AppRoute.Messaging.Chat(
+ identifier = ChatIdentifier.ByChatId(action.chatId),
+ jumpToMessageId = action.messageId,
+ )
+ )
+ }
+ }
+
// A tab press replaces the whole back stack (tab-bar semantics — see AppNavigationBar), so every
// tab home was destroyed and rebuilt on each switch: the wallet re-fetched its balances and the
// chat list scrolled back to the top. Hold each tab home's ViewModels and scroll state outside the
@@ -167,6 +189,7 @@ internal fun AppContent(
LocalCardExpansion provides cardExpansion,
LocalTabBarVisibility provides tabBarVisibility,
LocalFloatingToastHost provides toasts,
+ LocalReactionAlerts provides reactionAlerts,
) {
Box(modifier = Modifier.fillMaxSize()) {
// Mark the nav content as the haze source so the frosted bar blurs whatever scrolls beneath it.
@@ -301,6 +324,9 @@ internal fun AppContent(
// The search button's glass growing into Search's field, over everything below it.
searchMorph.Overlay(hazeState)
+ // Top of the app, over the status bar and the camera cutout, so it shows on any screen.
+ ReactionAlertHost(controller = reactionAlerts, hazeState = hazeState)
+
// The expanded currency-info overlay is hosted INSIDE the wallet nav entry (see CardExpandHost),
// not here — so a pushed Give/Convert/Withdraw naturally covers it (correct z-order) and the deck
// reorganises behind it. The controller is provided app-root (above) so its fly-state survives the
diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index 3357a304d8..624ead7119 100644
--- a/apps/flipcash/core/src/main/res/values/strings.xml
+++ b/apps/flipcash/core/src/main/res/values/strings.xml
@@ -1692,4 +1692,12 @@
- You sent %1$s to %2$d person
- You sent %1$s to %2$d people
+ %1$s reacted to your message
+ %1$s boosted their %2$s
+
+ - %1$s and %2$d other reacted
+ - %1$s and %2$d others reacted
+
+ Someone
+ This chat ↓
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 69bcaf56f5..a0610d66d7 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
@@ -28,6 +28,9 @@ import com.flipcash.app.updates.LocalAppUpdater
import com.flipcash.features.messenger.R
import androidx.compose.ui.res.stringResource
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.rememberUpdatedState
+import com.flipcash.shared.chat.alerts.LocalReactionAlerts
+import com.flipcash.shared.chat.alerts.ReactionAlertAction
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -147,6 +150,19 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
if (privateOutsider) flowNavigator.replaceStack(listOf(ChatStep.GroupProfile))
}
+ // A reaction pill tapped while this chat is open asks to scroll to its message and pulse it.
+ val reactionAlerts = LocalReactionAlerts.current
+ val currentChatId by rememberUpdatedState(state.chatId)
+ LaunchedEffect(reactionAlerts, viewModel) {
+ reactionAlerts?.actions
+ ?.filterIsInstance()
+ ?.collect { action ->
+ if (action.chatId == currentChatId) {
+ viewModel.dispatchEvent(ChatViewModel.Event.JumpToMessage(action.messageId))
+ }
+ }
+ }
+
// Here rather than in the action handler because the lookup comes first: the tap only knows a
// handle, and where it leads is the view model's to decide once the handle has an answer.
LaunchedEffect(viewModel) {
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt
index af6f409f39..b1aa33739a 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt
@@ -144,6 +144,7 @@ internal fun MessageList(
// True while the list is being carried to a new newest row, so the jump button stays down.
var autoScrolling by remember { mutableStateOf(false) }
HandleMessageReads(listState, messages, enabled = hasPositioned)
+ ReportVisibleToReactionAlerts(state.chatId, listState, messages)
// The first drag or fling after the list has settled. Not isScrollInProgress, which is
// also true through the animated and instant scrolls the list makes itself (opening,
// auto-scroll, the jump button): only a finger starts a DragInteraction, and a fling
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageReadReporter.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageReadReporter.kt
index a8be264ead..d36bcafaba 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageReadReporter.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageReadReporter.kt
@@ -2,7 +2,10 @@ package com.flipcash.app.messenger.internal.screens.components
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.shared.chat.alerts.LocalReactionAlerts
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
@@ -61,3 +64,34 @@ internal fun HandleMessageReads(
}
}
}
+
+/**
+ * Tells the reaction alerts which messages are on screen, so a reaction on one of them animates its
+ * bubble instead of raising a pill. Cleared when the chat leaves composition.
+ */
+@Composable
+internal fun ReportVisibleToReactionAlerts(
+ chatId: ChatId?,
+ listState: LazyListState,
+ messages: LazyPagingItems,
+) {
+ val alerts = LocalReactionAlerts.current ?: return
+ if (chatId == null) return
+ DisposableEffect(chatId, alerts) {
+ onDispose { alerts.clearOnScreen(chatId) }
+ }
+ LaunchedEffect(chatId, listState, messages, alerts) {
+ snapshotFlow {
+ val count = messages.itemCount
+ buildSet {
+ for (info in listState.layoutInfo.visibleItemsInfo) {
+ if (info.index !in 0 until count) continue
+ val message = messages.peek(info.index) as? ChatListItem.TranscriptMessage ?: continue
+ add(message.messageId)
+ }
+ }
+ }
+ .distinctUntilChanged()
+ .collect { visible -> alerts.reportOnScreen(chatId, visible) }
+ }
+}
diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionAlertHost.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionAlertHost.kt
new file mode 100644
index 0000000000..a9534bed48
--- /dev/null
+++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionAlertHost.kt
@@ -0,0 +1,203 @@
+package com.flipcash.shared.chat.ui
+
+import android.animation.ValueAnimator
+import android.os.Build
+import android.view.HapticFeedbackConstants
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.FastOutLinearInEasing
+import androidx.compose.animation.core.LinearOutSlowInEasing
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectVerticalDragGestures
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBars
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.BlurEffect
+import androidx.compose.ui.graphics.TileMode
+import androidx.compose.ui.graphics.TransformOrigin
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.unit.dp
+import com.flipcash.shared.chat.alerts.ReactionAlertContent.Kind
+import com.flipcash.shared.chat.alerts.ReactionAlertController
+import com.flipcash.shared.chat.alerts.ReactionAlertState
+import dev.chrisbanes.haze.HazeState
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlin.math.ln
+
+/**
+ * Draws the controller's alert over the app, as iOS does: a glass card that fades and unblurs in
+ * just under the status bar with a small spring settle, and lifts back out the same way. A new
+ * reaction on the card already up swaps its text in place with a small bump. Swipe up dismisses,
+ * a tap opens the message. The haptic plays once the card has landed, not when it starts.
+ *
+ * With animations off (Reduce Motion) the card only fades.
+ */
+@Composable
+fun ReactionAlertHost(
+ controller: ReactionAlertController,
+ modifier: Modifier = Modifier,
+ hazeState: HazeState? = null,
+) {
+ val live by controller.state.collectAsState()
+ val view = LocalView.current
+ val density = LocalDensity.current
+ val scope = rememberCoroutineScope()
+ val animate = remember(live) { ValueAnimator.areAnimatorsEnabled() }
+
+ // Kept after [live] clears so the exit has content to draw.
+ var shown by remember { mutableStateOf(null) }
+ if (live != null) shown = live
+
+ val lift = remember { Animatable(EnterLiftDp) }
+ val scale = remember { Animatable(EnterScale) }
+ val blur = remember { Animatable(EnterBlurDp) }
+ val alpha = remember { Animatable(0f) }
+ val drag = remember { Animatable(0f) }
+ var landed by remember { mutableStateOf(false) }
+
+ LaunchedEffect(live != null) {
+ if (live != null) {
+ if (alpha.value < 0.01f) {
+ lift.snapTo(if (animate) EnterLiftDp else 0f)
+ scale.snapTo(if (animate) EnterScale else 1f)
+ blur.snapTo(if (animate) EnterBlurDp else 0f)
+ }
+ coroutineScope {
+ launch { alpha.animateTo(1f, tween(if (animate) 180 else 200, easing = LinearOutSlowInEasing)) }
+ if (animate) {
+ launch { blur.animateTo(0f, tween(180, easing = LinearOutSlowInEasing)) }
+ launch { lift.animateTo(0f, EnterSpring) }
+ launch { scale.animateTo(1f, EnterSpring) }
+ }
+ }
+ } else if (shown != null) {
+ landed = false
+ coroutineScope {
+ launch { alpha.animateTo(0f, tween(if (animate) 220 else 200, easing = FastOutLinearInEasing)) }
+ if (animate) {
+ launch { lift.animateTo(ExitLiftDp, tween(220, easing = FastOutLinearInEasing)) }
+ launch { scale.animateTo(ExitScale, tween(220, easing = FastOutLinearInEasing)) }
+ launch { blur.animateTo(ExitBlurDp, tween(220, easing = FastOutLinearInEasing)) }
+ }
+ }
+ shown = null
+ drag.snapTo(0f)
+ }
+ }
+
+ // Haptics wait for the card to land; a merge into the card already up bumps it and ticks.
+ LaunchedEffect(live?.generation) {
+ val state = live ?: return@LaunchedEffect
+ val isUpdate = landed
+ if (!isUpdate) {
+ delay(LAND_MILLIS)
+ } else if (animate) {
+ scope.launch {
+ scale.animateTo(1.025f, spring(dampingRatio = 0.6f, stiffness = springStiffness(0.18f)))
+ delay(110)
+ scale.animateTo(1f, spring(dampingRatio = 0.7f, stiffness = springStiffness(0.32f)))
+ }
+ }
+ view.performHapticFeedback(hapticFor(state.content.kind, isUpdate))
+ landed = true
+ }
+
+ val current = shown ?: return
+ val interaction = remember { MutableInteractionSource() }
+
+ BoxWithConstraints(modifier = modifier.fillMaxSize()) {
+ val gesture = Modifier
+ .pointerInput(controller) {
+ detectVerticalDragGestures(
+ onDragEnd = {
+ if (drag.value < -SWIPE_DISMISS_DP) {
+ controller.dismiss()
+ } else {
+ scope.launch { drag.animateTo(0f, spring(dampingRatio = 0.7f, stiffness = springStiffness(0.3f))) }
+ }
+ },
+ onDragCancel = { scope.launch { drag.animateTo(0f) } },
+ ) { _, delta ->
+ scope.launch { drag.snapTo(drag.value + delta / density.density) }
+ }
+ }
+ .clickable(interactionSource = interaction, indication = null) { controller.onTap() }
+
+ ReactionAlertPill(
+ content = current.content,
+ inActiveChat = current.inActiveChat,
+ maxWidth = maxWidth - 24.dp,
+ hazeState = hazeState,
+ animate = animate,
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .windowInsetsPadding(WindowInsets.statusBars)
+ .padding(top = 4.dp, start = 12.dp, end = 12.dp)
+ .graphicsLayer {
+ translationY = (lift.value + dragResistance(drag.value)) * density.density
+ scaleX = scale.value
+ scaleY = scale.value
+ this.alpha = alpha.value
+ transformOrigin = TransformOrigin(0.5f, 0f)
+ if (Build.VERSION.SDK_INT >= 31 && blur.value > 0.1f) {
+ val radius = blur.value * density.density
+ renderEffect = BlurEffect(radius, radius, TileMode.Decal)
+ }
+ }
+ .then(gesture),
+ )
+ }
+}
+
+/** Follows an upward drag one to one and resists a downward one, so the card feels held in place. */
+private fun dragResistance(dp: Float): Float = if (dp < 0f) dp else 10f * ln(1f + dp / 10f)
+
+/** SwiftUI's `spring(response:)` as a Compose stiffness: (2π / response)². */
+private fun springStiffness(response: Float): Float {
+ val omega = (2.0 * Math.PI / response).toFloat()
+ return omega * omega
+}
+
+/** Boost = success; a first reaction = a firm tap; a merge into the card already up = a light tick. */
+private fun hapticFor(kind: Kind, isUpdate: Boolean): Int = when {
+ kind == Kind.Boost ->
+ if (Build.VERSION.SDK_INT >= 30) HapticFeedbackConstants.CONFIRM else HapticFeedbackConstants.CONTEXT_CLICK
+ isUpdate -> HapticFeedbackConstants.CLOCK_TICK
+ else -> HapticFeedbackConstants.CONTEXT_CLICK
+}
+
+private const val EnterLiftDp = -28f
+private const val ExitLiftDp = -24f
+private const val EnterScale = 0.92f
+private const val ExitScale = 0.94f
+private const val EnterBlurDp = 6f
+private const val ExitBlurDp = 4f
+private const val SWIPE_DISMISS_DP = 20f
+
+/** The spring settle: iOS `spring(response: 0.42, dampingFraction: 0.72)`. */
+private val EnterSpring = spring(dampingRatio = 0.72f, stiffness = springStiffness(0.42f))
+
+/** iOS plays the haptic 240 ms into the drop, as the spring lands. */
+private const val LAND_MILLIS = 240L
diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionAlertPill.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionAlertPill.kt
new file mode 100644
index 0000000000..df09b364c8
--- /dev/null
+++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionAlertPill.kt
@@ -0,0 +1,264 @@
+package com.flipcash.shared.chat.ui
+
+import androidx.compose.animation.Crossfade
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.TextAutoSize
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.zIndex
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.flipcash.core.R
+import com.flipcash.services.models.chat.BlobAccessContext
+import com.flipcash.shared.chat.alerts.ReactionAlertContent
+import com.flipcash.shared.chat.alerts.ReactionAlertContent.Kind
+import com.flipcash.shared.common.ui.ContactAvatar
+import com.getcode.theme.CodeTheme
+import dev.chrisbanes.haze.HazeInput
+import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.blur.HazeBlurStyle
+import dev.chrisbanes.haze.blur.HazeColorEffect
+import dev.chrisbanes.haze.blur.hazeBlur
+
+private val AvatarPalette = listOf(
+ Color(0xFFE8996B), Color(0xFF739EF2), Color(0xFF8CD18C), Color(0xFFD68CC8), Color(0xFFF0C864),
+)
+
+/** The iOS `AlertPill` banner's measurements, so the two platforms draw the same card. */
+object ReactionAlertPillSpec {
+ val MaxWidth = 378.dp
+ val CornerRadius = 28.dp
+ val HorizontalPadding = 18.dp
+ val VerticalPadding = 14.dp
+ val AvatarSize = 40.dp
+ val StackAvatarSize = 32.dp
+
+ /**
+ * iOS's dark-tinted glass lands a few steps lighter than the app background (about 30 over 24),
+ * not darker, so the tint is a near-black grey rather than black.
+ */
+ val Tint = Color(0xFF222224)
+ const val TINT_ALPHA = 0.6f
+
+ /** Used where there is no backdrop to blur: the same grey, nearly opaque. */
+ const val FLAT_ALPHA = 0.94f
+
+ val GlassStyle = HazeBlurStyle {
+ blurRadius(24.dp)
+ backgroundColor(Tint)
+ colorEffects(listOf(HazeColorEffect.tint(Tint.copy(alpha = TINT_ALPHA))))
+ }
+}
+
+/**
+ * The alert banner: a dark glass card with the reactor's avatar, the chat name, the title, the
+ * quoted message and the emoji or boost amount. [hazeState] is the app's blur source; without one the
+ * card is a flat translucent black.
+ *
+ * With [animate] false the text swap on a merge is instant, for the screenshot tests.
+ */
+@Composable
+fun ReactionAlertPill(
+ content: ReactionAlertContent,
+ inActiveChat: Boolean,
+ modifier: Modifier = Modifier,
+ maxWidth: Dp = ReactionAlertPillSpec.MaxWidth,
+ hazeState: HazeState? = null,
+ animate: Boolean = true,
+) {
+ val shape = RoundedCornerShape(ReactionAlertPillSpec.CornerRadius)
+ val glass = if (hazeState != null) {
+ Modifier.hazeBlur(HazeInput.Sources(hazeState), ReactionAlertPillSpec.GlassStyle)
+ } else {
+ Modifier.background(ReactionAlertPillSpec.Tint.copy(alpha = ReactionAlertPillSpec.FLAT_ALPHA))
+ }
+ Box(
+ modifier = modifier
+ .widthIn(max = minOf(ReactionAlertPillSpec.MaxWidth, maxWidth))
+ .fillMaxWidth()
+ .clip(shape)
+ .then(glass)
+ .border(0.5.dp, Color.White.copy(alpha = 0.12f), shape),
+ ) {
+ Crossfade(
+ targetState = content,
+ animationSpec = tween(if (animate) 150 else 0),
+ label = "alertContent",
+ ) { shown ->
+ AlertRow(shown, inActiveChat)
+ }
+ }
+}
+
+@Composable
+private fun AlertRow(content: ReactionAlertContent, inActiveChat: Boolean) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(
+ horizontal = ReactionAlertPillSpec.HorizontalPadding,
+ vertical = ReactionAlertPillSpec.VerticalPadding,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ if (content.kind == Kind.Several) {
+ AvatarStack(content)
+ } else {
+ AlertAvatar(content, index = 0, size = ReactionAlertPillSpec.AvatarSize, textSize = 16)
+ }
+ Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(
+ text = if (inActiveChat) stringResource(R.string.alert_thisChat) else content.chatName.orEmpty(),
+ style = CodeTheme.typography.caption,
+ color = CodeTheme.colors.textSecondary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ // Shrinks to fit before it would ellipsize: the title is never wrapped or cut.
+ androidx.compose.material3.Text(
+ text = title(content),
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.textMain,
+ maxLines = 1,
+ softWrap = false,
+ overflow = TextOverflow.Ellipsis,
+ autoSize = TextAutoSize.StepBased(
+ minFontSize = 11.sp,
+ maxFontSize = CodeTheme.typography.textSmall.fontSize,
+ stepSize = 0.5.sp,
+ ),
+ )
+ if (content.quote.isNotEmpty()) {
+ Text(
+ text = content.quote,
+ style = CodeTheme.typography.caption,
+ color = CodeTheme.colors.textSecondary,
+ maxLines = 1,
+ softWrap = false,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ when (content.kind) {
+ Kind.Reaction -> Text(content.emojis.last(), color = CodeTheme.colors.textMain, fontSize = 30.sp, maxLines = 1)
+ Kind.Boost -> Text(
+ text = "+" + boostCentsText(content.boostCents ?: 0),
+ style = CodeTheme.typography.textLarge,
+ color = CodeTheme.colors.success,
+ maxLines = 1,
+ )
+ // The newest two; the title already counts everyone.
+ Kind.Several -> Text(
+ text = content.emojis.takeLast(2).joinToString(""),
+ color = CodeTheme.colors.textMain,
+ fontSize = 20.sp,
+ maxLines = 1,
+ textAlign = TextAlign.End,
+ )
+ }
+ }
+}
+
+@Composable
+private fun title(content: ReactionAlertContent): String {
+ val first = content.actorNames.firstOrNull() ?: stringResource(R.string.alert_someone)
+ return when (content.kind) {
+ Kind.Reaction -> stringResource(R.string.alert_reaction_title, first)
+ Kind.Boost -> stringResource(R.string.alert_boost_title, first, content.emojis.last())
+ Kind.Several -> {
+ val others = content.actorNames.size - 1
+ pluralStringResource(R.plurals.alert_several_title, others, first, others)
+ }
+ }
+}
+
+/** Smaller than a lone avatar and overlapped lightly, with the first reactor on top. */
+@Composable
+private fun AvatarStack(content: ReactionAlertContent) {
+ val size = ReactionAlertPillSpec.StackAvatarSize
+ val step = size - CodeTheme.dimens.staticGrid.x2
+ val shown = content.actorNames.take(3)
+ Box(modifier = Modifier.width(size + step * (shown.size - 1)).height(size)) {
+ shown.indices.forEach { index ->
+ AlertAvatar(
+ content = content,
+ index = index,
+ size = size,
+ textSize = 13,
+ ring = true,
+ modifier = Modifier
+ .offset(x = step * index)
+ .zIndex((shown.size - index).toFloat()),
+ )
+ }
+ }
+}
+
+/** The actor's profile photo, else their initial on a palette fill. */
+@Composable
+private fun AlertAvatar(
+ content: ReactionAlertContent,
+ index: Int,
+ size: Dp,
+ textSize: Int,
+ modifier: Modifier = Modifier,
+ ring: Boolean = false,
+) {
+ val name = content.actorNames.getOrNull(index)
+ val photo = content.actorPhotos.getOrNull(index)
+ val fill = AvatarPalette[Math.floorMod(name?.hashCode() ?: index, AvatarPalette.size)]
+ val ringWidth = if (ring) CodeTheme.dimens.thickBorder else 0.dp
+ Box(
+ modifier = modifier
+ .size(size)
+ .clip(CircleShape)
+ .background(Color.Black.copy(alpha = 0.6f))
+ .padding(ringWidth)
+ .clip(CircleShape)
+ .background(fill),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (photo != null) {
+ ContactAvatar(
+ image = photo,
+ displayName = name.orEmpty(),
+ access = BlobAccessContext.profile(content.actorIds.getOrNull(index)),
+ modifier = Modifier.size(size - ringWidth * 2),
+ )
+ } else {
+ Text(
+ text = name?.trim()?.firstOrNull()?.uppercase() ?: "?",
+ color = Color.Black,
+ fontSize = textSize.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ }
+}
diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionAlertPillScreenshotTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionAlertPillScreenshotTest.kt
new file mode 100644
index 0000000000..7c67e9a730
--- /dev/null
+++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionAlertPillScreenshotTest.kt
@@ -0,0 +1,83 @@
+package com.flipcash.shared.chat.ui
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.view.View
+import androidx.activity.ComponentActivity
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.foundation.background
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.unit.dp
+import com.flipcash.app.theme.FlipcashPreview
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.shared.chat.alerts.ReactionAlertContent
+import com.flipcash.shared.chat.alerts.ReactionAlertContent.Kind
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+import java.io.File
+
+/**
+ * Renders each banner state to `build/screenshots/`.
+ * Not an assertion test: it shows layout and copy for comparison with the design. Animation is off
+ * so each file is the settled state.
+ */
+@RunWith(RobolectricTestRunner::class)
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+@Config(sdk = [34], qualifiers = "w402dp-h170dp-xhdpi")
+class ReactionAlertPillScreenshotTest {
+
+ @get:Rule
+ val composeRule = createAndroidComposeRule()
+
+ private val chat = ChatId(byteArrayOf(1))
+ private val quote = "Me! I'll send my \$40 tonight"
+
+ private fun content(kind: Kind, actors: List = listOf("Chloe"), emojis: List = listOf("🔥"), boost: Long? = null) =
+ ReactionAlertContent(chat, 1, kind, "Ballers", quote, actors, emojis, boost)
+
+ @Test fun reaction() = render(content(Kind.Reaction), "alert_reaction.png")
+
+ @Test fun boost() = render(content(Kind.Boost, boost = 500), "alert_boost.png")
+
+ @Test fun stacked() = render(
+ content(Kind.Several, actors = listOf("Chloe", "Sam", "Jo"), emojis = listOf("🔥", "❤️", "😂")),
+ "alert_stacked.png",
+ )
+
+ @Test fun inActiveChat() = render(content(Kind.Reaction), "alert_this_chat.png", inActiveChat = true)
+
+ private fun render(content: ReactionAlertContent, fileName: String, inActiveChat: Boolean = false) {
+ composeRule.mainClock.autoAdvance = false
+ composeRule.setContent {
+ Frame {
+ ReactionAlertPill(content, inActiveChat, animate = false)
+ }
+ }
+ repeat(10) { composeRule.mainClock.advanceTimeByFrame() }
+ val root: View = composeRule.activity.findViewById(android.R.id.content)
+ val bitmap = Bitmap.createBitmap(root.width.coerceAtLeast(1), root.height.coerceAtLeast(1), Bitmap.Config.ARGB_8888)
+ root.draw(Canvas(bitmap))
+ val file = File("build/screenshots").apply { mkdirs() }.resolve(fileName)
+ file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
+ println("SCREENSHOT_WRITTEN: ${file.absolutePath}")
+ }
+
+ // A dark-grey stage so the black pill reads against it.
+ @Composable
+ private fun Frame(content: @Composable () -> Unit) {
+ FlipcashPreview(showBackground = false) {
+ Box(Modifier.background(Color(0xFF2A2A2A)).padding(12.dp), contentAlignment = Alignment.TopCenter) {
+ content()
+ }
+ }
+ }
+}
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/alerts/ReactionActorProfiles.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/alerts/ReactionActorProfiles.kt
new file mode 100644
index 0000000000..f66d2e80e9
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/alerts/ReactionActorProfiles.kt
@@ -0,0 +1,39 @@
+package com.flipcash.shared.chat.alerts
+
+import com.flipcash.app.persistence.sources.UserProfileDataSource
+import com.flipcash.services.controllers.ProfileController
+import com.flipcash.services.models.UserProfile
+import com.getcode.opencode.model.core.ID
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * The profile of someone who reacted: their name and photo for the alert, from wherever the app
+ * already has them, fetching when it has nothing.
+ */
+fun interface ReactionActorProfiles {
+ suspend fun resolve(userId: ID): UserProfile?
+
+ companion object {
+ val None = ReactionActorProfiles { null }
+ }
+}
+
+/**
+ * Reads the `user_profiles` cache that every other avatar in the app reads, and on a miss asks the
+ * server once and writes the answer back there, so the next alert, and the rest of the app, find it.
+ */
+@Singleton
+internal class StoredReactionActorProfiles @Inject constructor(
+ private val userProfiles: UserProfileDataSource,
+ private val profileController: ProfileController,
+) : ReactionActorProfiles {
+
+ override suspend fun resolve(userId: ID): UserProfile? {
+ userProfiles.getCachedProfile(userId)
+ ?.takeIf { it.displayName.isNotBlank() || it.profilePicture != null }
+ ?.let { return it }
+ return profileController.getProfileForUser(userId).getOrNull()
+ ?.also { runCatching { userProfiles.store(userId, it) } }
+ }
+}
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertController.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertController.kt
new file mode 100644
index 0000000000..bca5e3abf8
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertController.kt
@@ -0,0 +1,178 @@
+package com.flipcash.shared.chat.alerts
+
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.ProcessLifecycleOwner
+import com.flipcash.libs.coroutines.DispatcherProvider
+import com.flipcash.services.models.chat.ChatId
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Where the stream hands a qualifying reaction. [None] drops it, for callers that have no pill. */
+fun interface ReactionAlertSink {
+ fun offer(event: ReactionAlertEvent)
+
+ companion object {
+ val None = ReactionAlertSink { }
+ }
+}
+
+/** A pill on screen. [generation] bumps when content merges in, so the UI can restart its effects. */
+data class ReactionAlertState(
+ val content: ReactionAlertContent,
+ /** The pill's chat is the one the user has open, so its top row reads "This chat ↓". */
+ val inActiveChat: Boolean,
+ val generation: Int,
+)
+
+/** What a tap on the pill asks for. */
+sealed interface ReactionAlertAction {
+ /** The chat is open: scroll to the message and pulse it. */
+ data class ScrollTo(val chatId: ChatId, val messageId: Long) : ReactionAlertAction
+
+ /** Anywhere else: open the chat at the message. */
+ data class OpenChat(val chatId: ChatId, val messageId: Long) : ReactionAlertAction
+}
+
+/**
+ * The pill's state machine: foreground and on-screen gating, the [ReactionAlertQueue], and the hold
+ * timer. It has its own state rather than extending `SessionToastController`, whose queue writes a
+ * `BillToast(amount, isDeposit)` into the bill state; a pill carries chat names, emoji and merges.
+ *
+ * The chat screen reports which messages are on screen through [reportOnScreen]. A reaction on one
+ * of them draws nothing here: the chip on the bubble already pops, and a boost shows its amount.
+ */
+@Singleton
+class ReactionAlertController internal constructor(
+ private val scope: CoroutineScope,
+ private val isForeground: () -> Boolean,
+) : ReactionAlertSink {
+
+ @Inject
+ constructor(dispatchers: DispatcherProvider) : this(
+ scope = CoroutineScope(dispatchers.Main + SupervisorJob()),
+ isForeground = {
+ ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
+ },
+ )
+
+ companion object {
+ /** The banner's exit (220 ms), plus a frame, before the next one drops in. */
+ const val CLOSE_MILLIS = 230L
+ }
+
+ private val queue = ReactionAlertQueue()
+ private val lock = Any()
+ private var holdJob: Job? = null
+ private var generation = 0
+
+ private var activeChat: ChatId? = null
+ private var onScreen: Set = emptySet()
+
+ private val _state = MutableStateFlow(null)
+ val state: StateFlow = _state.asStateFlow()
+
+ private val _actions = MutableSharedFlow(extraBufferCapacity = 4)
+ val actions: SharedFlow = _actions.asSharedFlow()
+
+ /** The open chat's visible message ids. Replaces the earlier report for [chatId]. */
+ fun reportOnScreen(chatId: ChatId, messageIds: Set) = synchronized(lock) {
+ activeChat = chatId
+ onScreen = messageIds
+ refreshActiveChatFlag()
+ }
+
+ /** The chat screen for [chatId] left. A later chat's report is not cleared by an earlier one's exit. */
+ fun clearOnScreen(chatId: ChatId) = synchronized(lock) {
+ if (activeChat == chatId) {
+ activeChat = null
+ onScreen = emptySet()
+ refreshActiveChatFlag()
+ }
+ }
+
+ override fun offer(event: ReactionAlertEvent) {
+ synchronized(lock) {
+ if (!isForeground()) return
+ if (event.chatId == activeChat && event.messageId in onScreen) return
+ when (queue.offer(event)) {
+ ReactionAlertQueue.Outcome.Started -> publishAndHold()
+ ReactionAlertQueue.Outcome.Merged -> publishAndHold()
+ ReactionAlertQueue.Outcome.Queued -> Unit
+ }
+ }
+ }
+
+ /** Closes the pill early (swipe up) or when its hold ends, then starts the next one. */
+ fun dismiss() = synchronized(lock) { close() }
+
+ fun onTap() {
+ val shown = synchronized(lock) {
+ val current = _state.value ?: return
+ close()
+ current
+ }
+ val chatId = shown.content.chatId
+ val messageId = shown.content.messageId
+ _actions.tryEmit(
+ if (shown.inActiveChat) ReactionAlertAction.ScrollTo(chatId, messageId)
+ else ReactionAlertAction.OpenChat(chatId, messageId),
+ )
+ }
+
+ /** Drops everything, for sign-out. */
+ fun reset() = synchronized(lock) {
+ holdJob?.cancel()
+ queue.clear()
+ _state.value = null
+ }
+
+ private fun publishAndHold() {
+ val content = queue.current ?: return
+ generation++
+ _state.value = ReactionAlertState(content, content.chatId == activeChat, generation)
+ holdJob?.cancel()
+ holdJob = scope.after(content.holdMillis) { synchronized(lock) { close() } }
+ }
+
+ private fun close() {
+ if (_state.value == null) return
+ holdJob?.cancel()
+ _state.value = null
+ // The next pill waits for this one's close animation.
+ holdJob = scope.after(CLOSE_MILLIS) {
+ synchronized(lock) {
+ val next = queue.advance()
+ if (next != null) {
+ if (isForeground()) publishAndHold() else queue.clear()
+ }
+ }
+ }
+ }
+
+ private fun refreshActiveChatFlag() {
+ _state.update { shown ->
+ shown?.copy(inActiveChat = shown.content.chatId == activeChat)
+ }
+ }
+
+ private fun CoroutineScope.after(millis: Long, block: () -> Unit): Job = launch {
+ delay(millis)
+ block()
+ }
+}
+
+val LocalReactionAlerts = staticCompositionLocalOf { null }
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertPolicy.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertPolicy.kt
new file mode 100644
index 0000000000..a7cb4e4d56
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertPolicy.kt
@@ -0,0 +1,104 @@
+package com.flipcash.shared.chat.alerts
+
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.chat.ChatMessage
+import com.flipcash.services.models.chat.MediaItem
+import com.flipcash.services.models.chat.MessageContent
+import com.flipcash.services.models.chat.ReactionUpdate
+import com.getcode.opencode.model.core.ID
+
+/**
+ * What the stream knows about a reaction worth telling the user about, before names are resolved.
+ * [boostDeltaCents] is set only for [ReactionUpdate.Action.BOOSTED].
+ */
+data class ReactionAlertEvent(
+ val chatId: ChatId,
+ val messageId: Long,
+ val emoji: String,
+ val actor: ID,
+ val action: ReactionUpdate.Action,
+ val boostDeltaCents: Long?,
+ val actorName: String?,
+ val chatName: String?,
+ val quote: String,
+ /** The actor's profile picture, when the roster or the profile cache has one. */
+ val actorPhoto: MediaItem? = null,
+)
+
+/**
+ * The trigger rules from `docs/specs/in-app-reaction-alerts.md`. Pure so each rule is a test.
+ *
+ * Foreground and on-screen are presentation concerns and live in [ReactionAlertController].
+ */
+object ReactionAlertPolicy {
+
+ /** What the stream should do with an update whose message may not be held locally. */
+ sealed interface Decision {
+ data object Drop : Decision
+
+ /** Rules 2 and 3 hold but the message's sender is unknown: fetch the message, then decide again. */
+ data object NeedsMessage : Decision
+ data object Alert : Decision
+ }
+
+ /**
+ * @param message the local copy of the message, or null when it is not cached.
+ */
+ fun decide(update: ReactionUpdate, selfId: ID?, message: ChatMessage?): Decision {
+ if (selfId == null) return Decision.Drop
+ // 3. REMOVED (and UNKNOWN) show nothing.
+ if (update.action != ReactionUpdate.Action.ADDED && update.action != ReactionUpdate.Action.BOOSTED) {
+ return Decision.Drop
+ }
+ // 2. Reactions from the user's other device arrive on the same stream.
+ if (update.actor == selfId) return Decision.Drop
+ // 1. The message must be the user's own.
+ if (message == null) return Decision.NeedsMessage
+ return if (isSentBy(message, selfId)) Decision.Alert else Decision.Drop
+ }
+
+ private fun isSentBy(message: ChatMessage, selfId: ID): Boolean =
+ message.isFromSelf || message.senderId == selfId
+
+ /**
+ * `actor_boost_usd` is the actor's new total on the emoji, so this boost is the difference from
+ * the total held before. With no earlier total the new total is shown.
+ */
+ fun boostDeltaCents(newTotal: Long, previousTotal: Long?): Long {
+ if (previousTotal == null) return newTotal
+ return (newTotal - previousTotal).coerceAtLeast(0)
+ }
+
+ /** The text the pill quotes: the message's text, or the text of the reply it carries. */
+ fun quoteOf(message: ChatMessage?): String {
+ val content = message?.content ?: return ""
+ for (part in content) {
+ when (part) {
+ is MessageContent.Text -> return part.text
+ is MessageContent.Reply -> part.content.filterIsInstance().firstOrNull()?.let { return it.text }
+ else -> Unit
+ }
+ }
+ return ""
+ }
+}
+
+/**
+ * The last total each actor held on each emoji, as seen on the stream. `ReactionState` keeps the
+ * emoji's total and the viewer's own part but no per-actor figure, so the boost delta is read from
+ * here. An ADDED seeds a total of zero, so a first boost after it is a true delta.
+ */
+class ActorBoostTotals {
+ private data class Key(val chatId: ChatId, val messageId: Long, val emoji: String, val actor: List)
+
+ private val totals = mutableMapOf()
+
+ fun previous(chatId: ChatId, messageId: Long, emoji: String, actor: ID): Long? =
+ totals[Key(chatId, messageId, emoji, actor)]
+
+ fun record(chatId: ChatId, messageId: Long, emoji: String, actor: ID, total: Long) {
+ totals[Key(chatId, messageId, emoji, actor)] = total
+ }
+
+ fun clear() = totals.clear()
+}
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertQueue.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertQueue.kt
new file mode 100644
index 0000000000..e1081faf1e
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertQueue.kt
@@ -0,0 +1,155 @@
+package com.flipcash.shared.chat.alerts
+
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.chat.MediaItem
+import com.getcode.opencode.model.core.ID
+import com.flipcash.services.models.chat.ReactionUpdate
+
+/** What one pill shows. Titles are composed by the UI from these fields, so they can be localised. */
+data class ReactionAlertContent(
+ val chatId: ChatId,
+ val messageId: Long,
+ val kind: Kind,
+ val chatName: String?,
+ val quote: String,
+ /** Distinct actors, in the order they arrived. Names are null when not resolved. */
+ val actorNames: List,
+ /** Distinct emoji, in the order they arrived. The pill shows at most [MAX_TRAILING_EMOJI]. */
+ val emojis: List,
+ /** The boost this alert reports, for [Kind.Boost]. */
+ val boostCents: Long? = null,
+ /** The actors' ids, parallel to [actorNames]: what authorizes re-minting an expired photo URL. */
+ val actorIds: List = emptyList(),
+ /** The actors' profile pictures, parallel to [actorNames]. Null where the actor has none. */
+ val actorPhotos: List = emptyList(),
+) {
+ enum class Kind { Reaction, Boost, Several }
+
+ /** 2.5 s, or 3.5 s for a boost, which carries an amount to read. */
+ val holdMillis: Long get() = if (kind == Kind.Boost) BOOST_HOLD_MILLIS else HOLD_MILLIS
+
+ val trailingEmojis: List get() = emojis.take(MAX_TRAILING_EMOJI)
+
+ companion object {
+ const val HOLD_MILLIS = 2_500L
+ const val BOOST_HOLD_MILLIS = 3_500L
+ const val MAX_TRAILING_EMOJI = 3
+
+ fun from(event: ReactionAlertEvent): ReactionAlertContent {
+ val boosted = event.action == ReactionUpdate.Action.BOOSTED
+ return ReactionAlertContent(
+ chatId = event.chatId,
+ messageId = event.messageId,
+ kind = if (boosted) Kind.Boost else Kind.Reaction,
+ chatName = event.chatName,
+ quote = event.quote,
+ actorNames = listOf(event.actorName),
+ emojis = listOf(event.emoji),
+ boostCents = if (boosted) event.boostDeltaCents else null,
+ actorIds = listOf(event.actor),
+ actorPhotos = listOf(event.actorPhoto),
+ )
+ }
+ }
+}
+
+/**
+ * One pill at a time. Events for the pill's own message merge into it and restart its hold; events
+ * for another message wait their turn, merging with each other when they share a message.
+ *
+ * Pure bookkeeping: the clock and the hold timer belong to [ReactionAlertController].
+ */
+class ReactionAlertQueue {
+
+ enum class Outcome {
+ /** Nothing was showing; the event is now the current pill and its hold should start. */
+ Started,
+
+ /** Merged into the current pill; its hold should restart. */
+ Merged,
+
+ /** Waiting behind the current pill. */
+ Queued,
+ }
+
+ var current: ReactionAlertContent? = null
+ private set
+
+ private val pending = ArrayDeque()
+
+ val pendingCount: Int get() = pending.size
+
+ /**
+ * [actorId] tells actors apart for the several-actors state; the event's actor name alone would
+ * merge two people with the same name, or two whose names did not resolve.
+ */
+ fun offer(event: ReactionAlertEvent): Outcome {
+ val incoming = ReactionAlertContent.from(event)
+ val shown = current
+ if (shown == null) {
+ current = incoming
+ actors[key(incoming)] = mutableListOf(event.actor)
+ return Outcome.Started
+ }
+ if (sameMessage(shown, incoming)) {
+ current = merge(shown, incoming, event.actor)
+ return Outcome.Merged
+ }
+ val index = pending.indexOfFirst { sameMessage(it, incoming) }
+ if (index >= 0) {
+ pending[index] = merge(pending[index], incoming, event.actor)
+ } else {
+ pending.addLast(incoming)
+ actors[key(incoming)] = mutableListOf(event.actor)
+ }
+ return Outcome.Queued
+ }
+
+ /** Closes the current pill and starts the next waiting one, if any. */
+ fun advance(): ReactionAlertContent? {
+ current?.let { actors.remove(key(it)) }
+ current = pending.removeFirstOrNull()
+ return current
+ }
+
+ /** Drops the current pill and everything waiting. */
+ fun clear() {
+ current = null
+ pending.clear()
+ actors.clear()
+ }
+
+ private val actors = mutableMapOf, Long>, MutableList>>()
+
+ private fun key(content: ReactionAlertContent) = content.chatId.bytes.toList() to content.messageId
+
+ private fun sameMessage(a: ReactionAlertContent, b: ReactionAlertContent) =
+ a.chatId == b.chatId && a.messageId == b.messageId
+
+ private fun merge(held: ReactionAlertContent, incoming: ReactionAlertContent, actor: List): ReactionAlertContent {
+ val known = actors.getOrPut(key(held)) { mutableListOf() }
+ val isNewActor = actor !in known
+ if (isNewActor) known.add(actor)
+ val names = if (isNewActor) held.actorNames + incoming.actorNames else held.actorNames
+ val several = known.size >= 2
+ val emojis = if (several) {
+ (held.emojis + incoming.emojis).distinct()
+ } else {
+ // One actor: the newest event swaps the content in place.
+ incoming.emojis
+ }
+ return held.copy(
+ kind = when {
+ several -> ReactionAlertContent.Kind.Several
+ else -> incoming.kind
+ },
+ actorNames = names,
+ actorIds = if (isNewActor) held.actorIds + incoming.actorIds else held.actorIds,
+ actorPhotos = if (isNewActor) held.actorPhotos + incoming.actorPhotos else held.actorPhotos,
+ emojis = emojis,
+ boostCents = if (several) null else incoming.boostCents,
+ quote = incoming.quote.ifEmpty { held.quote },
+ chatName = incoming.chatName ?: held.chatName,
+ )
+ }
+}
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt
index fc277f5f8a..4c0e821deb 100644
--- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt
@@ -13,6 +13,10 @@ import com.flipcash.shared.chat.RosterUpdateSink
import com.flipcash.shared.chat.RosterSearchSource
import com.flipcash.shared.chat.internal.DataStoreLobbyRequestStore
import com.flipcash.shared.chat.internal.DmOutgoingEncryption
+import com.flipcash.shared.chat.alerts.ReactionAlertController
+import com.flipcash.shared.chat.alerts.ReactionAlertSink
+import com.flipcash.shared.chat.alerts.ReactionActorProfiles
+import com.flipcash.shared.chat.alerts.StoredReactionActorProfiles
import com.flipcash.shared.chat.internal.MentionPoolUpdates
import com.flipcash.shared.chat.internal.MentionSuggestionPool
import com.flipcash.shared.chat.internal.ServerRosterSearchSource
@@ -93,6 +97,16 @@ abstract class ChatModule {
impl: MentionSuggestionPool
): MentionPoolUpdates
+ @Binds
+ internal abstract fun bindReactionActorProfiles(
+ impl: StoredReactionActorProfiles
+ ): ReactionActorProfiles
+
+ @Binds
+ internal abstract fun bindReactionAlertSink(
+ impl: ReactionAlertController
+ ): ReactionAlertSink
+
@Binds
@IntoSet
abstract fun bindSessionListener(
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt
index b93f82ddca..b871482a11 100644
--- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt
@@ -26,6 +26,11 @@ import com.flipcash.services.models.GetDeltaError
import com.flipcash.shared.chat.ActiveTypist
import com.flipcash.shared.chat.EventSequenceTracker
import com.flipcash.shared.chat.EventStreamOperations
+import com.flipcash.shared.chat.alerts.ActorBoostTotals
+import com.flipcash.shared.chat.alerts.ReactionActorProfiles
+import com.flipcash.shared.chat.alerts.ReactionAlertEvent
+import com.flipcash.shared.chat.alerts.ReactionAlertPolicy
+import com.flipcash.shared.chat.alerts.ReactionAlertSink
import com.flipcash.shared.chat.internal.ChatStateHolder
import com.flipcash.services.user.UserManager
import com.flipcash.shared.chat.internal.MentionPoolUpdates
@@ -94,6 +99,8 @@ class EventStreamDelegate @Inject constructor(
private val analytics: FlipcashAnalytics,
private val exchange: Exchange,
private val mentionPool: MentionPoolUpdates = MentionPoolUpdates.None,
+ private val reactionAlerts: ReactionAlertSink = ReactionAlertSink.None,
+ private val actorProfiles: ReactionActorProfiles = ReactionActorProfiles.None,
) : EventStreamOperations {
@@ -135,6 +142,9 @@ class EventStreamDelegate @Inject constructor(
*/
private val reactionStates: MutableMap> = mutableMapOf()
+ /** Each actor's last boost total per emoji, for the alert's delta ([ReactionAlertPolicy.boostDeltaCents]). */
+ private val actorBoostTotals = ActorBoostTotals()
+
/**
* Gets or creates the message's [ReactionState], seeding a new one from Room. The state is
* written back as a full summary, so an unseeded one would tombstone every stored emoji the
@@ -326,6 +336,7 @@ class EventStreamDelegate @Inject constructor(
}
internal fun clearAll() {
+ actorBoostTotals.clear()
sequenceTracker.clearAll()
reactionStates.clear()
typingTracker.clear()
@@ -469,6 +480,7 @@ class EventStreamDelegate @Inject constructor(
if (update.reactionUpdates.isNotEmpty()) {
val touchedMessageIds = mutableSetOf()
for (reactionUpdate in update.reactionUpdates) {
+ alertFor(chatId, reactionUpdate)
reactionState(chatId, reactionUpdate.messageId).applyUpdate(
emoji = reactionUpdate.emoji.value,
actorIsSelf = reactionUpdate.actor == userManager.accountId,
@@ -581,6 +593,91 @@ class EventStreamDelegate @Inject constructor(
}
// endregion
+
+ // region Reaction alerts
+
+ /**
+ * Applies the trigger rules to [reactionUpdate] and hands a qualifying one to [reactionAlerts].
+ * Runs before the update reaches [ReactionState], and always records the actor's total so the
+ * next boost has a previous one to subtract. Foreground and on-screen gating is the sink's.
+ */
+ private suspend fun alertFor(chatId: ChatId, reactionUpdate: ReactionUpdate) {
+ val emoji = reactionUpdate.emoji.value
+ val local = messageDataSource.getMessage(chatId, reactionUpdate.messageId)
+ val previous = actorBoostTotals.previous(chatId, reactionUpdate.messageId, emoji, reactionUpdate.actor)
+ ?: local?.reactions?.reactions
+ ?.firstOrNull { it.emoji.value == emoji }
+ ?.sampleReactors
+ ?.firstOrNull { it.userId == reactionUpdate.actor }
+ ?.boostCents
+ if (reactionUpdate.action == ReactionUpdate.Action.REMOVED) {
+ actorBoostTotals.record(chatId, reactionUpdate.messageId, emoji, reactionUpdate.actor, 0)
+ } else {
+ actorBoostTotals.record(
+ chatId, reactionUpdate.messageId, emoji, reactionUpdate.actor, reactionUpdate.actorBoostCents,
+ )
+ }
+
+ val selfId = userManager.accountId
+ when (ReactionAlertPolicy.decide(reactionUpdate, selfId, local)) {
+ ReactionAlertPolicy.Decision.Drop -> Unit
+ // Off the stream's path: resolving the actor's photo may go to the network.
+ ReactionAlertPolicy.Decision.Alert -> scope?.launch { emitAlert(chatId, reactionUpdate, local, previous) }
+ ?: emitAlert(chatId, reactionUpdate, local, previous)
+ ReactionAlertPolicy.Decision.NeedsMessage -> {
+ // Not cached: ask the server, off the stream's path, and drop the alert on failure.
+ scope?.launch {
+ val fetched = messagingController.getMessage(chatId, reactionUpdate.messageId).getOrNull()
+ ?: return@launch
+ if (ReactionAlertPolicy.decide(reactionUpdate, selfId, fetched) == ReactionAlertPolicy.Decision.Alert) {
+ emitAlert(chatId, reactionUpdate, fetched, previous)
+ }
+ }
+ }
+ }
+ }
+
+ private suspend fun emitAlert(
+ chatId: ChatId,
+ reactionUpdate: ReactionUpdate,
+ message: ChatMessage?,
+ previousActorTotal: Long?,
+ ) {
+ val selfId = userManager.accountId
+ val members = runCatching { memberDataSource.getMembersForChat(chatId) }.getOrDefault(emptyList())
+ val member = members.firstOrNull { it.userId == reactionUpdate.actor }?.userProfile
+ var actorName = member?.displayName?.takeIf { it.isNotBlank() }
+ var actorPhoto = member?.profilePicture
+ if (actorName == null || actorPhoto == null) {
+ // The roster of a large group is partial, and a member row may lack a photo: ask the
+ // profile cache, which fetches what it does not hold.
+ val resolved = runCatching { actorProfiles.resolve(reactionUpdate.actor) }.getOrNull()
+ actorName = actorName ?: resolved?.displayName?.takeIf { it.isNotBlank() }
+ actorPhoto = actorPhoto ?: resolved?.profilePicture
+ }
+ val chatName = runCatching { metadataDataSource.getTitle(chatId) }.getOrNull()?.takeIf { it.isNotBlank() }
+ ?: members.firstOrNull { it.userId != selfId }?.userProfile?.displayName?.takeIf { it.isNotBlank() }
+ reactionAlerts.offer(
+ ReactionAlertEvent(
+ chatId = chatId,
+ messageId = reactionUpdate.messageId,
+ emoji = reactionUpdate.emoji.value,
+ actor = reactionUpdate.actor,
+ action = reactionUpdate.action,
+ boostDeltaCents = if (reactionUpdate.action == ReactionUpdate.Action.BOOSTED) {
+ ReactionAlertPolicy.boostDeltaCents(reactionUpdate.actorBoostCents, previousActorTotal)
+ } else {
+ null
+ },
+ actorName = actorName,
+ actorPhoto = actorPhoto,
+ chatName = chatName,
+ quote = ReactionAlertPolicy.quoteOf(message),
+ ),
+ )
+ }
+
+ // endregion
}
/**
diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertControllerTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertControllerTest.kt
new file mode 100644
index 0000000000..37c64e403d
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertControllerTest.kt
@@ -0,0 +1,115 @@
+package com.flipcash.shared.chat.alerts
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ReactionAlertControllerTest {
+
+ private fun TestScope.controller(foreground: () -> Boolean = { true }) =
+ ReactionAlertController(scope = this, isForeground = foreground)
+
+ @Test
+ fun `pill shows then hides after the hold`() = runTest {
+ val c = controller()
+ c.offer(alertEvent())
+ runCurrent()
+ assertNotNull(c.state.value)
+ advanceTimeBy(2_501)
+ assertNull(c.state.value)
+ c.reset()
+ }
+
+ @Test
+ fun `merge restarts the hold`() = runTest {
+ val c = controller()
+ c.offer(alertEvent())
+ advanceTimeBy(2_000)
+ c.offer(alertEvent(actor = BOB, actorName = "Bob"))
+ advanceTimeBy(2_000)
+ assertNotNull(c.state.value)
+ assertEquals(2, c.state.value?.generation)
+ advanceTimeBy(600)
+ assertNull(c.state.value)
+ c.reset()
+ }
+
+ @Test
+ fun `next pill waits for the close animation`() = runTest {
+ val c = controller()
+ c.offer(alertEvent(messageId = 10))
+ c.offer(alertEvent(messageId = 11))
+ advanceTimeBy(2_501)
+ assertNull(c.state.value)
+ advanceTimeBy(ReactionAlertController.CLOSE_MILLIS + 1)
+ assertEquals(11, c.state.value?.content?.messageId)
+ c.reset()
+ }
+
+ @Test
+ fun `background drops the event`() = runTest {
+ val c = controller(foreground = { false })
+ c.offer(alertEvent())
+ runCurrent()
+ assertNull(c.state.value)
+ }
+
+ @Test
+ fun `message on screen in the open chat draws no pill`() = runTest {
+ val c = controller()
+ c.reportOnScreen(CHAT, setOf(10L, 11L))
+ c.offer(alertEvent(messageId = 10))
+ runCurrent()
+ assertNull(c.state.value)
+ }
+
+ @Test
+ fun `off screen message in the open chat shows with this chat label`() = runTest {
+ val c = controller()
+ c.reportOnScreen(CHAT, setOf(11L))
+ c.offer(alertEvent(messageId = 10))
+ runCurrent()
+ assertEquals(true, c.state.value?.inActiveChat)
+ c.reset()
+ }
+
+ @Test
+ fun `on screen in another chat still shows`() = runTest {
+ val c = controller()
+ c.reportOnScreen(OTHER_CHAT, setOf(10L))
+ c.offer(alertEvent(messageId = 10, chatId = CHAT))
+ runCurrent()
+ assertNotNull(c.state.value)
+ assertEquals(false, c.state.value?.inActiveChat)
+ c.reset()
+ }
+
+ @Test
+ fun `tap in the open chat scrolls, elsewhere opens`() = runTest {
+ val c = controller()
+ val seen = mutableListOf()
+ backgroundScope.launch { c.actions.collect { a -> seen += a } }
+ runCurrent()
+ c.offer(alertEvent())
+ runCurrent()
+ c.onTap()
+ runCurrent()
+ assertEquals(listOf(ReactionAlertAction.OpenChat(CHAT, 10)), seen)
+ c.reportOnScreen(CHAT, setOf(99L))
+ c.offer(alertEvent(messageId = 12))
+ advanceTimeBy(ReactionAlertController.CLOSE_MILLIS + 1)
+ runCurrent()
+ c.onTap()
+ runCurrent()
+ assertEquals(ReactionAlertAction.ScrollTo(CHAT, 12), seen.last())
+ c.reset()
+ }
+}
diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertPolicyTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertPolicyTest.kt
new file mode 100644
index 0000000000..ec6ac9eb44
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertPolicyTest.kt
@@ -0,0 +1,104 @@
+package com.flipcash.shared.chat.alerts
+
+import com.flipcash.services.models.chat.ReactionUpdate.Action
+import com.flipcash.shared.chat.alerts.ReactionAlertPolicy.Decision
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class ReactionAlertPolicyTest {
+
+ private fun decide(
+ update: com.flipcash.services.models.chat.ReactionUpdate,
+ message: com.flipcash.services.models.chat.ChatMessage?,
+ selfId: List? = SELF,
+ ) = ReactionAlertPolicy.decide(update, selfId, message)
+
+ @Test
+ fun `added reaction from another user on my message alerts`() {
+ assertEquals(Decision.Alert, decide(reactionUpdate(), message(SELF, fromSelf = true)))
+ }
+
+ @Test
+ fun `boost from another user on my message alerts`() {
+ assertEquals(Decision.Alert, decide(reactionUpdate(action = Action.BOOSTED), message(SELF)))
+ }
+
+ @Test
+ fun `sender id alone identifies my message`() {
+ assertEquals(Decision.Alert, decide(reactionUpdate(), message(SELF, fromSelf = false)))
+ }
+
+ @Test
+ fun `reaction on someone elses message is dropped`() {
+ assertEquals(Decision.Drop, decide(reactionUpdate(), message(BOB)))
+ }
+
+ @Test
+ fun `my own reaction from another device is dropped`() {
+ assertEquals(Decision.Drop, decide(reactionUpdate(actor = SELF), message(SELF)))
+ }
+
+ @Test
+ fun `removed and unknown actions are dropped`() {
+ assertEquals(Decision.Drop, decide(reactionUpdate(action = Action.REMOVED), message(SELF)))
+ assertEquals(Decision.Drop, decide(reactionUpdate(action = Action.UNKNOWN), message(SELF)))
+ }
+
+ @Test
+ fun `uncached message asks for a fetch`() {
+ assertEquals(Decision.NeedsMessage, decide(reactionUpdate(), null))
+ }
+
+ @Test
+ fun `removed or own reaction never triggers a fetch`() {
+ assertEquals(Decision.Drop, decide(reactionUpdate(action = Action.REMOVED), null))
+ assertEquals(Decision.Drop, decide(reactionUpdate(actor = SELF), null))
+ }
+
+ @Test
+ fun `signed out drops`() {
+ assertEquals(Decision.Drop, decide(reactionUpdate(), message(SELF), selfId = null))
+ }
+
+ @Test
+ fun `boost delta is the new total when no previous total is known`() {
+ assertEquals(500, ReactionAlertPolicy.boostDeltaCents(500, null))
+ }
+
+ @Test
+ fun `boost delta subtracts the actors previous total`() {
+ assertEquals(700, ReactionAlertPolicy.boostDeltaCents(1200, 500))
+ }
+
+ @Test
+ fun `boost delta never goes negative`() {
+ assertEquals(0, ReactionAlertPolicy.boostDeltaCents(100, 500))
+ }
+
+ @Test
+ fun `totals track add then boost then boost`() {
+ val totals = ActorBoostTotals()
+ assertNull(totals.previous(CHAT, 10, "🔥", ALICE))
+ totals.record(CHAT, 10, "🔥", ALICE, 0)
+ assertEquals(500, ReactionAlertPolicy.boostDeltaCents(500, totals.previous(CHAT, 10, "🔥", ALICE)))
+ totals.record(CHAT, 10, "🔥", ALICE, 500)
+ assertEquals(700, ReactionAlertPolicy.boostDeltaCents(1200, totals.previous(CHAT, 10, "🔥", ALICE)))
+ }
+
+ @Test
+ fun `totals are per actor, emoji and message`() {
+ val totals = ActorBoostTotals()
+ totals.record(CHAT, 10, "🔥", ALICE, 500)
+ assertNull(totals.previous(CHAT, 10, "🔥", BOB))
+ assertNull(totals.previous(CHAT, 10, "❤️", ALICE))
+ assertNull(totals.previous(CHAT, 11, "🔥", ALICE))
+ assertNull(totals.previous(OTHER_CHAT, 10, "🔥", ALICE))
+ }
+
+ @Test
+ fun `quote reads text or the reply text`() {
+ assertEquals("hello", ReactionAlertPolicy.quoteOf(message(SELF)))
+ assertEquals("", ReactionAlertPolicy.quoteOf(null))
+ }
+}
diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertQueueTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertQueueTest.kt
new file mode 100644
index 0000000000..009fa0a20d
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertQueueTest.kt
@@ -0,0 +1,99 @@
+package com.flipcash.shared.chat.alerts
+
+import com.flipcash.services.models.chat.ReactionUpdate.Action
+import com.flipcash.shared.chat.alerts.ReactionAlertContent.Kind
+import com.flipcash.shared.chat.alerts.ReactionAlertQueue.Outcome
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class ReactionAlertQueueTest {
+ private val queue = ReactionAlertQueue()
+
+ @Test
+ fun `first event starts a reaction pill`() {
+ assertEquals(Outcome.Started, queue.offer(alertEvent()))
+ assertEquals(Kind.Reaction, queue.current?.kind)
+ assertEquals(2_500, queue.current?.holdMillis)
+ }
+
+ @Test
+ fun `boost pill holds longer and carries the delta`() {
+ queue.offer(alertEvent(action = Action.BOOSTED, boostDeltaCents = 700))
+ assertEquals(Kind.Boost, queue.current?.kind)
+ assertEquals(700, queue.current?.boostCents)
+ assertEquals(3_500, queue.current?.holdMillis)
+ }
+
+ @Test
+ fun `same actor same message swaps in place`() {
+ queue.offer(alertEvent(emoji = "🔥"))
+ assertEquals(Outcome.Merged, queue.offer(alertEvent(emoji = "❤️", action = Action.BOOSTED, boostDeltaCents = 300)))
+ val c = queue.current!!
+ assertEquals(Kind.Boost, c.kind)
+ assertEquals(listOf("❤️"), c.emojis)
+ assertEquals(listOf("Alice"), c.actorNames)
+ }
+
+ @Test
+ fun `second actor on same message becomes several`() {
+ queue.offer(alertEvent())
+ assertEquals(Outcome.Merged, queue.offer(alertEvent(actor = BOB, actorName = "Bob", emoji = "😂")))
+ val c = queue.current!!
+ assertEquals(Kind.Several, c.kind)
+ assertEquals(listOf("Alice", "Bob"), c.actorNames)
+ assertEquals(listOf("🔥", "😂"), c.emojis)
+ assertNull(c.boostCents)
+ }
+
+ @Test
+ fun `trailing emoji are capped at three and distinct`() {
+ queue.offer(alertEvent(emoji = "a"))
+ queue.offer(alertEvent(actor = BOB, emoji = "b"))
+ queue.offer(alertEvent(actor = listOf(4), emoji = "c"))
+ queue.offer(alertEvent(actor = listOf(5), emoji = "d"))
+ queue.offer(alertEvent(actor = listOf(6), emoji = "a"))
+ assertEquals(listOf("a", "b", "c"), queue.current!!.trailingEmojis)
+ assertEquals(4, queue.current!!.emojis.size)
+ }
+
+ @Test
+ fun `unnamed actors still count as distinct`() {
+ queue.offer(alertEvent(actorName = null))
+ queue.offer(alertEvent(actor = BOB, actorName = null))
+ assertEquals(Kind.Several, queue.current!!.kind)
+ }
+
+ @Test
+ fun `other message waits and advance shows it`() {
+ queue.offer(alertEvent(messageId = 10))
+ assertEquals(Outcome.Queued, queue.offer(alertEvent(messageId = 11, emoji = "😂")))
+ assertEquals(1, queue.pendingCount)
+ assertEquals(11, queue.advance()?.messageId)
+ assertNull(queue.advance())
+ }
+
+ @Test
+ fun `queued events for one message merge`() {
+ queue.offer(alertEvent(messageId = 10))
+ queue.offer(alertEvent(messageId = 11))
+ queue.offer(alertEvent(messageId = 11, actor = BOB, actorName = "Bob"))
+ assertEquals(1, queue.pendingCount)
+ assertEquals(Kind.Several, queue.advance()?.kind)
+ }
+
+ @Test
+ fun `same message id in another chat is a different message`() {
+ queue.offer(alertEvent(chatId = CHAT))
+ assertEquals(Outcome.Queued, queue.offer(alertEvent(chatId = OTHER_CHAT)))
+ }
+
+ @Test
+ fun `clear empties everything`() {
+ queue.offer(alertEvent())
+ queue.offer(alertEvent(messageId = 11))
+ queue.clear()
+ assertNull(queue.current)
+ assertEquals(0, queue.pendingCount)
+ }
+}
diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertTestFixtures.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertTestFixtures.kt
new file mode 100644
index 0000000000..d23f72babf
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertTestFixtures.kt
@@ -0,0 +1,61 @@
+package com.flipcash.shared.chat.alerts
+
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.chat.ChatMessage
+import com.flipcash.services.models.chat.Emoji
+import com.flipcash.services.models.chat.MessageContent
+import com.flipcash.services.models.chat.ReactionUpdate
+import kotlinx.datetime.Instant
+
+internal val SELF: List = listOf(1)
+internal val ALICE: List = listOf(2)
+internal val BOB: List = listOf(3)
+internal val CHAT = ChatId(byteArrayOf(9))
+internal val OTHER_CHAT = ChatId(byteArrayOf(8))
+
+internal fun reactionUpdate(
+ actor: List = ALICE,
+ action: ReactionUpdate.Action = ReactionUpdate.Action.ADDED,
+ emoji: String = "🔥",
+ messageId: Long = 10,
+ actorBoostCents: Long = 0,
+) = ReactionUpdate(
+ messageId = messageId,
+ emoji = Emoji(emoji),
+ actor = actor,
+ action = action,
+ count = 1,
+ version = 1,
+ reactedAt = Instant.fromEpochSeconds(1000),
+ actorBoostCents = actorBoostCents,
+)
+
+internal fun message(senderId: List?, fromSelf: Boolean = false, text: String = "hello") = ChatMessage(
+ messageId = 10,
+ senderId = senderId,
+ content = listOf(MessageContent.Text(text)),
+ timestamp = Instant.fromEpochSeconds(1000),
+ unreadSeq = 0,
+ isFromSelf = fromSelf,
+)
+
+internal fun alertEvent(
+ messageId: Long = 10,
+ chatId: ChatId = CHAT,
+ actor: List = ALICE,
+ actorName: String? = "Alice",
+ emoji: String = "🔥",
+ action: ReactionUpdate.Action = ReactionUpdate.Action.ADDED,
+ boostDeltaCents: Long? = null,
+ quote: String = "hello",
+) = ReactionAlertEvent(
+ chatId = chatId,
+ messageId = messageId,
+ emoji = emoji,
+ actor = actor,
+ action = action,
+ boostDeltaCents = boostDeltaCents,
+ actorName = actorName,
+ chatName = "Moony",
+ quote = quote,
+)