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
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,11 @@ internal class ChatViewModel @Inject constructor(
* its actions disabled.
*/
val selection: ChatListItem.ContentBubble? = null,
/**
* Whether [selection] was made by a double tap, which presents only the quick reaction strip:
* the lift and backdrop without the selection bar. Only meaningful while [selection] is set.
*/
val reactionStripOnly: Boolean = false,
/** The message the composer is editing, or `null` when it is composing a new one. */
val editing: EditingMessage? = null,
/**
Expand Down Expand Up @@ -603,6 +608,12 @@ internal class ChatViewModel @Inject constructor(

/** Selects [bubble], or leaves selection mode if it is already the selected one. */
data class ToggleMessageSelection(val bubble: ChatListItem.ContentBubble) : Event

/**
* Selects [bubble] for the quick reaction strip alone, as a double tap does; nothing when it
* can't take a reaction once narrowed to what is open now.
*/
data class PresentReactionStrip(val bubble: ChatListItem.ContentBubble) : Event
data object ClearMessageSelection : Event

// The message actions carry what they act on rather than reading it back off the selection:
Expand Down Expand Up @@ -2657,19 +2668,25 @@ internal class ChatViewModel @Inject constructor(
// By message rather than by row: a message split around its card is one
// selection whichever of its rows was pressed.
val alreadySelected = state.selection?.messageKey == event.bubble.messageKey
// The transcript resolved this bubble when it was mapped, which may have been
// well inside a window that has since closed. Narrow it again here so the bar
// offers what is open now rather than what was open when the row was built.
// The same goes for membership: a viewer who has left since keeps only what a
// reader outside the group may do.
val selected = event.bubble.takeUnless { alreadySelected }?.let { bubble ->
val open = bubble.capabilities
.withinWindows(bubble.timestamp, state.messagePolicy)
bubble.copy(
capabilities = if (state.isOutsideGroup) open.readOnly() else open,
)
// Narrowed again so the bar offers what is open now, not when the row was built.
val selected = event.bubble.takeUnless { alreadySelected }?.narrowedFor(state)
state.copy(
selection = selected,
reactionStripOnly = false,
confirmingDelete = false,
).withQuickReactionStrip()
}
is Event.PresentReactionStrip -> { state ->
val selected = event.bubble.narrowedFor(state).takeIf { it.canReact }
if (selected == null) {
state
} else {
state.copy(
selection = selected,
reactionStripOnly = true,
confirmingDelete = false,
).withQuickReactionStrip()
}
state.copy(selection = selected, confirmingDelete = false).withQuickReactionStrip()
}
Event.ClearMessageSelection -> { state ->
state.copy(selection = null, confirmingDelete = false).withQuickReactionStrip()
Expand Down Expand Up @@ -2753,6 +2770,17 @@ internal class ChatViewModel @Inject constructor(
}
}

/**
* This bubble with its capabilities narrowed to what is open now. The transcript resolved it when it
* was mapped, which may have been well inside a window that has since closed, so a selection offers
* what is open at the moment it is made. The same goes for membership: a viewer who has left since
* keeps only what a reader outside the group may do.
*/
private fun ChatListItem.ContentBubble.narrowedFor(state: ChatViewModel.State): ChatListItem.ContentBubble {
val open = capabilities.withinWindows(timestamp, state.messagePolicy)
return copy(capabilities = if (state.isOutsideGroup) open.readOnly() else open)
}

/**
* [ChatViewModel.State.quickReactionStrip] rebuilt for the current selection: empty while nothing
* is selected, while the selected message can't be reacted to, or before the inputs have loaded.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package com.flipcash.app.messenger.internal.screens

import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.runtime.Composable
Expand All @@ -10,6 +14,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.testTag
Expand Down Expand Up @@ -154,6 +162,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
viewModel.dispatchEvent(ChatViewModel.Event.ToggleMessageSelection(action.bubble))
}

is ChatAction.PresentReactionStrip -> {
viewModel.dispatchEvent(ChatViewModel.Event.PresentReactionStrip(action.bubble))
}

ChatAction.ClearSelection -> {
viewModel.dispatchEvent(ChatViewModel.Event.ClearMessageSelection)
}
Expand Down Expand Up @@ -273,12 +285,43 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
)
},
bottomBar = {
UserControlBottomBar(
state = state,
hazeState = hazeState,
onAction = chatActionHandler,
dispatch = viewModel::dispatchEvent,
// Behind the backdrop with the transcript while a message is raised, dimmed and blurred
// as its rows are, and a tap on it dismisses the selection as a tap on them does. An
// edit raises a message too, but it is the composer doing the editing, so it stays sharp.
val behindBackdrop = state.selection != null && state.editing == null
val dimAlpha by animateFloatAsState(
targetValue = if (behindBackdrop) 0.4f else 1f,
label = "composerDim",
)
val dimBlur by animateDpAsState(
targetValue = if (behindBackdrop) 8.dp else 0.dp,
label = "composerBlur",
)
Box {
Box(
modifier = Modifier
.blur(dimBlur, BlurredEdgeTreatment.Unbounded)
.graphicsLayer { alpha = dimAlpha },
) {
UserControlBottomBar(
state = state,
hazeState = hazeState,
onAction = chatActionHandler,
dispatch = viewModel::dispatchEvent,
)
}
if (behindBackdrop) {
Box(
modifier = Modifier
.matchParentSize()
.pointerInput(Unit) {
detectTapGestures {
viewModel.dispatchEvent(ChatViewModel.Event.ClearMessageSelection)
}
},
)
}
}
},
) { overlapPadding ->
// The transcript and the info card go behind a blur together, because they are one surface
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ internal fun ChatTopBar(
// would offer the profile and leave back as the only way out.
val mode: TopBarMode = when {
state.editing != null -> TopBarMode.Editing
state.selection != null -> TopBarMode.Selecting(state.selection)
// A double tap presents the strip alone, over the ordinary title bar.
state.selection != null && !state.reactionStripOnly -> TopBarMode.Selecting(state.selection)
else -> TopBarMode.Conversation
}
AnimatedContent(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.flipcash.app.messenger.internal.screens.components

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
Expand Down Expand Up @@ -40,6 +42,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.dp
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.graphics.graphicsLayer
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
Expand Down Expand Up @@ -149,16 +154,38 @@ internal fun MessageList(
// The backdrop, read once for everything it covers: the rows, the bubbles' own targets,
// and the contact card at the start of history all stop taking taps together.
val selecting = state.selection != null || state.editing != null
// The items past the oldest message (its date, the info or invite card) are not rows, so
// they take the rows' backdrop dim and blur here; nothing among them can be the focus.
val trailingDimAlpha by animateFloatAsState(
targetValue = if (selecting) 0.4f else 1f,
label = "trailingDim",
)
val trailingDimBlur by animateDpAsState(
targetValue = if (selecting) 8.dp else 0.dp,
label = "trailingBlur",
)
val behindBackdrop = Modifier
.blur(trailingDimBlur, BlurredEdgeTreatment.Unbounded)
.graphicsLayer { alpha = trailingDimAlpha }

// A message can be long-pressed while it is running under the top bar, which the fade
// there makes easy to do — and the bar then swaps to the actions for it, so the row the
// backdrop leaves sharp sits behind the buttons acting on it. Bring it down level with the
// bar's lower edge before that happens. Selecting and then editing is one focus, not two,
// so the key doesn't change across that step and the row isn't scrolled twice.
//
// A message that takes reactions comes down further, far enough for the quick strip to sit
// above it: the strip is always above, and there's always room to make, since the info card
// at the start of history sits over the oldest message.
val focusedMessageId = state.editing?.messageId ?: state.selection?.messageId
val stripRoom = with(LocalDensity.current) {
(QuickReactionStripPlacement.StripHeight + QuickReactionStripPlacement.Gap + STRIP_BAR_GAP)
.roundToPx()
}
LaunchedEffect(focusedMessageId) {
if (focusedMessageId == null) return@LaunchedEffect
val buried = -listState.layoutInfo.headroomAbove(messages, focusedMessageId)
val room = if (state.editing == null && state.selection?.canReact == true) stripRoom else 0
val buried = room - listState.layoutInfo.headroomAbove(messages, focusedMessageId)
if (buried > 0) listState.animateScrollBy(buried.toFloat())
}

Expand Down Expand Up @@ -430,7 +457,9 @@ internal fun MessageList(
// rather than fetched.
if (messages.itemCount == 0 && state.obscuresTranscript) {
item(key = "gated-transcript-placeholder") {
GatedTranscriptPlaceholder(modifier = Modifier.fillParentMaxWidth())
GatedTranscriptPlaceholder(
modifier = Modifier.fillParentMaxWidth().then(behindBackdrop),
)
}
}

Expand All @@ -449,7 +478,9 @@ internal fun MessageList(
if (oldestTimestamp != null) {
item(key = "trailing-date-${oldestTimestamp.epochSeconds}") {
Box(
modifier = Modifier.padding(bottom = CodeTheme.dimens.grid.x2),
modifier = Modifier
.padding(bottom = CodeTheme.dimens.grid.x2)
.then(behindBackdrop),
) {
DateSeparatorRow(oldestTimestamp)
}
Expand All @@ -473,7 +504,7 @@ internal fun MessageList(
)
}
BoxWithConstraints(
modifier = Modifier.fillParentMaxWidth(),
modifier = Modifier.fillParentMaxWidth().then(behindBackdrop),
contentAlignment = Alignment.Center,
) {
// The width a link card gets in a DM transcript, so this reads as the
Expand All @@ -498,7 +529,8 @@ internal fun MessageList(
item {
Box(
modifier = Modifier
.fillParentMaxWidth(),
.fillParentMaxWidth()
.then(behindBackdrop),
contentAlignment = Alignment.Center,
) {
ChatInfoCard(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,16 @@ internal fun MessageRow(
onAction(ChatAction.ToggleSelection(target))
}
}
// A double tap presents the strip alone, as iMessage's tapback does. Hoisted like select: a
// bubble with a tap target of its own -- a cash bubble, a link card, a quote -- reports it too,
// and holds its single tap for the double-tap timeout to tell the two apart. A button inside a
// card is not given it, so it stays instant.
val presentStrip = bubble?.takeIf { it.canReact }?.let { target ->
{
vibrator.tick()
onAction(ChatAction.PresentReactionStrip(target))
}
}

val dimAlpha by animateFloatAsState(
targetValue = if (focused) 1f else 0.4f,
Expand Down Expand Up @@ -241,6 +251,10 @@ internal fun MessageRow(
interactionSource = interactionSource,
indication = null,
onLongClick = select,
onDoubleClick = presentStrip,
// select plays the tick; the platform's own long-press haptic on top of
// it is felt as a second buzz.
hapticFeedbackEnabled = false,
// Only reachable with the backdrop down, so the tap has nothing to
// dismiss but the keyboard.
onClick = { keyboard.hide() },
Expand Down Expand Up @@ -357,6 +371,7 @@ internal fun MessageRow(
// so the row's long-press never reaches it. Handing it the
// same gesture is what makes a cash bubble selectable.
onLongClick = select,
onDoubleClick = presentStrip,
position = bubblePositionOf(
index,
item,
Expand Down Expand Up @@ -561,7 +576,8 @@ private fun senderNameInset(showsGutter: Boolean): Dp =
CodeTheme.dimens.grid.x1
}

private val STRIP_BAR_GAP = 8.dp
/** Between the top bar and the highest the quick strip goes. */
internal val STRIP_BAR_GAP = 8.dp

private val AFFORDANCE_SIZE = 32.dp
private val AFFORDANCE_INSET = 20.dp
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.flipcash.shared.chat.reactions.ReactionStrip
import com.flipcash.shared.chat.reactions.SelfReaction
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Instant

Expand Down Expand Up @@ -114,4 +115,39 @@ class ChatReactionsReducerTest {

assertEquals(selected, state)
}

@Test
fun `a double tap selects the bubble for the strip alone`() {
val state = reduce(
ChatViewModel.State(messagePolicy = unbounded, quickReactionInputs = inputs),
ChatViewModel.Event.PresentReactionStrip(bubble(1)),
)

assertEquals(1L, state.selection?.messageId)
assertTrue(state.reactionStripOnly)
assertTrue(state.quickReactionStrip.isNotEmpty())
}

@Test
fun `a double tap on a message that can't be reacted to does nothing`() {
val before = ChatViewModel.State(messagePolicy = unbounded, quickReactionInputs = inputs)

val state = reduce(before, ChatViewModel.Event.PresentReactionStrip(bubble(1, canReact = false)))

assertEquals(before, state)
}

@Test
fun `a long-press after a double tap brings the selection bar back`() {
val stripOnly = reduce(
ChatViewModel.State(messagePolicy = unbounded, quickReactionInputs = inputs),
ChatViewModel.Event.PresentReactionStrip(bubble(1)),
)
val cleared = reduce(stripOnly, ChatViewModel.Event.ClearMessageSelection)

val selected = reduce(cleared, ChatViewModel.Event.ToggleMessageSelection(bubble(1)))

assertEquals(1L, selected.selection?.messageId)
assertFalse(selected.reactionStripOnly)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ sealed interface ChatAction {
*/
data class ToggleSelection(val bubble: ChatListItem.ContentBubble) : ChatAction

/**
* Presents only the quick reaction strip over [bubble], as a double tap does: the lift and
* backdrop a long-press gives, with no selection bar or actions. Dismissed like a selection.
*/
data class PresentReactionStrip(val bubble: ChatListItem.ContentBubble) : ChatAction

/** Leaves selection mode without acting on anything. */
data object ClearSelection : ChatAction

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ fun ChatQuotePanel(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null,
) {
val accent = quote.accent ?: CodeTheme.colors.tertiary
val name = quote.nameAccent ?: accent
Expand All @@ -64,7 +65,10 @@ fun ChatQuotePanel(
.addIf(onClick != null || onLongClick != null) {
Modifier.combinedClickable(
onLongClick = onLongClick,
onDoubleClick = onDoubleClick,
onClick = { onClick?.invoke() },
// The row's select plays the tick; a second, platform haptic doubles it.
hapticFeedbackEnabled = false,
)
}
.height(IntrinsicSize.Min),
Expand Down
Loading
Loading