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
17 changes: 17 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,23 @@
<string name="label_messageDeleted">This message was deleted</string>
<string name="label_chat_preview_deletedMessage">Message deleted</string>

<!-- Chat message selection bar and its actions -->
<string name="action_copy">Copy</string>
<string name="action_edit">Edit</string>
<string name="action_delete">Delete</string>
<string name="action_moreMessageActions">More message actions</string>
<string name="action_clearMessageSelection">Clear selection</string>
<string name="action_deleteForEveryone">Delete For Everyone</string>
<string name="action_cancelEdit">Cancel edit</string>
<string name="action_confirmEdit">Confirm edit</string>
<string name="title_deleteMessage">Delete message?</string>
<string name="description_deleteMessage">This can\'t be undone</string>
<string name="title_messageNotEdited">Message Not Edited</string>
<string name="description_messageNotEdited">Your change couldn\'t be saved, so the message is unchanged.</string>
<string name="title_messageNotDeleted">Message Not Deleted</string>
<string name="description_messageNotDeleted">The message couldn\'t be deleted, so it is still visible to everyone.</string>
<string name="title_clipboardLabelMessage">Message</string>

<string name="label_unknownContact">Unknown Contact</string>
<string name="title_allowFullContactAccess">Allow Full Contact Access</string>
<string name="subtitle_allowFullContactAccess">Make sure you can send cash and identify people you know</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.flipcash.app.messenger.internal

import android.content.ClipboardManager
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.snapshotFlow
Expand All @@ -15,7 +16,11 @@ import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.app.core.chat.ChatParticipant
import com.flipcash.app.core.contacts.DeviceContact
import com.flipcash.app.core.extensions.setText
import com.flipcash.app.core.ui.ConfirmationStyle
import com.flipcash.shared.chat.MessageCapability
import com.flipcash.shared.chat.applying
import com.flipcash.shared.chat.resolveCapabilities
import com.flipcash.shared.chat.models.ChatListItem
import com.flipcash.shared.chat.models.ReceiptStatus
import com.flipcash.shared.chat.models.SeparatorConfig
Expand Down Expand Up @@ -105,6 +110,7 @@ internal class ChatViewModel @Inject constructor(
private val userManager: UserManager,
private val resources: ResourceHelper,
private val analytics: FlipcashAnalyticsService,
private val clipboardManager: ClipboardManager,
) : BaseViewModel<ChatViewModel.State, ChatViewModel.Event>(
initialState = State(),
updateStateForEvent = updateStateForEvent,
Expand Down Expand Up @@ -146,12 +152,47 @@ internal class ChatViewModel @Inject constructor(
// open would be missed by the bottom bar before it subscribes, whereas state is durable
// until the input is actually composed and can consume it.
val messageInputRequested: Boolean = false,
/**
* The message the selection bar is acting on, or `null` when the ordinary title bar is up.
*
* One message at a time: every capability the transcript resolves — copy, edit, delete —
* applies to a single message, so a multi-selection would only ever be a bar with most of
* its actions disabled.
*/
val selection: ChatListItem.ContentBubble? = null,
/** The message the composer is editing, or `null` when it is composing a new one. */
val editing: EditingMessage? = null,
/**
* True while the delete confirmation is up.
*
* The sheet is modal, so nothing behind it should still read as the focus: the selected
* message falls back behind the backdrop with the rest of the transcript until the sheet
* closes, rather than sitting sharp and half-clipped at the sheet's own edge.
*/
val confirmingDelete: Boolean = false,
) {
// Opening the participant's profile (the entry point to blocking) is only available for tip DMs.
val canViewProfile: Boolean
get() = chatType == ChatType.TIP_DM

/** What the selection bar may offer, straight from what the transcript already resolved. */
val selectionCapabilities: Set<MessageCapability>
get() = selection?.capabilities.orEmpty()
}

/**
* An edit in progress.
*
* [stashedDraft] is whatever the composer held when the edit began; leaving edit mode — by
* confirming, cancelling, or backing out — puts it back, so starting an edit never costs the
* user a half-written message.
*/
data class EditingMessage(
val messageId: Long,
val originalText: String,
val stashedDraft: String,
)

sealed interface Event {
data class OnChatOpened(val identifier: ChatIdentifier) : Event
data class OnContactFound(val contact: DeviceContact): Event
Expand Down Expand Up @@ -194,12 +235,36 @@ internal class ChatViewModel @Inject constructor(
data class LimitsChanged(val limits: Limits?) : Event
data class AdvanceReadPointer(val messageId: Long) : Event
data class ChatDeactivated(val isReadOnly: Boolean) : Event

/** Selects [bubble], or leaves selection mode if it is already the selected one. */
data class ToggleMessageSelection(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:
// the reducer runs before the handlers do, so an action that dismisses the selection bar
// would otherwise have cleared its own subject before the handler saw it.
data class CopyMessage(val text: String) : Event
data class EditMessage(val messageId: Long, val text: String) : Event
data class DeleteMessage(val messageId: Long) : Event

data object SubmitEdit : Event
data object CancelEdit : Event
data object EditingEnded : Event
}

@OptIn(ExperimentalCoroutinesApi::class)
private val messageStream = stateFlow.mapNotNull { it.chatId }
.distinctUntilChanged()
.flatMapLatest { chatCoordinator.observeMessagesPaged(it) }
// Cached here rather than after the mapping below so the overlay composes over the page
// cache: an edit or delete awaiting the server re-runs the mapping without re-fetching.
.cachedIn(viewModelScope)

/** Edits and deletes the server has not answered yet, composed over the stored transcript. */
@OptIn(ExperimentalCoroutinesApi::class)
private val pendingMutations = stateFlow.mapNotNull { it.chatId }
.distinctUntilChanged()
.flatMapLatest { chatCoordinator.observePendingMutations(it) }

@OptIn(ExperimentalCoroutinesApi::class)
val otherReadPointer = stateFlow.mapNotNull { it.chatId }
Expand All @@ -208,9 +273,10 @@ internal class ChatViewModel @Inject constructor(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)

@OptIn(ExperimentalCoroutinesApi::class)
val messages: Flow<PagingData<ChatListItem>> = messageStream
.map { pagingData ->
pagingData.flatMap { message ->
val messages: Flow<PagingData<ChatListItem>> =
combine(messageStream, pendingMutations) { pagingData, mutations ->
pagingData.flatMap { stored ->
val message = stored.applying(mutations[stored.messageId])
message.content.mapIndexed { index, content ->
val enriched = if (content is MessageContent.Cash && content.tokenName.isBlank()) {
val token = tokenCoordinator.getTokenMetadata(content.mint).getOrNull()?.token
Expand Down Expand Up @@ -239,6 +305,10 @@ internal class ChatViewModel @Inject constructor(
// A null author is a moderation removal, which reads as someone else's.
deletedByViewer = (enriched as? MessageContent.Deleted)?.deletedBy
?.let { it == userManager.accountId } == true,
// Resolved once, here, so no menu re-derives it: a later group-role
// taxonomy becomes another input to the resolver rather than a branch at
// each action site.
capabilities = resolveCapabilities(message),
)
}
}.insertSeparators { before: ChatListItem.ContentBubble?, after: ChatListItem.ContentBubble? ->
Expand All @@ -247,7 +317,7 @@ internal class ChatViewModel @Inject constructor(
ChatListItem.DateSeparator(before.timestamp)
} else null
}
}.cachedIn(viewModelScope)
}

private val maxAmountFlow by lazy {
combine(
Expand Down Expand Up @@ -364,6 +434,7 @@ internal class ChatViewModel @Inject constructor(
initTokenAndExchangeObservers()
initTypingHandlers()
initSendHandlers()
initMessageActionHandlers()
}
}

Expand Down Expand Up @@ -623,6 +694,91 @@ internal class ChatViewModel @Inject constructor(
.launchIn(viewModelScope)
}

private fun initMessageActionHandlers() {
eventFlow.filterIsInstance<Event.CopyMessage>()
.onEach { event ->
clipboardManager.setText(
text = event.text,
label = resources.getString(R.string.title_clipboardLabelMessage),
)
}
.launchIn(viewModelScope)

// Pre-filling the composer writes to the live TextFieldState, so it runs on the main
// thread for the same reason clearing it after a send does.
eventFlow.filterIsInstance<Event.EditMessage>()
.onEach { event -> stateFlow.value.chatInputState.setTextAndPlaceCursorAtEnd(event.text) }
.flowOn(Dispatchers.Main.immediate)
.launchIn(viewModelScope)

eventFlow.filterIsInstance<Event.SubmitEdit>()
.onEach {
val editing = stateFlow.value.editing ?: return@onEach
val chatId = stateFlow.value.chatId ?: return@onEach
val text = stateFlow.value.chatInputState.text.toString()
finishEditing(editing)

// Confirming an unchanged edit is still a way out of edit mode; it just isn't a
// request. An empty body isn't an edit either — deleting is the other action.
if (text.isBlank() || text == editing.originalText) return@onEach

viewModelScope.launch {
chatCoordinator.editMessage(chatId, editing.messageId, text)
.onFailure { cause ->
trace("failed to edit message - ${cause.localizedMessage}")
BottomBarManager.showError(
title = resources.getString(R.string.title_messageNotEdited),
message = resources.getString(R.string.description_messageNotEdited),
)
}
}
}
.flowOn(Dispatchers.Main.immediate)
.launchIn(viewModelScope)

eventFlow.filterIsInstance<Event.CancelEdit>()
.onEach { finishEditing(stateFlow.value.editing ?: return@onEach) }
.flowOn(Dispatchers.Main.immediate)
.launchIn(viewModelScope)

eventFlow.filterIsInstance<Event.DeleteMessage>()
.onEach { event ->
val chatId = stateFlow.value.chatId ?: return@onEach
BottomBarManager.showAlert(
title = resources.getString(R.string.title_deleteMessage),
message = resources.getString(R.string.description_deleteMessage),
actions = listOf(
BottomBarAction(
text = resources.getString(R.string.action_deleteForEveryone),
) {
viewModelScope.launch {
chatCoordinator.deleteMessage(chatId, event.messageId)
.onFailure { cause ->
trace("failed to delete message - ${cause.localizedMessage}")
BottomBarManager.showError(
title = resources.getString(R.string.title_messageNotDeleted),
message = resources.getString(R.string.description_messageNotDeleted),
)
}
}
},
),
showCancel = true,
// Closing the sheet ends the selection either way. Cancelling would otherwise
// leave the message alone behind the backdrop with a bar the user just backed
// out of, which reads as a second confirmation still pending.
onDismiss = { dispatchEvent(Event.ClearMessageSelection) },
)
}
.launchIn(viewModelScope)
}

/** Leaves edit mode, restoring the draft the edit interrupted. */
private fun finishEditing(editing: EditingMessage) {
stateFlow.value.chatInputState.setTextAndPlaceCursorAtEnd(editing.stashedDraft)
dispatchEvent(Event.EditingEnded)
}

private fun initSendHandlers() {
// Send text message
eventFlow.filterIsInstance<Event.SendMessage>()
Expand Down Expand Up @@ -1012,6 +1168,39 @@ internal class ChatViewModel @Inject constructor(
is Event.LimitsChanged -> { state -> state.copy(limits = event.limits) }
is Event.AdvanceReadPointer -> { state -> state }
is Event.ChatDeactivated -> { state -> state.copy(isAnonymous = event.isReadOnly) }
is Event.ToggleMessageSelection -> { state ->
val alreadySelected = state.selection?.itemKey == event.bubble.itemKey
state.copy(
selection = event.bubble.takeUnless { alreadySelected },
confirmingDelete = false,
)
}
Event.ClearMessageSelection -> { state ->
state.copy(selection = null, confirmingDelete = false)
}
is Event.CopyMessage -> { state ->
state.copy(selection = null, confirmingDelete = false)
}
is Event.EditMessage -> { state ->
state.copy(
selection = null,
confirmingDelete = false,
editing = EditingMessage(
messageId = event.messageId,
originalText = event.text,
// Starting a second edit before the first ends must not stash the
// first edit's text as if it were the user's draft.
stashedDraft = state.editing?.stashedDraft
?: state.chatInputState.text.toString(),
),
)
}
// Selection survives the confirmation sheet, but the focus does not: the sheet is
// modal, so the transcript behind it goes uniformly dim until the sheet closes.
is Event.DeleteMessage -> { state -> state.copy(confirmingDelete = true) }
Event.SubmitEdit -> { state -> state }
Event.CancelEdit -> { state -> state }
Event.EditingEnded -> { state -> state.copy(editing = null) }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ internal object ChatAnimations {
// Delivered -> Read label swap — scale + opacity.
val readSwap: SpringSpec<Float> = spring(dampingRatio = 0.74f, stiffness = Spring.StiffnessHigh)

// Long-press lift — the row dips under the finger, then springs up while it stands selected.
// Matches the scale UIKit's context menu gives its preview on iOS.
val lift: SpringSpec<Float> = spring(dampingRatio = 0.68f, stiffness = 600f)

// Receipt label exit when a new message is sent — fade out + collapse.
private val deliveredIntSize: SpringSpec<IntSize> = spring(dampingRatio = 0.88f, stiffness = 250f)
val receiptExit: ExitTransition = shrinkVertically(deliveredIntSize) + fadeOut(delivered)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.flipcash.app.messenger.internal.screens

import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.runtime.Composable
Expand Down Expand Up @@ -56,11 +57,30 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
is ChatAction.ViewToken -> {
keyboard.hideIfVisible {
viewModel.dispatchEvent(
ChatViewModel.Event.OpenScreen(AppRoute.Token.Info(action.mint))
ChatViewModel.Event.OpenScreen(
// A drill-in from the transcript, so push it: the fade-in-place
// expand is the wallet card growing into its own detail, and there
// is no card here for it to grow from.
AppRoute.Token.Info(action.mint, asPush = true)
)
)
}
}

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

ChatAction.ClearSelection -> {
viewModel.dispatchEvent(ChatViewModel.Event.ClearMessageSelection)
}

ChatAction.CancelEdit -> {
viewModel.dispatchEvent(ChatViewModel.Event.CancelEdit)
}

is ChatAction.ViewProfile -> {
// The triggers (top-bar tap, contact-card chevron) are only clickable for tip DMs
// (see State.canViewProfile), so no gating is needed here.
Expand All @@ -75,13 +95,22 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
Unit
}

// Back unwinds the message actions before it leaves the conversation, innermost first: an edit
// in progress, then the selection bar.
BackHandler(enabled = state.editing != null) {
viewModel.dispatchEvent(ChatViewModel.Event.CancelEdit)
}
BackHandler(enabled = state.editing == null && state.selection != null) {
viewModel.dispatchEvent(ChatViewModel.Event.ClearMessageSelection)
}

CodeScaffold(
// The input bar rides the keyboard; the message list is inset by it either way.
modifier = Modifier.imePadding(),
// The list runs the full height and passes under both bars, each of which fades it out
// against the background at its own edge.
barPlacement = ScaffoldBarPlacement.Overlay,
topBar = { ChatTopBar(navigator, state, chatActionHandler) },
topBar = { ChatTopBar(navigator, state, chatActionHandler, viewModel::dispatchEvent) },
bottomBar = {
UserControlBottomBar(
state = state,
Expand Down
Loading
Loading