From a321c817f3dedc14866829d3371e76d40674e058 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 25 Oct 2026 00:42:23 -0400 Subject: [PATCH 1/6] feat(chat): decide and queue in-app alerts for reactions and boosts on my messages --- .../chat/alerts/ReactionAlertController.kt | 178 ++++++++++++++++++ .../shared/chat/alerts/ReactionAlertPolicy.kt | 101 ++++++++++ .../shared/chat/alerts/ReactionAlertQueue.kt | 145 ++++++++++++++ .../flipcash/shared/chat/inject/ChatModule.kt | 7 + .../internal/delegates/EventStreamDelegate.kt | 84 +++++++++ .../alerts/ReactionAlertControllerTest.kt | 115 +++++++++++ .../chat/alerts/ReactionAlertPolicyTest.kt | 104 ++++++++++ .../chat/alerts/ReactionAlertQueueTest.kt | 99 ++++++++++ .../chat/alerts/ReactionAlertTestFixtures.kt | 61 ++++++ 9 files changed, 894 insertions(+) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertController.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertPolicy.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertQueue.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertControllerTest.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertPolicyTest.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertQueueTest.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertTestFixtures.kt 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..9c10d32df6 --- /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 { + /** Text fade (120 ms) and height collapse, then the width back into the island (200 ms). */ + const val CLOSE_MILLIS = 260L + 200L + } + + 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..5558861618 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertPolicy.kt @@ -0,0 +1,101 @@ +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.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 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..b4d55b019c --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/alerts/ReactionAlertQueue.kt @@ -0,0 +1,145 @@ +package com.flipcash.shared.chat.alerts + +import com.flipcash.services.models.chat.ChatId +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, +) { + 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, + ) + } + } +} + +/** + * 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, + 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..5691c14238 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,8 @@ 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.internal.MentionPoolUpdates import com.flipcash.shared.chat.internal.MentionSuggestionPool import com.flipcash.shared.chat.internal.ServerRosterSearchSource @@ -93,6 +95,11 @@ abstract class ChatModule { impl: MentionSuggestionPool ): MentionPoolUpdates + @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..519fffaf5b 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,10 @@ 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.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 +98,7 @@ class EventStreamDelegate @Inject constructor( private val analytics: FlipcashAnalytics, private val exchange: Exchange, private val mentionPool: MentionPoolUpdates = MentionPoolUpdates.None, + private val reactionAlerts: ReactionAlertSink = ReactionAlertSink.None, ) : EventStreamOperations { @@ -135,6 +140,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 +334,7 @@ class EventStreamDelegate @Inject constructor( } internal fun clearAll() { + actorBoostTotals.clear() sequenceTracker.clearAll() reactionStates.clear() typingTracker.clear() @@ -469,6 +478,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 +591,80 @@ 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 + ReactionAlertPolicy.Decision.Alert -> 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 actorName = members.firstOrNull { it.userId == reactionUpdate.actor } + ?.userProfile?.displayName?.takeIf { it.isNotBlank() } + 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, + 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, +) From 23a7fc4bd81b2aa5e73f4828423bbd834cd371d1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 25 Oct 2026 00:47:49 -0400 Subject: [PATCH 2/6] feat(flipcash): show in-app reaction and boost alert pill from the cutout --- .../com/flipcash/app/internal/ui/App.kt | 1 + .../flipcash/app/internal/ui/HomeViewModel.kt | 1 + .../app/internal/ui/navigation/AppContent.kt | 26 ++ .../core/src/main/res/values/strings.xml | 9 + .../internal/screens/MessengerScreen.kt | 16 + .../screens/components/MessageList.kt | 1 + .../screens/components/MessageReadReporter.kt | 34 ++ .../shared/chat/ui/ReactionAlertCutout.kt | 35 +++ .../shared/chat/ui/ReactionAlertHost.kt | 176 +++++++++++ .../shared/chat/ui/ReactionAlertPill.kt | 297 ++++++++++++++++++ 10 files changed, 596 insertions(+) create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionAlertCutout.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionAlertHost.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionAlertPill.kt 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..e264aabc2b 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) + // 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..d308bb008d 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1692,4 +1692,13 @@ 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 ↓ + now 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/ReactionAlertCutout.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionAlertCutout.kt new file mode 100644 index 0000000000..13f993e114 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionAlertCutout.kt @@ -0,0 +1,35 @@ +package com.flipcash.shared.chat.ui + +import android.graphics.Rect + +/** Where the pill grows out of, derived from `DisplayCutout.getBoundingRects()`. */ +sealed interface ReactionAlertAnchor { + /** No top cutout: the pill drops from the top as a banner. */ + data object Banner : ReactionAlertAnchor + + /** A cutout the pill is centred on (a punch-hole camera or a notch). */ + data class Centered(val rect: Rect) : ReactionAlertAnchor + + /** A corner hole: the pill's near edge is fixed to the hole, so it grows inward. */ + data class Corner(val rect: Rect, val atStart: Boolean) : ReactionAlertAnchor +} + +/** + * Picks the top cutout. Rects on the sides or bottom (a landscape notch, a bottom hole) are not + * anchors; with several top rects the one nearest the top edge wins. + */ +fun reactionAlertAnchor(rects: List, screenWidthPx: Int): ReactionAlertAnchor { + val top = rects + .filter { it.width() > 0 && it.height() > 0 && it.top <= it.height() } + .minByOrNull { it.top } + ?: return ReactionAlertAnchor.Banner + val centreX = (top.left + top.right) / 2f + val offCentre = kotlin.math.abs(centreX - screenWidthPx / 2f) + return if (offCentre <= screenWidthPx * CENTRED_TOLERANCE) { + ReactionAlertAnchor.Centered(top) + } else { + ReactionAlertAnchor.Corner(top, atStart = centreX < screenWidthPx / 2f) + } +} + +private const val CENTRED_TOLERANCE = 0.1f 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..937b89d0f9 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionAlertHost.kt @@ -0,0 +1,176 @@ +package com.flipcash.shared.chat.ui + +import android.animation.ValueAnimator +import android.os.Build +import android.view.HapticFeedbackConstants +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.clickable +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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.ReactionAlertController +import com.flipcash.shared.chat.alerts.ReactionAlertState +import kotlinx.coroutines.delay + +/** + * Draws the controller's pill over the app. It walks the motion table: width grows out of the + * cutout (180 ms), height and radius open on a spring, and on close the text fades, the height + * collapses (260 ms in all) and the width returns to the cutout (200 ms). + * + * With animations off (Reduce Motion) the pill appears in its expanded shape and crossfades. + */ +@Composable +fun ReactionAlertHost( + controller: ReactionAlertController, + modifier: Modifier = Modifier, +) { + val live by controller.state.collectAsState() + val view = LocalView.current + val density = LocalDensity.current + val animate = remember(live) { ValueAnimator.areAnimatorsEnabled() } + + // Kept after [live] clears so the close animation has content to draw. + var shown by remember { mutableStateOf(null) } + var phase by remember { mutableStateOf(ReactionAlertPhase.Rest) } + if (live != null) shown = live + + LaunchedEffect(live != null) { + if (live != null) { + if (animate) { + phase = ReactionAlertPhase.Compact + delay(ReactionAlertPillSpec.GROW_MILLIS.toLong()) + } + phase = ReactionAlertPhase.Expanded + } else if (shown != null) { + if (animate) { + phase = ReactionAlertPhase.Compact + delay(260) + } + phase = ReactionAlertPhase.Rest + if (animate) delay(200) + shown = null + } + } + + // One tap on the haptic per content swap, boost or reaction alike. + LaunchedEffect(live?.generation) { + if (live == null) return@LaunchedEffect + val constant = if (Build.VERSION.SDK_INT >= 30) { + HapticFeedbackConstants.CONFIRM + } else { + HapticFeedbackConstants.CONTEXT_CLICK + } + view.performHapticFeedback(constant) + } + + val current = shown ?: return + val interaction = remember { MutableInteractionSource() } + + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val screenWidthPx = with(density) { maxWidth.roundToPx() } + val anchor = remember(screenWidthPx, view.rootWindowInsets) { + val rects = view.rootWindowInsets?.displayCutout?.boundingRects.orEmpty() + reactionAlertAnchor(rects, screenWidthPx) + } + val gesture = Modifier + .pointerInput(controller) { + var total = 0f + detectVerticalDragGestures( + onDragStart = { total = 0f }, + onDragEnd = { if (total < -SWIPE_UP_PX) controller.dismiss() }, + ) { _, delta -> total += delta } + } + .clickable(interactionSource = interaction, indication = null) { controller.onTap() } + + val maxPillWidth = maxWidth - 16.dp + when (anchor) { + ReactionAlertAnchor.Banner -> AnimatedVisibility( + visible = phase != ReactionAlertPhase.Rest, + enter = slideInVertically(tween(300)) { -it } + fadeIn(), + exit = slideOutVertically(tween(260)) { -it } + fadeOut(), + modifier = Modifier + .align(Alignment.TopCenter) + .windowInsetsPadding(WindowInsets.statusBars) + .padding(top = 8.dp), + ) { + ReactionAlertPill( + content = current.content, + phase = ReactionAlertPhase.Expanded, + inActiveChat = current.inActiveChat, + maxWidth = maxPillWidth, + animate = animate, + modifier = gesture, + ) + } + + is ReactionAlertAnchor.Centered -> { + val (w, h) = with(density) { anchor.rect.width().toDp() to anchor.rect.height().toDp() } + ReactionAlertPill( + content = current.content, + phase = phase, + inActiveChat = current.inActiveChat, + restWidth = w, + restHeight = h, + maxWidth = maxPillWidth, + animate = animate, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = with(density) { anchor.rect.top.toDp() }) + .then(if (phase == ReactionAlertPhase.Rest) Modifier else gesture), + ) + } + + is ReactionAlertAnchor.Corner -> { + val (w, h) = with(density) { anchor.rect.width().toDp() to anchor.rect.height().toDp() } + val edge = with(density) { + if (anchor.atStart) anchor.rect.left.toDp() else (screenWidthPx - anchor.rect.right).toDp() + } + ReactionAlertPill( + content = current.content, + phase = phase, + inActiveChat = current.inActiveChat, + restWidth = w, + restHeight = h, + // The hole sits where the top row would, so content starts below it. + expandedTopInset = (h - 12.dp).coerceAtLeast(0.dp), + maxWidth = maxPillWidth - edge, + animate = animate, + modifier = Modifier + .align(if (anchor.atStart) Alignment.TopStart else Alignment.TopEnd) + .padding( + top = with(density) { anchor.rect.top.toDp() }, + start = if (anchor.atStart) edge else 0.dp, + end = if (anchor.atStart) 0.dp else edge, + ) + .then(if (phase == ReactionAlertPhase.Rest) Modifier else gesture), + ) + } + } + } +} + +private const val SWIPE_UP_PX = 40f 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..c2feba4a59 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionAlertPill.kt @@ -0,0 +1,297 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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.shared.chat.alerts.ReactionAlertContent +import com.flipcash.shared.chat.alerts.ReactionAlertContent.Kind + +/** The pill's three resting shapes. The host walks Rest -> Compact -> Expanded and back. */ +enum class ReactionAlertPhase { Rest, Compact, Expanded } + +private val PillBlack = Color.Black +private val TextMain = Color.White +private val TextDim = Color.White.copy(alpha = 0.5f) +private val BoostGreen = Color(0xFF34C759) +private val AvatarPalette = listOf(Color(0xFFE8996B), Color(0xFF739EF2), Color(0xFF8CD18C)) + +/** Figma `Alert/Reaction pill` (node 12643:117): widths from the Compact and Expanded variants. */ +object ReactionAlertPillSpec { + val CompactWidth = 236.dp + val ExpandedWidth = 378.dp + val CompactHeight = 36.dp + val ExpandedRadius = 44.dp + + /** Motion table: grow width 0-180 ms, then open on a spring (stiffness 380, damping 0.78). */ + const val GROW_MILLIS = 180 + const val STIFFNESS = 380f + const val DAMPING = 0.78f + + fun openSpring(): FiniteAnimationSpec = spring(dampingRatio = DAMPING, stiffness = STIFFNESS) +} + +/** + * The alert pill, drawn for [phase]. [restWidth] x [restHeight] is the island or cutout it grows out + * of; [expandedTopInset] pushes the expanded content below a corner hole. + * + * With [animate] false the sizes jump, which is Reduce Motion's crossfade-in-place and the + * screenshot tests' fixed frame. + */ +@Composable +fun ReactionAlertPill( + content: ReactionAlertContent, + phase: ReactionAlertPhase, + inActiveChat: Boolean, + modifier: Modifier = Modifier, + restWidth: Dp = 125.dp, + restHeight: Dp = 36.dp, + expandedTopInset: Dp = 0.dp, + maxWidth: Dp = ReactionAlertPillSpec.ExpandedWidth, + animate: Boolean = true, +) { + val expandedWidth = minOf(ReactionAlertPillSpec.ExpandedWidth, maxWidth) + val targetWidth = when (phase) { + ReactionAlertPhase.Rest -> restWidth + ReactionAlertPhase.Compact -> minOf(ReactionAlertPillSpec.CompactWidth, expandedWidth) + ReactionAlertPhase.Expanded -> expandedWidth + } + val targetRadius = when (phase) { + ReactionAlertPhase.Rest -> restHeight / 2 + ReactionAlertPhase.Compact -> ReactionAlertPillSpec.CompactHeight / 2 + ReactionAlertPhase.Expanded -> ReactionAlertPillSpec.ExpandedRadius + } + val widthSpec: FiniteAnimationSpec = if (animate) { + if (phase == ReactionAlertPhase.Expanded) ReactionAlertPillSpec.openSpring() + else tween(ReactionAlertPillSpec.GROW_MILLIS) + } else { + tween(0) + } + val width by animateDpAsState(targetWidth, widthSpec, label = "alertWidth") + val radius by animateDpAsState( + targetRadius, + if (animate) ReactionAlertPillSpec.openSpring() else tween(0), + label = "alertRadius", + ) + + Box( + modifier = modifier + .width(width) + .clip(RoundedCornerShape(radius)) + .background(PillBlack) + .then(if (animate) Modifier.animateContentSize(ReactionAlertPillSpec.openSpring()) else Modifier), + ) { + Crossfade( + targetState = phase, + animationSpec = tween(if (animate) 120 else 0), + label = "alertPhase", + ) { shown -> + when (shown) { + ReactionAlertPhase.Rest -> Spacer(Modifier.height(restHeight)) + ReactionAlertPhase.Compact -> CompactRow(content) + ReactionAlertPhase.Expanded -> ExpandedColumn(content, inActiveChat, expandedTopInset) + } + } + } +} + +@Composable +private fun CompactRow(content: ReactionAlertContent) { + Row( + modifier = Modifier + .height(ReactionAlertPillSpec.CompactHeight) + .padding(start = 6.dp, end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + AlertAvatar(content.actorNames.first(), index = 0, size = 24.dp, textSize = 10) + Spacer(Modifier.weight(1f)) + when (content.kind) { + Kind.Boost -> Text( + text = boostCentsText(content.boostCents ?: 0, wholeDollars = true), + color = BoostGreen, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + else -> Text( + text = content.emojis.last(), + color = TextMain, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } +} + +@Composable +private fun ExpandedColumn(content: ReactionAlertContent, inActiveChat: Boolean, topInset: Dp) { + Column( + modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 12.dp + topInset, bottom = 18.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(modifier = Modifier.fillMaxWidthCompat(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + text = if (inActiveChat) stringResource(R.string.alert_thisChat) else content.chatName.orEmpty(), + color = TextDim, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = stringResource(R.string.alert_now), + color = TextDim, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + ) + } + Row( + modifier = Modifier.fillMaxWidthCompat(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (content.kind == Kind.Several) { + AvatarStack(content) + } else { + AlertAvatar(content.actorNames.first(), index = 0, size = 40.dp, textSize = 16) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title(content), + color = TextMain, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (content.quote.isNotEmpty()) { + Text( + text = "“${content.quote}”", + color = TextDim, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + when (content.kind) { + Kind.Reaction -> Text(content.emojis.last(), color = TextMain, fontSize = 30.sp, fontWeight = FontWeight.SemiBold) + Kind.Boost -> Text( + text = "+" + boostCentsText(content.boostCents ?: 0), + color = BoostGreen, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + Kind.Several -> Text( + text = content.trailingEmojis.joinToString(""), + color = TextMain, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + 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) + } + } +} + +@Composable +private fun AvatarStack(content: ReactionAlertContent) { + Box { + val shown = content.actorNames.take(3) + shown.forEachIndexed { index, name -> + AlertAvatar( + name = name, + index = index, + size = 40.dp, + textSize = 16, + ring = true, + modifier = Modifier.offset(x = 26.dp * index), + ) + } + // Reserve the stack's width: the avatars are offset, so Box would measure only the first. + Spacer(Modifier.width(40.dp + 26.dp * (shown.size - 1)).height(40.dp)) + } +} + +/** The actor's initial on a palette fill. Swap for the profile photo when one is available. */ +@Composable +private fun AlertAvatar( + name: String?, + index: Int, + size: Dp, + textSize: Int, + modifier: Modifier = Modifier, + ring: Boolean = false, +) { + val fill = AvatarPalette[(name?.hashCode()?.let { Math.floorMod(it, AvatarPalette.size) } ?: index)] + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .background(if (ring) PillBlack else fill) + .padding(if (ring) 2.dp else 0.dp) + .clip(CircleShape) + .background(fill), + contentAlignment = Alignment.Center, + ) { + Text( + text = name?.trim()?.firstOrNull()?.uppercase() ?: "?", + color = Color.Black, + fontSize = textSize.sp, + fontWeight = FontWeight.SemiBold, + ) + } +} + +private fun Modifier.fillMaxWidthCompat(): Modifier = this.then(Modifier.fillMaxWidth()) From 121da476e1f3e7eb48d96a3c69724f9a9894517d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 25 Oct 2026 00:49:02 -0400 Subject: [PATCH 3/6] test(chat-ui): add screenshot and anchor tests for the reaction alert pill --- .../shared/chat/ui/ReactionAlertAnchorTest.kt | 45 +++++++++ .../ui/ReactionAlertPillScreenshotTest.kt | 91 +++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionAlertAnchorTest.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionAlertPillScreenshotTest.kt diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionAlertAnchorTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionAlertAnchorTest.kt new file mode 100644 index 0000000000..645670ea6d --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionAlertAnchorTest.kt @@ -0,0 +1,45 @@ +package com.flipcash.shared.chat.ui + +import android.graphics.Rect +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +class ReactionAlertAnchorTest { + private val width = 1080 + + @Test + fun `no cutout falls back to the banner`() { + assertEquals(ReactionAlertAnchor.Banner, reactionAlertAnchor(emptyList(), width)) + } + + @Test + fun `centred punch hole anchors centred`() { + val anchor = reactionAlertAnchor(listOf(Rect(500, 40, 580, 120)), width) + assertIs(anchor) + } + + @Test + fun `corner hole at the start grows inward from its left edge`() { + val anchor = reactionAlertAnchor(listOf(Rect(40, 40, 120, 120)), width) + assertIs(anchor) + assertTrue(anchor.atStart) + } + + @Test + fun `corner hole at the end anchors the right edge`() { + val anchor = reactionAlertAnchor(listOf(Rect(960, 40, 1040, 120)), width) + assertIs(anchor) + assertEquals(false, anchor.atStart) + } + + @Test + fun `side and bottom cutouts are not anchors`() { + assertEquals(ReactionAlertAnchor.Banner, reactionAlertAnchor(listOf(Rect(0, 900, 80, 1000)), width)) + assertEquals(ReactionAlertAnchor.Banner, reactionAlertAnchor(listOf(Rect(500, 2300, 580, 2400)), width)) + } +} 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..4796cac86f --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionAlertPillScreenshotTest.kt @@ -0,0 +1,91 @@ +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 pill state of Figma `Alert/Reaction pill` (node 12643:117) 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 rest() = render(content(Kind.Reaction), ReactionAlertPhase.Rest, "alert_rest.png") + + @Test fun compactReaction() = render(content(Kind.Reaction), ReactionAlertPhase.Compact, "alert_compact_reaction.png") + + @Test fun compactBoost() = render(content(Kind.Boost, boost = 500), ReactionAlertPhase.Compact, "alert_compact_boost.png") + + @Test fun expandedReaction() = render(content(Kind.Reaction), ReactionAlertPhase.Expanded, "alert_expanded_reaction.png") + + @Test fun expandedBoost() = render(content(Kind.Boost, boost = 500), ReactionAlertPhase.Expanded, "alert_expanded_boost.png") + + @Test fun expandedStacked() = render( + content(Kind.Several, actors = listOf("Chloe", "Sam", "Jo"), emojis = listOf("🔥", "❤️", "😂")), + ReactionAlertPhase.Expanded, + "alert_expanded_stacked.png", + ) + + @Test fun expandedInActiveChat() = + render(content(Kind.Reaction), ReactionAlertPhase.Expanded, "alert_expanded_this_chat.png", inActiveChat = true) + + private fun render(content: ReactionAlertContent, phase: ReactionAlertPhase, fileName: String, inActiveChat: Boolean = false) { + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + Frame { + ReactionAlertPill(content, phase, 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() + } + } + } +} From 98c47b674ceedeb1e8284f89c3e73248a6077448 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 25 Oct 2026 23:03:37 -0400 Subject: [PATCH 4/6] feat(chat): show reaction alerts as a glass banner with the reactor's photo Replace the camera-cutout pill with a blurred dark card under the status bar, matching iOS: fade and unblur with a spring settle, swipe up to dismiss, haptics on landing. Resolve the actor's name and photo from the profile cache before falling back to Someone. Add a debug-only broadcast receiver that fires canned reaction, boost and stack alerts. --- apps/flipcash/app/build.gradle.kts | 1 + .../app/src/debug/AndroidManifest.xml | 8 +- .../app/debug/ReactionAlertDebugReceiver.kt | 108 +++++++ .../app/internal/ui/navigation/AppContent.kt | 2 +- .../core/src/main/res/values/strings.xml | 1 - .../shared/chat/ui/ReactionAlertCutout.kt | 35 -- .../shared/chat/ui/ReactionAlertHost.kt | 231 ++++++++------ .../shared/chat/ui/ReactionAlertPill.kt | 299 ++++++++---------- .../shared/chat/ui/ReactionAlertAnchorTest.kt | 45 --- .../ui/ReactionAlertPillScreenshotTest.kt | 24 +- .../chat/alerts/ReactionActorProfiles.kt | 39 +++ .../chat/alerts/ReactionAlertController.kt | 4 +- .../shared/chat/alerts/ReactionAlertPolicy.kt | 3 + .../shared/chat/alerts/ReactionAlertQueue.kt | 10 + .../flipcash/shared/chat/inject/ChatModule.kt | 7 + .../internal/delegates/EventStreamDelegate.kt | 19 +- 16 files changed, 464 insertions(+), 372 deletions(-) create mode 100644 apps/flipcash/app/src/debug/kotlin/com/flipcash/app/debug/ReactionAlertDebugReceiver.kt delete mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionAlertCutout.kt delete mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionAlertAnchorTest.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/alerts/ReactionActorProfiles.kt 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/navigation/AppContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt index e264aabc2b..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 @@ -325,7 +325,7 @@ internal fun AppContent( searchMorph.Overlay(hazeState) // Top of the app, over the status bar and the camera cutout, so it shows on any screen. - ReactionAlertHost(controller = reactionAlerts) + 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 diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index d308bb008d..624ead7119 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1700,5 +1700,4 @@ Someone This chat ↓ - now diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionAlertCutout.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionAlertCutout.kt deleted file mode 100644 index 13f993e114..0000000000 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionAlertCutout.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.flipcash.shared.chat.ui - -import android.graphics.Rect - -/** Where the pill grows out of, derived from `DisplayCutout.getBoundingRects()`. */ -sealed interface ReactionAlertAnchor { - /** No top cutout: the pill drops from the top as a banner. */ - data object Banner : ReactionAlertAnchor - - /** A cutout the pill is centred on (a punch-hole camera or a notch). */ - data class Centered(val rect: Rect) : ReactionAlertAnchor - - /** A corner hole: the pill's near edge is fixed to the hole, so it grows inward. */ - data class Corner(val rect: Rect, val atStart: Boolean) : ReactionAlertAnchor -} - -/** - * Picks the top cutout. Rects on the sides or bottom (a landscape notch, a bottom hole) are not - * anchors; with several top rects the one nearest the top edge wins. - */ -fun reactionAlertAnchor(rects: List, screenWidthPx: Int): ReactionAlertAnchor { - val top = rects - .filter { it.width() > 0 && it.height() > 0 && it.top <= it.height() } - .minByOrNull { it.top } - ?: return ReactionAlertAnchor.Banner - val centreX = (top.left + top.right) / 2f - val offCentre = kotlin.math.abs(centreX - screenWidthPx / 2f) - return if (offCentre <= screenWidthPx * CENTRED_TOLERANCE) { - ReactionAlertAnchor.Centered(top) - } else { - ReactionAlertAnchor.Corner(top, atStart = centreX < screenWidthPx / 2f) - } -} - -private const val CENTRED_TOLERANCE = 0.1f 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 index 937b89d0f9..a9534bed48 100644 --- 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 @@ -3,15 +3,14 @@ package com.flipcash.shared.chat.ui import android.animation.ValueAnimator import android.os.Build import android.view.HapticFeedbackConstants -import androidx.compose.animation.AnimatedVisibility +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.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.WindowInsets @@ -21,156 +20,184 @@ 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.runtime.collectAsState 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 pill over the app. It walks the motion table: width grows out of the - * cutout (180 ms), height and radius open on a spring, and on close the text fades, the height - * collapses (260 ms in all) and the width returns to the cutout (200 ms). + * 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 pill appears in its expanded shape and crossfades. + * 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 close animation has content to draw. + // Kept after [live] clears so the exit has content to draw. var shown by remember { mutableStateOf(null) } - var phase by remember { mutableStateOf(ReactionAlertPhase.Rest) } 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 (animate) { - phase = ReactionAlertPhase.Compact - delay(ReactionAlertPillSpec.GROW_MILLIS.toLong()) + 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) } + } } - phase = ReactionAlertPhase.Expanded } else if (shown != null) { - if (animate) { - phase = ReactionAlertPhase.Compact - delay(260) + 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)) } + } } - phase = ReactionAlertPhase.Rest - if (animate) delay(200) shown = null + drag.snapTo(0f) } } - // One tap on the haptic per content swap, boost or reaction alike. + // Haptics wait for the card to land; a merge into the card already up bumps it and ticks. LaunchedEffect(live?.generation) { - if (live == null) return@LaunchedEffect - val constant = if (Build.VERSION.SDK_INT >= 30) { - HapticFeedbackConstants.CONFIRM - } else { - HapticFeedbackConstants.CONTEXT_CLICK + 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(constant) + view.performHapticFeedback(hapticFor(state.content.kind, isUpdate)) + landed = true } val current = shown ?: return val interaction = remember { MutableInteractionSource() } BoxWithConstraints(modifier = modifier.fillMaxSize()) { - val screenWidthPx = with(density) { maxWidth.roundToPx() } - val anchor = remember(screenWidthPx, view.rootWindowInsets) { - val rects = view.rootWindowInsets?.displayCutout?.boundingRects.orEmpty() - reactionAlertAnchor(rects, screenWidthPx) - } val gesture = Modifier .pointerInput(controller) { - var total = 0f detectVerticalDragGestures( - onDragStart = { total = 0f }, - onDragEnd = { if (total < -SWIPE_UP_PX) controller.dismiss() }, - ) { _, delta -> total += delta } + 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() } - val maxPillWidth = maxWidth - 16.dp - when (anchor) { - ReactionAlertAnchor.Banner -> AnimatedVisibility( - visible = phase != ReactionAlertPhase.Rest, - enter = slideInVertically(tween(300)) { -it } + fadeIn(), - exit = slideOutVertically(tween(260)) { -it } + fadeOut(), - modifier = Modifier - .align(Alignment.TopCenter) - .windowInsetsPadding(WindowInsets.statusBars) - .padding(top = 8.dp), - ) { - ReactionAlertPill( - content = current.content, - phase = ReactionAlertPhase.Expanded, - inActiveChat = current.inActiveChat, - maxWidth = maxPillWidth, - animate = animate, - modifier = gesture, - ) - } - - is ReactionAlertAnchor.Centered -> { - val (w, h) = with(density) { anchor.rect.width().toDp() to anchor.rect.height().toDp() } - ReactionAlertPill( - content = current.content, - phase = phase, - inActiveChat = current.inActiveChat, - restWidth = w, - restHeight = h, - maxWidth = maxPillWidth, - animate = animate, - modifier = Modifier - .align(Alignment.TopCenter) - .padding(top = with(density) { anchor.rect.top.toDp() }) - .then(if (phase == ReactionAlertPhase.Rest) Modifier else gesture), - ) - } - - is ReactionAlertAnchor.Corner -> { - val (w, h) = with(density) { anchor.rect.width().toDp() to anchor.rect.height().toDp() } - val edge = with(density) { - if (anchor.atStart) anchor.rect.left.toDp() else (screenWidthPx - anchor.rect.right).toDp() + 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) + } } - ReactionAlertPill( - content = current.content, - phase = phase, - inActiveChat = current.inActiveChat, - restWidth = w, - restHeight = h, - // The hole sits where the top row would, so content starts below it. - expandedTopInset = (h - 12.dp).coerceAtLeast(0.dp), - maxWidth = maxPillWidth - edge, - animate = animate, - modifier = Modifier - .align(if (anchor.atStart) Alignment.TopStart else Alignment.TopEnd) - .padding( - top = with(density) { anchor.rect.top.toDp() }, - start = if (anchor.atStart) edge else 0.dp, - end = if (anchor.atStart) 0.dp else edge, - ) - .then(if (phase == ReactionAlertPhase.Rest) Modifier else gesture), - ) - } - } + .then(gesture), + ) } } -private const val SWIPE_UP_PX = 40f +/** 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 index c2feba4a59..9b9391ebff 100644 --- 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 @@ -1,13 +1,9 @@ package com.flipcash.shared.chat.ui import androidx.compose.animation.Crossfade -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.FiniteAnimationSpec -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.spring 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 @@ -19,14 +15,16 @@ 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.runtime.getValue 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 @@ -37,132 +35,104 @@ 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 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 -/** The pill's three resting shapes. The host walks Rest -> Compact -> Expanded and back. */ -enum class ReactionAlertPhase { Rest, Compact, Expanded } - -private val PillBlack = Color.Black private val TextMain = Color.White private val TextDim = Color.White.copy(alpha = 0.5f) private val BoostGreen = Color(0xFF34C759) -private val AvatarPalette = listOf(Color(0xFFE8996B), Color(0xFF739EF2), Color(0xFF8CD18C)) +private val AvatarPalette = listOf( + Color(0xFFE8996B), Color(0xFF739EF2), Color(0xFF8CD18C), Color(0xFFD68CC8), Color(0xFFF0C864), +) -/** Figma `Alert/Reaction pill` (node 12643:117): widths from the Compact and Expanded variants. */ +/** The iOS `AlertPill` banner's measurements, so the two platforms draw the same card. */ object ReactionAlertPillSpec { - val CompactWidth = 236.dp - val ExpandedWidth = 378.dp - val CompactHeight = 36.dp - val ExpandedRadius = 44.dp + val MaxWidth = 378.dp + val CornerRadius = 28.dp + val HorizontalPadding = 18.dp + val VerticalPadding = 14.dp + val AvatarSize = 40.dp + val StackAvatarSize = 32.dp + val StackOverlap = 10.dp + + /** The black glass tint of iOS's `glassEffect(.regular.tint(black 0.55))`. */ + const val TINT_ALPHA = 0.55f - /** Motion table: grow width 0-180 ms, then open on a spring (stiffness 380, damping 0.78). */ - const val GROW_MILLIS = 180 - const val STIFFNESS = 380f - const val DAMPING = 0.78f + /** Used where there is no backdrop to blur: a translucent black that still reads as one surface. */ + const val FLAT_ALPHA = 0.88f - fun openSpring(): FiniteAnimationSpec = spring(dampingRatio = DAMPING, stiffness = STIFFNESS) + val GlassStyle = HazeBlurStyle { + blurRadius(24.dp) + backgroundColor(Color.Black) + colorEffects(listOf(HazeColorEffect.tint(Color.Black.copy(alpha = TINT_ALPHA)))) + } } /** - * The alert pill, drawn for [phase]. [restWidth] x [restHeight] is the island or cutout it grows out - * of; [expandedTopInset] pushes the expanded content below a corner hole. + * 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 sizes jump, which is Reduce Motion's crossfade-in-place and the - * screenshot tests' fixed frame. + * With [animate] false the text swap on a merge is instant, for the screenshot tests. */ @Composable fun ReactionAlertPill( content: ReactionAlertContent, - phase: ReactionAlertPhase, inActiveChat: Boolean, modifier: Modifier = Modifier, - restWidth: Dp = 125.dp, - restHeight: Dp = 36.dp, - expandedTopInset: Dp = 0.dp, - maxWidth: Dp = ReactionAlertPillSpec.ExpandedWidth, + maxWidth: Dp = ReactionAlertPillSpec.MaxWidth, + hazeState: HazeState? = null, animate: Boolean = true, ) { - val expandedWidth = minOf(ReactionAlertPillSpec.ExpandedWidth, maxWidth) - val targetWidth = when (phase) { - ReactionAlertPhase.Rest -> restWidth - ReactionAlertPhase.Compact -> minOf(ReactionAlertPillSpec.CompactWidth, expandedWidth) - ReactionAlertPhase.Expanded -> expandedWidth - } - val targetRadius = when (phase) { - ReactionAlertPhase.Rest -> restHeight / 2 - ReactionAlertPhase.Compact -> ReactionAlertPillSpec.CompactHeight / 2 - ReactionAlertPhase.Expanded -> ReactionAlertPillSpec.ExpandedRadius - } - val widthSpec: FiniteAnimationSpec = if (animate) { - if (phase == ReactionAlertPhase.Expanded) ReactionAlertPillSpec.openSpring() - else tween(ReactionAlertPillSpec.GROW_MILLIS) + val shape = RoundedCornerShape(ReactionAlertPillSpec.CornerRadius) + val glass = if (hazeState != null) { + Modifier.hazeBlur(HazeInput.Sources(hazeState), ReactionAlertPillSpec.GlassStyle) } else { - tween(0) + Modifier.background(Color.Black.copy(alpha = ReactionAlertPillSpec.FLAT_ALPHA)) } - val width by animateDpAsState(targetWidth, widthSpec, label = "alertWidth") - val radius by animateDpAsState( - targetRadius, - if (animate) ReactionAlertPillSpec.openSpring() else tween(0), - label = "alertRadius", - ) - Box( modifier = modifier - .width(width) - .clip(RoundedCornerShape(radius)) - .background(PillBlack) - .then(if (animate) Modifier.animateContentSize(ReactionAlertPillSpec.openSpring()) else 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 = phase, - animationSpec = tween(if (animate) 120 else 0), - label = "alertPhase", + targetState = content, + animationSpec = tween(if (animate) 150 else 0), + label = "alertContent", ) { shown -> - when (shown) { - ReactionAlertPhase.Rest -> Spacer(Modifier.height(restHeight)) - ReactionAlertPhase.Compact -> CompactRow(content) - ReactionAlertPhase.Expanded -> ExpandedColumn(content, inActiveChat, expandedTopInset) - } + AlertRow(shown, inActiveChat) } } } @Composable -private fun CompactRow(content: ReactionAlertContent) { +private fun AlertRow(content: ReactionAlertContent, inActiveChat: Boolean) { Row( modifier = Modifier - .height(ReactionAlertPillSpec.CompactHeight) - .padding(start = 6.dp, end = 12.dp), + .fillMaxWidth() + .padding( + horizontal = ReactionAlertPillSpec.HorizontalPadding, + vertical = ReactionAlertPillSpec.VerticalPadding, + ), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - AlertAvatar(content.actorNames.first(), index = 0, size = 24.dp, textSize = 10) - Spacer(Modifier.weight(1f)) - when (content.kind) { - Kind.Boost -> Text( - text = boostCentsText(content.boostCents ?: 0, wholeDollars = true), - color = BoostGreen, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - ) - else -> Text( - text = content.emojis.last(), - color = TextMain, - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, - ) + if (content.kind == Kind.Several) { + AvatarStack(content) + } else { + AlertAvatar(content, index = 0, size = ReactionAlertPillSpec.AvatarSize, textSize = 16) } - } -} - -@Composable -private fun ExpandedColumn(content: ReactionAlertContent, inActiveChat: Boolean, topInset: Dp) { - Column( - modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 12.dp + topInset, bottom = 18.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row(modifier = Modifier.fillMaxWidthCompat(), horizontalArrangement = Arrangement.SpaceBetween) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( text = if (inActiveChat) stringResource(R.string.alert_thisChat) else content.chatName.orEmpty(), color = TextDim, @@ -170,65 +140,49 @@ private fun ExpandedColumn(content: ReactionAlertContent, inActiveChat: Boolean, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), ) - Text( - text = stringResource(R.string.alert_now), - color = TextDim, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, + // Shrinks to fit before it would ellipsize: the title is never wrapped or cut. + androidx.compose.material3.Text( + text = title(content), + color = TextMain, + fontFamily = androidx.compose.material.LocalTextStyle.current.fontFamily, + fontWeight = FontWeight.SemiBold, maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + autoSize = TextAutoSize.StepBased(minFontSize = 11.sp, maxFontSize = 15.sp, stepSize = 0.5.sp), ) - } - Row( - modifier = Modifier.fillMaxWidthCompat(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - if (content.kind == Kind.Several) { - AvatarStack(content) - } else { - AlertAvatar(content.actorNames.first(), index = 0, size = 40.dp, textSize = 16) - } - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + if (content.quote.isNotEmpty()) { Text( - text = title(content), - color = TextMain, - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - if (content.quote.isNotEmpty()) { - Text( - text = "“${content.quote}”", - color = TextDim, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - when (content.kind) { - Kind.Reaction -> Text(content.emojis.last(), color = TextMain, fontSize = 30.sp, fontWeight = FontWeight.SemiBold) - Kind.Boost -> Text( - text = "+" + boostCentsText(content.boostCents ?: 0), - color = BoostGreen, - fontSize = 20.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - ) - Kind.Several -> Text( - text = content.trailingEmojis.joinToString(""), - color = TextMain, - fontSize = 20.sp, - fontWeight = FontWeight.SemiBold, + text = content.quote, + color = TextDim, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, maxLines = 1, - textAlign = TextAlign.End, + softWrap = false, + overflow = TextOverflow.Ellipsis, ) } } + when (content.kind) { + Kind.Reaction -> Text(content.emojis.last(), color = TextMain, fontSize = 30.sp, fontWeight = FontWeight.SemiBold, maxLines = 1) + Kind.Boost -> Text( + text = "+" + boostCentsText(content.boostCents ?: 0), + color = BoostGreen, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + // The newest two; the title already counts everyone. + Kind.Several -> Text( + text = content.emojis.takeLast(2).joinToString(""), + color = TextMain, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + textAlign = TextAlign.End, + ) + } } } @@ -245,53 +199,66 @@ private fun title(content: ReactionAlertContent): String { } } +/** Smaller than a lone avatar and overlapped lightly, with the first reactor on top. */ @Composable private fun AvatarStack(content: ReactionAlertContent) { - Box { - val shown = content.actorNames.take(3) - shown.forEachIndexed { index, name -> + val size = ReactionAlertPillSpec.StackAvatarSize + val step = size - ReactionAlertPillSpec.StackOverlap + val shown = content.actorNames.take(3) + Box(modifier = Modifier.width(size + step * (shown.size - 1)).height(size)) { + shown.indices.forEach { index -> AlertAvatar( - name = name, + content = content, index = index, - size = 40.dp, - textSize = 16, + size = size, + textSize = 13, ring = true, - modifier = Modifier.offset(x = 26.dp * index), + modifier = Modifier + .offset(x = step * index) + .zIndex((shown.size - index).toFloat()), ) } - // Reserve the stack's width: the avatars are offset, so Box would measure only the first. - Spacer(Modifier.width(40.dp + 26.dp * (shown.size - 1)).height(40.dp)) } } -/** The actor's initial on a palette fill. Swap for the profile photo when one is available. */ +/** The actor's profile photo, else their initial on a palette fill. */ @Composable private fun AlertAvatar( - name: String?, + content: ReactionAlertContent, index: Int, size: Dp, textSize: Int, modifier: Modifier = Modifier, ring: Boolean = false, ) { - val fill = AvatarPalette[(name?.hashCode()?.let { Math.floorMod(it, AvatarPalette.size) } ?: index)] + 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) 2.dp else 0.dp Box( modifier = modifier .size(size) .clip(CircleShape) - .background(if (ring) PillBlack else fill) - .padding(if (ring) 2.dp else 0.dp) + .background(Color.Black.copy(alpha = 0.6f)) + .padding(ringWidth) .clip(CircleShape) .background(fill), contentAlignment = Alignment.Center, ) { - Text( - text = name?.trim()?.firstOrNull()?.uppercase() ?: "?", - color = Color.Black, - fontSize = textSize.sp, - fontWeight = FontWeight.SemiBold, - ) + 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, + ) + } } } - -private fun Modifier.fillMaxWidthCompat(): Modifier = this.then(Modifier.fillMaxWidth()) diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionAlertAnchorTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionAlertAnchorTest.kt deleted file mode 100644 index 645670ea6d..0000000000 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionAlertAnchorTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.flipcash.shared.chat.ui - -import android.graphics.Rect -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertTrue - -@RunWith(RobolectricTestRunner::class) -class ReactionAlertAnchorTest { - private val width = 1080 - - @Test - fun `no cutout falls back to the banner`() { - assertEquals(ReactionAlertAnchor.Banner, reactionAlertAnchor(emptyList(), width)) - } - - @Test - fun `centred punch hole anchors centred`() { - val anchor = reactionAlertAnchor(listOf(Rect(500, 40, 580, 120)), width) - assertIs(anchor) - } - - @Test - fun `corner hole at the start grows inward from its left edge`() { - val anchor = reactionAlertAnchor(listOf(Rect(40, 40, 120, 120)), width) - assertIs(anchor) - assertTrue(anchor.atStart) - } - - @Test - fun `corner hole at the end anchors the right edge`() { - val anchor = reactionAlertAnchor(listOf(Rect(960, 40, 1040, 120)), width) - assertIs(anchor) - assertEquals(false, anchor.atStart) - } - - @Test - fun `side and bottom cutouts are not anchors`() { - assertEquals(ReactionAlertAnchor.Banner, reactionAlertAnchor(listOf(Rect(0, 900, 80, 1000)), width)) - assertEquals(ReactionAlertAnchor.Banner, reactionAlertAnchor(listOf(Rect(500, 2300, 580, 2400)), width)) - } -} 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 index 4796cac86f..7c67e9a730 100644 --- 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 @@ -26,7 +26,7 @@ import org.robolectric.annotation.GraphicsMode import java.io.File /** - * Renders each pill state of Figma `Alert/Reaction pill` (node 12643:117) to `build/screenshots/`. + * 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. */ @@ -44,30 +44,22 @@ class ReactionAlertPillScreenshotTest { 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 rest() = render(content(Kind.Reaction), ReactionAlertPhase.Rest, "alert_rest.png") + @Test fun reaction() = render(content(Kind.Reaction), "alert_reaction.png") - @Test fun compactReaction() = render(content(Kind.Reaction), ReactionAlertPhase.Compact, "alert_compact_reaction.png") + @Test fun boost() = render(content(Kind.Boost, boost = 500), "alert_boost.png") - @Test fun compactBoost() = render(content(Kind.Boost, boost = 500), ReactionAlertPhase.Compact, "alert_compact_boost.png") - - @Test fun expandedReaction() = render(content(Kind.Reaction), ReactionAlertPhase.Expanded, "alert_expanded_reaction.png") - - @Test fun expandedBoost() = render(content(Kind.Boost, boost = 500), ReactionAlertPhase.Expanded, "alert_expanded_boost.png") - - @Test fun expandedStacked() = render( + @Test fun stacked() = render( content(Kind.Several, actors = listOf("Chloe", "Sam", "Jo"), emojis = listOf("🔥", "❤️", "😂")), - ReactionAlertPhase.Expanded, - "alert_expanded_stacked.png", + "alert_stacked.png", ) - @Test fun expandedInActiveChat() = - render(content(Kind.Reaction), ReactionAlertPhase.Expanded, "alert_expanded_this_chat.png", inActiveChat = true) + @Test fun inActiveChat() = render(content(Kind.Reaction), "alert_this_chat.png", inActiveChat = true) - private fun render(content: ReactionAlertContent, phase: ReactionAlertPhase, fileName: String, inActiveChat: Boolean = false) { + private fun render(content: ReactionAlertContent, fileName: String, inActiveChat: Boolean = false) { composeRule.mainClock.autoAdvance = false composeRule.setContent { Frame { - ReactionAlertPill(content, phase, inActiveChat, animate = false) + ReactionAlertPill(content, inActiveChat, animate = false) } } repeat(10) { composeRule.mainClock.advanceTimeByFrame() } 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 index 9c10d32df6..bca5e3abf8 100644 --- 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 @@ -70,8 +70,8 @@ class ReactionAlertController internal constructor( ) companion object { - /** Text fade (120 ms) and height collapse, then the width back into the island (200 ms). */ - const val CLOSE_MILLIS = 260L + 200L + /** The banner's exit (220 ms), plus a frame, before the next one drops in. */ + const val CLOSE_MILLIS = 230L } private val queue = ReactionAlertQueue() 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 index 5558861618..a7cb4e4d56 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -20,6 +21,8 @@ data class ReactionAlertEvent( 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, ) /** 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 index b4d55b019c..e1081faf1e 100644 --- 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 @@ -1,6 +1,8 @@ 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. */ @@ -16,6 +18,10 @@ data class ReactionAlertContent( 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 } @@ -40,6 +46,8 @@ data class ReactionAlertContent( actorNames = listOf(event.actorName), emojis = listOf(event.emoji), boostCents = if (boosted) event.boostDeltaCents else null, + actorIds = listOf(event.actor), + actorPhotos = listOf(event.actorPhoto), ) } } @@ -136,6 +144,8 @@ class ReactionAlertQueue { 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 }, 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 5691c14238..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 @@ -15,6 +15,8 @@ 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 @@ -95,6 +97,11 @@ abstract class ChatModule { impl: MentionSuggestionPool ): MentionPoolUpdates + @Binds + internal abstract fun bindReactionActorProfiles( + impl: StoredReactionActorProfiles + ): ReactionActorProfiles + @Binds internal abstract fun bindReactionAlertSink( impl: ReactionAlertController 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 519fffaf5b..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 @@ -27,6 +27,7 @@ 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 @@ -99,6 +100,7 @@ class EventStreamDelegate @Inject constructor( private val exchange: Exchange, private val mentionPool: MentionPoolUpdates = MentionPoolUpdates.None, private val reactionAlerts: ReactionAlertSink = ReactionAlertSink.None, + private val actorProfiles: ReactionActorProfiles = ReactionActorProfiles.None, ) : EventStreamOperations { @@ -619,7 +621,9 @@ class EventStreamDelegate @Inject constructor( val selfId = userManager.accountId when (ReactionAlertPolicy.decide(reactionUpdate, selfId, local)) { ReactionAlertPolicy.Decision.Drop -> Unit - ReactionAlertPolicy.Decision.Alert -> emitAlert(chatId, reactionUpdate, local, previous) + // 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 { @@ -641,8 +645,16 @@ class EventStreamDelegate @Inject constructor( ) { val selfId = userManager.accountId val members = runCatching { memberDataSource.getMembersForChat(chatId) }.getOrDefault(emptyList()) - val actorName = members.firstOrNull { it.userId == reactionUpdate.actor } - ?.userProfile?.displayName?.takeIf { it.isNotBlank() } + 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( @@ -658,6 +670,7 @@ class EventStreamDelegate @Inject constructor( null }, actorName = actorName, + actorPhoto = actorPhoto, chatName = chatName, quote = ReactionAlertPolicy.quoteOf(message), ), From 49f3dfe3f8b3f8d13b6f569177135ff172a18d8b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 25 Oct 2026 23:09:55 -0400 Subject: [PATCH 5/6] fix(chat-ui): lift the reaction alert glass to match iOS The black backing plus a 55% black tint drew the card at about 12 over a 25 background. iOS's tinted glass sits slightly lighter, about 30 over 24. A near-black grey tint at 60% now lands at 30-32 over 25. --- .../shared/chat/ui/ReactionAlertPill.kt | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) 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 index 9b9391ebff..d59b06264d 100644 --- 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 @@ -62,16 +62,20 @@ object ReactionAlertPillSpec { val StackAvatarSize = 32.dp val StackOverlap = 10.dp - /** The black glass tint of iOS's `glassEffect(.regular.tint(black 0.55))`. */ - const val TINT_ALPHA = 0.55f + /** + * 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: a translucent black that still reads as one surface. */ - const val FLAT_ALPHA = 0.88f + /** 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(Color.Black) - colorEffects(listOf(HazeColorEffect.tint(Color.Black.copy(alpha = TINT_ALPHA)))) + backgroundColor(Tint) + colorEffects(listOf(HazeColorEffect.tint(Tint.copy(alpha = TINT_ALPHA)))) } } @@ -95,7 +99,7 @@ fun ReactionAlertPill( val glass = if (hazeState != null) { Modifier.hazeBlur(HazeInput.Sources(hazeState), ReactionAlertPillSpec.GlassStyle) } else { - Modifier.background(Color.Black.copy(alpha = ReactionAlertPillSpec.FLAT_ALPHA)) + Modifier.background(ReactionAlertPillSpec.Tint.copy(alpha = ReactionAlertPillSpec.FLAT_ALPHA)) } Box( modifier = modifier From 6fa6f84973a322fe4a978653189944790abc928a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 26 Oct 2026 10:07:30 -0400 Subject: [PATCH 6/6] fix(chat): use theme tokens in the reaction alert banner The banner set font size and weight by hand and carried its own white, dim-white and green colours. Text now uses the caption, textSmall and textLarge styles with textMain, textSecondary and success; the avatar stack overlap and ring use the dimens grid and thickBorder. --- .../shared/chat/ui/ReactionAlertPill.kt | 40 +++++++++---------- 1 file changed, 18 insertions(+), 22 deletions(-) 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 index d59b06264d..df09b364c8 100644 --- 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 @@ -39,15 +39,13 @@ 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 TextMain = Color.White -private val TextDim = Color.White.copy(alpha = 0.5f) -private val BoostGreen = Color(0xFF34C759) private val AvatarPalette = listOf( Color(0xFFE8996B), Color(0xFF739EF2), Color(0xFF8CD18C), Color(0xFFD68CC8), Color(0xFFF0C864), ) @@ -60,7 +58,6 @@ object ReactionAlertPillSpec { val VerticalPadding = 14.dp val AvatarSize = 40.dp val StackAvatarSize = 32.dp - val StackOverlap = 10.dp /** * iOS's dark-tinted glass lands a few steps lighter than the app background (about 30 over 24), @@ -139,29 +136,30 @@ private fun AlertRow(content: ReactionAlertContent, inActiveChat: Boolean) { Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( text = if (inActiveChat) stringResource(R.string.alert_thisChat) else content.chatName.orEmpty(), - color = TextDim, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, + 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), - color = TextMain, - fontFamily = androidx.compose.material.LocalTextStyle.current.fontFamily, - fontWeight = FontWeight.SemiBold, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, - autoSize = TextAutoSize.StepBased(minFontSize = 11.sp, maxFontSize = 15.sp, stepSize = 0.5.sp), + autoSize = TextAutoSize.StepBased( + minFontSize = 11.sp, + maxFontSize = CodeTheme.typography.textSmall.fontSize, + stepSize = 0.5.sp, + ), ) if (content.quote.isNotEmpty()) { Text( text = content.quote, - color = TextDim, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, @@ -169,20 +167,18 @@ private fun AlertRow(content: ReactionAlertContent, inActiveChat: Boolean) { } } when (content.kind) { - Kind.Reaction -> Text(content.emojis.last(), color = TextMain, fontSize = 30.sp, fontWeight = FontWeight.SemiBold, maxLines = 1) + Kind.Reaction -> Text(content.emojis.last(), color = CodeTheme.colors.textMain, fontSize = 30.sp, maxLines = 1) Kind.Boost -> Text( text = "+" + boostCentsText(content.boostCents ?: 0), - color = BoostGreen, - fontSize = 20.sp, - fontWeight = FontWeight.SemiBold, + 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 = TextMain, + color = CodeTheme.colors.textMain, fontSize = 20.sp, - fontWeight = FontWeight.SemiBold, maxLines = 1, textAlign = TextAlign.End, ) @@ -207,7 +203,7 @@ private fun title(content: ReactionAlertContent): String { @Composable private fun AvatarStack(content: ReactionAlertContent) { val size = ReactionAlertPillSpec.StackAvatarSize - val step = size - ReactionAlertPillSpec.StackOverlap + 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 -> @@ -238,7 +234,7 @@ private fun AlertAvatar( 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) 2.dp else 0.dp + val ringWidth = if (ring) CodeTheme.dimens.thickBorder else 0.dp Box( modifier = modifier .size(size)