From a3eabbe5aebee1bc35dbafc8a0e22544a0aea249 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 07:02:42 -0400 Subject: [PATCH 1/6] feat(chat): apply real-time payment request updates by version --- .../shared/chat/PaymentRequestUpdateSink.kt | 17 ++ .../flipcash/shared/chat/PaymentRequests.kt | 100 ++++++++- .../flipcash/shared/chat/inject/ChatModule.kt | 7 + .../internal/delegates/EventStreamDelegate.kt | 15 +- .../shared/chat/PaymentRequestsUpdateTest.kt | 189 ++++++++++++++++++ 5 files changed, 321 insertions(+), 7 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequestUpdateSink.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PaymentRequestsUpdateTest.kt diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequestUpdateSink.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequestUpdateSink.kt new file mode 100644 index 0000000000..9980994758 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequestUpdateSink.kt @@ -0,0 +1,17 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.paymentrequest.PaymentRequestUpdate +import com.getcode.opencode.model.core.ID + +/** Where the chat event stream sends payment request updates. [PaymentRequests] is the real one. */ +interface PaymentRequestUpdateSink { + /** Applies [updates] in order and returns the requests whose held copy needs a [refetch]. */ + suspend fun onUpdates(updates: List): List + + suspend fun refetch(id: ID) + + object None : PaymentRequestUpdateSink { + override suspend fun onUpdates(updates: List): List = emptyList() + override suspend fun refetch(id: ID) = Unit + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequests.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequests.kt index 8767aff282..274a99ad28 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequests.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequests.kt @@ -12,7 +12,9 @@ import com.flipcash.services.models.paymentrequest.PaymentRequest import com.flipcash.services.models.paymentrequest.PaymentRequestDetails import com.flipcash.services.models.paymentrequest.PaymentRequestPage import com.flipcash.services.models.paymentrequest.PaymentRequestSection +import com.flipcash.services.models.paymentrequest.PaymentRequestStatus import com.flipcash.services.models.paymentrequest.PaymentRequestText +import com.flipcash.services.models.paymentrequest.PaymentRequestUpdate import com.flipcash.services.models.paymentrequest.newerOf import com.getcode.opencode.model.core.ID import kotlinx.coroutines.flow.Flow @@ -28,15 +30,17 @@ import javax.inject.Singleton /** * The payment requests the viewer has seen, by id, each the newest copy fetched. * - * No event carries a request change, so screens refetch (see the spec's refetch table) and every - * fetched request goes through [ingest]: its text is opened once, then [merge] keeps the newer copy. + * Screens refetch (see the spec's refetch table) and every fetched request goes through [ingest]: + * its text is opened once, then [merge] keeps the newer copy. The chat's event stream also announces + * changes, best effort, which [onUpdates] applies by version (see [applyUpdate]); polling stays as + * reconciliation for whatever it misses. * A request's `payment_intent_id` never enters the store; [fetch] hands it to the caller. */ @Singleton class PaymentRequests @Inject constructor( private val controller: PaymentRequestController, private val crypto: GroupContentCrypto, -) { +) : PaymentRequestUpdateSink { private val requests = MutableStateFlow>(emptyMap()) val state: StateFlow> = requests.asStateFlow() @@ -114,6 +118,43 @@ class PaymentRequests @Inject constructor( requests.update { held -> opened.fold(held, ::merge) } } + /** + * Applies the stream's request updates in order. A `created` goes through [ingest]; an + * `updates` is applied or ignored by [applyUpdate], which also says when the held copy cannot + * be trusted. Those requests are returned for [refetch] rather than fetched here, so the + * stream is not held up by the network. + */ + override suspend fun onUpdates(updates: List): List { + val refetch = LinkedHashSet() + for (update in updates) { + when (val kind = update.kind) { + is PaymentRequestUpdate.Kind.Created -> ingest(listOf(kind.paymentRequest)) + is PaymentRequestUpdate.Kind.Updates, PaymentRequestUpdate.Kind.Unknown -> { + var stale = false + requests.update { held -> + val outcome = applyUpdate(held, update) + stale = outcome.refetch + outcome.requests + } + if (stale) refetch += update.paymentRequestId + } + } + } + return refetch.toList() + } + + /** Refetches [id] to reconcile a missed or unappliable update; one fetch at a time per request. */ + override suspend fun refetch(id: ID) { + var claimed = false + inFlight.update { held -> claimed = id !in held; held.plusElement(id) } + if (!claimed) return + try { + fetch(id) + } finally { + inFlight.update { it.minusElement(id) } + } + } + fun clear() { requests.value = emptyMap() _missing.value = emptySet() @@ -133,6 +174,9 @@ class PaymentRequests @Inject constructor( return request.copy(kind = kind.copy(split = kind.split.copy(text = text))) } + /** [requests] after an update, and whether the request must be refetched to reconcile it. */ + data class UpdateOutcome(val requests: Map, val refetch: Boolean) + companion object { const val OPEN_PAGE = 100 const val RESOLVED_PAGE = 20 @@ -150,5 +194,55 @@ class PaymentRequests @Inject constructor( if (sealedOverOpened) return held return held + (incoming.id to newerOf(current, incoming)) } + + /** + * Applies an [PaymentRequestUpdate.Kind.Updates] to the held copy of its request, by version + * (L is the held version, V the update's): + * - no held copy: ignored; the request is fetched when it is shown; + * - V <= L: already applied, ignored; + * - V > L + 1: an update was missed, so refetch; + * - V == L + 1: every change is applied in order and the version set to V, unless one + * cannot be (unknown kind, or a share the copy lacks), in which case none is applied and + * the request is refetched rather than taking V for a copy missing a change. + * + * A [PaymentRequestUpdate.Kind.Unknown] cannot be applied, so it refetches a request that is + * held. A `created` is not handled here: it merges like a fetched copy ([merge]). + */ + fun applyUpdate(held: Map, update: PaymentRequestUpdate): UpdateOutcome { + val current = held[update.paymentRequestId] ?: return UpdateOutcome(held, refetch = false) + val kind = update.kind + if (kind !is PaymentRequestUpdate.Kind.Updates) { + return UpdateOutcome(held, refetch = kind is PaymentRequestUpdate.Kind.Unknown) + } + if (kind.version <= current.version) return UpdateOutcome(held, refetch = false) + if (kind.version > current.version + 1) return UpdateOutcome(held, refetch = true) + val applied = kind.changes.fold(current) { copy, change -> + copy?.let { applyChange(it, change) } + } ?: return UpdateOutcome(held, refetch = true) + return UpdateOutcome(held + (current.id to applied.copy(version = kind.version)), refetch = false) + } + + /** [request] with [change] applied, or null when it can't be. */ + private fun applyChange(request: PaymentRequest, change: PaymentRequestUpdate.Change): PaymentRequest? = when (change) { + is PaymentRequestUpdate.Change.SharePaid -> { + val kind = request.kind as? PaymentRequest.Kind.GroupSplit + val share = kind?.split?.shareOf(change.userId) + if (kind == null || share == null || change.paid == null) null + else request.copy( + kind = kind.copy( + split = kind.split.copy( + // The update carries no amount: keep the held share's. + shares = kind.split.shares.map { if (it.userId == change.userId) it.copy(paid = change.paid) else it }, + ), + ), + ) + } + is PaymentRequestUpdate.Change.StatusChanged -> when (change.status) { + PaymentRequestStatus.Completed, PaymentRequestStatus.Closed -> + request.copy(status = change.status, closedAt = change.closedAt ?: request.closedAt) + else -> null + } + PaymentRequestUpdate.Change.Unknown -> null + } } } 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 4c0e821deb..0e6efa50e9 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 @@ -14,6 +14,8 @@ import com.flipcash.shared.chat.RosterSearchSource import com.flipcash.shared.chat.internal.DataStoreLobbyRequestStore import com.flipcash.shared.chat.internal.DmOutgoingEncryption import com.flipcash.shared.chat.alerts.ReactionAlertController +import com.flipcash.shared.chat.PaymentRequestUpdateSink +import com.flipcash.shared.chat.PaymentRequests import com.flipcash.shared.chat.alerts.ReactionAlertSink import com.flipcash.shared.chat.alerts.ReactionActorProfiles import com.flipcash.shared.chat.alerts.StoredReactionActorProfiles @@ -102,6 +104,11 @@ abstract class ChatModule { impl: StoredReactionActorProfiles ): ReactionActorProfiles + @Binds + internal abstract fun bindPaymentRequestUpdateSink( + impl: PaymentRequests + ): PaymentRequestUpdateSink + @Binds internal abstract fun bindReactionAlertSink( impl: ReactionAlertController diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt index 815d330c68..93da1bfc5b 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt @@ -30,6 +30,7 @@ import com.flipcash.shared.chat.alerts.ActorBoostTotals import com.flipcash.shared.chat.alerts.ReactionActorProfiles import com.flipcash.shared.chat.alerts.ReactionAlertEvent import com.flipcash.shared.chat.alerts.ReactionAlertPolicy +import com.flipcash.shared.chat.PaymentRequestUpdateSink import com.flipcash.shared.chat.alerts.ReactionAlertSink import com.flipcash.shared.chat.internal.ChatStateHolder import com.flipcash.services.user.UserManager @@ -101,6 +102,7 @@ class EventStreamDelegate @Inject constructor( private val mentionPool: MentionPoolUpdates = MentionPoolUpdates.None, private val reactionAlerts: ReactionAlertSink = ReactionAlertSink.None, private val actorProfiles: ReactionActorProfiles = ReactionActorProfiles.None, + private val paymentRequestUpdates: PaymentRequestUpdateSink = PaymentRequestUpdateSink.None, ) : EventStreamOperations { @@ -476,10 +478,15 @@ class EventStreamDelegate @Inject constructor( // --- Lobby changes --- - // TODO(flipcash2 0.23.0): ChatUpdate.paymentRequestUpdates arrive here but nothing applies - // them yet. Apply by version per PaymentRequestUpdate's docs (created: keep greater; - // updates: ignore <= held, apply in order at held + 1, refetch beyond or on an unknown - // change) once a payment request store consumes the stream. + // Best effort and unsequenced like the roster above: the store applies them by version, and + // refetches (off this path) any request whose update it missed or couldn't apply. + if (update.paymentRequestUpdates.isNotEmpty()) { + val stale = paymentRequestUpdates.onUpdates(update.paymentRequestUpdates) + if (stale.isNotEmpty()) { + scope?.launch { stale.forEach { paymentRequestUpdates.refetch(it) } } + } + } + if (update.lobbyUpdates.isNotEmpty()) { _events.send(Event.LobbyChanged(chatId, update.lobbyUpdates)) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PaymentRequestsUpdateTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PaymentRequestsUpdateTest.kt new file mode 100644 index 0000000000..bcf2297374 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PaymentRequestsUpdateTest.kt @@ -0,0 +1,189 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.paymentrequest.PaidShare +import com.flipcash.services.models.paymentrequest.PaymentRequest +import com.flipcash.services.models.paymentrequest.PaymentRequestStatus +import com.flipcash.services.models.paymentrequest.PaymentRequestText +import com.flipcash.services.models.paymentrequest.PaymentRequestUpdate +import com.flipcash.services.models.paymentrequest.PaymentRequestUpdate.Change +import com.flipcash.services.models.paymentrequest.Share +import com.flipcash.services.models.paymentrequest.Split +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** Request updates apply by version: ignore at or below, apply at +1, refetch beyond or when unappliable. */ +class PaymentRequestsUpdateTest { + + private val id = List(16) { 9 } + private val alice = List(16) { 2 } + private val bob = List(16) { 3 } + private val paidAt = Instant.fromEpochSeconds(500) + private val closedAt = Instant.fromEpochSeconds(600) + + private fun share(user: List, paid: PaidShare? = null) = + Share(user, Fiat(quarks = 10_000_000, currencyCode = CurrencyCode.USD), paid) + + private fun request(version: Long, shares: List = listOf(share(alice), share(bob))) = PaymentRequest( + id = id, + creator = List(16) { 1 }, + createdAt = Instant.fromEpochSeconds(0), + status = PaymentRequestStatus.Open, + closedAt = null, + version = version, + kind = PaymentRequest.Kind.GroupSplit( + chatId = ChatId(ByteArray(32) { 3 }), + split = Split(PaymentRequestText.Plain("Dinner", ""), CurrencyCode.USD, Instant.fromEpochSeconds(1_000), shares), + ), + ) + + private fun updates(version: Long, vararg changes: Change) = + PaymentRequestUpdate(id, PaymentRequestUpdate.Kind.Updates(version, changes.toList())) + + private val paid = PaidShare(paidAt, PaidShare.How.Payment) + + private fun apply(held: PaymentRequest?, update: PaymentRequestUpdate) = + PaymentRequests.applyUpdate(if (held == null) emptyMap() else mapOf(id to held), update) + + @Test + fun `an update at the next version applies and sets the version`() { + val out = apply(request(3), updates(4, Change.SharePaid(alice, paid))) + assertFalse(out.refetch) + val result = out.requests.getValue(id) + assertEquals(4, result.version) + assertEquals(paid, result.split?.shareOf(alice)?.paid) + assertNull(result.split?.shareOf(bob)?.paid) + } + + @Test + fun `a paid share keeps the held amount`() { + val held = request(3) + val result = apply(held, updates(4, Change.SharePaid(bob, paid))).requests.getValue(id) + assertEquals(held.split?.shareOf(bob)?.amount, result.split?.shareOf(bob)?.amount) + } + + @Test + fun `a payment replaces a hand mark on the same share`() { + val manual = PaidShare(Instant.fromEpochSeconds(100), PaidShare.How.Manual) + val held = request(3, listOf(share(alice, manual), share(bob))) + val result = apply(held, updates(4, Change.SharePaid(alice, paid))).requests.getValue(id) + assertEquals(paid, result.split?.shareOf(alice)?.paid) + } + + @Test + fun `a status change sets status and closedAt`() { + val out = apply(request(3), updates(4, Change.StatusChanged(PaymentRequestStatus.Closed, closedAt))) + val result = out.requests.getValue(id) + assertEquals(PaymentRequestStatus.Closed, result.status) + assertEquals(closedAt, result.closedAt) + assertEquals(4, result.version) + } + + @Test + fun `the last share paid and completion apply together in order`() { + val held = request(3, listOf(share(alice, paid), share(bob))) + val out = apply( + held, + updates(4, Change.SharePaid(bob, paid), Change.StatusChanged(PaymentRequestStatus.Completed, closedAt)), + ) + val result = out.requests.getValue(id) + assertTrue(result.split!!.shares.all { it.isPaid }) + assertEquals(PaymentRequestStatus.Completed, result.status) + assertEquals(closedAt, result.closedAt) + } + + @Test + fun `an update at the held version is ignored`() { + val held = request(4) + val out = apply(held, updates(4, Change.StatusChanged(PaymentRequestStatus.Closed, closedAt))) + assertEquals(mapOf(id to held), out.requests) + assertFalse(out.refetch) + } + + @Test + fun `an update below the held version is ignored even when it has an unknown change`() { + val held = request(5) + val out = apply(held, updates(3, Change.Unknown)) + assertEquals(mapOf(id to held), out.requests) + assertFalse(out.refetch) + } + + @Test + fun `an update delivered twice applies once`() { + val update = updates(4, Change.SharePaid(alice, paid)) + val once = apply(request(3), update) + val twice = PaymentRequests.applyUpdate(once.requests, update) + assertEquals(once.requests, twice.requests) + assertFalse(twice.refetch) + } + + @Test + fun `an update past the next version leaves the copy and asks for a refetch`() { + val held = request(3) + val out = apply(held, updates(5, Change.SharePaid(alice, paid))) + assertEquals(mapOf(id to held), out.requests) + assertTrue(out.refetch) + } + + @Test + fun `an out-of-order update is refetched, and the earlier one then ignored`() { + val held = request(3) + val late = updates(5, Change.StatusChanged(PaymentRequestStatus.Closed, closedAt)) + assertTrue(apply(held, late).refetch) + // The refetch landed at version 5; the update for 4 that shows up afterwards is stale. + val fetched = request(5) + assertFalse(PaymentRequests.applyUpdate(mapOf(id to fetched), updates(4, Change.SharePaid(alice, paid))).refetch) + } + + @Test + fun `no held copy ignores the update without a refetch`() { + val out = apply(null, updates(4, Change.SharePaid(alice, paid))) + assertTrue(out.requests.isEmpty()) + assertFalse(out.refetch) + } + + @Test + fun `an unknown change applies none of the batch and refetches`() { + val held = request(3) + val out = apply(held, updates(4, Change.SharePaid(alice, paid), Change.Unknown)) + assertEquals(mapOf(id to held), out.requests) + assertTrue(out.refetch) + } + + @Test + fun `a share the copy does not have applies none of the batch and refetches`() { + val held = request(3) + val stranger = List(16) { 7 } + val out = apply( + held, + updates(4, Change.SharePaid(alice, paid), Change.SharePaid(stranger, paid)), + ) + assertEquals(mapOf(id to held), out.requests) + assertTrue(out.refetch) + } + + @Test + fun `an unknown update kind refetches a held request and ignores an absent one`() { + val unknown = PaymentRequestUpdate(id, PaymentRequestUpdate.Kind.Unknown) + val held = request(3) + assertTrue(apply(held, unknown).refetch) + assertEquals(mapOf(id to held), apply(held, unknown).requests) + assertFalse(apply(null, unknown).refetch) + } + + @Test + fun `a created request merges like a fetched copy`() { + // Created goes through merge: the greater version wins, EXPIRED over OPEN at a tie. + val held = mapOf(id to request(5)) + assertEquals(held, PaymentRequests.merge(held, request(4))) + assertEquals(6, PaymentRequests.merge(held, request(6))[id]?.version) + val expired = request(5).copy(status = PaymentRequestStatus.Expired) + assertEquals(PaymentRequestStatus.Expired, PaymentRequests.merge(mapOf(id to expired), request(5))[id]?.status) + } +} From 79ca18dc8ccb9177484dfe134b9b51ff6d8d4b1d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 07:07:37 -0400 Subject: [PATCH 2/6] feat(activity): persist the requested payment on sent and received payments --- .../app/core/feed/ActivityFeedMessage.kt | 5 +++ .../NotificationToEntityMapper.kt | 4 +-- .../NotificationToEntityMapperTest.kt | 34 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/feed/ActivityFeedMessage.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/feed/ActivityFeedMessage.kt index 8adcf7cbec..5a7bc0e832 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/feed/ActivityFeedMessage.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/feed/ActivityFeedMessage.kt @@ -1,6 +1,7 @@ package com.flipcash.app.core.feed import com.flipcash.services.models.BoostedReaction +import com.flipcash.services.models.RequestedPayment import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.LocalFiat @@ -112,6 +113,8 @@ sealed interface MessageMetadata { val userId: ID? = null, // Defaulted so a row persisted before this field existed still decodes (as null). val boostedReaction: BoostedReaction? = null, + // Defaulted like [boostedReaction]. A oneof with it on the wire, so at most one is set. + val requestedPayment: RequestedPayment? = null, ) : MessageMetadata @Serializable @@ -126,6 +129,8 @@ sealed interface MessageMetadata { val userId: ID? = null, // Defaulted so a row persisted before this field existed still decodes (as null). val boostedReaction: BoostedReaction? = null, + // Defaulted like [boostedReaction]. A oneof with it on the wire, so at most one is set. + val requestedPayment: RequestedPayment? = null, ) : MessageMetadata /** diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapper.kt index 4144a5a395..655120a51f 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapper.kt @@ -71,8 +71,8 @@ class MetadataMapper @Inject constructor(): Mapper MessageMetadata.DirectlySentCrypto(from.phoneNumber, from.userId, from.boostedReaction) - is NotificationMetadata.ReceivedCrypto -> MessageMetadata.ReceivedCrypto(from.phoneNumber, from.userId, from.boostedReaction) + is NotificationMetadata.DirectlySentCrypto -> MessageMetadata.DirectlySentCrypto(from.phoneNumber, from.userId, from.boostedReaction, from.requestedPayment) + is NotificationMetadata.ReceivedCrypto -> MessageMetadata.ReceivedCrypto(from.phoneNumber, from.userId, from.boostedReaction, from.requestedPayment) is NotificationMetadata.IndirectlySentCrypto -> MessageMetadata.IndirectlySentCrypto(from.creator, from.canCancel) NotificationMetadata.Unknown -> MessageMetadata.Unknown is NotificationMetadata.WithdrewCrypto -> MessageMetadata.WithdrewCrypto(from.swapMetadata?.toMessage()) diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapperTest.kt index 088a82705b..70abad3b99 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapperTest.kt @@ -8,6 +8,7 @@ import com.flipcash.services.models.chat.Emoji import com.flipcash.services.models.ActivityFeedNotification import com.flipcash.services.models.NotificationMetadata import com.flipcash.services.models.NotificationState +import com.flipcash.services.models.RequestedPayment import com.flipcash.services.models.Substitution import com.getcode.opencode.model.core.ID import kotlin.time.Instant @@ -159,4 +160,37 @@ class NotificationToEntityMapperTest { assertEquals("+15551234567", received.phoneNumber) assertNull(received.boostedReaction) } + + // --- Requested payment persistence: rides the same `metadata` JSON column. --- + + private val requested = RequestedPayment( + chatId = ChatId(ByteArray(32) { (it + 9).toByte() }), + paymentRequestId = List(16) { it.toByte() }, + ) + + @Test + fun `a sent payment of a requested share reads back with its chat and request`() { + val read = roundTrip( + NotificationMetadata.DirectlySentCrypto(userId = listOf(0x0A), requestedPayment = requested) + ) as MessageMetadata.DirectlySentCrypto + + assertEquals(requested, read.requestedPayment) + assertNull(read.boostedReaction) + } + + @Test + fun `a received payment of a requested share reads back with its chat and request`() { + val read = roundTrip( + NotificationMetadata.ReceivedCrypto(userId = listOf(0x0A), requestedPayment = requested) + ) as MessageMetadata.ReceivedCrypto + + assertEquals(requested, read.requestedPayment) + } + + @Test + fun `a payment without a request reads back null`() { + val read = roundTrip(NotificationMetadata.DirectlySentCrypto(userId = listOf(0x0A))) + as MessageMetadata.DirectlySentCrypto + assertNull(read.requestedPayment) + } } From 35c60459b1e126f7097078d35fcda0dbf187491d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 08:17:11 -0400 Subject: [PATCH 3/6] feat(activity): link a requested payment to its request details --- .../kotlin/com/flipcash/app/core/AppRoute.kt | 6 +++ .../core/src/main/res/values/strings.xml | 1 + .../internal/request/RequestDetailsScreen.kt | 19 ++++++++- .../request/RequestDetailsViewModel.kt | 8 ++++ .../transactions/TransactionDetailsScreen.kt | 3 ++ .../internal/TransactionDetailsViewModel.kt | 17 ++++++++ .../TransactionDetailsViewModelTest.kt | 39 +++++++++++++++++++ .../transactionhistory/TransactionDetails.kt | 5 +++ .../TransactionDetailsContent.kt | 10 +++++ .../internal/TransactionDetailsMapper.kt | 1 + .../internal/TransactionItemMapper.kt | 7 ++++ .../src/main/res/values/strings.xml | 1 + .../TransactionDetailsMapperTest.kt | 24 ++++++++++++ 13 files changed, 140 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 5704c87c20..db92384ad5 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -412,11 +412,17 @@ sealed interface AppRoute : NavKey, Parcelable { // inside the app. The conversation offers a chip back to it; a link from outside the // app has nowhere to go back to and sets none. val returnToTitle: String? = null, + // Open with this request's details (its id in base58) over the transcript: where a + // payment of a share leads back to its request. Needs a [ChatIdentifier.ByChatId], since + // the details are keyed by chat. + val openRequestId: String? = null, ) : Messaging, FlowRoute { override val initialStack: List get() = when { openOnJoinRequests -> listOf(ChatStep.JoinRequests) openOnProfile -> listOf(ChatStep.GroupProfile) + openRequestId != null && identifier is ChatIdentifier.ByChatId -> + listOf(ChatStep.Conversation, ChatStep.RequestDetails(identifier.chatId, openRequestId)) else -> listOf(ChatStep.Conversation) } } diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index be9f6e2903..35bd643e1f 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1149,6 +1149,7 @@ Requests aren\'t available in this currency yet. Request + This request is no longer available. Requested by %1$s Requested by you ✓ Paid diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt index a3f2716781..146eca2f9a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt @@ -112,6 +112,7 @@ internal fun RequestDetailsScreen( val token by viewModel.token.collectAsStateWithLifecycle() val pay by viewModel.pay.collectAsStateWithLifecycle() val now by viewModel.now.collectAsStateWithLifecycle() + val unavailable by viewModel.unavailable.collectAsStateWithLifecycle() val viewerId = viewModel.viewerId val current = request @@ -170,7 +171,23 @@ internal fun RequestDetailsScreen( } }, ) { padding -> - if (current == null) return@CodeScaffold + if (current == null) { + if (unavailable) { + Box( + modifier = Modifier.fillMaxSize().padding(padding).padding(CodeTheme.dimens.inset), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.description_requestUnavailable), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + modifier = Modifier.testTag("request_details_unavailable"), + ) + } + } + return@CodeScaffold + } RequestDetailsContent( request = current, members = members, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt index ba50c9372f..92af2607d9 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt @@ -117,6 +117,14 @@ internal class RequestDetailsViewModel @Inject constructor( .flatMapLatest { s -> s.requestId?.let(requests::request) ?: flowOf(null) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + /** + * Whether a fetch found the request DENIED or NOT_FOUND (the viewer left the group, say) and + * none is held to show. The screen says so instead of staying blank. + */ + val unavailable: StateFlow = combine(_state, requests.missing, request) { s, missing, held -> + held == null && s.requestId in missing + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + /** The chat's members by id, for the creator's and each share's name and avatar. */ val members: StateFlow> = combine(_state, rosters.state) { s, all -> all[s.chatId].orEmpty().associateBy { it.userId } diff --git a/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/TransactionDetailsScreen.kt b/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/TransactionDetailsScreen.kt index 32d07f14a9..45c0423cf1 100644 --- a/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/TransactionDetailsScreen.kt +++ b/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/TransactionDetailsScreen.kt @@ -56,6 +56,9 @@ fun TransactionDetailsScreen(id: ID) { onViewMessage = { state.boostedMessageRoute?.let { navigator.push(it) } }, + onViewRequest = { + state.requestedPaymentRoute?.let { navigator.push(it) } + }, onCancel = { viewModel.dispatchEvent(TransactionDetailsViewModel.Event.OnCancelRequested) }, ) } diff --git a/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModel.kt b/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModel.kt index fa64d22c35..4e5758d066 100644 --- a/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModel.kt +++ b/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModel.kt @@ -22,6 +22,7 @@ import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.TransactionOperations import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.model.core.ID +import com.getcode.utils.base58 import com.getcode.solana.keys.PublicKey import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel @@ -92,6 +93,22 @@ class TransactionDetailsViewModel @Inject constructor( jumpToMessageId = boost.messageId, ) } + + /** + * Where "View Request" goes when the entry paid a share of a request: the chat with that + * request's details open over it. Read off the stored snapshot and not validated, so a + * request that has since closed, or that the viewer can no longer read, is handled by + * Request details itself. A boost wins if both are set. + */ + val requestedPaymentRoute: AppRoute? get() { + val details = transaction?.details ?: return null + if (details.boostedReaction != null) return null + val requested = details.requestedPayment ?: return null + return AppRoute.Messaging.Chat( + identifier = ChatIdentifier.ByChatId(requested.chatId), + openRequestId = requested.paymentRequestId.base58, + ) + } } sealed interface Event { diff --git a/apps/flipcash/features/transactions/src/test/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModelTest.kt b/apps/flipcash/features/transactions/src/test/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModelTest.kt index fe13d0d0a7..1e47b64639 100644 --- a/apps/flipcash/features/transactions/src/test/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModelTest.kt +++ b/apps/flipcash/features/transactions/src/test/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModelTest.kt @@ -9,6 +9,8 @@ import com.flipcash.app.core.chat.ProfileOrigin import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.models.BoostedReaction +import com.flipcash.services.models.RequestedPayment +import com.getcode.utils.base58 import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.Emoji import com.flipcash.shared.transactionhistory.TransactionDetails @@ -164,4 +166,41 @@ class TransactionDetailsViewModelTest { assertNull(state.boostedMessageRoute) assertTrue(state.transaction!!.details.canViewInChat) } + + // --- "View Request": the route opens the request's details over its chat. --- + + private val requested = RequestedPayment(chatId = dmChatId, paymentRequestId = List(16) { it.toByte() }) + + private fun stateWith(requested: RequestedPayment?, boost: BoostedReaction?): TransactionDetailsViewModel.State { + val details = mockk(relaxed = true) { + every { requestedPayment } returns requested + every { boostedReaction } returns boost + } + val transaction = mockk(relaxed = true) { every { this@mockk.details } returns details } + return TransactionDetailsViewModel.State(transaction = transaction) + } + + @Test + fun `R1 a requested payment opens its request in its chat`() { + val route = stateWith(requested, boost = null).requestedPaymentRoute + assertEquals( + AppRoute.Messaging.Chat( + identifier = ChatIdentifier.ByChatId(dmChatId), + openRequestId = requested.paymentRequestId.base58, + ), + route, + ) + } + + @Test + fun `R2 a boost wins when both are set`() { + val state = stateWith(requested, boost) + assertNull(state.requestedPaymentRoute) + assertEquals(boostRoute, state.boostedMessageRoute) + } + + @Test + fun `R3 a payment with no request has no request route`() { + assertNull(stateWith(null, null).requestedPaymentRoute) + } } diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetails.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetails.kt index d03a8bd5cc..9cf55b2f5f 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetails.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetails.kt @@ -2,6 +2,7 @@ package com.flipcash.shared.transactionhistory import androidx.annotation.StringRes import com.flipcash.services.models.BoostedReaction +import com.flipcash.services.models.RequestedPayment import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Token @@ -111,6 +112,9 @@ enum class TransactionStatus(@StringRes val labelRes: Int) { * @param boostedReaction The reaction this payment boosted, when it did. Its presence swaps the * chat button for "View Message", which opens the boosted message — and shows it even on a split * boost, which has no counterparty for [canViewInChat] to open. + * @param requestedPayment The payment request this payment paid a share of, when it did. Swaps the + * chat button for "View Request" the way [boostedReaction] does; a boost wins if both are set. A + * snapshot of what the payment was for, not kept current. */ data class TransactionDetails( val id: String, @@ -132,4 +136,5 @@ data class TransactionDetails( val canCancel: Boolean = false, val canViewInChat: Boolean = false, val boostedReaction: BoostedReaction? = null, + val requestedPayment: RequestedPayment? = null, ) diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsContent.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsContent.kt index c1aca2644a..ff03c9ade5 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsContent.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsContent.kt @@ -64,6 +64,8 @@ fun TransactionDetailsContent( onViewInChat: () -> Unit = { }, /** Opens the boosted message in its chat; the button shows whenever the entry boosted one. */ onViewMessage: () -> Unit = { }, + /** Opens the request this payment paid a share of; shown when there is no boost. */ + onViewRequest: () -> Unit = { }, /** The button opens the counterparty's profile rather than a chat, because no DM exists yet. */ viewsProfile: Boolean = false, onCancel: () -> Unit = { }, @@ -114,6 +116,14 @@ fun TransactionDetailsContent( shape = CodeTheme.shapes.extraSmall, onClick = onViewMessage, ) + } else if (details.requestedPayment != null) { + CodeButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.action_txnDetails_viewRequest), + buttonState = ButtonState.Filled05, + shape = CodeTheme.shapes.extraSmall, + onClick = onViewRequest, + ) } else if (details.canViewInChat) { CodeButton( modifier = Modifier.fillMaxWidth(), diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt index 42e6e0e167..feaccece0e 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt @@ -105,6 +105,7 @@ internal class TransactionDetailsMapper @Inject constructor( // open it with. canViewInChat = counterparty != null, boostedReaction = boost, + requestedPayment = requestedPaymentOf(meta), ) } diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt index 9d84abe316..3c21e005bf 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt @@ -4,6 +4,7 @@ import com.flipcash.app.core.feed.ActivityFeedMessageWithToken import com.flipcash.app.core.feed.MessageMetadata import com.flipcash.app.core.feed.MessageSubstitution import com.flipcash.services.models.BoostedReaction +import com.flipcash.services.models.RequestedPayment import com.flipcash.services.models.UserProfile import com.flipcash.shared.transactionhistory.R import com.flipcash.shared.transactionhistory.TransactionAvatar @@ -124,6 +125,12 @@ internal fun boostedReactionOf(meta: MessageMetadata?): BoostedReaction? = when else -> null } +internal fun requestedPaymentOf(meta: MessageMetadata?): RequestedPayment? = when (meta) { + is MessageMetadata.DirectlySentCrypto -> meta.requestedPayment + is MessageMetadata.ReceivedCrypto -> meta.requestedPayment + else -> null +} + /** A boost that paid both the message's sender and the group's creator names neither. */ internal fun isSplitBoost(meta: MessageMetadata?): Boolean = boostedReactionOf(meta) != null && userIdOf(meta) == null && when (meta) { diff --git a/apps/flipcash/shared/transaction-history/src/main/res/values/strings.xml b/apps/flipcash/shared/transaction-history/src/main/res/values/strings.xml index 74c6f65147..a786d72cd5 100644 --- a/apps/flipcash/shared/transaction-history/src/main/res/values/strings.xml +++ b/apps/flipcash/shared/transaction-history/src/main/res/values/strings.xml @@ -64,6 +64,7 @@ View in Chat View Message + View Request View Profile Copy transaction ID Cancel diff --git a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsMapperTest.kt b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsMapperTest.kt index 10d622a221..770de4afe8 100644 --- a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsMapperTest.kt +++ b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsMapperTest.kt @@ -7,6 +7,7 @@ import com.flipcash.app.core.feed.MessageState import com.flipcash.app.core.feed.SwapState import com.flipcash.app.core.feed.SwappedCryptoMetadata import com.flipcash.services.models.BoostedReaction +import com.flipcash.services.models.RequestedPayment import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.Emoji @@ -395,4 +396,27 @@ class TransactionDetailsMapperTest { assertNull(d.subtitle) assertEquals(TransactionKind.Sent, d.kind) } + + private val requested = RequestedPayment( + chatId = ChatId(ByteArray(32) { 4 }), + paymentRequestId = List(16) { it.toByte() }, + ) + + @Test + fun `a sent payment of a requested share carries the request`() { + val details = map(MessageMetadata.DirectlySentCrypto(userId = knownUserId, requestedPayment = requested)) + assertEquals(requested, details.requestedPayment) + assertNull(details.boostedReaction) + } + + @Test + fun `a received payment of a requested share carries the request`() { + val details = map(MessageMetadata.ReceivedCrypto(userId = knownUserId, requestedPayment = requested)) + assertEquals(requested, details.requestedPayment) + } + + @Test + fun `a payment with no request carries none`() { + assertNull(map(MessageMetadata.DirectlySentCrypto(userId = knownUserId)).requestedPayment) + } } From dc6f444d610bb507428faaee55452353d3d85801 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 10:03:21 -0400 Subject: [PATCH 4/6] feat(chat): animate live request changes on the deck and details The ring sweeps to the new paid count over about 300 ms, a request that ends fades into its ended look, and a share fades to paid. Only value changes animate, and the deck holds its order while an animation runs. --- .../internal/request/ChatRequestsViewModel.kt | 35 ++++++++- .../messenger/internal/request/DeckHold.kt | 30 +++++++ .../internal/request/DeckHostState.kt | 4 +- .../messenger/internal/request/RequestDeck.kt | 46 ++++++++--- .../internal/request/RequestDetailsScreen.kt | 27 ++++--- .../internal/request/DeckHoldTest.kt | 78 +++++++++++++++++++ .../chat/ui/request/PaymentRequestUi.kt | 46 +++++++---- .../shared/chat/ui/request/ViewerStatus.kt | 28 ++++++- 8 files changed, 253 insertions(+), 41 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHold.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckHoldTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModel.kt index e9cab01c02..59cfa63544 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModel.kt @@ -9,6 +9,8 @@ import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.PaymentRequests import com.getcode.opencode.model.core.ID import dagger.hilt.android.lifecycle.HiltViewModel +import com.flipcash.shared.chat.ui.request.RequestMotion +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -69,8 +71,8 @@ internal class ChatRequestsViewModel @Inject constructor( chatId.flatMapLatest { id -> id?.let { requests.inChat(it) } ?: flowOf(emptyList()) }, _now, cycled, - ) { inChat, now, cycled -> deckOrder(inChat, now, viewerId).rotated(cycled) } - .collect { _deck.value = it } + ) { inChat, now, cycled -> deckOrder(inChat, now, viewerId).rotated(cycled) to now } + .collect { (next, now) -> publish(next, now) } } viewModelScope.launch { while (true) { @@ -80,6 +82,35 @@ internal class ChatRequestsViewModel @Inject constructor( } } + private var shownNow = _now.value + private var settle: Job? = null + private var settledDeck: List = emptyList() + + /** + * Puts [next] on the deck. A request ending moves it in the sort order; the deck keeps its + * order for the ended look's fade first, and only then lets the list slide it into place. + * Changes arriving meanwhile update the cards in place and join the pending settle. + */ + private fun publish(next: List, now: Instant) { + val shown = _deck.value + val holding = settle?.isActive == true + val held = if (holding) inShownOrder(shown, next) else holdForEndedFade(shown, shownNow, next, now) + shownNow = now + settledDeck = next + if (held == null) { + settle?.cancel() + _deck.value = next + return + } + _deck.value = held + if (!holding) { + settle = viewModelScope.launch { + delay(RequestMotion.CHANGE_MILLIS.toLong()) + _deck.value = settledDeck + } + } + } + /** * Points the deck at [chat] and fills it from the requests already held, so a chat opened with * known requests shows its deck on the first frame instead of fading it in. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHold.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHold.kt new file mode 100644 index 0000000000..03360cf90a --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHold.kt @@ -0,0 +1,30 @@ +package com.flipcash.app.messenger.internal.request + +import com.flipcash.services.models.paymentrequest.PaymentRequest +import kotlin.time.Instant + +/** + * [next]'s contents in [shown]'s order, when ending a request is all that moves the deck: the same + * requests, a request that was open at [shownNow] and is ended at [now], and a different order. + * The deck keeps [shown]'s order while the ended look fades, then settles to [next]. Null when + * nothing needs holding. + */ +internal fun holdForEndedFade( + shown: List, + shownNow: Instant, + next: List, + now: Instant, +): List? { + if (shown.map { it.id } == next.map { it.id }) return null + val slotted = inShownOrder(shown, next) ?: return null + val ended = shown.indices.any { shown[it].isOpen(shownNow) && !slotted[it].isOpen(now) } + return slotted.takeIf { ended } +} + +/** [next]'s contents in [shown]'s order, or null unless both hold the same requests. */ +internal fun inShownOrder(shown: List, next: List): List? { + if (shown.size != next.size) return null + val byId = next.associateBy { it.id } + if (byId.size != next.size || shown.any { it.id !in byId }) return null + return shown.map { byId.getValue(it.id) } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHostState.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHostState.kt index 0c8e3f3462..fb9c402341 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHostState.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHostState.kt @@ -134,7 +134,9 @@ internal class DeckHostState(owed: Set) { } } -private val SETTLE_SPRING = spring(stiffness = Spring.StiffnessMediumLow) +/** The deck's settle motion; a reorder after a request ends slides on it too. */ +internal const val SETTLE_STIFFNESS = Spring.StiffnessMediumLow +private val SETTLE_SPRING = spring(stiffness = SETTLE_STIFFNESS) /** A [DeckHostState] for one visit to a chat; reopening the chat starts it over. */ @Composable diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt index 2d8b3144b2..180fd31615 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt @@ -2,6 +2,10 @@ package com.flipcash.app.messenger.internal.request import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import com.flipcash.shared.chat.ui.request.RequestMotion import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout @@ -546,11 +550,12 @@ private fun StripFront( val share = request.unpaidShareOf(viewerId, now) val standing = request.viewerStanding(viewerId, now) val ended = !request.isOpen(now) + val alpha by animateFloatAsState(if (ended) ENDED_ALPHA else 1f, RequestMotion.change(), label = "ended fade") Row( modifier = modifier .height(DeckStyle.Strip.height) .background(CodeTheme.colors.background, shape) - .alpha(if (ended) ENDED_ALPHA else 1f) + .alpha(alpha) .background(CodeTheme.colors.surfaceVariant, shape) .border(1.dp, if (share != null) CodeTheme.colors.success else CodeTheme.colors.border, shape) .padding(horizontal = CodeTheme.dimens.grid.x3), @@ -649,10 +654,11 @@ private fun DeckFront( val ended = !request.isOpen(now) val split = request.split val standing = request.viewerStanding(viewerId, now) + val alpha by animateFloatAsState(if (ended) ENDED_ALPHA else 1f, RequestMotion.change(), label = "ended fade") Row( modifier = modifier .background(CodeTheme.colors.background, PILL_SHAPE) - .alpha(if (ended) ENDED_ALPHA else 1f) + .alpha(alpha) .background(CodeTheme.colors.surfaceVariant, PILL_SHAPE) .border(1.dp, CodeTheme.colors.border, PILL_SHAPE) .padding( @@ -752,7 +758,12 @@ internal fun RequestRing( val status = request.resolvedStatus(now) val ended = status != PaymentRequestStatus.Open val fraction = if (status == PaymentRequestStatus.Completed) 1f else request.split?.paidFraction ?: 0f - val accent = if (ended) CodeTheme.colors.textSecondary else CodeTheme.colors.success + val accent by animateColorAsState( + if (ended) CodeTheme.colors.textSecondary else CodeTheme.colors.success, + RequestMotion.change(), + label = "ring accent", + ) + val sweep by animateFloatAsState(fraction, RequestMotion.change(), label = "ring sweep") val track = CodeTheme.colors.divider val inset = size * (4f / 26f) Box(modifier = modifier.size(size), contentAlignment = Alignment.Center) { @@ -761,17 +772,19 @@ internal fun RequestRing( val arcSize = Size(this.size.width - stroke, this.size.height - stroke) val topLeft = Offset(stroke / 2, stroke / 2) drawArc(track, 0f, 360f, false, topLeft, arcSize, style = Stroke(stroke)) - if (fraction > 0f) { - drawArc(accent, -90f, 360f * fraction.coerceIn(0f, 1f), false, topLeft, arcSize, style = Stroke(stroke)) + if (sweep > 0f) { + drawArc(accent, -90f, 360f * sweep.coerceIn(0f, 1f), false, topLeft, arcSize, style = Stroke(stroke)) } drawCircle(accent, radius = this.size.minDimension / 2 - inset.toPx()) } - Text( - text = if (status == PaymentRequestStatus.Closed) "✓" else "$", - style = if (size > 30.dp) CodeTheme.typography.textMedium else CodeTheme.typography.caption, - color = Color.Black, - ) - if (paid) PaidBadge(glyph = size, ended = ended) + Crossfade(targetState = status == PaymentRequestStatus.Closed, animationSpec = RequestMotion.change(), label = "ring glyph") { closed -> + Text( + text = if (closed) "✓" else "$", + style = if (size > 30.dp) CodeTheme.typography.textMedium else CodeTheme.typography.caption, + color = Color.Black, + ) + } + PaidBadge(glyph = size, ended = ended, paid = paid) } } @@ -865,6 +878,11 @@ internal fun ExpandedRequests( ) { itemsIndexed(deck, key = { _, request -> request.id.hashCode() }) { index, request -> val title = request.deckTitle + val endedAlpha by animateFloatAsState( + if (request.isOpen(now)) 1f else ENDED_ALPHA, + RequestMotion.change(), + label = "ended fade", + ) val card = Modifier .fillMaxWidth() .then( @@ -880,12 +898,15 @@ internal fun ExpandedRequests( enter = slideInVertically { -it * (index + 1) } + fadeIn(), exit = slideOutVertically { -it * (index + 1) } + fadeOut(), ) + // A reorder after a request ends slides into place on the deck's settle spring. + .animateItem(fadeInSpec = null, fadeOutSpec = null, placementSpec = SETTLE_SPRING_OFFSET) + } ) // surfaceVariant is translucent; the opaque base keeps the transcript from // showing through, and sits outside the alpha so ended cards stay solid. .background(CodeTheme.colors.background, CARD_SHAPE) - .alpha(if (request.isOpen(now)) 1f else ENDED_ALPHA) + .alpha(endedAlpha) .background(CodeTheme.colors.surfaceVariant, CARD_SHAPE) .clickable { onOpen(request) } .padding(CodeTheme.dimens.grid.x3) @@ -979,6 +1000,7 @@ private const val FLING_VELOCITY = 1_000f private const val DROP_SCALE = 0.06f // How far past one pill height the front card can be pulled, as a fraction of it. private const val RUBBER_BAND = 0.25f +private val SETTLE_SPRING_OFFSET = spring(stiffness = SETTLE_STIFFNESS) private val DECK_SPRING = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMedium) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt index 146eca2f9a..82e54df812 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt @@ -4,7 +4,9 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.animation.Crossfade import androidx.compose.foundation.layout.Box +import com.flipcash.shared.chat.ui.request.RequestMotion import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -361,15 +363,22 @@ private fun ShareRow( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Text( - text = when { - share.isPaid -> stringResource(R.string.label_sharePaid) - requestOpen -> stringResource(R.string.label_shareUnpaid) - else -> stringResource(R.string.label_shareUnpaidEnded) - }, - style = CodeTheme.typography.textSmall, - color = if (share.isPaid) CodeTheme.colors.success else CodeTheme.colors.textSecondary, - ) + // Fades to the paid state when the share turns paid while the screen is up. + Crossfade( + targetState = if (share.isPaid) 2 else if (requestOpen) 1 else 0, + animationSpec = RequestMotion.change(), + label = "share state", + ) { state -> + Text( + text = when (state) { + 2 -> stringResource(R.string.label_sharePaid) + 1 -> stringResource(R.string.label_shareUnpaid) + else -> stringResource(R.string.label_shareUnpaidEnded) + }, + style = CodeTheme.typography.textSmall, + color = if (state == 2) CodeTheme.colors.success else CodeTheme.colors.textSecondary, + ) + } } Text( text = share.amount.requestText(), diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckHoldTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckHoldTest.kt new file mode 100644 index 0000000000..760a81a3e1 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckHoldTest.kt @@ -0,0 +1,78 @@ +package com.flipcash.app.messenger.internal.request + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.paymentrequest.PaymentRequest +import com.flipcash.services.models.paymentrequest.PaymentRequestStatus +import com.flipcash.services.models.paymentrequest.PaymentRequestText +import com.flipcash.services.models.paymentrequest.Share +import com.flipcash.services.models.paymentrequest.Split +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import kotlin.time.Instant + +/** The deck holds its order while an ending request fades, then settles. */ +class DeckHoldTest { + + private val now = Instant.fromEpochSeconds(500) + + private fun request(id: Int, status: PaymentRequestStatus = PaymentRequestStatus.Open, endsAt: Long = 1_000) = PaymentRequest( + id = List(16) { id.toByte() }, + creator = List(16) { 1 }, + createdAt = Instant.fromEpochSeconds(0), + status = status, + closedAt = null, + version = 1, + kind = PaymentRequest.Kind.GroupSplit( + chatId = ChatId(ByteArray(32) { 3 }), + split = Split( + text = PaymentRequestText.Plain("Dinner", ""), + currency = CurrencyCode.USD, + endsAt = Instant.fromEpochSeconds(endsAt), + shares = listOf(Share(List(16) { 2 }, Fiat(20.0, CurrencyCode.USD), null)), + ), + ), + ) + + @Test + fun `a request closing keeps the old order with the new contents`() { + val a = request(1) + val b = request(2) + val closedA = request(1, PaymentRequestStatus.Closed) + val held = holdForEndedFade(shown = listOf(a, b), shownNow = now, next = listOf(b, closedA), now = now) + assertEquals(listOf(closedA, b), held) + } + + @Test + fun `a request expiring by the clock holds the order too`() { + val a = request(1, endsAt = 600) + val b = request(2) + val later = Instant.fromEpochSeconds(700) + val held = holdForEndedFade(listOf(a, b), now, listOf(b, a), later) + assertEquals(listOf(a, b), held) + } + + @Test + fun `an order change with nothing ending is not held`() { + val a = request(1) + val b = request(2) + assertNull(holdForEndedFade(listOf(a, b), now, listOf(b, a), now)) + } + + @Test + fun `an unchanged order needs no hold`() { + val a = request(1) + val closedA = request(1, PaymentRequestStatus.Closed) + assertNull(holdForEndedFade(listOf(a), now, listOf(closedA), now)) + } + + @Test + fun `a different set of requests is not held`() { + val a = request(1) + val b = request(2) + val c = request(3) + assertNull(holdForEndedFade(listOf(a, b), now, listOf(c, request(1, PaymentRequestStatus.Closed)), now)) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestUi.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestUi.kt index 5462c69d26..4c54bec410 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestUi.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestUi.kt @@ -2,6 +2,9 @@ package com.flipcash.shared.chat.ui.request import androidx.compose.ui.platform.LocalContext import android.content.res.Resources +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -85,18 +88,24 @@ fun PaymentRequest.progressText(): String { @Composable fun RequestProgressBar(fraction: Float, ended: Boolean, modifier: Modifier = Modifier) { val shape = RoundedCornerShape(1.5.dp) + val shown by animateFloatAsState(fraction, RequestMotion.change(), label = "request progress") + val fill by animateColorAsState( + if (ended) CodeTheme.colors.textSecondary else CodeTheme.colors.success, + RequestMotion.change(), + label = "request progress fill", + ) Box( modifier = modifier .fillMaxWidth() .height(3.dp) .background(CodeTheme.colors.divider, shape), ) { - if (fraction > 0f) { + if (shown > 0f) { Box( modifier = Modifier .fillMaxHeight() - .fillMaxWidth(fraction.coerceIn(0f, 1f)) - .background(if (ended) CodeTheme.colors.textSecondary else CodeTheme.colors.success, shape), + .fillMaxWidth(shown.coerceIn(0f, 1f)) + .background(fill, shape), ) } } @@ -124,6 +133,11 @@ fun PaymentRequestCardContent( ) { val loading = request == null || title == null val ended = request != null && !request.isOpen(now) + val glyphFill by animateColorAsState( + if (ended) CodeTheme.colors.textSecondary else CodeTheme.colors.success, + RequestMotion.change(), + label = "request glyph fill", + ) Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), @@ -135,11 +149,11 @@ fun PaymentRequestCardContent( Box( modifier = glyphModifier .size(34.dp) - .background(if (ended) CodeTheme.colors.textSecondary else CodeTheme.colors.success, CircleShape), + .background(glyphFill, CircleShape), contentAlignment = Alignment.Center, ) { Text(text = "$", style = CodeTheme.typography.textLarge, color = Color.Black) - if (standing is ViewerStanding.Paid) PaidBadge(glyph = 34.dp, ended = ended) + PaidBadge(glyph = 34.dp, ended = ended, paid = standing is ViewerStanding.Paid) } Column { Text( @@ -179,16 +193,18 @@ fun PaymentRequestCardContent( val viewer = standing?.takeUnless { loading }?.text() if (viewer != null) { Spacer(Modifier.weight(1f)) - Text( - text = viewer, - style = CodeTheme.typography.caption, - color = when (standing) { - is ViewerStanding.Paid, is ViewerStanding.Owes -> CodeTheme.colors.success - else -> CodeTheme.colors.textSecondary - }, - maxLines = 1, - modifier = detailModifier, - ) + // The viewer's part cross-fades when it changes, as a share turns paid. + Crossfade(targetState = viewer, animationSpec = RequestMotion.change(), modifier = detailModifier, label = "viewer standing") { text -> + Text( + text = text, + style = CodeTheme.typography.caption, + color = when (standing) { + is ViewerStanding.Paid, is ViewerStanding.Owes -> CodeTheme.colors.success + else -> CodeTheme.colors.textSecondary + }, + maxLines = 1, + ) + } } } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/ViewerStatus.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/ViewerStatus.kt index 003b5d4311..e103bdd530 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/ViewerStatus.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/ViewerStatus.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.draw.alpha import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -132,23 +134,45 @@ private object FirstThatFitsPolicy : MeasurePolicy { measurables.maxOf { it.minIntrinsicHeight(width) } } +/** + * How a live change to a request moves: the same ease-out over [CHANGE_MILLIS] for the ring and + * bar sweeping to a new value, the ended look fading in, and a share turning paid. Tweens follow + * the system animator duration scale, so they are instant with animations off. Every use starts + * at its current value, so nothing plays on first appearance. + */ +object RequestMotion { + const val CHANGE_MILLIS = 300 + + fun change(): androidx.compose.animation.core.TweenSpec = + androidx.compose.animation.core.tween(CHANGE_MILLIS, easing = androidx.compose.animation.core.LinearOutSlowInEasing) +} + /** * The paid badge at a glyph's bottom-right: a success circle with a black "✓", ringed in the card * surface so it reads apart from the glyph. 10dp on a 26dp glyph, scaled with [glyph]. Greys with * the glyph once the request has ended. */ @Composable -fun BoxScope.PaidBadge(glyph: Dp, ended: Boolean) { +fun BoxScope.PaidBadge(glyph: Dp, ended: Boolean, paid: Boolean = true) { + // Fades in when the share turns paid; already paid when first drawn, it just shows. + val visibility by androidx.compose.animation.core.animateFloatAsState(if (paid) 1f else 0f, RequestMotion.change(), label = "paid badge") + val fill by androidx.compose.animation.animateColorAsState( + if (ended) CodeTheme.colors.textSecondary else CodeTheme.colors.success, + RequestMotion.change(), + label = "paid badge fill", + ) + if (visibility <= 0f) return val badge = glyph * (10f / 26f) val ring = glyph * (1.5f / 26f) val surface = CodeTheme.colors.surfaceVariant.compositeOver(CodeTheme.colors.background) Box( modifier = Modifier .align(Alignment.BottomEnd) + .alpha(visibility) .size(badge + ring * 2) .background(surface, CircleShape) .padding(ring) - .background(if (ended) CodeTheme.colors.textSecondary else CodeTheme.colors.success, CircleShape), + .background(fill, CircleShape), contentAlignment = Alignment.Center, ) { Text( From c1d71928b0dbf470ffff94c66c2fe5ddbb3a367f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 10:27:16 -0400 Subject: [PATCH 5/6] fix(chat): refetch a payment request for every cash message that pays it The trigger lived in the message bubble, so it only fired for rows that were composed and keyed the dedupe on the request id, which dropped the second payment to the same request. ChatRequestsViewModel now watches the open chat's stored messages and calls PaymentRequests.refetch once per cash message sent after the chat opened. --- .../internal/request/ChatRequests.kt | 2 - .../internal/request/ChatRequestsViewModel.kt | 19 ++++-- .../internal/request/PaymentArrivals.kt | 24 +++++++ .../internal/request/PaymentArrivalsTest.kt | 67 +++++++++++++++++++ .../flipcash/shared/chat/ui/MessageBubble.kt | 4 -- .../chat/ui/request/PaymentRequestBubble.kt | 12 ---- 6 files changed, 104 insertions(+), 24 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/PaymentArrivals.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/PaymentArrivalsTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequests.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequests.kt index f9f9a04329..ba0fea305f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequests.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequests.kt @@ -41,9 +41,7 @@ internal fun rememberPaymentRequestCards( requests = held, missing = missing, now = now, - openedAt = viewModel.openedAt, onNeeded = viewModel::fetchIfAbsent, - onRefresh = viewModel::refresh, onOpen = { open(chatId, it) }, ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModel.kt index 59cfa63544..f3764039e5 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModel.kt @@ -6,6 +6,7 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.paymentrequest.PaymentRequest import com.flipcash.services.models.paymentrequest.deckOrder import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.PaymentRequests import com.getcode.opencode.model.core.ID import dagger.hilt.android.lifecycle.HiltViewModel @@ -17,7 +18,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -33,6 +36,7 @@ import kotlin.time.Instant @HiltViewModel internal class ChatRequestsViewModel @Inject constructor( private val requests: PaymentRequests, + private val chatCoordinator: ChatCoordinator, private val userManager: UserManager, ) : ViewModel() { @@ -62,9 +66,6 @@ internal class ChatRequestsViewModel @Inject constructor( /** The deck, front card first. Empty hides it. */ val deck: StateFlow> = _deck.asStateFlow() - /** Requests refetched because a message about them arrived, so a re-shown row doesn't refetch. */ - private val refreshed = mutableSetOf() - init { viewModelScope.launch { combine( @@ -74,6 +75,12 @@ internal class ChatRequestsViewModel @Inject constructor( ) { inChat, now, cycled -> deckOrder(inChat, now, viewerId).rotated(cycled) to now } .collect { (next, now) -> publish(next, now) } } + viewModelScope.launch { + chatId.filterNotNull().flatMapLatest { id -> + val arrivals = PaymentArrivals(openedAt) + chatCoordinator.observeMessages(id).map(arrivals::newlyPaid) + }.collect { paid -> paid.forEach(::refetch) } + } viewModelScope.launch { while (true) { delay(1.minutes) @@ -139,9 +146,9 @@ internal class ChatRequestsViewModel @Inject constructor( viewModelScope.launch { requests.fetchIfAbsent(id) } } - fun refresh(id: ID) { - if (!refreshed.add(id)) return - viewModelScope.launch { requests.fetch(id) } + /** A failed refetch leaves the held copy as it was; the 45 s poll reconciles it. */ + private fun refetch(id: ID) { + viewModelScope.launch { runCatching { requests.refetch(id) } } } /** Swipe down on the deck: the front card goes to the back. */ diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/PaymentArrivals.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/PaymentArrivals.kt new file mode 100644 index 0000000000..022226ddc5 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/PaymentArrivals.kt @@ -0,0 +1,24 @@ +package com.flipcash.app.messenger.internal.request + +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.getcode.opencode.model.core.ID +import kotlin.time.Instant + +/** + * Picks out the requests an open chat's cash messages pay. A payment landing changes its request + * and nothing else says so, so each such message sent after [openedAt] earns one refetch. Older + * messages are history loading in, and a message seen again (the list re-emits on every change) + * is not counted twice. Two messages for one request are two refetches; the in-flight dedupe in + * `PaymentRequests.refetch` folds those that overlap. + */ +internal class PaymentArrivals(private val openedAt: Instant) { + private val seen = mutableSetOf() + + /** The request ids to refetch for [messages], one per message not seen before. */ + fun newlyPaid(messages: List): List = + messages.mapNotNull { message -> + val id = message.content.firstNotNullOfOrNull { (it as? MessageContent.Cash)?.paymentRequestId } + id?.takeIf { message.timestamp > openedAt && seen.add(message.messageId) } + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/PaymentArrivalsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/PaymentArrivalsTest.kt new file mode 100644 index 0000000000..c7ff1c3c0f --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/PaymentArrivalsTest.kt @@ -0,0 +1,67 @@ +package com.flipcash.app.messenger.internal.request + +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.Mint +import org.junit.Assert.assertEquals +import org.junit.Test +import kotlin.time.Instant + +/** A cash message that pays a request earns that request one refetch, once per message. */ +class PaymentArrivalsTest { + + private val openedAt = Instant.fromEpochSeconds(1_000) + private val after = Instant.fromEpochSeconds(1_001) + private val before = Instant.fromEpochSeconds(999) + private val request: ID = List(16) { 7 } + + private fun cash(id: Long, at: Instant, pays: ID? = request, intent: Byte = id.toByte()) = ChatMessage( + messageId = id, + senderId = null, + content = listOf( + MessageContent.Cash( + intentId = List(32) { intent }, + amount = Fiat(5.0), + mint = Mint.usdf, + paymentRequestId = pays, + ), + ), + timestamp = at, + unreadSeq = id, + ) + + @Test + fun `a new cash message with a request id triggers a refetch`() { + val arrivals = PaymentArrivals(openedAt) + assertEquals(listOf(request), arrivals.newlyPaid(listOf(cash(1, after)))) + } + + @Test + fun `a cash message without a request id does not`() { + val arrivals = PaymentArrivals(openedAt) + assertEquals(emptyList(), arrivals.newlyPaid(listOf(cash(1, after, pays = null)))) + } + + @Test + fun `a cash message older than the chat open does not`() { + val arrivals = PaymentArrivals(openedAt) + assertEquals(emptyList(), arrivals.newlyPaid(listOf(cash(1, before)))) + } + + @Test + fun `the same message seen twice triggers one refetch`() { + val arrivals = PaymentArrivals(openedAt) + val message = cash(1, after) + assertEquals(listOf(request), arrivals.newlyPaid(listOf(message))) + assertEquals(emptyList(), arrivals.newlyPaid(listOf(message))) + } + + @Test + fun `two messages for the same request trigger two refetches`() { + val arrivals = PaymentArrivals(openedAt) + assertEquals(listOf(request), arrivals.newlyPaid(listOf(cash(1, after)))) + assertEquals(listOf(request), arrivals.newlyPaid(listOf(cash(1, after), cash(2, after)))) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index c1063d915b..1d98ed7504 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -74,7 +74,6 @@ import com.flipcash.shared.chat.ui.request.FetchWhenAbsent import com.flipcash.shared.chat.ui.request.LocalPaymentRequestCards import com.flipcash.shared.chat.ui.request.PaymentRequestBubbleDefaults import com.flipcash.shared.chat.ui.request.PaymentRequestCardBody -import com.flipcash.shared.chat.ui.request.RefreshOnArrival import com.flipcash.shared.chat.models.ChatAction import com.flipcash.shared.chat.models.ChatQuote import com.flipcash.shared.chat.models.LinkCard @@ -217,9 +216,6 @@ fun ContentBubble( return@Row } - // A share payment landing changes its request, and nothing else says so. - (item.content as? MessageContent.Cash)?.paymentRequestId?.let { RefreshOnArrival(it, item.timestamp) } - when (val content = item.content) { is MessageContent.Text -> TextBubble( modifier = modifier, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestBubble.kt index 73f67ec4e4..8fae3e3b7f 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestBubble.kt @@ -25,12 +25,8 @@ class PaymentRequestCards( val requests: Map, val missing: Set, val now: Instant, - /** When the chat screen opened: messages after it arrived while the reader watched. */ - val openedAt: Instant, /** Fetches a request the store doesn't hold; safe to call on every composition. */ val onNeeded: (ID) -> Unit, - /** Refetches a request whether or not the store holds it. */ - val onRefresh: (ID) -> Unit, val onOpen: (ID) -> Unit, ) { fun stateOf(id: ID, isFromSelf: Boolean, senderId: ID?): RequestCardState = requestCardState( @@ -55,14 +51,6 @@ internal fun FetchWhenAbsent(id: ID, cards: PaymentRequestCards) { LaunchedEffect(id, absent) { if (absent) cards.onNeeded(id) } } -/** Refetches [id] once when a message about it, sent at [sentAt], arrives while the chat is open. */ -@Composable -internal fun RefreshOnArrival(id: ID, sentAt: Instant) { - val cards = LocalPaymentRequestCards.current ?: return - val arrived = sentAt > cards.openedAt - LaunchedEffect(id, arrived) { if (arrived) cards.onRefresh(id) } -} - @Composable internal fun PaymentRequestCardBody(state: RequestCardState, now: Instant) { when (state) { From ea96861531404c62387bd95b1c864ef36ebe177c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 11:03:11 -0400 Subject: [PATCH 6/6] feat(chat): hide the cash bubble for a request share payment The request card and Request details already show the payment, so the transcript draws no bubble for a cash message with a payment_request_id. The filter runs after paging, so keys and load-more still see the message. The chat list preview, unread count, arrival and divider queries skip it too, and a jump to its id lands on the nearest older drawn message. --- .../app/messenger/internal/ChatViewModel.kt | 5 +++ .../screens/components/MessageList.kt | 19 +++++++- .../shared/chat/models/RequestShare.kt | 14 ++++++ .../shared/chat/models/RequestShareTest.kt | 34 ++++++++++++++ .../app/persistence/dao/ChatMessageDao.kt | 33 +++++++++++--- .../app/persistence/dao/ChatMessageDaoTest.kt | 44 ++++++++++++++++++- 6 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/RequestShare.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/RequestShareTest.kt 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 db91f4cd26..a3b9914bbc 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 @@ -133,6 +133,7 @@ import com.flipcash.shared.chat.canReact import com.flipcash.shared.chat.chatDraftOf import com.flipcash.shared.chat.groupAccess import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.paysRequestShare import com.flipcash.shared.chat.models.ChatQuote import com.flipcash.shared.chat.models.ChatQuoteSnippet import com.flipcash.shared.chat.models.PhotoMessageContext @@ -1541,6 +1542,10 @@ internal class ChatViewModel @AssistedInject constructor( ) } + // A request share's payment is shown by the request card, not by a bubble. The + // message is still in the page: its key, and so the next page's, are unchanged. + if (content.paysRequestShare) return@flatMapIndexed emptyList() + val enriched = if (content is MessageContent.Cash && content.tokenName.isBlank()) { val token = tokenCoordinator.getTokenMetadata(content.mint).getOrNull()?.token if (token != null) { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index b1aa33739a..58c7dea4f3 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -330,9 +330,9 @@ internal fun MessageList( // over an empty list has no append to hint. A quote tap is past this already. snapshotFlow { hasLoaded && listState.layoutInfo.visibleItemsInfo.isNotEmpty() } .first { it } - messages.walkUntil(budget) { indexOf(messages, target) }?.let { + messages.walkUntil(budget) { indexOfOrNearestOlder(messages, target) }?.let { listState.centerItem(it) - attentionId = target + attentionId = (messages.peek(it) as? ChatListItem.ContentBubble)?.messageId ?: target attentionRequest++ } // A visit opened at a message is positioned once this lands, found or not: reads are @@ -1070,6 +1070,21 @@ private fun indexOf(messages: LazyPagingItems, messageId: Long): I (messages.peek(i) as? ChatListItem.ContentBubble)?.messageId == messageId } +/** + * [indexOf], or the newest loaded message older than [messageId] when it isn't drawn. A request + * share's cash message is stored and paged but has no row, and a link or quote to it (a payment + * push opens at its message) lands on its neighbour instead of walking the whole budget for nothing. + * + * Rows run newest first, so the first one at or below [messageId] is the nearest. Pending sends + * carry non-positive ids and say nothing about where [messageId] sits. A target that is simply not + * loaded yet has no such row, because every loaded message is newer than it. + */ +private fun indexOfOrNearestOlder(messages: LazyPagingItems, messageId: Long): Int? = + indexOf(messages, messageId) ?: (0 until messages.itemCount).firstOrNull { i -> + val id = (messages.peek(i) as? ChatListItem.ContentBubble)?.messageId + id != null && id > 0 && id <= messageId + } + /** * Brings [index] to the middle of the transcript, which is where iOS lands a jump too * (`ChatViewController.scrollToRow`, `.centeredVertically`). diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/RequestShare.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/RequestShare.kt new file mode 100644 index 0000000000..0ab8c7c375 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/RequestShare.kt @@ -0,0 +1,14 @@ +package com.flipcash.shared.chat.models + +import com.flipcash.services.models.chat.MessageContent + +/** + * Whether this content is a cash message that pays a payment request share. The request's card and + * Request details already show that payment, so the transcript draws no bubble for it. The message + * stays stored and paged; only the mapping to rows skips it. + * + * The chat list's preview and unread queries apply the same test in SQL (`ChatMessageDao`), so a + * change here has to change them too. + */ +val MessageContent.paysRequestShare: Boolean + get() = this is MessageContent.Cash && paymentRequestId != null diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/RequestShareTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/RequestShareTest.kt new file mode 100644 index 0000000000..0e5fcd6ccf --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/RequestShareTest.kt @@ -0,0 +1,34 @@ +package com.flipcash.shared.chat.models + +import com.flipcash.services.models.chat.MessageContent +import com.getcode.opencode.model.financial.toFiat +import com.getcode.solana.keys.Mint +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RequestShareTest { + + @Test + fun `a cash message carrying a request id pays a share`() { + assertTrue(cash(paymentRequestId = byteArrayOf(1, 2, 3).toList()).paysRequestShare) + } + + @Test + fun `a cash message without a request id is a normal payment`() { + assertFalse(cash(paymentRequestId = null).paysRequestShare) + } + + @Test + fun `other content never pays a share`() { + assertFalse(MessageContent.Text("hi").paysRequestShare) + } + + private fun cash(paymentRequestId: List?) = + MessageContent.Cash( + intentId = listOf(1), + amount = 5.toFiat(), + mint = Mint.usdf, + paymentRequestId = paymentRequestId, + ) +} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt index 57058311a4..8b624c1a8a 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt @@ -12,6 +12,23 @@ import com.flipcash.app.persistence.entities.EncryptionState import com.flipcash.app.persistence.entities.MessageStatus import kotlinx.coroutines.flow.Flow +/** + * Excludes a cash message that pays a payment request share: the transcript draws no bubble for it + * (`MessageContent.paysRequestShare`), so it is not a preview, an unread message or an arrival. + * + * `content_json` is serialized with defaults, so a cash entry always carries `"paymentRequestId"`: + * `null` unless it paid a share. The widget that shows a request carries the same key under another + * type, hence the `"cash"` test. An unescaped `"` cannot come from message text, which is escaped. + */ +private const val NOT_REQUEST_SHARE = + "AND NOT (COALESCE(content_json, '') LIKE '%\"type\":\"cash\"%' " + + "AND COALESCE(content_json, '') LIKE '%\"paymentRequestId\":\"%') " + +/** [NOT_REQUEST_SHARE] for the correlated subqueries that alias the table as `m`. */ +private const val NOT_REQUEST_SHARE_M = + "AND NOT (COALESCE(m.content_json, '') LIKE '%\"type\":\"cash\"%' " + + "AND COALESCE(m.content_json, '') LIKE '%\"paymentRequestId\":\"%') " + @Dao interface ChatMessageDao { @@ -57,7 +74,7 @@ interface ChatMessageDao { * the newest id including tombstones, or a delete would regress the read pointer and leave the * chat unread forever. */ - @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND is_deleted = 0 AND content_json IS NOT NULL AND content_json != '[]' AND (encryption_state IS NULL OR encryption_state != 'KEY_PENDING') ORDER BY timestamp_epoch_ms DESC, message_id DESC LIMIT 1") + @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND is_deleted = 0 AND content_json IS NOT NULL AND content_json != '[]' AND (encryption_state IS NULL OR encryption_state != 'KEY_PENDING') " + NOT_REQUEST_SHARE + "ORDER BY timestamp_epoch_ms DESC, message_id DESC LIMIT 1") suspend fun getLatestVisible(chatIdHex: String): ChatMessageEntity? /** @@ -73,6 +90,7 @@ interface ChatMessageDao { "WHERE m.chat_id_hex = c.chat_id_hex AND m.is_deleted = 0 " + "AND m.content_json IS NOT NULL AND m.content_json != '[]' " + "AND (m.encryption_state IS NULL OR m.encryption_state != 'KEY_PENDING') " + + NOT_REQUEST_SHARE_M + "ORDER BY m.timestamp_epoch_ms DESC, m.message_id DESC LIMIT 1) " + "FROM (SELECT DISTINCT chat_id_hex FROM chat_messages) c)" ) @@ -89,6 +107,7 @@ interface ChatMessageDao { "WHERE m.chat_id_hex = c.chat_id_hex AND m.is_deleted = 0 " + "AND m.content_json IS NOT NULL AND m.content_json != '[]' " + "AND (m.encryption_state IS NULL OR m.encryption_state != 'KEY_PENDING') " + + NOT_REQUEST_SHARE_M + "ORDER BY m.timestamp_epoch_ms DESC, m.message_id DESC LIMIT 1) " + "FROM (SELECT DISTINCT chat_id_hex FROM chat_messages) c)" ) @@ -117,7 +136,8 @@ interface ChatMessageDao { "SELECT COUNT(*) FROM chat_messages " + "WHERE chat_id_hex = :chatIdHex " + "AND sender_id_hex IS NOT NULL AND sender_id_hex != :selfIdHex " + - "AND is_deleted = 0 AND message_id > :afterId" + "AND is_deleted = 0 AND message_id > :afterId " + + NOT_REQUEST_SHARE ) suspend fun countInboundAfter(chatIdHex: String, selfIdHex: String, afterId: Long): Int @@ -129,7 +149,8 @@ interface ChatMessageDao { "SELECT COUNT(*) FROM chat_messages " + "WHERE chat_id_hex = :chatIdHex " + "AND sender_id_hex IS NOT NULL AND sender_id_hex != :selfIdHex " + - "AND is_deleted = 0 AND message_id > :afterId" + "AND is_deleted = 0 AND message_id > :afterId " + + NOT_REQUEST_SHARE ) fun observeInboundCountAfter(chatIdHex: String, selfIdHex: String, afterId: Long): Flow @@ -141,7 +162,8 @@ interface ChatMessageDao { "SELECT MAX(message_id) FROM chat_messages " + "WHERE chat_id_hex = :chatIdHex " + "AND sender_id_hex IS NOT NULL AND sender_id_hex != :selfIdHex " + - "AND is_deleted = 0" + "AND is_deleted = 0 " + + NOT_REQUEST_SHARE ) suspend fun newestInboundId(chatIdHex: String, selfIdHex: String): Long? @@ -156,7 +178,8 @@ interface ChatMessageDao { @Query( "SELECT MIN(message_id) FROM chat_messages " + "WHERE chat_id_hex = :chatIdHex AND message_id > :afterId " + - "AND sender_id_hex IS NOT NULL AND sender_id_hex != :selfIdHex" + "AND sender_id_hex IS NOT NULL AND sender_id_hex != :selfIdHex " + + NOT_REQUEST_SHARE ) suspend fun firstNotSentByAfter(chatIdHex: String, selfIdHex: String, afterId: Long): Long? diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt index 94c520e0b5..4cfa6c5d3d 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt @@ -978,7 +978,7 @@ class ChatMessageDaoTest { fun `firstNotSentByAfter skips a senderless row`() = runTest { dao.upsert(listOf(from(SENDER_HEX, 1), text(2, "system").copy(senderIdHex = null), from(SENDER_HEX, 3))) - assertEquals(3L, firstNotOwn(pointer = 1)) + assertEquals(3L, dao.firstNotSentByAfter(CHAT_HEX, SELF_HEX, 1)) } @Test @@ -1000,7 +1000,47 @@ class ChatMessageDaoTest { fun `the divider lands below a senderless row that follows the pointer`() = runTest { dao.upsert(listOf(from(SELF_HEX, 1), text(2, "system").copy(senderIdHex = null), from(SENDER_HEX, 3))) - assertEquals(3L, firstNotOwn(pointer = 1)) + assertEquals(3L, dao.firstNotSentByAfter(CHAT_HEX, SELF_HEX, 1)) assertEquals(1, unread(pointer = 1)) } + + private fun cash(messageId: Long, paymentRequestId: String?) = from(SENDER_HEX, messageId).copy( + contentJson = listOf( + MessageContentSerialized.Cash(intentId = "intent", quarks = 5, paymentRequestId = paymentRequestId), + ), + ) + + private fun requestWidget(messageId: Long) = from(SENDER_HEX, messageId).copy( + contentJson = listOf(MessageContentSerialized.WidgetPaymentRequest(paymentRequestId = "req")), + ) + + @Test + fun `the preview falls back past a cash message that pays a request share`() = runTest { + dao.upsert(listOf(text(1, "one"), cash(2, paymentRequestId = "req"))) + + assertEquals(1L, dao.getLatestVisible(CHAT_HEX)?.messageId) + assertEquals(listOf(1L), dao.getLatestVisibleForAllChats().map { it.messageId }) + assertEquals(listOf(1L), dao.observeLatestVisibleForAllChats().first().map { it.messageId }) + // The anchors that mark read still see it, so the pointer is never rewound. + assertEquals(2L, dao.getLatest(CHAT_HEX)?.messageId) + } + + @Test + fun `a cash message without a request id and a request widget are still previewed`() = runTest { + dao.upsert(listOf(text(1, "one"), cash(2, paymentRequestId = null))) + assertEquals(2L, dao.getLatestVisible(CHAT_HEX)?.messageId) + + dao.upsert(listOf(requestWidget(3))) + assertEquals(3L, dao.getLatestVisible(CHAT_HEX)?.messageId) + } + + @Test + fun `a request share payment is not unread, an arrival or the divider's message`() = runTest { + dao.upsert(listOf(from(SELF_HEX, 1), cash(2, paymentRequestId = "req"), cash(3, paymentRequestId = null))) + + assertEquals(1, unread(pointer = 1)) + assertEquals(3L, dao.firstNotSentByAfter(CHAT_HEX, SELF_HEX, 1)) + assertEquals(3L, dao.newestInboundId(CHAT_HEX, SELF_HEX)) + assertEquals(1, dao.observeInboundCountAfter(CHAT_HEX, SELF_HEX, 1).first()) + } }