From 9416af22806f6f9534c4c67950e93c9995d63839 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 09:27:53 -0400 Subject: [PATCH 1/3] fix(activity): phrase in-chat payments as sends, not tips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The activity feed titled every received peer payment "Tip from ", so an in-chat send showed up in the recipient's history as a tip. The server already separates the two: it picks the verb from the payment's `ChatMetadata.TipDmPayment.Location`, so a tip-card tip arrives as "Tipped" and an in-chat send as "Sent". `resolveTitle` discarded that verb on the received side and hardcoded the tip phrasing. Read it instead, and give a plain send its own directional labels — "Sent to " and "Received from ". The verb is the only signal available: `activity/v1` models both as plain sent/received crypto with no structured tip flag. Matching it holds while `localized_text` is English-only; a localized feed would need the distinction promoted into the notification metadata. --- .../internal/TransactionItemMapper.kt | 34 +++++++++++---- .../src/main/res/values/strings.xml | 4 ++ .../TransactionItemMapperTest.kt | 42 ++++++++++++++++++- 3 files changed, 70 insertions(+), 10 deletions(-) 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 bdfa882ed..c39b5ea47 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 @@ -87,10 +87,14 @@ private fun convertTitle( * preferring the observed display name for `UserId` substitutions (server * [MessageSubstitution.fallback] otherwise). * - * Otherwise the server sends a bare verb (e.g. "Tipped", "Received") and the client completes it - * with the relevant subject, resolved reactively (the bare verb shows until it lands): - * - received tips read "Tip from "; - * - tips/sends and anything else with a counterparty append the **counterparty** name — "Tipped Sally". + * Otherwise the server sends a bare verb and the client completes it with the counterparty, resolved + * reactively (the bare verb shows until it lands). The verb is what separates a tip from a plain + * send — the server picks it from the payment's `ChatMetadata.TipDmPayment.Location`, so a tip-card + * tip reads "Tipped" and an in-chat send reads "Sent". Peer payments are phrased from it: + * - sent: "Tipped Sally" for a tip, "Sent to Sally" otherwise; + * - received: "Tip from Sally" for a tip, "Received from Sally" otherwise. + * + * Anything else with a counterparty appends the name to the server verb. * * Buys/sells/deposits carry no counterparty, so they render the server text verbatim * ("Purchased", "Sold", "Added"). @@ -117,15 +121,29 @@ private fun resolveTitle( return result } - val counterpartyName = counterparty?.displayName?.takeIf { it.isNotBlank() } + val counterpartyName = counterparty?.displayName?.takeIf { it.isNotBlank() } ?: return text + val isTip = text.isTipVerb() return when (meta) { is MessageMetadata.ReceivedCrypto -> - if (counterpartyName != null) resources.getString(R.string.title_activity_tipFrom, counterpartyName) else text - else -> - if (counterpartyName != null) "$text $counterpartyName" else text + if (isTip) resources.getString(R.string.title_activity_tipFrom, counterpartyName) + else resources.getString(R.string.title_activity_receivedFrom, counterpartyName) + is MessageMetadata.DirectlySentCrypto -> + // "Tipped" already reads as a transitive verb; "Sent" needs the preposition. + if (isTip) "$text $counterpartyName" + else resources.getString(R.string.title_activity_sentTo, counterpartyName) + else -> "$text $counterpartyName" } } +/** + * Whether the server's bare verb marks this payment as a tip ("Tip", "Tipped") rather than a plain + * send ("Sent", "Received"). The activity feed carries no structured tip flag — `activity/v1` models + * a peer payment only as directly-sent/received crypto — so the verb is the only signal the client + * gets. Matching it is safe while `localized_text` is English-only; a localized feed would need the + * distinction promoted into the notification metadata. + */ +private fun String.isTipVerb(): Boolean = trim().startsWith("tip", ignoreCase = true) + private fun userIdOf(meta: MessageMetadata?): ID? = when (meta) { is MessageMetadata.DirectlySentCrypto -> meta.userId is MessageMetadata.ReceivedCrypto -> meta.userId 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 e237f5f3f..2c3c42e80 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 @@ -2,6 +2,10 @@ Tip from %1$s + + Sent to %1$s + + Received from %1$s %1$s → %2$s diff --git a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt index e1a8e0c96..115555e9e 100644 --- a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt +++ b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt @@ -29,6 +29,8 @@ class TransactionItemMapperTest { private val resources = FakeResourceHelper() .stub(R.string.title_activity_tipFrom, "Tip from %1\$s") + .stub(R.string.title_activity_sentTo, "Sent to %1\$s") + .stub(R.string.title_activity_receivedFrom, "Received from %1\$s") .stub(R.string.title_activity_convert, "%1\$s → %2\$s") private val mapper = TransactionItemMapper(resources) @@ -100,7 +102,7 @@ class TransactionItemMapperTest { } @Test - fun `bare verb title appends the resolved counterparty name`() { + fun `sent tip appends the resolved counterparty name`() { val msg = feedMessage(metadata = MessageMetadata.DirectlySentCrypto(userId = knownUserId)) .copy(text = "Tipped", textSubstitutions = emptyList()) val item = mapper.map(ActivityFeedMessageWithToken(msg, token = null) to cached) @@ -120,12 +122,48 @@ class TransactionItemMapperTest { @Test fun `received tip reads Tip from the counterparty`() { val msg = feedMessage(metadata = MessageMetadata.ReceivedCrypto(userId = knownUserId)) - .copy(text = "Received", textSubstitutions = emptyList()) + .copy(text = "Tipped", textSubstitutions = emptyList()) val item = mapper.map(ActivityFeedMessageWithToken(msg, token = null) to cached) assertEquals("Tip from Sally The Streamer", item.title) } + @Test + fun `an in-chat send reads Sent to the counterparty, not Tipped`() { + val msg = feedMessage(metadata = MessageMetadata.DirectlySentCrypto(userId = knownUserId)) + .copy(text = "Sent", textSubstitutions = emptyList()) + val item = mapper.map(ActivityFeedMessageWithToken(msg, token = null) to cached) + + assertEquals("Sent to Sally The Streamer", item.title) + } + + @Test + fun `a received in-chat send reads Received from the counterparty, not Tip from`() { + val msg = feedMessage(metadata = MessageMetadata.ReceivedCrypto(userId = knownUserId)) + .copy(text = "Sent", textSubstitutions = emptyList()) + val item = mapper.map(ActivityFeedMessageWithToken(msg, token = null) to cached) + + assertEquals("Received from Sally The Streamer", item.title) + } + + @Test + fun `a received send keeps Received from when the server says Received`() { + val msg = feedMessage(metadata = MessageMetadata.ReceivedCrypto(userId = knownUserId)) + .copy(text = "Received", textSubstitutions = emptyList()) + val item = mapper.map(ActivityFeedMessageWithToken(msg, token = null) to cached) + + assertEquals("Received from Sally The Streamer", item.title) + } + + @Test + fun `a received send stays bare until the counterparty profile resolves`() { + val msg = feedMessage(metadata = MessageMetadata.ReceivedCrypto(userId = knownUserId)) + .copy(text = "Sent", textSubstitutions = emptyList()) + val item = mapper.map(ActivityFeedMessageWithToken(msg, token = null) to emptyMap()) + + assertEquals("Sent", item.title) + } + @Test fun `bought token renders the server text verbatim`() { val token = token(address = Mint.usdc, name = "Dad Cash", symbol = "DADCASH") From 59c8fb592b7b7acbd689e4af507cf2f0053e6572 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 09:43:08 -0400 Subject: [PATCH 2/3] fix(analytics): report an in-chat send as Sent Cash, not Sent Tip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Sent Tip` vs `Sent Cash` was chosen by who the peer was, so a payment into a tip DM reported as a tip whether it came from a tip card or from the money button inside the thread. No data was lost — the event carried `Origin: chat` — but any count on `Sent Tip` that didn't split on `Origin` read in-chat sends as tips. Draw the same line the activity feed now draws, from `ChatMetadata.TipDmPayment.Location`: only a tip card payment is a tip. Every send from the chat screen is `CHAT`, so the branch collapses to `Sent Cash` and `Sent Tip` is left to the tip card flow in `TippingCoordinator`. `SentTip.origin` is unchanged and now only ever reports `tipcard`. Removing the property would break continuity of the existing series, so it stays. --- .../flipcash/app/messenger/internal/ChatViewModel.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 285de0dfe..31066a24a 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 @@ -708,12 +708,12 @@ internal class ChatViewModel @Inject constructor( } } - // A payment into a tip DM is a tip; a contact DM is a plain cash send. - val transferEvent = if (stateFlow.value.participant is ChatParticipant.TipUser) { - Analytics.Transfer.SentTip(TipOrigin.CHAT) - } else { - Analytics.Transfer.SentCash - } + // Only a tip card payment is a tip — the same line the activity feed + // draws, from `ChatMetadata.TipDmPayment.Location`. Every send from this + // screen is `CHAT`, whether the peer is a contact or a tip user, so it + // reports as a plain cash send. `Sent Tip` is left to the tip card flow + // in `TippingCoordinator`. + val transferEvent = Analytics.Transfer.SentCash result.onSuccess { dispatchEvent(Event.SendStateUpdated(success = true)) From 5343325689d486617faf499d6f27b888f4df4dd0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 09:52:24 -0400 Subject: [PATCH 3/3] refactor(analytics): drop the Origin property from Sent Tip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Sent Tip` now only fires for a tip card payment, so `Origin` is constant at `Tipcard` and says nothing a Mixpanel query can use. Drop the property and the `TipOrigin.propertyValue` mapping behind it; `SentTip` becomes an object with no state. `TipOrigin` itself stays — it still selects the payment's `ChatMetadata.TipDmPayment.Location`, which is what the server reads to pick the activity feed's verb. --- .../kotlin/com/flipcash/app/analytics/Analytics.kt | 3 +-- .../main/kotlin/com/flipcash/app/analytics/Events.kt | 12 ++---------- .../flipcash/shared/tipping/TippingCoordinator.kt | 4 ++-- 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt index aa875c92e..14bee4923 100644 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt @@ -4,7 +4,6 @@ import androidx.compose.runtime.Composable import com.flipcash.app.core.DisplayNameSource import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.services.internal.model.thirdparty.OnRampProvider -import com.flipcash.services.models.TipOrigin import com.flipcash.services.models.chat.ChatType import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.libs.analytics.AnalyticsService @@ -87,7 +86,7 @@ object Analytics { } data object SentCash : Transfer - data class SentTip(val origin: TipOrigin) : Transfer + data object SentTip : Transfer } enum class OnrampSource { Settings, Balance, Give } enum class AddMoneySource { Menu, GiveShortfall, BuyShortfall, Chat, Scanner, Balance } diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt index e6f96b6cc..d218cd9c7 100644 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt @@ -4,7 +4,6 @@ import androidx.core.net.toUri import com.flipcash.app.core.DisplayNameSource import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.services.internal.model.thirdparty.OnRampProvider -import com.flipcash.services.models.TipOrigin import com.flipcash.services.models.chat.ChatType import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.model.core.ID @@ -151,9 +150,8 @@ internal sealed interface AnalyticsEvent { override val name = "Receive Cash Link" } - data class SentTip(val origin: TipOrigin) : Transfer { + data object SentTip : Transfer { override val name = "Sent Tip" - override fun toProperties() = mapOf("Origin" to origin.propertyValue) } data object SentCash : ChatEvent { @@ -451,15 +449,9 @@ internal fun Analytics.Transfer.toAnalyticsEvent(): AnalyticsEvent = when (this) is Analytics.Transfer.SentCashLink.Clipboard -> AnalyticsEvent.SentCashLink(clipboard = true) is Analytics.Transfer.SentCashLink.App -> AnalyticsEvent.SentCashLink(app = name) is Analytics.Transfer.SentCash -> AnalyticsEvent.SentCash - is Analytics.Transfer.SentTip -> AnalyticsEvent.SentTip(origin = origin) + is Analytics.Transfer.SentTip -> AnalyticsEvent.SentTip } -internal val TipOrigin.propertyValue: String - get() = when (this) { - TipOrigin.TIPCARD -> "Tipcard" - TipOrigin.CHAT -> "Chat" - } - internal val DisplayNameSource.propertyValue: String get() = when (this) { DisplayNameSource.Onboarding -> "Onboarding" diff --git a/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt b/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt index 5a6a674bc..419762c48 100644 --- a/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt +++ b/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt @@ -204,7 +204,7 @@ class TippingCoordinator @Inject constructor( delay(400.milliseconds) setSendState(LoadingSuccessState()) analytics.transfer( - event = Analytics.Transfer.SentTip(TipOrigin.TIPCARD), + event = Analytics.Transfer.SentTip, amount = verifiedFiat.localFiat, successful = true, ) @@ -217,7 +217,7 @@ class TippingCoordinator @Inject constructor( }.onFailure { cause -> setSendState(LoadingSuccessState()) analytics.transfer( - event = Analytics.Transfer.SentTip(TipOrigin.TIPCARD), + event = Analytics.Transfer.SentTip, amount = verifiedFiat.localFiat, error = cause, )