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, +)