Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1852,5 +1852,4 @@
<item quantity="other">%1$s and %2$d others reacted</item>
</plurals>
<string name="alert_someone">Someone</string>
<string name="alert_thisChat">This chat ↓</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,6 @@ import com.flipcash.features.messenger.R
import androidx.compose.ui.res.stringResource
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
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
Expand Down Expand Up @@ -167,19 +164,6 @@ 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<ReactionAlertAction.ScrollTo>()
?.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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,6 @@ fun ReactionAlertHost(

ReactionAlertPill(
content = current.content,
inActiveChat = current.inActiveChat,
maxWidth = maxWidth - 24.dp,
hazeState = hazeState,
animate = animate,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ object ReactionAlertPillSpec {
@Composable
fun ReactionAlertPill(
content: ReactionAlertContent,
inActiveChat: Boolean,
modifier: Modifier = Modifier,
maxWidth: Dp = ReactionAlertPillSpec.MaxWidth,
hazeState: HazeState? = null,
Expand All @@ -111,13 +110,13 @@ fun ReactionAlertPill(
animationSpec = tween(if (animate) 150 else 0),
label = "alertContent",
) { shown ->
AlertRow(shown, inActiveChat)
AlertRow(shown)
}
}
}

@Composable
private fun AlertRow(content: ReactionAlertContent, inActiveChat: Boolean) {
private fun AlertRow(content: ReactionAlertContent) {
Row(
modifier = Modifier
.fillMaxWidth()
Expand All @@ -135,7 +134,7 @@ 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(),
text = content.chatName.orEmpty(),
style = CodeTheme.typography.caption,
color = CodeTheme.colors.textSecondary,
maxLines = 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,11 @@ class ReactionAlertPillScreenshotTest {
"alert_stacked.png",
)

@Test fun inActiveChat() = render(content(Kind.Reaction), "alert_this_chat.png", inActiveChat = true)

private fun render(content: ReactionAlertContent, fileName: String, inActiveChat: Boolean = false) {
private fun render(content: ReactionAlertContent, fileName: String) {
composeRule.mainClock.autoAdvance = false
composeRule.setContent {
Frame {
ReactionAlertPill(content, inActiveChat, animate = false)
ReactionAlertPill(content, animate = false)
}
}
repeat(10) { composeRule.mainClock.advanceTimeByFrame() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ 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
Expand All @@ -33,17 +31,12 @@ fun interface 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. */
/** Open the chat at the message. */
data class OpenChat(val chatId: ChatId, val messageId: Long) : ReactionAlertAction
}

Expand All @@ -52,8 +45,9 @@ sealed interface ReactionAlertAction {
* 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.
* The chat screen reports itself as the open chat through [reportOnScreen]. A reaction in that chat
* draws no pill, on screen or not: the pill is for chats the user is not in. Opening a chat also
* closes its pill and drops its queued ones.
*/
@Singleton
class ReactionAlertController internal constructor(
Expand All @@ -80,34 +74,36 @@ class ReactionAlertController internal constructor(
private var generation = 0

private var activeChat: ChatId? = null
private var onScreen: Set<Long> = emptySet()

private val _state = MutableStateFlow<ReactionAlertState?>(null)
val state: StateFlow<ReactionAlertState?> = _state.asStateFlow()

private val _actions = MutableSharedFlow<ReactionAlertAction>(extraBufferCapacity = 4)
val actions: SharedFlow<ReactionAlertAction> = _actions.asSharedFlow()

/** The open chat's visible message ids. Replaces the earlier report for [chatId]. */
/**
* [chatId] is the open chat. [messageIds], the ids visible in it, no longer affect alerts. When
* the open chat changes, its pill closes and its queued pills are dropped.
*/
@Suppress("UNUSED_PARAMETER")
fun reportOnScreen(chatId: ChatId, messageIds: Set<Long>) = synchronized(lock) {
if (activeChat == chatId) return@synchronized
activeChat = chatId
onScreen = messageIds
refreshActiveChatFlag()
queue.dropPending(chatId)
if (queue.current?.chatId == chatId) close()
}

/** 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
if (event.chatId == activeChat) return
when (queue.offer(event)) {
ReactionAlertQueue.Outcome.Started -> publishAndHold()
ReactionAlertQueue.Outcome.Merged -> publishAndHold()
Expand All @@ -127,10 +123,7 @@ class ReactionAlertController internal constructor(
}
val chatId = shown.content.chatId
val messageId = shown.content.messageId
_actions.tryEmit(
if (shown.inActiveChat) ReactionAlertAction.ScrollTo(chatId, messageId)
else ReactionAlertAction.OpenChat(chatId, messageId),
)
_actions.tryEmit(ReactionAlertAction.OpenChat(chatId, messageId))
}

/** Drops everything, for sign-out. */
Expand All @@ -143,7 +136,7 @@ class ReactionAlertController internal constructor(
private fun publishAndHold() {
val content = queue.current ?: return
generation++
_state.value = ReactionAlertState(content, content.chatId == activeChat, generation)
_state.value = ReactionAlertState(content, generation)
holdJob?.cancel()
holdJob = scope.after(content.holdMillis) { synchronized(lock) { close() } }
}
Expand All @@ -163,12 +156,6 @@ class ReactionAlertController internal constructor(
}
}

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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@ class ReactionAlertQueue {
return current
}

/** Drops the waiting pills for [chatId]. The current pill is left to its owner to close. */
fun dropPending(chatId: ChatId) {
pending.removeAll { dropped ->
(dropped.chatId == chatId).also { if (it) actors.remove(key(dropped)) }
}
}

/** Drops the current pill and everything waiting. */
fun clear() {
current = null
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.flipcash.shared.chat.alerts

import com.flipcash.services.models.chat.ReactionUpdate
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
Expand Down Expand Up @@ -72,44 +73,91 @@ class ReactionAlertControllerTest {
}

@Test
fun `off screen message in the open chat shows with this chat label`() = runTest {
fun `off screen message in the open chat draws no pill`() = runTest {
val c = controller()
c.reportOnScreen(CHAT, setOf(11L))
c.offer(alertEvent(messageId = 10))
runCurrent()
assertEquals(true, c.state.value?.inActiveChat)
c.reset()
assertNull(c.state.value)
}

@Test
fun `on screen in another chat still shows`() = runTest {
fun `reaction in another chat shows with the chat name`() = 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)
assertEquals(CHAT, c.state.value?.content?.chatId)
c.reset()
}

@Test
fun `tap in the open chat scrolls, elsewhere opens`() = runTest {
fun `no chat open shows the pill`() = runTest {
val c = controller()
val seen = mutableListOf<ReactionAlertAction>()
backgroundScope.launch { c.actions.collect { a -> seen += a } }
c.offer(alertEvent())
runCurrent()
assertNotNull(c.state.value)
c.reset()
}

@Test
fun `opening a chat closes its showing pill`() = runTest {
val c = controller()
c.offer(alertEvent())
runCurrent()
c.onTap()
assertNotNull(c.state.value)
c.reportOnScreen(CHAT, setOf(10L))
assertNull(c.state.value)
c.reset()
}

@Test
fun `opening a chat drops its queued pills and keeps others`() = runTest {
val c = controller()
c.offer(alertEvent(messageId = 10, chatId = CHAT))
c.offer(alertEvent(messageId = 11, chatId = CHAT))
c.offer(alertEvent(messageId = 12, chatId = OTHER_CHAT))
runCurrent()
assertEquals(listOf<ReactionAlertAction>(ReactionAlertAction.OpenChat(CHAT, 10)), seen)
c.reportOnScreen(CHAT, setOf(99L))
c.offer(alertEvent(messageId = 12))
c.reportOnScreen(CHAT, emptySet())
assertNull(c.state.value)
advanceTimeBy(ReactionAlertController.CLOSE_MILLIS + 1)
assertEquals(OTHER_CHAT, c.state.value?.content?.chatId)
assertEquals(12, c.state.value?.content?.messageId)
c.reset()
}

@Test
fun `boost in the open chat draws no pill`() = runTest {
val c = controller()
c.reportOnScreen(CHAT, setOf(99L))
c.offer(alertEvent(messageId = 10, action = ReactionUpdate.Action.BOOSTED, boostDeltaCents = 500))
runCurrent()
assertNull(c.state.value)
}

@Test
fun `boost in another chat shows with the amount`() = runTest {
val c = controller()
c.reportOnScreen(OTHER_CHAT, emptySet())
c.offer(alertEvent(messageId = 10, action = ReactionUpdate.Action.BOOSTED, boostDeltaCents = 500))
runCurrent()
assertEquals(ReactionAlertContent.Kind.Boost, c.state.value?.content?.kind)
assertEquals(500L, c.state.value?.content?.boostCents)
c.reset()
}

@Test
fun `tap opens the chat at the message`() = runTest {
val c = controller()
val seen = mutableListOf<ReactionAlertAction>()
backgroundScope.launch { c.actions.collect { a -> seen += a } }
runCurrent()
c.offer(alertEvent())
runCurrent()
c.onTap()
runCurrent()
assertEquals(ReactionAlertAction.ScrollTo(CHAT, 12), seen.last())
assertEquals(listOf<ReactionAlertAction>(ReactionAlertAction.OpenChat(CHAT, 10)), seen)
c.reset()
}
}
Loading