Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>";
* - 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").
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
<resources>
<!-- Activity feed row title for a received tip; %1$s is the sender's display name. -->
<string name="title_activity_tipFrom">Tip from %1$s</string>
<!-- Activity feed row title for a peer payment the user sent; %1$s is the recipient's display name. -->
<string name="title_activity_sentTo">Sent to %1$s</string>
<!-- Activity feed row title for a peer payment the user received; %1$s is the sender's display name. -->
<string name="title_activity_receivedFrom">Received from %1$s</string>
<!-- Activity feed row title for a convert; %1$s is the source token name, %2$s the destination. -->
<string name="title_activity_convert">%1$s → %2$s</string>
<!-- Fee line beneath a convert's amount in the activity feed; %1$s is the formatted fee. -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand Down
Loading