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
@@ -0,0 +1,64 @@
package com.flipcash.app.messenger.internal

import com.flipcash.shared.chat.ChatDraftReply
import com.flipcash.shared.chat.ChatDraftSnippet
import com.flipcash.shared.chat.models.ChatQuote
import com.flipcash.shared.chat.models.ChatQuoteSnippet
import com.getcode.opencode.model.financial.CurrencyCode
import com.getcode.opencode.model.financial.Fiat
import com.getcode.ui.utils.generateComplementaryColorPalette

/**
* The composer's reply strip, translated between the shape the screen renders and the shape the
* draft store keeps.
*
* The translation lives in this module because this is the only one that can see both: the store is
* in `:shared:chat`, which has no dependency on the chat UI's models, and the accents are derived
* from a palette that only the UI layer owns.
*/
internal fun ChatQuote.toDraftReply(): ChatDraftReply = ChatDraftReply(
messageId = messageId,
authorName = authorName,
senderIdHex = senderIdHex,
snippet = when (val snippet = snippet) {
is ChatQuoteSnippet.Text -> ChatDraftSnippet.Text(snippet.body)
is ChatQuoteSnippet.Cash -> ChatDraftSnippet.Cash(
quarks = snippet.amount.quarks,
currencyCode = snippet.amount.currencyCode.name,
tokenName = snippet.tokenName,
)
},
)

/**
* Rebuilds the strip from what was stored, rather than from the transcript.
*
* A restored draft is read before any history is, so the cited message may not be in the loaded
* window — or in the database at all, for a chat whose sync has not run yet. The accents are
* re-derived from the sender id because the palette is a pure function of it; a citation stored
* without one comes back with the same null accents it had.
*/
@OptIn(ExperimentalStdlibApi::class)
internal fun ChatDraftReply.toChatQuote(): ChatQuote {
val palette = senderIdHex?.let { hex ->
runCatching { hex.hexToByteArray().toList() }.getOrNull()
?.let { generateComplementaryColorPalette(it) }
}
return ChatQuote(
messageId = messageId,
authorName = authorName,
snippet = when (val snippet = snippet) {
is ChatDraftSnippet.Text -> ChatQuoteSnippet.Text(snippet.body)
is ChatDraftSnippet.Cash -> ChatQuoteSnippet.Cash(
amount = Fiat(
quarks = snippet.quarks,
currencyCode = CurrencyCode.tryValueOf(snippet.currencyCode) ?: CurrencyCode.USD,
),
tokenName = snippet.tokenName,
)
},
accent = palette?.first,
nameAccent = palette?.second,
senderIdHex = senderIdHex,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import android.content.ClipboardManager
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewModelScope
Expand Down Expand Up @@ -69,6 +71,9 @@ import com.flipcash.shared.amountentry.AmountEntryLabel
import com.flipcash.shared.amountentry.AmountEntryStyle
import com.flipcash.shared.chat.ActiveTypist
import com.flipcash.shared.chat.ChatCoordinator
import com.flipcash.shared.chat.ChatDraftSnapshot
import com.flipcash.shared.chat.ChatDraftStore
import com.flipcash.shared.chat.chatDraftOf
import com.flipcash.shared.chat.ChatMembership
import com.flipcash.shared.payments.ContactPaymentDelegate
import com.flipcash.shared.payments.TipPaymentDelegate
Expand All @@ -93,12 +98,14 @@ import com.getcode.view.LoadingSuccessState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
Expand All @@ -116,6 +123,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlin.math.min
import kotlin.time.Duration
Expand Down Expand Up @@ -151,6 +159,7 @@ internal class ChatViewModel @Inject constructor(
private val linkCardClassifier: LinkCardClassifier,
private val linkCardResolver: LinkCardResolver,
private val cashLinkClaims: CashLinkClaims,
private val chatDraftStore: ChatDraftStore,
) : BaseViewModel<ChatViewModel.State, ChatViewModel.Event>(
initialState = State(),
updateStateForEvent = updateStateForEvent,
Expand Down Expand Up @@ -729,6 +738,7 @@ internal class ChatViewModel @Inject constructor(
},
accent = palette?.first,
nameAccent = palette?.second,
senderIdHex = senderId?.hexEncodedString(),
)
}

Expand Down Expand Up @@ -836,11 +846,20 @@ internal class ChatViewModel @Inject constructor(
)
}

/**
* The last chance to write a draft. A backgrounded app can be killed without another callback
* reaching this ViewModel, so STOPPED — not teardown — is what makes process death survivable.
*/
private val draftFlushObserver = object : DefaultLifecycleObserver {
override fun onStop(owner: LifecycleOwner) = flushDraft()
}

init {
// Essential — needed immediately for chat display
initChatHandlers()
initLinkCardFreshness()
initClaimReplies()
initDraftHandlers()

viewModelScope.launch {
// Yield to let the first frame render before setting up remaining collectors
Expand Down Expand Up @@ -1030,6 +1049,7 @@ internal class ChatViewModel @Inject constructor(

if (chatId != null) {
dispatchEvent(Event.ChatFound(chatId))
restoreDraft(chatId)
chatCoordinator.setActiveChatId(chatId)
viewModelScope.launch { openTranscript(chatId) }
chatCoordinator.dismissNotifications(chatId)
Expand Down Expand Up @@ -1589,6 +1609,10 @@ internal class ChatViewModel @Inject constructor(

stateFlow.value.chatInputState.setTextAndPlaceCursorAtEnd("")
if (replyToMessageId != null) dispatchEvent(Event.CancelReply)
// Now, not on delivery: the text is in the pending message from here on, and a
// send that fails leaves a "Not sent" bubble with a retry, which is the
// transcript's own record of it. Restoring a draft as well would duplicate it.
chatDraftStore.saveInBackground(chatId, ChatDraftSnapshot.Empty)

viewModelScope.launch {
chatCoordinator.sendMessage(chatId, textToSend, replyToMessageId)
Expand Down Expand Up @@ -1804,7 +1828,66 @@ internal class ChatViewModel @Inject constructor(
}.launchIn(viewModelScope)
}

/**
* What the composer would leave behind if this screen went away right now.
*
* An in-progress edit contributes the new-message draft it displaced rather than the edit
* body, so a chat left mid-edit reopens in new-message mode with the user's own words — the
* same place cancelling the edit would have put them.
*/
private fun draftSnapshot(): ChatDraftSnapshot {
val state = stateFlow.value
return chatDraftOf(
composerText = state.chatInputState.text.toString(),
replyTarget = state.replyingTo?.toDraftReply(),
editStash = state.editing?.stashedDraft,
)
}

/** Writes off [viewModelScope], which is already cancelled by the time [onCleared] runs. */
private fun flushDraft() {
val chatId = stateFlow.value.chatId ?: return
chatDraftStore.saveInBackground(chatId, draftSnapshot())
}

@OptIn(FlowPreview::class)
private fun initDraftHandlers() {
ProcessLifecycleOwner.get().lifecycle.addObserver(draftFlushObserver)

// The composer text lives in a TextFieldState, so it never re-emits through stateFlow and
// needs its own snapshot subscription; the reply strip and the edit stash do come from
// state. Debounced to keep a database write off the keystroke path — the flushes are what
// cover the cases a debounce would lose.
combine(
snapshotFlow { stateFlow.value.chatInputState.text.toString() },
stateFlow.map { it.replyingTo }.distinctUntilChanged(),
stateFlow.map { it.editing }.distinctUntilChanged(),
) { _, _, _ -> }
.drop(1) // the empty composer the screen opens with, before a restore can have run
.debounce(DRAFT_WRITE_DEBOUNCE)
.onEach {
val chatId = stateFlow.value.chatId ?: return@onEach
chatDraftStore.save(chatId, draftSnapshot())
}
.launchIn(viewModelScope)
}

/**
* Puts back what was left in this chat. Silent by design: the text and the reply strip return,
* focus and the keyboard do not.
*/
private suspend fun restoreDraft(chatId: ChatId) {
val draft = chatDraftStore.load(chatId) ?: return
// Pre-filling the composer writes to the live TextFieldState, so it runs on the main thread.
withContext(Dispatchers.Main.immediate) {
stateFlow.value.chatInputState.setTextAndPlaceCursorAtEnd(draft.text)
}
draft.replyTarget?.let { dispatchEvent(Event.ReplyToMessage(it.toChatQuote())) }
}

override fun onCleared() {
ProcessLifecycleOwner.get().lifecycle.removeObserver(draftFlushObserver)
flushDraft()
chatCoordinator.setActiveChatId(null)
linkCardResolver.dispose()
}
Expand Down Expand Up @@ -1918,6 +2001,9 @@ internal class ChatViewModel @Inject constructor(
*/
private val CLAIM_REFRESH_INTERVAL = 15.seconds

/** Long enough to coalesce a burst of typing, short enough to survive a fast exit. */
private val DRAFT_WRITE_DEBOUNCE = 300.milliseconds

val updateStateForEvent: (Event) -> ((State) -> State) = { event ->
when (event) {
is Event.OnChatOpened -> { state ->
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
package com.flipcash.app.messenger.internal

import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import com.flipcash.app.core.chat.ChatParticipant
import com.flipcash.app.core.contacts.DeviceContact
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatRuleRequirement
import com.flipcash.shared.chat.ChatDraftReply
import com.flipcash.shared.chat.ChatDraftSnapshot
import com.flipcash.shared.chat.ChatDraftSnippet
import com.flipcash.shared.chat.GroupAccess
import com.flipcash.shared.chat.models.ChatQuote
import com.flipcash.shared.chat.models.ChatQuoteSnippet
import com.flipcash.shared.chat.chatDraftOf
import com.getcode.opencode.model.core.bytes
import com.getcode.opencode.model.financial.Fiat
import com.getcode.view.LoadingSuccessState
import org.junit.Test
import java.util.UUID
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue

Expand Down Expand Up @@ -205,4 +213,127 @@ class ChatViewModelStateTest {
// Without the name, "$100" would not say which holding clears the bar.
assertEquals("Jeffy", jeffy.nameInRequirement)
}

/**
* The other half of the strip's independence from the transcript: a citation that was stored
* and read back has to render the same as the one the transcript mapped. Both accents are
* derived rather than stored, so what has to survive the row is the sender id they come from.
*/
@Test
fun `a stored reply strip comes back rendering the same`() {
val quote = ChatQuote(
messageId = 42L,
authorName = "Ana",
snippet = ChatQuoteSnippet.Text("are we still on for 5?"),
accent = null,
nameAccent = null,
senderIdHex = "0f1e2d3c4b5a6978",
)

val restored = quote.toDraftReply().toChatQuote()

assertEquals(quote.messageId, restored.messageId)
assertEquals(quote.authorName, restored.authorName)
assertEquals(quote.snippet, restored.snippet)
assertEquals(quote.senderIdHex, restored.senderIdHex)
// Derived, so they arrive non-null on the way back even though the fixture above left them
// unset — which is the point: the colours are a function of the id, not of the transcript.
assertNotNull(restored.accent)
assertNotNull(restored.nameAccent)
}

/**
* Rule 5 against the real reducer rather than a model of it. `Event.EditMessage` is what moves
* the user's words into the stash, and everything downstream reads them from there — so an
* edit left mid-flight persists the draft it displaced and never the message being edited.
*/
@Test
fun `an edit persists the draft it displaced`() {
val typed = ChatViewModel.State()
typed.chatInputState.setTextAndPlaceCursorAtEnd("half a thought")

val editing = ChatViewModel.updateStateForEvent(
ChatViewModel.Event.EditMessage(messageId = 7L, text = "an older message")
)(typed)
// Entering an edit puts the message body in the field; the reducer stashes what was there.
editing.chatInputState.setTextAndPlaceCursorAtEnd("an older message")

assertEquals(
ChatDraftSnapshot(text = "half a thought", replyTarget = null),
editing.draftSnapshot(),
)
}

/**
* An edit takes the composer, reply strip included — so a reply aimed and then left for an edit
* is not part of what the edit stashed. Pinned because it is the one place the persisted draft
* is narrower than what was on screen a moment earlier, and it follows from a reducer rule
* written for a different reason.
*/
@Test
fun `a reply aimed before an edit is not stashed with it`() {
val aimed = ChatViewModel.updateStateForEvent(
ChatViewModel.Event.ReplyToMessage(
ChatQuote(
messageId = 42L,
authorName = "Ana",
snippet = ChatQuoteSnippet.Text("are we still on for 5?"),
accent = null,
nameAccent = null,
)
)
)(ChatViewModel.State())
aimed.chatInputState.setTextAndPlaceCursorAtEnd("half a thought")

val editing = ChatViewModel.updateStateForEvent(
ChatViewModel.Event.EditMessage(messageId = 7L, text = "an older message")
)(aimed)

assertNull(editing.replyingTo)
assertEquals(
ChatDraftSnapshot(text = "half a thought", replyTarget = null),
editing.draftSnapshot(),
)
}

/**
* Cancelling an edit is the same answer by the path the app already had: the stash goes back in
* the field, and from then on it is an ordinary draft.
*/
@Test
fun `cancelling an edit leaves an ordinary draft`() {
val editing = ChatViewModel.updateStateForEvent(
ChatViewModel.Event.EditMessage(messageId = 7L, text = "an older message")
)(ChatViewModel.State().also { it.chatInputState.setTextAndPlaceCursorAtEnd("half a thought") })

val ended = ChatViewModel.updateStateForEvent(ChatViewModel.Event.EditingEnded)(editing)
ended.chatInputState.setTextAndPlaceCursorAtEnd("half a thought")

assertNull(ended.editing)
assertEquals(
ChatDraftSnapshot(text = "half a thought", replyTarget = null),
ended.draftSnapshot(),
)
}

/**
* The composer once a send has emptied it. The store deletes on this rather than storing it,
* which is what keeps a chat typed in once and sent from restoring an empty draft for ever.
*/
@Test
fun `an emptied composer is no draft at all`() {
val sent = ChatViewModel.updateStateForEvent(
ChatViewModel.Event.CancelReply
)(ChatViewModel.State())
sent.chatInputState.setTextAndPlaceCursorAtEnd("")

assertTrue(sent.draftSnapshot().isEmpty)
}

/** What the ViewModel's own `draftSnapshot` composes, over a state the reducer produced. */
private fun ChatViewModel.State.draftSnapshot(): ChatDraftSnapshot = chatDraftOf(
composerText = chatInputState.text.toString(),
replyTarget = replyingTo?.toDraftReply(),
editStash = editing?.stashedDraft,
)
}
Loading
Loading