From def9c1ece8fc3998ee0017fe24004f4dc6984ccb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 23:08:34 -0400 Subject: [PATCH 1/2] feat(chat): keep a half-typed message when you leave the chat Type half a message, back out to check something, come back: the field is empty. `chatInputState` is a `TextFieldState` held inside `ChatViewModel.State`, so the words never existed anywhere but the screen the pop destroyed. They now go in a `chat_draft` table behind a `ChatDraftStore` in `:shared:chat`, keyed by the same chat id hex `ChatEntityMapper` uses for messages. That includes the locally derived TIP_DM id from `generateChatId`, so a draft typed at someone never messaged before survives the first tip creating the chat with no key migration, and groups need nothing extra. The table arrives as `AutoMigration(from = 34, to = 35)`. It has to: the builder falls back to a destructive migration, and a draft is the one row in this database that cannot be re-fetched from the server afterwards. The rules are the cross-platform design's, pinned by `test-vectors/chat_draft.json` synced into this module's test resources and replayed by `ChatDraftVectorTest`: - Text is stored verbatim; trimming stays at submit. - Text that trims to empty *and* no reply target deletes the row. A missing row and an empty one are different states. - A reply strip aimed with no text yet is still a draft. - Sending clears it the moment the field clears, not on delivery. - An edit persists the new-message draft it displaced, never the edit body. - Restore is silent: the text and the strip come back, focus and the keyboard do not. `chatDraftOf` holds the edit rule so the ViewModel and the vector replay read it from one place. `ChatQuote` gained `senderIdHex` because its two accents are derived from the sender id rather than stored, and a strip rebuilt from a row has to render the same as one the transcript mapped. Writes are debounced 300ms, with immediate flushes from `onCleared` and from process lifecycle STOPPED. The flushes are what make process death survivable, since neither platform guarantees a teardown callback runs first. `saveInBackground` uses the store's own scope because `viewModelScope` is already cancelled by the time `onCleared` runs. Cleared by logout through `RealChatCoordinator.teardown`, by leaving a group, and by blocking, which hides the chat. A failed send needs nothing here: `MessageStatus.FAILED` already renders as "Not sent" with a retry, so the transcript is the record of that text and a draft as well would duplicate it. iOS differs there deliberately, which is why that case sits outside the fixture. --- .../messenger/internal/ChatDraftBinding.kt | 64 ++ .../app/messenger/internal/ChatViewModel.kt | 86 ++ .../internal/ChatViewModelStateTest.kt | 131 +++ .../flipcash/shared/chat/models/ChatQuote.kt | 6 + .../com/flipcash/shared/chat/ChatDraft.kt | 96 ++ .../flipcash/shared/chat/ChatDraftStore.kt | 42 + .../flipcash/shared/chat/inject/ChatModule.kt | 8 + .../chat/internal/RealChatCoordinator.kt | 26 + .../chat/internal/RealChatDraftStore.kt | 106 +++ .../chat/ChatCoordinatorEagerBalanceTest.kt | 1 + .../shared/chat/ChatCoordinatorEventsTest.kt | 1 + .../chat/ChatCoordinatorTeardownTest.kt | 1 + .../shared/chat/ChatDraftVectorTest.kt | 172 ++++ .../shared/chat/ChatHistoryHydrationTest.kt | 1 + .../shared/chat/GroupChatRoutingTest.kt | 1 + .../shared/chat/ReceivedCounterTest.kt | 1 + .../flipcash/shared/chat/ReceivedEventTest.kt | 1 + .../chat/src/test/resources/chat_draft.json | 285 ++++++ .../35.json | 858 ++++++++++++++++++ .../app/persistence/FlipcashDatabase.kt | 11 +- .../app/persistence/dao/ChatDraftDao.kt | 23 + .../persistence/entities/ChatDraftEntity.kt | 38 + .../app/persistence/dao/ChatDraftDaoTest.kt | 105 +++ .../sources/ChatDraftDataSource.kt | 62 ++ 24 files changed, 2125 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatDraftBinding.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatDraft.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatDraftStore.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatDraftStore.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatDraftVectorTest.kt create mode 100644 apps/flipcash/shared/chat/src/test/resources/chat_draft.json create mode 100644 apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/35.json create mode 100644 apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatDraftDao.kt create mode 100644 apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatDraftEntity.kt create mode 100644 apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatDraftDaoTest.kt create mode 100644 apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatDraftDataSource.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatDraftBinding.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatDraftBinding.kt new file mode 100644 index 0000000000..39014577bd --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatDraftBinding.kt @@ -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, + ) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index dc043ef159..933aa78050 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -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 @@ -67,6 +69,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 @@ -91,12 +96,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 @@ -114,6 +121,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 @@ -149,6 +157,7 @@ internal class ChatViewModel @Inject constructor( private val linkCardClassifier: LinkCardClassifier, private val linkCardResolver: LinkCardResolver, private val cashLinkClaims: CashLinkClaims, + private val chatDraftStore: ChatDraftStore, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -702,6 +711,7 @@ internal class ChatViewModel @Inject constructor( }, accent = palette?.first, nameAccent = palette?.second, + senderIdHex = senderId?.hexEncodedString(), ) } @@ -809,11 +819,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 @@ -1003,6 +1022,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) @@ -1517,6 +1537,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) @@ -1732,7 +1756,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() } @@ -1846,6 +1929,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 -> diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt index 9f433096b8..6737467c55 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt @@ -1,10 +1,17 @@ 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 @@ -12,6 +19,7 @@ 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 @@ -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, + ) } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatQuote.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatQuote.kt index 66e523b8eb..99ca359cb6 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatQuote.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatQuote.kt @@ -34,6 +34,12 @@ data class ChatQuote( * from the one derivation, so they cannot disagree about whose colour this is. */ val nameAccent: Color?, + /** + * The cited sender's id, hex-encoded, kept so the two accents above can be derived again from + * a citation that was stored and read back rather than mapped from the transcript. Null for a + * message with no sender id, which is the same case the palette is null for. + */ + val senderIdHex: String? = null, ) /** What a quote shows of the message it cites. */ diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatDraft.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatDraft.kt new file mode 100644 index 0000000000..146c3d6f82 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatDraft.kt @@ -0,0 +1,96 @@ +package com.flipcash.shared.chat + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * The reply strip's own copy of the message it cites, rather than a pointer into the transcript. + * + * A draft outlives the loaded message window — it is read back after a process death, before any + * history is fetched — so the strip has to be able to render from what was stored. A cited message + * missing from the window is not a deleted message; the strip is dropped only when the message is + * known to be deleted, and the draft text survives that. + * + * [senderIdHex] is here because the strip's accent colours are derived from the sender's id rather + * than stored with the quote. Null for a sender the composer could not identify, which costs the + * colours and nothing else. + */ +@Serializable +data class ChatDraftReply( + val messageId: Long, + val authorName: String, + val senderIdHex: String? = null, + val snippet: ChatDraftSnippet, +) + +/** What the reply strip shows of the cited message. */ +@Serializable +sealed interface ChatDraftSnippet { + @Serializable + @SerialName("text") + data class Text(val body: String) : ChatDraftSnippet + + /** + * Cash is stored as its parts rather than as the domain amount type: this JSON sits in a + * database row that has to keep parsing after that type moves. + */ + @Serializable + @SerialName("cash") + data class Cash( + val quarks: Long, + val currencyCode: String, + val tokenName: String, + ) : ChatDraftSnippet +} + +/** + * What the composer would leave behind right now: the text as typed, and the reply target if one + * is aimed. No [ChatDraft.savedAt] — that is the store's to stamp. + */ +data class ChatDraftSnapshot( + val text: String, + val replyTarget: ChatDraftReply?, +) { + /** + * Whether this snapshot is the absence of a draft. Text that trims away *and* no reply target + * means the row is deleted rather than written as `""`: a chat typed in once and then cleared + * would otherwise restore an empty draft forever. A missing row and an empty one are different + * states, and only the first is reachable. + */ + val isEmpty: Boolean + get() = text.isBlank() && replyTarget == null + + companion object { + val Empty = ChatDraftSnapshot(text = "", replyTarget = null) + } +} + +/** A stored draft, as read back. */ +data class ChatDraft( + val text: String, + val replyTarget: ChatDraftReply?, + val savedAt: Long, +) + +/** + * The composer's state reduced to what gets persisted. + * + * Two rules live here rather than at the call site, so the ViewModel and the vector suite are + * running the same code: + * + * - **Saved verbatim.** No trimming on the way in; a trailing space mid-sentence is part of what + * was being typed. Trimming stays where it already is, at submit. + * - **An edit is never what persists.** While [editStash] is non-null the composer is showing an + * older message being edited, and what the draft holds is the new-message text the edit + * displaced — the same text cancelling the edit would put back. Leaving a chat mid-edit + * therefore returns you to new-message mode with your own words. + */ +fun chatDraftOf( + composerText: String, + replyTarget: ChatDraftReply?, + editStash: String?, +): ChatDraftSnapshot = if (editStash != null) { + ChatDraftSnapshot(text = editStash, replyTarget = null) +} else { + ChatDraftSnapshot(text = composerText, replyTarget = replyTarget) +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatDraftStore.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatDraftStore.kt new file mode 100644 index 0000000000..d28dc4d616 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatDraftStore.kt @@ -0,0 +1,42 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatId + +/** + * Holds the half-typed message a chat was left with, so backing out of the screen stops being what + * discards it. + * + * Keyed by chat id, including a DM id derived locally by [DmChatResolver.generateChatId] for a chat + * that does not exist on the server yet — a draft typed at someone never messaged survives the + * first tip creating the chat, with no key migration. Groups are a chat id like any other. + * + * Nothing here reaches the network; a draft is local to the device that typed it. + */ +interface ChatDraftStore { + + /** The draft left in [chatId], or null if there is none. Returned verbatim, untrimmed. */ + suspend fun load(chatId: ChatId): ChatDraft? + + /** + * Writes [snapshot] as the draft for [chatId], or deletes the row when the snapshot is + * [ChatDraftSnapshot.isEmpty]. + */ + suspend fun save(chatId: ChatId, snapshot: ChatDraftSnapshot) + + /** + * [save] for the two callers that cannot suspend and cannot be waited on: `onCleared`, which + * runs after the ViewModel's scope is cancelled, and the process-lifecycle stop that is the + * last callback before the app may be killed. Both need the write to outlive the caller, so it + * goes on this store's own scope. + */ + fun saveInBackground(chatId: ChatId, snapshot: ChatDraftSnapshot) + + /** + * Drops [chatId]'s draft. For the chat-level exits — leaving a group, blocking the other side — + * where the composer is gone along with the reason to keep what was in it. + */ + suspend fun clear(chatId: ChatId) + + /** Drops every draft. Logout and account switch; there is no TTL beyond this. */ + suspend fun clearAll() +} 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 59f1f2d9f8..e00d8f11f5 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 @@ -1,7 +1,9 @@ package com.flipcash.shared.chat.inject import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatDraftStore import com.flipcash.shared.chat.internal.RealChatCoordinator +import com.flipcash.shared.chat.internal.RealChatDraftStore import com.getcode.opencode.providers.SessionListener import dagger.Binds import dagger.Module @@ -20,6 +22,12 @@ abstract class ChatModule { impl: RealChatCoordinator ): ChatCoordinator + @Binds + @Singleton + abstract fun bindChatDraftStore( + impl: RealChatDraftStore + ): ChatDraftStore + @Binds @IntoSet abstract fun bindSessionListener( diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt index 5e5678c951..f0f14f246c 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt @@ -9,6 +9,7 @@ import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatDraftStore import com.flipcash.shared.chat.ChatState import com.flipcash.shared.chat.DmChatResolver import com.flipcash.shared.chat.EventStreamOperations @@ -80,6 +81,7 @@ class RealChatCoordinator @Inject constructor( private val messagingDelegate: MessagingDelegate, private val groupFeedDelegate: GroupFeedDelegate, private val stateHolder: ChatStateHolder, + private val draftStore: ChatDraftStore, private val userManager: UserManager, private val networkObserver: NetworkConnectivityListener, private val dispatchers: DispatcherProvider, @@ -236,6 +238,27 @@ class RealChatCoordinator @Inject constructor( syncFeeds() } + /** + * Overrides the [FeedOperations] delegation to drop the chat's draft when it is being hidden. + * + * Blocking someone is the caller here, and it removes the composer along with the reason to + * keep what was half-typed into it. Unhiding does not put a draft back — there is nothing to + * put back — so only the hiding direction clears. + */ + override suspend fun setChatHidden(chatId: ChatId, hidden: Boolean) { + feedDelegate.setChatHidden(chatId, hidden) + if (hidden) draftStore.clear(chatId) + } + + /** + * Overrides the [GroupOperations] delegation to drop the group's draft once leaving succeeds. + * + * Only on success: a failed leave leaves you in the group with the composer still there, and + * the draft is what it was. + */ + override suspend fun leave(chatId: ChatId): Result = + groupFeedDelegate.leave(chatId).onSuccess { draftStore.clear(chatId) } + /** * Fetches both halves of the conversation list. * @@ -261,6 +284,9 @@ class RealChatCoordinator @Inject constructor( networkObserverJob?.cancel() stateHolder.reset() eventStreamDelegate.clearAll() + // Before the scope dies: a draft is the one thing here that was never on the server, so + // logout is the only chance to drop it. + draftStore.clearAll() cluster.value = null supervisorJob.cancel() trace(tag = TAG, message = "teardown complete", type = TraceType.Process) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatDraftStore.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatDraftStore.kt new file mode 100644 index 0000000000..0c8f9f62bd --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatDraftStore.kt @@ -0,0 +1,106 @@ +package com.flipcash.shared.chat.internal + +import com.flipcash.app.persistence.sources.ChatDraftDataSource +import com.flipcash.app.persistence.sources.ChatDraftRecord +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ChatDraft +import com.flipcash.shared.chat.ChatDraftReply +import com.flipcash.shared.chat.ChatDraftSnapshot +import com.flipcash.shared.chat.ChatDraftStore +import com.getcode.utils.TraceType +import com.getcode.utils.hexEncodedString +import com.getcode.utils.trace +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton + +/** + * [ChatDraftStore] over the `chat_draft` table. + * + * The reply target is serialized here rather than in the data source because its shape is this + * module's: the row stores a blob, and what the blob means is the composer's business. + */ +@Singleton +class RealChatDraftStore internal constructor( + private val dataSource: ChatDraftDataSource, + dispatchers: DispatcherProvider, + private val now: () -> Long, +) : ChatDraftStore { + + @Inject + constructor( + dataSource: ChatDraftDataSource, + dispatchers: DispatcherProvider, + ) : this(dataSource, dispatchers, System::currentTimeMillis) + + // Deliberately not a caller's scope: [saveInBackground]'s two callers are a ViewModel scope + // that has just been cancelled and a process about to be backgrounded, so a write parented to + // either would be cancelled at exactly the moment it matters. + private val scope = CoroutineScope(dispatchers.IO + SupervisorJob()) + + override suspend fun load(chatId: ChatId): ChatDraft? { + val record = dataSource.get(chatId.hex) ?: return null + return ChatDraft( + text = record.text, + replyTarget = record.replyTargetJson?.let { decodeReply(it) }, + savedAt = record.savedAt, + ) + } + + override suspend fun save(chatId: ChatId, snapshot: ChatDraftSnapshot) { + if (snapshot.isEmpty) { + dataSource.delete(chatId.hex) + return + } + dataSource.upsert( + ChatDraftRecord( + chatIdHex = chatId.hex, + text = snapshot.text, + replyTargetJson = snapshot.replyTarget?.let { json.encodeToString(it) }, + savedAt = now(), + ) + ) + } + + override fun saveInBackground(chatId: ChatId, snapshot: ChatDraftSnapshot) { + scope.launch { save(chatId, snapshot) } + } + + override suspend fun clear(chatId: ChatId) { + dataSource.delete(chatId.hex) + } + + override suspend fun clearAll() { + dataSource.clear() + } + + /** + * A reply target that no longer parses costs the strip and not the words: the text is the part + * that exists nowhere else, so a blob written by an older shape of [ChatDraftReply] degrades to + * a plain draft rather than taking the draft down with it. + */ + private fun decodeReply(blob: String): ChatDraftReply? = + runCatching { json.decodeFromString(blob) } + .onFailure { + trace( + tag = TAG, + message = "dropping unreadable reply target", + error = it, + type = TraceType.Error, + ) + } + .getOrNull() + + // Matches ChatEntityMapper.chatIdHex, so a draft is keyed the same way as the chat's messages. + private val ChatId.hex: String + get() = bytes.toList().hexEncodedString() + + companion object { + private const val TAG = "ChatDraftStore" + private val json = Json { ignoreUnknownKeys = true } + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt index 670ecce947..03b15f6a8f 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt @@ -129,6 +129,7 @@ class ChatCoordinatorEagerBalanceTest { dmChatResolverDelegate = dmChatResolverDelegate, messagingDelegate = messagingDelegate, stateHolder = stateHolder, + draftStore = mockk(relaxed = true), userManager = userManager, networkObserver = mockk(relaxed = true), dispatchers = testDispatchers, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt index ebb63ca3e7..0a3eba41ef 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt @@ -132,6 +132,7 @@ class ChatCoordinatorEventsTest { dmChatResolverDelegate = dmChatResolverDelegate, messagingDelegate = messagingDelegate, stateHolder = stateHolder, + draftStore = mockk(relaxed = true), userManager = userManager, networkObserver = mockk(relaxed = true), dispatchers = testDispatchers, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt index f53cee6e6c..f0d1225bdb 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt @@ -110,6 +110,7 @@ class ChatCoordinatorTeardownTest { senderResolver = mockk(relaxed = true), ), stateHolder = stateHolder, + draftStore = mockk(relaxed = true), userManager = userManager, networkObserver = mockk(relaxed = true), dispatchers = TestDispatchers(TestCoroutineScheduler()), diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatDraftVectorTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatDraftVectorTest.kt new file mode 100644 index 0000000000..3b4a7efeb4 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatDraftVectorTest.kt @@ -0,0 +1,172 @@ +package com.flipcash.shared.chat + +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.persistence.sources.ChatDraftDataSource +import com.flipcash.app.persistence.sources.ChatDraftRecord +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.internal.RealChatDraftStore +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.runTest +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * `test-vectors/chat_draft.json`. The canonical copy lives in the orchestrator repo; this one is + * synced. A failure here is either a real regression or a cross-platform decision that has to be + * made in the canonical fixture and re-synced to both platforms — never a local edit. + * + * The vectors are composer actions and the draft they must leave behind, so the replay below is + * the composer's own state machine: what the ViewModel does to `chatInputState`, `replyingTo` and + * the edit stash, minus the Compose types. [chatDraftOf] is the shared piece — the rule about what + * an in-progress edit contributes lives there rather than in the ViewModel, so this suite and the + * app read it from the same place. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class ChatDraftVectorTest { + + private val chatId = ChatId(hex = "a1b2c3d4") + + @Test + fun `drafts match the cross-platform vectors`() = runTest { + for (vector in vectors()) { + val name = vector.getString("name") + val note = vector.getString("note") + val store = storeOverOneRow() + val composer = Composer() + + val actions = vector.getJSONArray("actions") + for (i in 0 until actions.length()) { + composer.apply(actions.getJSONObject(i), chatId, store) + } + + val actual = store.load(chatId) + val expected = vector.optJSONObject("draft") + + if (expected == null) { + assertNull(actual, "vector `$name`: $note") + continue + } + + assertNotNull(actual, "vector `$name`: $note") + assertEquals(expected.getString("text"), actual.text, "vector `$name`: $note") + + val expectedReply = expected.optJSONObject("replyTarget") + if (expectedReply == null) { + assertNull(actual.replyTarget, "vector `$name`: $note") + } else { + val reply = assertNotNull(actual.replyTarget, "vector `$name`: $note") + assertEquals( + expectedReply.getString("messageId"), + messageIds.nameOf(reply.messageId), + "vector `$name`: $note", + ) + assertEquals(expectedReply.getString("author"), reply.authorName, "vector `$name`: $note") + assertEquals( + ChatDraftSnippet.Text(expectedReply.getString("snippet")), + reply.snippet, + "vector `$name`: $note", + ) + } + } + } + + /** + * The composer as the ViewModel keeps it: absolute text, an aimed reply, and the new-message + * draft an edit displaced. The reducers this mirrors are `Event.ReplyToMessage`, + * `Event.EditMessage`, `Event.SendMessage` and `finishEditing`. + */ + private class Composer { + private var text: String = "" + private var replyTarget: ChatDraftReply? = null + private var editStash: String? = null + + suspend fun apply(action: JSONObject, chatId: ChatId, store: ChatDraftStore) { + when (val op = action.getString("op")) { + "type" -> text = action.getString("text") + + // Aiming a reply leaves any edit, which is how the ReplyToMessage reducer works. + "reply" -> { + replyTarget = action.optJSONObject("target")?.toDraftReply() + editStash = null + } + + // Entering an edit stashes the new-message draft once — a second edit without + // leaving the first keeps the original stash — and drops the reply strip. + "edit" -> { + editStash = editStash ?: text + text = action.getString("text") + replyTarget = null + } + + // Cancelling puts the stashed draft back, so it is an ordinary draft again. + "cancelEdit" -> { + text = editStash.orEmpty() + editStash = null + } + + // The field clears and the draft goes with it, at that moment rather than on + // delivery. + "send" -> { + text = "" + replyTarget = null + store.save(chatId, ChatDraftSnapshot.Empty) + } + + "leave" -> store.save(chatId, chatDraftOf(text, replyTarget, editStash)) + + else -> error("unknown op `$op`") + } + } + } + + /** A [ChatDraftDataSource] backed by a single nullable row, which is all one chat needs. */ + private fun storeOverOneRow(): ChatDraftStore { + var row: ChatDraftRecord? = null + val dataSource = mockk() + val record = slot() + coEvery { dataSource.get(any()) } answers { row } + coEvery { dataSource.upsert(capture(record)) } answers { row = record.captured } + coEvery { dataSource.delete(any()) } answers { row = null } + coEvery { dataSource.clear() } answers { row = null } + return RealChatDraftStore(dataSource, TestDispatchers(TestCoroutineScheduler())) { 0L } + } + + private fun vectors(): List { + val json = javaClass.classLoader!! + .getResourceAsStream("chat_draft.json")!! + .bufferedReader().use { it.readText() } + val array = JSONObject(json).getJSONArray("vectors") + return (0 until array.length()).map { array.getJSONObject(it) } + } + + companion object { + /** + * The fixture identifies a cited message by UUID; Android's message ids are longs. The id + * is opaque to every rule being pinned here, so it is carried through the round trip + * rather than reinterpreted — what the vectors assert is that the same message comes back. + */ + private val messageIds = object { + private val assigned = mutableMapOf() + fun idOf(name: String): Long = assigned.getOrPut(name) { assigned.size + 1L } + fun nameOf(id: Long): String? = assigned.entries.firstOrNull { it.value == id }?.key + } + + private fun JSONObject.toDraftReply() = ChatDraftReply( + messageId = messageIds.idOf(getString("messageId")), + authorName = getString("author"), + snippet = ChatDraftSnippet.Text(getString("snippet")), + ) + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt index a3e14a066f..67c95a5e48 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt @@ -142,6 +142,7 @@ class ChatHistoryHydrationTest { senderResolver = mockk(relaxed = true), ), stateHolder = stateHolder, + draftStore = mockk(relaxed = true), userManager = userManager, networkObserver = mockk(relaxed = true), dispatchers = TestDispatchers(testScheduler), diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt index 94678ab472..744c003067 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt @@ -125,6 +125,7 @@ class GroupChatRoutingTest { ), groupFeedDelegate = groupFeedDelegate, stateHolder = stateHolder, + draftStore = mockk(relaxed = true), userManager = userManager, networkObserver = mockk(relaxed = true), dispatchers = testDispatchers, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt index 2f12aa4294..1b98b96316 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt @@ -150,6 +150,7 @@ class ReceivedCounterTest { dmChatResolverDelegate = dmChatResolverDelegate, messagingDelegate = messagingDelegate, stateHolder = stateHolder, + draftStore = mockk(relaxed = true), userManager = userManager, networkObserver = mockk(relaxed = true), dispatchers = testDispatchers, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt index 3c5633d12b..fbfccf06c6 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt @@ -153,6 +153,7 @@ class ReceivedEventTest { dmChatResolverDelegate = dmChatResolverDelegate, messagingDelegate = messagingDelegate, stateHolder = stateHolder, + draftStore = mockk(relaxed = true), userManager = userManager, networkObserver = mockk(relaxed = true), dispatchers = testDispatchers, diff --git a/apps/flipcash/shared/chat/src/test/resources/chat_draft.json b/apps/flipcash/shared/chat/src/test/resources/chat_draft.json new file mode 100644 index 0000000000..d2d2a9179a --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/resources/chat_draft.json @@ -0,0 +1,285 @@ +{ + "algorithm": "chat-draft", + "note": "Chat composer drafts: a sequence of composer actions and the draft the store must hold afterwards. `draft` is null when no row should exist -- an empty draft and no draft are different states, and rule 2 is the difference. `type` sets the composer text absolutely; `leave` is the screen going away, which flushes. Text is stored verbatim, including whitespace; trimming happens at submit only. Behavior parity, not a computation with an external reference. The failed-send case is deliberately absent: it is the one behaviour the two platforms deliberately differ on.", + "vectors": [ + { + "name": "verbatim-trailing-space", + "actions": [ + { + "op": "type", + "text": "see you at 5 " + }, + { + "op": "leave" + } + ], + "draft": { + "text": "see you at 5 ", + "replyTarget": null + }, + "note": "Rule 1. The trailing space is mid-sentence, not noise: trimming belongs at submit, which is where both apps already do it." + }, + { + "name": "verbatim-leading-space", + "actions": [ + { + "op": "type", + "text": " thinking about it " + }, + { + "op": "leave" + } + ], + "draft": { + "text": " thinking about it ", + "replyTarget": null + }, + "note": "Rule 1 again, on the way back out: restore does not trim either." + }, + { + "name": "whitespace-only-is-no-draft", + "actions": [ + { + "op": "type", + "text": " " + }, + { + "op": "leave" + } + ], + "draft": null, + "note": "Rule 2. Trims to empty with no reply target, so the row is removed rather than stored as whitespace." + }, + { + "name": "cleared-after-typing-is-no-draft", + "actions": [ + { + "op": "type", + "text": "never mind" + }, + { + "op": "type", + "text": "" + }, + { + "op": "leave" + } + ], + "draft": null, + "note": "Rule 2's actual failure mode: without the delete, a chat typed in once and cleared restores an empty draft for ever." + }, + { + "name": "bare-reply-target-is-a-draft", + "actions": [ + { + "op": "reply", + "target": { + "messageId": "9f1a6d5f-7c8e-4b21-9f3e-2b0b4d1e9a0b", + "author": "Ana", + "snippet": "are we still on for 5?" + } + }, + { + "op": "leave" + } + ], + "draft": { + "text": "", + "replyTarget": { + "messageId": "9f1a6d5f-7c8e-4b21-9f3e-2b0b4d1e9a0b", + "author": "Ana", + "snippet": "are we still on for 5?" + } + }, + "note": "Rule 3. Aiming a reply and walking away is intent worth keeping, and dropping it is the wrong-context send this feature exists to prevent." + }, + { + "name": "reply-target-with-text", + "actions": [ + { + "op": "reply", + "target": { + "messageId": "9f1a6d5f-7c8e-4b21-9f3e-2b0b4d1e9a0b", + "author": "Ana", + "snippet": "are we still on for 5?" + } + }, + { + "op": "type", + "text": "on my way" + }, + { + "op": "leave" + } + ], + "draft": { + "text": "on my way", + "replyTarget": { + "messageId": "9f1a6d5f-7c8e-4b21-9f3e-2b0b4d1e9a0b", + "author": "Ana", + "snippet": "are we still on for 5?" + } + }, + "note": "The ordinary case: both halves come back together." + }, + { + "name": "whitespace-with-reply-target-is-a-draft", + "actions": [ + { + "op": "reply", + "target": { + "messageId": "9f1a6d5f-7c8e-4b21-9f3e-2b0b4d1e9a0b", + "author": "Ana", + "snippet": "are we still on for 5?" + } + }, + { + "op": "type", + "text": " " + }, + { + "op": "leave" + } + ], + "draft": { + "text": " ", + "replyTarget": { + "messageId": "9f1a6d5f-7c8e-4b21-9f3e-2b0b4d1e9a0b", + "author": "Ana", + "snippet": "are we still on for 5?" + } + }, + "note": "Rule 2 needs BOTH halves empty. The strip is still aimed, so the row stays -- and rule 1 still stores the whitespace verbatim." + }, + { + "name": "reply-target-cleared-leaves-the-text", + "actions": [ + { + "op": "reply", + "target": { + "messageId": "9f1a6d5f-7c8e-4b21-9f3e-2b0b4d1e9a0b", + "author": "Ana", + "snippet": "are we still on for 5?" + } + }, + { + "op": "type", + "text": "on my way" + }, + { + "op": "reply", + "target": null + }, + { + "op": "leave" + } + ], + "draft": { + "text": "on my way", + "replyTarget": null + }, + "note": "Dismissing the strip is not clearing the message." + }, + { + "name": "send-clears", + "actions": [ + { + "op": "type", + "text": "on my way" + }, + { + "op": "send" + }, + { + "op": "leave" + } + ], + "draft": null, + "note": "Rule 4. The text lives in the pending message the moment the field clears; a draft as well would duplicate it." + }, + { + "name": "send-clears-the-reply-target-too", + "actions": [ + { + "op": "reply", + "target": { + "messageId": "9f1a6d5f-7c8e-4b21-9f3e-2b0b4d1e9a0b", + "author": "Ana", + "snippet": "are we still on for 5?" + } + }, + { + "op": "type", + "text": "yes" + }, + { + "op": "send" + }, + { + "op": "leave" + } + ], + "draft": null, + "note": "Rule 4 covers the whole draft, not just its text." + }, + { + "name": "edit-persists-the-stash", + "actions": [ + { + "op": "type", + "text": "half a thought" + }, + { + "op": "edit", + "text": "an older message" + }, + { + "op": "leave" + } + ], + "draft": { + "text": "half a thought", + "replyTarget": null + }, + "note": "Rule 5. What is saved is the new-message draft the edit displaced, never the edit body -- leaving mid-edit must not overwrite your words with someone else's message." + }, + { + "name": "edit-cancelled-restores-the-stash", + "actions": [ + { + "op": "type", + "text": "half a thought" + }, + { + "op": "edit", + "text": "an older message" + }, + { + "op": "cancelEdit" + }, + { + "op": "leave" + } + ], + "draft": { + "text": "half a thought", + "replyTarget": null + }, + "note": "Same answer by the path both apps already implement: cancelling an edit puts the stashed draft back." + }, + { + "name": "edit-with-nothing-stashed-is-no-draft", + "actions": [ + { + "op": "edit", + "text": "an older message" + }, + { + "op": "leave" + } + ], + "draft": null, + "note": "Rule 5 with an empty composer: the edit displaced nothing, so there is nothing to persist." + } + ] +} diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/35.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/35.json new file mode 100644 index 0000000000..eb4131f018 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/35.json @@ -0,0 +1,858 @@ +{ + "formatVersion": 1, + "database": { + "version": 35, + "identityHash": "13789ec17dc06451d549fc9255fbff5c", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', `textSubstitutions` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `market_cap_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "marketCapMetricsJson", + "columnName": "market_cap_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, `analytics_counted_through` INTEGER NOT NULL DEFAULT 0, `title` TEXT, `picture_json` TEXT, `member_count` INTEGER NOT NULL DEFAULT 0, `roster_version` INTEGER NOT NULL DEFAULT 0, `rules_json` TEXT, `is_member` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "analyticsCountedThrough", + "columnName": "analytics_counted_through", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "pictureJson", + "columnName": "picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "memberCount", + "columnName": "member_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rosterVersion", + "columnName": "roster_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rulesJson", + "columnName": "rules_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isMember", + "columnName": "is_member", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, `is_deleted` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + }, + "indices": [ + { + "name": "index_chat_messages_chat_id_hex_timestamp_epoch_ms", + "unique": false, + "columnNames": [ + "chat_id_hex", + "timestamp_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_chat_id_hex_timestamp_epoch_ms` ON `${TABLE_NAME}` (`chat_id_hex`, `timestamp_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "chat_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `text` TEXT NOT NULL, `reply_target_json` TEXT, `saved_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTargetJson", + "columnName": "reply_target_json", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `username` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '13789ec17dc06451d549fc9255fbff5c')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index 115bff2173..ad9f5635b5 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.asStateFlow import com.flipcash.app.persistence.converters.ChatTypeConverters import com.flipcash.app.persistence.converters.TokenTypeConverters import com.flipcash.app.persistence.dao.BlockedUserDao +import com.flipcash.app.persistence.dao.ChatDraftDao import com.flipcash.app.persistence.dao.ChatMemberDao import com.flipcash.app.persistence.dao.ChatMessageDao import com.flipcash.app.persistence.dao.ChatMetadataDao @@ -28,6 +29,7 @@ import com.flipcash.app.persistence.dao.MessageDao import com.flipcash.app.persistence.dao.TokenDao import com.flipcash.app.persistence.dao.UserProfileDao import com.flipcash.app.persistence.entities.BlockedUserEntity +import com.flipcash.app.persistence.entities.ChatDraftEntity import com.flipcash.app.persistence.entities.ChatMemberEntity import com.flipcash.app.persistence.entities.ChatMessageEntity import com.flipcash.app.persistence.entities.ChatMetadataEntity @@ -56,6 +58,7 @@ import com.getcode.utils.subByteArray ChatMetadataEntity::class, ChatMessageEntity::class, ChatMemberEntity::class, + ChatDraftEntity::class, BlockedUserEntity::class, UserProfileEntity::class, ], @@ -95,8 +98,13 @@ import com.getcode.utils.subByteArray AutoMigration(from = 31, to = 32, spec = FlipcashDatabase.Migration31To32::class), AutoMigration(from = 32, to = 33), // chat_messages index on (chat_id_hex, timestamp_epoch_ms) AutoMigration(from = 33, to = 34), // chat_metadata group columns: title, picture, roster, rules, membership + // The chat_draft table. It has to arrive as an AutoMigration rather than on the + // fallbackToDestructiveMigration() below: a draft is the one row in this database that + // cannot be re-fetched, so a version bump that dropped the file would delete the + // half-written messages this table exists to keep. + AutoMigration(from = 34, to = 35), // chat_draft table ], - version = 34, + version = 35, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { @@ -108,6 +116,7 @@ abstract class FlipcashDatabase : RoomDatabase() { abstract fun chatMetadataDao(): ChatMetadataDao abstract fun chatMessageDao(): ChatMessageDao abstract fun chatMemberDao(): ChatMemberDao + abstract fun chatDraftDao(): ChatDraftDao abstract fun blockedUserDao(): BlockedUserDao abstract fun userProfileDao(): UserProfileDao diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatDraftDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatDraftDao.kt new file mode 100644 index 0000000000..50245533d7 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatDraftDao.kt @@ -0,0 +1,23 @@ +package com.flipcash.app.persistence.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.flipcash.app.persistence.entities.ChatDraftEntity + +@Dao +interface ChatDraftDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(draft: ChatDraftEntity) + + @Query("SELECT * FROM chat_draft WHERE chat_id_hex = :chatIdHex") + suspend fun getByChatId(chatIdHex: String): ChatDraftEntity? + + @Query("DELETE FROM chat_draft WHERE chat_id_hex = :chatIdHex") + suspend fun deleteByChatId(chatIdHex: String) + + @Query("DELETE FROM chat_draft") + suspend fun deleteAll() +} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatDraftEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatDraftEntity.kt new file mode 100644 index 0000000000..7747f4f21d --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatDraftEntity.kt @@ -0,0 +1,38 @@ +package com.flipcash.app.persistence.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * The half-typed message left behind in a chat, so leaving the screen stops being what discards it. + * + * Keyed by chat id rather than by a row id: there is one draft per conversation, and the id a + * TIP_DM derives locally is the same one the chat gets when the first tip creates it, so a draft + * typed at someone never messaged before needs no key migration to survive. + * + * [replyTargetJson] is the reply strip's own snapshot of the message it cites, serialized by + * `:apps:flipcash:shared:chat` rather than by a type converter here. The strip has to render + * without the cited message being loaded, so what it stores is a copy, not a pointer into + * `chat_messages` — and keeping the shape in the module that owns it leaves this table free of + * composer types. + * + * A row is only ever present when there is something to restore: a draft whose text trims to empty + * with no reply target is deleted rather than stored as `""`, so a chat typed in once and cleared + * restores nothing instead of an empty draft forever. + */ +@Entity(tableName = "chat_draft") +data class ChatDraftEntity( + @PrimaryKey + @ColumnInfo(name = "chat_id_hex") + val chatIdHex: String, + + @ColumnInfo(name = "text") + val text: String, + + @ColumnInfo(name = "reply_target_json") + val replyTargetJson: String?, + + @ColumnInfo(name = "saved_at") + val savedAt: Long, +) diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatDraftDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatDraftDaoTest.kt new file mode 100644 index 0000000000..10404bf68c --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatDraftDaoTest.kt @@ -0,0 +1,105 @@ +package com.flipcash.app.persistence.dao + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.entities.ChatDraftEntity +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The `chat_draft` round trip. What a draft is worth is that it comes back exactly as typed, so + * the cases here are the ones that survive a naive column: whitespace kept verbatim, a row that is + * text with no reply target, and one chat's draft not disturbing another's. + */ +@RunWith(RobolectricTestRunner::class) +class ChatDraftDaoTest { + + private lateinit var db: FlipcashDatabase + private lateinit var dao: ChatDraftDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, FlipcashDatabase::class.java) + .allowMainThreadQueries() + .build() + dao = db.chatDraftDao() + } + + @After + fun tearDown() { + db.close() + } + + private fun draft( + chatIdHex: String = CHAT_HEX, + text: String = "on my way", + replyTargetJson: String? = null, + savedAt: Long = 1_000, + ) = ChatDraftEntity( + chatIdHex = chatIdHex, + text = text, + replyTargetJson = replyTargetJson, + savedAt = savedAt, + ) + + @Test + fun `a draft comes back byte for byte`() = runTest { + val stored = draft(text = " see you at 5 ", replyTargetJson = REPLY_JSON) + dao.upsert(stored) + + assertEquals(stored, dao.getByChatId(CHAT_HEX)) + } + + @Test + fun `a chat with no draft reads as absent rather than empty`() = runTest { + assertNull(dao.getByChatId(CHAT_HEX)) + } + + @Test + fun `a second save replaces the first`() = runTest { + dao.upsert(draft(text = "half a thought", savedAt = 1_000)) + dao.upsert(draft(text = "half a thought, finished", savedAt = 2_000)) + + val row = dao.getByChatId(CHAT_HEX) + assertEquals("half a thought, finished", row?.text) + assertEquals(2_000, row?.savedAt) + } + + @Test + fun `clearing one chat leaves the others`() = runTest { + dao.upsert(draft(chatIdHex = CHAT_HEX)) + dao.upsert(draft(chatIdHex = OTHER_CHAT_HEX, text = "different chat")) + + dao.deleteByChatId(CHAT_HEX) + + assertNull(dao.getByChatId(CHAT_HEX)) + assertEquals("different chat", dao.getByChatId(OTHER_CHAT_HEX)?.text) + } + + @Test + fun `logout empties the table`() = runTest { + dao.upsert(draft(chatIdHex = CHAT_HEX)) + dao.upsert(draft(chatIdHex = OTHER_CHAT_HEX)) + + dao.deleteAll() + + assertNull(dao.getByChatId(CHAT_HEX)) + assertNull(dao.getByChatId(OTHER_CHAT_HEX)) + } + + companion object { + private const val CHAT_HEX = "a1b2c3d4" + private const val OTHER_CHAT_HEX = "e5f60718" + private const val REPLY_JSON = + """{"messageId":42,"authorName":"Ana","snippet":{"type":"text","body":"are we still on for 5?"}}""" + } +} diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatDraftDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatDraftDataSource.kt new file mode 100644 index 0000000000..5d19d24dff --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatDraftDataSource.kt @@ -0,0 +1,62 @@ +package com.flipcash.app.persistence.sources + +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.entities.ChatDraftEntity +import javax.inject.Inject +import javax.inject.Singleton + +/** + * One chat's stored draft, as it sits in the row. + * + * The reply target stays a JSON string on this side of the boundary: its shape belongs to the + * chat layer that writes and reads it, and nothing here needs to look inside. + */ +data class ChatDraftRecord( + val chatIdHex: String, + val text: String, + val replyTargetJson: String?, + val savedAt: Long, +) + +/** + * The `chat_draft` table. + * + * No database means no drafts rather than an error, matching the other data sources here: the + * instance is null only before a user has logged in and after one has logged out, and neither of + * those has a composer on screen. + */ +@Singleton +class ChatDraftDataSource @Inject constructor() { + + private val db: FlipcashDatabase? + get() = FlipcashDatabase.getInstance() + + suspend fun get(chatIdHex: String): ChatDraftRecord? = + db?.chatDraftDao()?.getByChatId(chatIdHex)?.let { + ChatDraftRecord( + chatIdHex = it.chatIdHex, + text = it.text, + replyTargetJson = it.replyTargetJson, + savedAt = it.savedAt, + ) + } + + suspend fun upsert(record: ChatDraftRecord) { + db?.chatDraftDao()?.upsert( + ChatDraftEntity( + chatIdHex = record.chatIdHex, + text = record.text, + replyTargetJson = record.replyTargetJson, + savedAt = record.savedAt, + ) + ) + } + + suspend fun delete(chatIdHex: String) { + db?.chatDraftDao()?.deleteByChatId(chatIdHex) + } + + suspend fun clear() { + db?.chatDraftDao()?.deleteAll() + } +} From a5313111238cb8a60a2371d26436e4ccf00e5ee1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 23:21:46 -0400 Subject: [PATCH 2/2] fix(chat): let a draft outlive a logout The draft wipe was in teardown(), which is plain sign-out. Everything else in that method stops a job or resets in-memory state; the one call that erased persisted rows was this one, and it contradicted the reason AuthManager gives for keeping the rest: the database is named from the account entropy, so a logout never has to empty a table to keep accounts apart, and the next login reopens the same file. A half-written message is the row a user would most notice losing to a sign-out they expected to be reversible. Moved to clearCache(), alongside the transcript it belongs with, which deleteAndLogout() is the only caller of. Also re-syncs chat_draft.json, which gained two reply-then-edit vectors. Both already pass: entering an edit drops the strip and stashes the text, and cancelling puts the text back without the strip. --- .../chat/internal/RealChatCoordinator.kt | 7 ++- .../chat/ChatCoordinatorTeardownTest.kt | 24 ++++++- .../chat/src/test/resources/chat_draft.json | 63 ++++++++++++++++++- 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt index f0f14f246c..bda60b0b17 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt @@ -284,9 +284,6 @@ class RealChatCoordinator @Inject constructor( networkObserverJob?.cancel() stateHolder.reset() eventStreamDelegate.clearAll() - // Before the scope dies: a draft is the one thing here that was never on the server, so - // logout is the only chance to drop it. - draftStore.clearAll() cluster.value = null supervisorJob.cancel() trace(tag = TAG, message = "teardown complete", type = TraceType.Process) @@ -294,6 +291,10 @@ class RealChatCoordinator @Inject constructor( override suspend fun clearCache() { messagingDelegate.clear() + // Drafts go with the cache rather than with the session. They outlive a logout for the + // same reason the transcript does — the database is per-account, so the next login reopens + // the same file — and account deletion is the one path that is meant to erase them. + draftStore.clearAll() trace(tag = TAG, message = "cache cleared", type = TraceType.Process) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt index f0d1225bdb..1c2a05e9d2 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt @@ -49,6 +49,7 @@ class ChatCoordinatorTeardownTest { private lateinit var metadataDataSource: ChatMetadataDataSource private lateinit var messageDataSource: ChatMessageDataSource private lateinit var memberDataSource: ChatMemberDataSource + private lateinit var draftStore: ChatDraftStore private lateinit var coordinator: RealChatCoordinator @Before @@ -67,6 +68,7 @@ class ChatCoordinatorTeardownTest { metadataDataSource = mockk(relaxed = true) messageDataSource = mockk(relaxed = true) memberDataSource = mockk(relaxed = true) + draftStore = mockk(relaxed = true) val messagingController = mockk(relaxed = true) val stateHolder = ChatStateHolder() @@ -110,7 +112,7 @@ class ChatCoordinatorTeardownTest { senderResolver = mockk(relaxed = true), ), stateHolder = stateHolder, - draftStore = mockk(relaxed = true), + draftStore = draftStore, userManager = userManager, networkObserver = mockk(relaxed = true), dispatchers = TestDispatchers(TestCoroutineScheduler()), @@ -127,6 +129,18 @@ class ChatCoordinatorTeardownTest { coVerify(exactly = 0) { memberDataSource.clear() } } + /** + * A draft belongs to the cache, not to the session. It survives a logout for the same reason + * the transcript does, and the half-written message is the part the user would most notice + * losing to a sign-out they expected to be reversible. + */ + @Test + fun `teardown leaves drafts alone`() = runTest { + coordinator.teardown() + + coVerify(exactly = 0) { draftStore.clearAll() } + } + @Test fun `clearCache erases metadata, messages, and members`() = runTest { coordinator.clearCache() @@ -135,4 +149,12 @@ class ChatCoordinatorTeardownTest { coVerify(exactly = 1) { messageDataSource.clear() } coVerify(exactly = 1) { memberDataSource.clear() } } + + /** Account deletion is the one path that erases a draft, because nothing else erases anything. */ + @Test + fun `clearCache erases drafts`() = runTest { + coordinator.clearCache() + + coVerify(exactly = 1) { draftStore.clearAll() } + } } diff --git a/apps/flipcash/shared/chat/src/test/resources/chat_draft.json b/apps/flipcash/shared/chat/src/test/resources/chat_draft.json index d2d2a9179a..3dd6597589 100644 --- a/apps/flipcash/shared/chat/src/test/resources/chat_draft.json +++ b/apps/flipcash/shared/chat/src/test/resources/chat_draft.json @@ -1,6 +1,6 @@ { "algorithm": "chat-draft", - "note": "Chat composer drafts: a sequence of composer actions and the draft the store must hold afterwards. `draft` is null when no row should exist -- an empty draft and no draft are different states, and rule 2 is the difference. `type` sets the composer text absolutely; `leave` is the screen going away, which flushes. Text is stored verbatim, including whitespace; trimming happens at submit only. Behavior parity, not a computation with an external reference. The failed-send case is deliberately absent: it is the one behaviour the two platforms deliberately differ on.", + "note": "Chat composer drafts: a sequence of composer actions and the draft the store must hold afterwards. `draft` is null when no row should exist -- an empty draft and no draft are different states, and rule 2 is the difference. `type` sets the composer text absolutely; `leave` is the screen going away, which flushes. Text is stored verbatim, including whitespace; trimming happens at submit only. Behavior parity, not a computation with an external reference. The failed-send case is deliberately absent: it is the one behaviour the two platforms deliberately differ on. `replyTarget.messageId` is an opaque token -- carry it through, do not parse it.", "vectors": [ { "name": "verbatim-trailing-space", @@ -267,6 +267,67 @@ }, "note": "Same answer by the path both apps already implement: cancelling an edit puts the stashed draft back." }, + { + "name": "reply-then-edit-keeps-the-text-drops-the-strip", + "actions": [ + { + "op": "reply", + "target": { + "messageId": "9f1a6d5f-7c8e-4b21-9f3e-2b0b4d1e9a0b", + "author": "Ana", + "snippet": "are we still on for 5?" + } + }, + { + "op": "type", + "text": "half a thought" + }, + { + "op": "edit", + "text": "an older message" + }, + { + "op": "leave" + } + ], + "draft": { + "text": "half a thought", + "replyTarget": null + }, + "note": "Rule 5 where reply and edit collide. Both composers are single-mode, so the edit takes the field and the strip goes with it -- but the words are the user's and are stashed like any other displaced draft. Losing them is the bug this vector exists to catch." + }, + { + "name": "reply-then-edit-cancelled-restores-text-only", + "actions": [ + { + "op": "reply", + "target": { + "messageId": "9f1a6d5f-7c8e-4b21-9f3e-2b0b4d1e9a0b", + "author": "Ana", + "snippet": "are we still on for 5?" + } + }, + { + "op": "type", + "text": "half a thought" + }, + { + "op": "edit", + "text": "an older message" + }, + { + "op": "cancelEdit" + }, + { + "op": "leave" + } + ], + "draft": { + "text": "half a thought", + "replyTarget": null + }, + "note": "Cancelling puts the stash back, and the strip stays gone -- the edit dismissed it, which is not something cancel undoes." + }, { "name": "edit-with-nothing-stashed-is-no-draft", "actions": [