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
1 change: 1 addition & 0 deletions apps/flipcash/shared/transaction-history/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ dependencies {
implementation(project(":libs:datetime"))

testImplementation(libs.bundles.unit.testing)
testImplementation(testFixtures(project(":ui:resources")))
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ 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.UserProfile
import com.flipcash.shared.transactionhistory.R
import com.flipcash.shared.transactionhistory.TransactionAvatar
import com.flipcash.shared.transactionhistory.TransactionListItem
import com.getcode.opencode.mapper.Mapper
import com.getcode.opencode.model.core.ID
import com.getcode.opencode.model.financial.Token
import com.getcode.solana.keys.Mint
import com.getcode.util.resources.ResourceHelper
import com.getcode.utils.hexEncodedString
import javax.inject.Inject

Expand All @@ -19,7 +19,9 @@ import javax.inject.Inject
* inside the paging transform; profiles arrive reactively as the cache is observed, so a
* not-yet-cached counterparty simply resolves later when its profile lands.
*/
internal class TransactionItemMapper @Inject constructor(): Mapper<Pair<ActivityFeedMessageWithToken, Map<String, UserProfile>>, TransactionListItem> {
internal class TransactionItemMapper @Inject constructor(
private val resources: ResourceHelper,
): Mapper<Pair<ActivityFeedMessageWithToken, Map<String, UserProfile>>, TransactionListItem> {
override fun map(from: Pair<ActivityFeedMessageWithToken, Map<String, UserProfile>>): TransactionListItem {
val (source, profiles) = from
val msg = source.message
Expand All @@ -45,7 +47,7 @@ internal class TransactionItemMapper @Inject constructor(): Mapper<Pair<Activity
// row to the literal key "null". Duplicate keys wedge the LazyColumn under the app's
// SharedTransitionLayout lookahead (whole-app freeze as a duplicate-keyed row scrolls in).
id = msg.id.hexEncodedString(),
title = resolveTitle(meta, msg.text, msg.textSubstitutions, counterparty, token, profiles),
title = resolveTitle(resources, meta, msg.text, msg.textSubstitutions, counterparty, profiles),
timestamp = msg.timestamp,
avatar = avatar,
signedAmountPrefix = prefix,
Expand All @@ -62,18 +64,20 @@ internal class TransactionItemMapper @Inject constructor(): Mapper<Pair<Activity
* preferring the observed display name for `UserId` substitutions (server
* [MessageSubstitution.fallback] otherwise).
*
* Otherwise the server sends a bare verb (e.g. "Tipped", "Purchased", "Received") and the client
* completes it with the relevant subject, resolved reactively (the bare verb shows until it lands):
* - buys/sells append the **token** name — "Purchased Dad Cash", "Sold Dad Cash";
* - received tips read "Received Tip From <name>";
* 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".
*
* Buys/sells/deposits carry no counterparty, so they render the server text verbatim
* ("Purchased", "Sold", "Added").
*/
private fun resolveTitle(
resources: ResourceHelper,
meta: MessageMetadata?,
text: String,
substitutions: List<MessageSubstitution>,
counterparty: UserProfile?,
token: Token?,
profiles: Map<String, UserProfile>,
): String {
if (substitutions.isNotEmpty()) {
Expand All @@ -91,17 +95,9 @@ private fun resolveTitle(
}

val counterpartyName = counterparty?.displayName?.takeIf { it.isNotBlank() }
val tokenName = token?.name?.takeIf { it.isNotBlank() } ?: token?.symbol?.takeIf { it.isNotBlank() }
return when (meta) {
is MessageMetadata.ReceivedCrypto ->
if (counterpartyName != null) "$text Tip From $counterpartyName" else text
MessageMetadata.BoughtToken ->
// Buying dollars (USDF — the only buyable stablecoin) is just adding money — read it as
// such, not "Purchased Dollars".
if (token?.address == Mint.usdf) "Added Money"
else if (tokenName != null) "$text $tokenName" else text
MessageMetadata.SoldToken ->
if (tokenName != null) "$text $tokenName" else text
if (counterpartyName != null) resources.getString(R.string.title_activity_tipFrom, counterpartyName) else text
else ->
if (counterpartyName != null) "$text $counterpartyName" else text
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<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>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.flipcash.app.core.feed.MessageSubstitution
import com.flipcash.services.models.UserProfile
import com.flipcash.shared.transactionhistory.internal.TransactionItemMapper
import com.getcode.opencode.model.core.ID
import com.getcode.util.resources.FakeResourceHelper
import com.getcode.opencode.model.financial.CurrencyCode
import com.getcode.opencode.model.financial.Fiat
import com.getcode.opencode.model.financial.HolderMetrics
Expand All @@ -24,7 +25,9 @@ import org.junit.Test

class TransactionItemMapperTest {

private val mapper = TransactionItemMapper()
private val resources = FakeResourceHelper()
.stub(R.string.title_activity_tipFrom, "Tip from %1\$s")
private val mapper = TransactionItemMapper(resources)

private val knownUserId: ID = listOf<Byte>(0x0A, 0x0B, 0x0C)
private val knownProfile = UserProfile.Empty.copy(displayName = "Sally The Streamer")
Expand Down Expand Up @@ -112,50 +115,41 @@ class TransactionItemMapperTest {
}

@Test
fun `received tip reads Received Tip From the counterparty`() {
fun `received tip reads Tip from the counterparty`() {
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 Tip From Sally The Streamer", item.title)
assertEquals("Tip from Sally The Streamer", item.title)
}

@Test
fun `bought token appends the token name`() {
fun `bought token renders the server text verbatim`() {
val token = token(address = Mint.usdc, name = "Dad Cash", symbol = "DADCASH")
val msg = feedMessage(metadata = MessageMetadata.BoughtToken)
.copy(text = "Purchased", textSubstitutions = emptyList())
val item = mapper.map(ActivityFeedMessageWithToken(msg, token) to emptyMap())

assertEquals("Purchased Dad Cash", item.title)
assertEquals("Purchased", item.title)
}

@Test
fun `bought dollars reads Added Money instead of Purchased Dollars`() {
fun `bought dollars renders the server text verbatim`() {
val msg = feedMessage(metadata = MessageMetadata.BoughtToken)
.copy(text = "Purchased", textSubstitutions = emptyList())
.copy(text = "Added", textSubstitutions = emptyList())
val item = mapper.map(ActivityFeedMessageWithToken(msg, usdfToken()) to emptyMap())

assertEquals("Added Money", item.title)
assertEquals("Added", item.title)
}

@Test
fun `sold token appends the token name`() {
fun `sold token renders the server text verbatim`() {
val token = token(address = Mint.usdc, name = "Dad Cash", symbol = "DADCASH")
val msg = feedMessage(metadata = MessageMetadata.SoldToken)
.copy(text = "Sold", textSubstitutions = emptyList())
val item = mapper.map(ActivityFeedMessageWithToken(msg, token) to emptyMap())

assertEquals("Sold Dad Cash", item.title)
}

@Test
fun `bought token stays bare until token metadata resolves`() {
val msg = feedMessage(metadata = MessageMetadata.BoughtToken)
.copy(text = "Purchased", textSubstitutions = emptyList())
val item = mapper.map(ActivityFeedMessageWithToken(msg, token = null) to emptyMap())

assertEquals("Purchased", item.title)
assertEquals("Sold", item.title)
}

@Test
Expand Down
Loading