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 697db2c7cf..8adcf7cbec 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,5 +1,6 @@ package com.flipcash.app.core.feed +import com.flipcash.services.models.BoostedReaction import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.LocalFiat @@ -19,6 +20,12 @@ data class ActivityFeedMessageWithToken( val message: ActivityFeedMessage, val token: Token?, val toToken: Token? = null, + /** + * The display name of the group a boost was sent in, from the local chat store; null when the + * entry isn't a boost, the chat is a DM (stored without a title) or isn't stored at all (never + * fetched). + */ + val boostChatName: String? = null, ) data class ActivityFeedMessage( @@ -103,6 +110,8 @@ sealed interface MessageMetadata { data class DirectlySentCrypto( val phoneNumber: String? = null, val userId: ID? = null, + // Defaulted so a row persisted before this field existed still decodes (as null). + val boostedReaction: BoostedReaction? = null, ) : MessageMetadata @Serializable @@ -115,6 +124,8 @@ sealed interface MessageMetadata { data class ReceivedCrypto( val phoneNumber: String? = null, val userId: ID? = null, + // Defaulted so a row persisted before this field existed still decodes (as null). + val boostedReaction: BoostedReaction? = null, ) : MessageMetadata /** 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 83d5e13111..32d07f14a9 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 @@ -53,6 +53,9 @@ fun TransactionDetailsScreen(id: ID) { onViewInChat = { state.counterpartDestination?.let { navigator.push(it) } }, + onViewMessage = { + state.boostedMessageRoute?.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 09096c8bc7..fa64d22c35 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 @@ -4,6 +4,7 @@ import android.content.ClipboardManager import androidx.lifecycle.viewModelScope import com.flipcash.app.blocklist.DmDestinationResolver import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ProfileOrigin import com.flipcash.app.core.extensions.onResult import com.flipcash.app.core.extensions.setText @@ -78,6 +79,19 @@ class TransactionDetailsViewModel @Inject constructor( val counterpartDestination: AppRoute? = null, ) { val viewsProfile: Boolean get() = counterpartDestination is AppRoute.Messaging.Profile + + /** + * Where "View Message" goes when the entry boosted a reaction: the chat, scrolled to and + * flashing that message, through the route a message deeplink builds. Read straight off the + * stored snapshot — it is not validated, so a deleted message or a chat the viewer has left + * lands exactly where a stale message link does. + */ + val boostedMessageRoute: AppRoute? get() = transaction?.details?.boostedReaction?.let { boost -> + AppRoute.Messaging.Chat( + identifier = ChatIdentifier.ByChatId(boost.chatId), + jumpToMessageId = boost.messageId, + ) + } } 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 2209ac5839..fe13d0d0a7 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 @@ -8,7 +8,10 @@ import com.flipcash.app.core.chat.ProfileAddress 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.chat.ChatId +import com.flipcash.services.models.chat.Emoji +import com.flipcash.shared.transactionhistory.TransactionDetails import com.flipcash.services.user.UserManager import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator import com.flipcash.shared.transactionhistory.ResolvedTransaction @@ -125,4 +128,40 @@ class TransactionDetailsViewModelTest { assertNull(vm.stateFlow.value.counterpartDestination) assertFalse(vm.stateFlow.value.viewsProfile) } + + // --- "View Message": the route is read off the stored boost snapshot (D1-D3). --- + + private val boost = BoostedReaction(chatId = dmChatId, messageId = 77L, emoji = Emoji("\uD83D\uDD25")) + private val boostRoute = AppRoute.Messaging.Chat( + identifier = ChatIdentifier.ByChatId(dmChatId), + jumpToMessageId = 77L, + ) + + private fun stateWith(boost: BoostedReaction?, canViewInChat: Boolean): TransactionDetailsViewModel.State { + val details = mockk(relaxed = true) { + every { boostedReaction } returns boost + every { this@mockk.canViewInChat } returns canViewInChat + } + val transaction = mockk(relaxed = true) { every { this@mockk.details } returns details } + return TransactionDetailsViewModel.State(transaction = transaction) + } + + @Test + fun `D1 a boost with a counterparty opens the boosted message in its chat`() { + val state = stateWith(boost, canViewInChat = true) + assertEquals(boostRoute, state.boostedMessageRoute) + } + + @Test + fun `D2 a split boost opens the same route with no counterparty`() { + val state = stateWith(boost, canViewInChat = false) + assertEquals(boostRoute, state.boostedMessageRoute) + } + + @Test + fun `D3 without a boost there is no message route and the chat button is as before`() { + val state = stateWith(null, canViewInChat = true) + assertNull(state.boostedMessageRoute) + assertTrue(state.transaction!!.details.canViewInChat) + } } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt index d089917ac2..2db45e47a3 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt @@ -13,7 +13,10 @@ import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.models.chat.ViewerState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton @@ -29,6 +32,25 @@ class ChatMetadataDataSource @Inject constructor( fun observeAll(): Flow> = db?.chatMetadataDao()?.observeAll() ?: emptyFlow() + /** + * Titles of the chats the viewer is a member of, keyed by chat-id hex. Chats without a title + * (DMs) and chats the viewer has left are absent. + * + * The database is resolved at collection time: the per-user DB is created at login, and a + * `@Singleton` consumer builds this flow before then. [observeAll] captures the null DB once and + * stays empty, which is why this does not wrap it. + */ + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + fun observeMemberTitles(): Flow> = + FlipcashDatabase.observeInstance() + .flatMapLatest { database -> + database?.chatMetadataDao()?.observeAll()?.map { rows -> + rows.filter { it.isMember && !it.title.isNullOrBlank() } + .associate { it.chatIdHex to it.title.orEmpty() } + } ?: flowOf(emptyMap()) + } + .distinctUntilChanged() + /** One chat's row by [chatId], re-emitted on change; `null` while it is not stored. */ fun observeById(chatId: ChatId): Flow = db?.chatMetadataDao()?.observeById(mapper.chatIdHex(chatId)) ?: emptyFlow() 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 8640f6604d..4144a5a395 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) - is NotificationMetadata.ReceivedCrypto -> MessageMetadata.ReceivedCrypto(from.phoneNumber, from.userId) + is NotificationMetadata.DirectlySentCrypto -> MessageMetadata.DirectlySentCrypto(from.phoneNumber, from.userId, from.boostedReaction) + is NotificationMetadata.ReceivedCrypto -> MessageMetadata.ReceivedCrypto(from.phoneNumber, from.userId, from.boostedReaction) 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 b80e8717b1..088a82705b 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 @@ -1,6 +1,10 @@ package com.flipcash.app.persistence.sources.mapper.notifications +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.chat.ChatId +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 @@ -8,6 +12,7 @@ import com.flipcash.services.models.Substitution import com.getcode.opencode.model.core.ID import kotlin.time.Instant import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Test @@ -67,4 +72,91 @@ class NotificationToEntityMapperTest { assertEquals("Deposited \$30.00", entity.text) assertNull(entity.textSubstitutions) } + + // --- Boosted reaction persistence: the field rides the `metadata` JSON column. --- + + private val boost = BoostedReaction( + chatId = ChatId(ByteArray(32) { (it + 5).toByte() }), + messageId = 42L, + emoji = Emoji("\uD83D\uDD25"), + ) + + private fun notification(metadata: NotificationMetadata) = ActivityFeedNotification( + id = listOf(0x01, 0x02, 0x03), + text = "Sent", + amount = null, + timestamp = Instant.fromEpochSeconds(1700000000L), + state = NotificationState.COMPLETED, + metadata = metadata, + textSubstitutions = emptyList(), + ) + + private fun roundTrip(metadata: NotificationMetadata): MessageMetadata? = + MessageEntityToFeedMessageMapper().map(mapper.map(notification(metadata))).metadata + + @Test + fun `P1 a sent boost reads back with the same chat, message and emoji`() { + val read = roundTrip( + NotificationMetadata.DirectlySentCrypto(userId = listOf(0x0A), boostedReaction = boost) + ) as MessageMetadata.DirectlySentCrypto + + assertEquals(boost, read.boostedReaction) + assertEquals(boost.chatId, read.boostedReaction?.chatId) + assertEquals(42L, read.boostedReaction?.messageId) + assertEquals(Emoji("\uD83D\uDD25"), read.boostedReaction?.emoji) + } + + @Test + fun `P1 a received boost reads back with the same chat, message and emoji`() { + val read = roundTrip( + NotificationMetadata.ReceivedCrypto(userId = listOf(0x0A), boostedReaction = boost) + ) as MessageMetadata.ReceivedCrypto + + assertEquals(boost, read.boostedReaction) + } + + @Test + fun `P1 a split boost keeps its boost without a counterparty`() { + val read = roundTrip(NotificationMetadata.DirectlySentCrypto(boostedReaction = boost)) + as MessageMetadata.DirectlySentCrypto + + assertNull(read.userId) + assertEquals(boost, read.boostedReaction) + } + + @Test + fun `P2 a payment without a boost reads back null`() { + val sent = roundTrip(NotificationMetadata.DirectlySentCrypto(userId = listOf(0x0A))) + as MessageMetadata.DirectlySentCrypto + val received = roundTrip(NotificationMetadata.ReceivedCrypto(userId = listOf(0x0A))) + as MessageMetadata.ReceivedCrypto + + assertNull(sent.boostedReaction) + assertNull(received.boostedReaction) + } + + @Test + fun `boost rows keep the class names MessageDao filters on`() { + val json = mapper.map( + notification(NotificationMetadata.ReceivedCrypto(boostedReaction = boost)) + ).metadata + assertNotNull(json) + assertEquals(true, json!!.contains("com.flipcash.app.core.feed.MessageMetadata.ReceivedCrypto")) + } + + @Test + fun `P3 metadata JSON written before the field existed decodes with no boost`() { + val sentBefore = + """{"type":"com.flipcash.app.core.feed.MessageMetadata.DirectlySentCrypto","userId":[10,11,12]}""" + val receivedBefore = + """{"type":"com.flipcash.app.core.feed.MessageMetadata.ReceivedCrypto","phoneNumber":"+15551234567"}""" + + val sent = MessageMetadata.from(sentBefore) as MessageMetadata.DirectlySentCrypto + val received = MessageMetadata.from(receivedBefore) as MessageMetadata.ReceivedCrypto + + assertEquals(listOf(10, 11, 12), sent.userId) + assertNull(sent.boostedReaction) + assertEquals("+15551234567", received.phoneNumber) + assertNull(received.boostedReaction) + } } diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt index e79d6dd67c..63de8a8008 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt @@ -10,6 +10,7 @@ import androidx.paging.map import com.flipcash.app.core.feed.ActivityFeedMessage import com.flipcash.app.core.feed.ActivityFeedMessageWithToken import com.flipcash.app.core.feed.MessageMetadata +import com.flipcash.app.persistence.sources.ChatMetadataDataSource import com.flipcash.app.persistence.sources.MessageDataSource import com.flipcash.app.persistence.sources.UserProfileDataSource import com.flipcash.app.persistence.sources.mapper.notifications.MessageEntityToFeedMessageMapper @@ -24,6 +25,8 @@ import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.UserProfile import com.flipcash.services.user.UserManager import com.flipcash.shared.transactionhistory.internal.TransactionDetailsMapper +import com.flipcash.shared.transactionhistory.internal.boostedReactionOf +import com.flipcash.shared.transactionhistory.internal.isSplitBoost import com.flipcash.shared.transactionhistory.internal.TransactionItemMapper import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Token @@ -48,6 +51,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap @@ -83,6 +87,7 @@ class ActivityFeedCoordinator @Inject internal constructor( private val transactionDetailsMapper: TransactionDetailsMapper, private val userProfiles: UserProfileDataSource, private val profileController: ProfileController, + private val chats: ChatMetadataDataSource, ) { private val pagingConfig = PagingConfig(pageSize = 20) @@ -193,8 +198,8 @@ class ActivityFeedCoordinator @Inject internal constructor( fun recentTransactions(): Flow> = messages .cachedIn(scope) - .combine(resolvers) { paging, (profiles, tokens) -> - paging.map { msg -> resolveRow(msg, profiles, tokens) } + .combine(resolvers) { paging, resolved -> + paging.map { msg -> resolveRow(msg, resolved) } } /** @@ -207,9 +212,9 @@ class ActivityFeedCoordinator @Inject internal constructor( */ private fun resolveRow( msg: ActivityFeedMessage, - profiles: Map, - tokens: Map, + resolved: Resolvers, ): TransactionListItem { + val (profiles, tokens, chatNames) = resolved val counterparty = counterpartyOf(msg.metadata) counterparty ?.takeUnless { profiles.containsKey(it.hexEncodedString()) } @@ -226,6 +231,7 @@ class ActivityFeedCoordinator @Inject internal constructor( message = msg, token = token, toToken = destinationTokenOf(msg.metadata, tokens), + boostChatName = boostChatNameOf(msg.metadata, chatNames), ) to profiles ) } @@ -288,6 +294,13 @@ class ActivityFeedCoordinator @Inject internal constructor( private fun destinationTokenOf(meta: MessageMetadata?, tokens: Map): Token? = convertOf(meta)?.toMint?.let { tokens[Mint(it.bytes)] } + /** A split boost's chat name, read from the local chat store only — never fetched. */ + private fun boostChatNameOf(meta: MessageMetadata?, chatNames: Map): String? { + if (!isSplitBoost(meta)) return null + val chatId = boostedReactionOf(meta)?.chatId ?: return null + return chatNames[chats.chatIdHex(chatId)] + } + private fun counterpartyOf(meta: NotificationMetadata?): ID? = when (meta) { is NotificationMetadata.DirectlySentCrypto -> meta.userId is NotificationMetadata.ReceivedCrypto -> meta.userId @@ -302,8 +315,8 @@ class ActivityFeedCoordinator @Inject internal constructor( * and an uncached counterparty triggers a background fetch (see [ensureProfile]). */ fun recentTransactions(limit: Int): Flow> = - combine(dataSource.observeRecent(limit), resolvers) { messages, (profiles, tokens) -> - messages.map { msg -> resolveRow(msg, profiles, tokens) } + combine(dataSource.observeRecent(limit), resolvers) { messages, resolved -> + messages.map { msg -> resolveRow(msg, resolved) } } /** @@ -313,8 +326,8 @@ class ActivityFeedCoordinator @Inject internal constructor( * token/profile resolution. */ fun recentTransactions(mint: Mint, limit: Int): Flow> = - combine(dataSource.observeRecent(mint, limit), resolvers) { messages, (profiles, tokens) -> - messages.map { msg -> resolveRow(msg, profiles, tokens) } + combine(dataSource.observeRecent(mint, limit), resolvers) { messages, resolved -> + messages.map { msg -> resolveRow(msg, resolved) } } /** @@ -326,8 +339,8 @@ class ActivityFeedCoordinator @Inject internal constructor( * screen shows before the first read lands. */ fun transactionDetails(id: ID): Flow = - combine(dataSource.observeById(id), resolvers) { message, (profiles, tokens) -> - message?.let { resolveDetails(it, profiles, tokens) } + combine(dataSource.observeById(id), resolvers) { message, resolved -> + message?.let { resolveDetails(it, resolved) } } /** @@ -340,9 +353,9 @@ class ActivityFeedCoordinator @Inject internal constructor( */ private fun resolveDetails( msg: ActivityFeedMessage, - profiles: Map, - tokens: Map, + resolved: Resolvers, ): ResolvedTransaction { + val (profiles, tokens, chatNames) = resolved val counterparty = counterpartyOf(msg.metadata) counterparty ?.takeUnless { profiles.containsKey(it.hexEncodedString()) } @@ -362,6 +375,10 @@ class ActivityFeedCoordinator @Inject internal constructor( message = msg, token = token, toToken = destinationToken, + // Any boost, not only a split one: the receipt's Chat row names the chat of a + // DM boost too. Local store only, never fetched. + boostChatName = boostedReactionOf(msg.metadata)?.chatId + ?.let { chatNames[chats.chatIdHex(it)] }, ) to profiles ), message = msg, @@ -370,6 +387,16 @@ class ActivityFeedCoordinator @Inject internal constructor( ) } + /** + * Titles of the chats the viewer is still a member of, keyed by chat-id hex. Only a split boost + * reads it, to name the row after its chat. A chat the viewer has left, or one that hasn't + * synced, is simply absent, and the row falls back to the nameless title. + * + * Starts empty so rows render before the chat store answers. + */ + private val chatNames: Flow> = chats.observeMemberTitles() + .onStart { emit(emptyMap()) } + /** * Observed profile + token caches, paired for a single [combine] against the cached pages. All * three are network-free reads that re-emit as they hydrate, so rows resolve reactively from @@ -378,15 +405,23 @@ class ActivityFeedCoordinator @Inject internal constructor( * Held-token metadata is laid over [badgeTokens] rather than under it: the held cache is * refreshed on every balance update, the badge memo is fetched once and never again. */ - private val resolvers: Flow, Map>> = + private val resolvers: Flow = combine( userProfiles.observeProfiles(), tokenProvider.observeTokenCache(), badgeTokens, - ) { profiles, tokens, badges -> - profiles to (badges + tokens) + chatNames, + ) { profiles, tokens, badges, chatNames -> + Resolvers(profiles, badges + tokens, chatNames) } + /** What a row resolves against, all read from memory; see [resolvers]. */ + private data class Resolvers( + val profiles: Map, + val tokens: Map, + val chatNames: Map, + ) + /** * Reactive "has money ever come in" — any completed incoming entry in the feed: an on-ramp buy, * a deposit, or a tip received. 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 61f4f1db56..d03a8bd5cc 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 @@ -1,6 +1,7 @@ package com.flipcash.shared.transactionhistory import androidx.annotation.StringRes +import com.flipcash.services.models.BoostedReaction import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Token @@ -23,6 +24,8 @@ import kotlin.time.Instant * from taking a bill in person — both arrive as an unattributed `ReceivedCrypto`. */ enum class TransactionKind(@StringRes val headingRes: Int) { + Boosted(R.string.title_txnDetails_youBoosted), + BoostReceived(R.string.title_txnDetails_boostReceived), Tipped(R.string.title_txnDetails_youTipped), Received(R.string.title_txnDetails_youReceived), Sent(R.string.title_txnDetails_youSent), @@ -105,6 +108,9 @@ enum class TransactionStatus(@StringRes val labelRes: Int) { * @param canCancel Whether the movement can still be pulled back — an open cash link, and nothing * else. Drawn as the app bar's end action. * @param canViewInChat Whether the counterparty's conversation can be opened from here. + * @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. */ data class TransactionDetails( val id: String, @@ -125,4 +131,5 @@ data class TransactionDetails( val received: Fiat? = null, val canCancel: Boolean = false, val canViewInChat: Boolean = false, + val boostedReaction: BoostedReaction? = 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 a91b582330..c1aca2644a 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 @@ -62,6 +62,8 @@ fun TransactionDetailsContent( onBack: () -> Unit = { }, onCopyId: () -> Unit = { }, onViewInChat: () -> Unit = { }, + /** Opens the boosted message in its chat; the button shows whenever the entry boosted one. */ + onViewMessage: () -> Unit = { }, /** The button opens the counterparty's profile rather than a chat, because no DM exists yet. */ viewsProfile: Boolean = false, onCancel: () -> Unit = { }, @@ -104,7 +106,15 @@ fun TransactionDetailsContent( IdCard(id = details.id, onCopyId = onCopyId) - if (details.canViewInChat) { + if (details.boostedReaction != null) { + CodeButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.action_txnDetails_viewMessage), + buttonState = ButtonState.Filled05, + shape = CodeTheme.shapes.extraSmall, + onClick = onViewMessage, + ) + } else if (details.canViewInChat) { CodeButton( modifier = Modifier.fillMaxWidth(), text = stringResource( @@ -173,6 +183,9 @@ private fun Header( style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, textAlign = TextAlign.Center, + // A boost's sentence can carry a long group name; two lines, then the tail goes. + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) } } 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 4fd07fd5d8..42e6e0e167 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 @@ -5,6 +5,7 @@ import com.flipcash.app.core.feed.MessageMetadata import com.flipcash.app.core.feed.MessageState import com.flipcash.app.core.feed.SwapState import com.flipcash.services.models.UserProfile +import com.flipcash.shared.transactionhistory.R import com.flipcash.shared.transactionhistory.TransactionAvatar import com.flipcash.shared.transactionhistory.TransactionDetails import com.flipcash.shared.transactionhistory.TransactionKind @@ -45,6 +46,26 @@ internal class TransactionDetailsMapper @Inject constructor( val counterparty = userIdOf(meta)?.let { profiles[it.hexEncodedString()] } val convert = convertOf(meta) val kind = kindOf(meta, msg.text) + val boost = boostedReactionOf(meta) + val counterpartyName = counterparty?.displayName?.takeIf { it.isNotBlank() } + val boostLine = boost?.let { boostLineOf(meta, it.emoji.value, source.boostChatName) } + // A split boost has nobody to name, so the group takes the name's place; the line then + // never repeats it. A counterparty that hasn't resolved doesn't borrow the group's name + // either: the boost line alone is the heading. + val split = isSplitBoost(meta) + val splitChat = source.boostChatName?.takeIf { it.isNotBlank() && split } + val heading = when { + boost == null -> counterpartyName + split -> splitChat ?: boostLine + else -> counterpartyName ?: boostLine + } + val boostSubtitle = when { + boost == null -> null + split && splitChat != null -> + boostLineOf(meta, boost.emoji.value, chatName = null) + counterpartyName != null -> boostLine + else -> null + } return TransactionDetails( // Base58, not the row's hex: this is the value someone pastes into a support ticket or @@ -54,8 +75,11 @@ internal class TransactionDetailsMapper @Inject constructor( avatar = avatarOf(counterparty, convert != null, meta, token, source.toToken), // A person entry is titled by the person; everything else falls back to the kind's own // heading, which is also what an unresolved counterparty gets. - heading = counterparty?.displayName?.takeIf { it.isNotBlank() }, - subtitle = subtitleOf(kind, token, source.toToken), + // The name stays the large heading, with the boost's sentence under it; with no name + // the sentence itself is the heading. A boost always sets one, which is what keeps the + // kind's format-string heading from ever being drawn. + heading = heading, + subtitle = boostSubtitle ?: subtitleOf(kind, token, source.toToken), signedAmountPrefix = when { meta == null -> null meta.isOutgoing -> "-" @@ -80,9 +104,26 @@ internal class TransactionDetailsMapper @Inject constructor( // The counterpart action opens a conversation or a profile, and both need somebody to // open it with. canViewInChat = counterparty != null, + boostedReaction = boost, ) } + /** + * The boost's sentence: the emoji, and "in {chat}" when [chatName] is a stored group. A DM is + * stored without a title and a missing chat isn't stored, so both arrive as null and drop it. + */ + private fun boostLineOf(meta: MessageMetadata?, emoji: String, chatName: String?): String { + val chat = chatName?.takeIf { it.isNotBlank() } + val received = meta is MessageMetadata.ReceivedCrypto + return when { + received && chat != null -> + resources.getString(R.string.title_txnDetails_boostReceivedIn, emoji, chat) + received -> resources.getString(R.string.title_txnDetails_boostReceived, emoji) + chat != null -> resources.getString(R.string.title_txnDetails_youBoostedIn, emoji, chat) + else -> resources.getString(R.string.title_txnDetails_youBoosted, emoji) + } + } + /** * The other side of the movement, for the kinds whose heading doesn't already carry it. Null * wherever the header already says everything, and wherever the metadata can't answer it: a @@ -116,12 +157,14 @@ internal class TransactionDetailsMapper @Inject constructor( */ private fun kindOf(meta: MessageMetadata?, text: String): TransactionKind = when (meta) { is MessageMetadata.DirectlySentCrypto -> when { + meta.boostedReaction != null -> TransactionKind.Boosted isUnidentifiedBill(meta) -> TransactionKind.GaveCash text.isTipVerb() -> TransactionKind.Tipped else -> TransactionKind.Sent } is MessageMetadata.ReceivedCrypto -> - if (isUnidentifiedBill(meta)) TransactionKind.ReceivedCash else TransactionKind.Received + if (meta.boostedReaction != null) TransactionKind.BoostReceived + else if (isUnidentifiedBill(meta)) TransactionKind.ReceivedCash else TransactionKind.Received is MessageMetadata.IndirectlySentCrypto -> TransactionKind.SentCashLink is MessageMetadata.WithdrewCrypto -> TransactionKind.Withdraw MessageMetadata.DepositedCrypto -> TransactionKind.Deposit 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 8009e0d328..9d84abe316 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 @@ -3,6 +3,7 @@ package com.flipcash.shared.transactionhistory.internal 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.UserProfile import com.flipcash.shared.transactionhistory.R import com.flipcash.shared.transactionhistory.TransactionAvatar @@ -57,6 +58,7 @@ internal class TransactionItemMapper @Inject constructor( id = msg.id.hexEncodedString(), messageId = msg.id, title = convertTitle(resources, source) + ?: boostTitle(resources, meta, counterparty, source.boostChatName) ?: resolveTitle(resources, meta, msg.text, msg.textSubstitutions, counterparty, profiles), timestamp = msg.timestamp, avatar = avatar, @@ -85,6 +87,51 @@ private fun convertTitle( return resources.getString(R.string.title_activity_convert, from, to) } +/** + * The title of a payment that boosted a reaction, or null when [meta] carries no boost. + * + * The boost wins over every other title, the tip verb and the server's substitutions included, and + * it never falls back to the server text: while the counterparty's name is unresolved the row reads + * the no-name form, then gains the name when the profile lands. + * + * A split boost has no counterparty at all, so it is named for the chat it was sent in instead. + * [chatName] is only consulted then; a one-payee boost whose profile hasn't landed yet stays + * nameless rather than borrowing the chat's name. + */ +internal fun boostTitle( + resources: ResourceHelper, + meta: MessageMetadata?, + counterparty: UserProfile?, + chatName: String?, +): String? { + val boost = boostedReactionOf(meta) ?: return null + val emoji = boost.emoji.value + val name = counterparty?.displayName?.takeIf { it.isNotBlank() } + val isReceived = meta is MessageMetadata.ReceivedCrypto + val chat = chatName?.takeIf { it.isNotBlank() && isSplitBoost(meta) } + return when { + isReceived && name != null -> resources.getString(R.string.title_activity_boostFrom, emoji, name) + isReceived -> resources.getString(R.string.title_activity_boostReceived, emoji) + name != null -> resources.getString(R.string.title_activity_boostedFor, emoji, name) + chat != null -> resources.getString(R.string.title_activity_boostedIn, emoji, chat) + else -> resources.getString(R.string.title_activity_boosted, emoji) + } +} + +internal fun boostedReactionOf(meta: MessageMetadata?): BoostedReaction? = when (meta) { + is MessageMetadata.DirectlySentCrypto -> meta.boostedReaction + is MessageMetadata.ReceivedCrypto -> meta.boostedReaction + 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) { + is MessageMetadata.DirectlySentCrypto -> meta.phoneNumber == null + is MessageMetadata.ReceivedCrypto -> meta.phoneNumber == null + else -> false + } + /** * Resolves the row title. * 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 771f2caa48..74c6f65147 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 @@ -6,6 +6,12 @@ Sent to %1$s Received from %1$s + + Boosted %1$s for %2$s + Boost %1$s from %2$s + Boosted %1$s in %2$s + Boosted %1$s + Boost %1$s received %1$s → %2$s @@ -16,6 +22,10 @@ You tipped + You boosted %1$s + You boosted %1$s in %2$s + Boost %1$s received + Boost %1$s received in %2$s You received You sent You gave cash @@ -53,6 +63,7 @@ View in Chat + View Message 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 a0f3b58704..10d622a221 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 @@ -6,7 +6,10 @@ import com.flipcash.app.core.feed.MessageMetadata 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.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.Emoji import com.flipcash.shared.transactionhistory.internal.TransactionDetailsMapper import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.CurrencyCode @@ -41,6 +44,10 @@ class TransactionDetailsMapperTest { private val resources = FakeResourceHelper() .stub(R.string.title_activity_convert, "%1\$s → %2\$s") .stub(R.string.label_txnDetails_inPerson, "In Person") + .stub(R.string.title_txnDetails_youBoosted, "You boosted %1\$s") + .stub(R.string.title_txnDetails_youBoostedIn, "You boosted %1\$s in %2\$s") + .stub(R.string.title_txnDetails_boostReceived, "Boost %1\$s received") + .stub(R.string.title_txnDetails_boostReceivedIn, "Boost %1\$s received in %2\$s") private val mapper = TransactionDetailsMapper(resources) private val knownUserId: ID = listOf(0x0A, 0x0B, 0x0C) @@ -77,11 +84,13 @@ class TransactionDetailsMapperTest { token: Token? = null, toToken: Token? = null, amount: LocalFiat = twentyDollars, + chatName: String? = null, ): TransactionDetails = mapper.map( ActivityFeedMessageWithToken( feedMessage(metadata, text, state, amount), token = token, toToken = toToken, + boostChatName = chatName, ) to cached ) @@ -290,4 +299,100 @@ class TransactionDetailsMapperTest { } // endregion + + // --- Boosted reaction: the "View Message" button's inputs (D1-D3) --- + + private val boost = BoostedReaction( + chatId = ChatId(ByteArray(32) { 9 }), + messageId = 7L, + emoji = Emoji("\uD83D\uDD25"), + ) + + @Test + fun `D1 a boost with a counterparty carries the boost for View Message`() { + val details = map(MessageMetadata.DirectlySentCrypto(userId = knownUserId, boostedReaction = boost)) + assertEquals(boost, details.boostedReaction) + } + + @Test + fun `D2 a split boost carries the boost although there is nobody to view in chat`() { + val details = map(MessageMetadata.DirectlySentCrypto(boostedReaction = boost)) + assertEquals(boost, details.boostedReaction) + assertFalse(details.canViewInChat) + } + + @Test + fun `D3 without a boost the existing button logic is untouched`() { + val details = map(MessageMetadata.DirectlySentCrypto(userId = knownUserId)) + assertNull(details.boostedReaction) + assertTrue(details.canViewInChat) + } + + // --- Boosted reaction: detail header (H1-H11) --- + + private val fire = "\uD83D\uDD25" + private val sent = MessageMetadata.DirectlySentCrypto(userId = knownUserId, boostedReaction = boost) + private val received = MessageMetadata.ReceivedCrypto(userId = knownUserId, boostedReaction = boost) + private val unresolved = + MessageMetadata.DirectlySentCrypto(userId = listOf(1, 2, 3), boostedReaction = boost) + + private fun assertHeader(d: TransactionDetails, heading: String, subtitle: String?) { + assertEquals(heading, d.heading) + assertEquals(subtitle, d.subtitle) + } + + @Test + fun `H1 a sent boost in a stored group names the group under the name`() = + assertHeader( + map(sent, chatName = "Grampa's Photo Community"), + "Sally The Streamer", "You boosted $fire in Grampa's Photo Community", + ) + + @Test + fun `H2 a sent boost in a DM drops the chat`() = + assertHeader(map(sent), "Sally The Streamer", "You boosted $fire") + + @Test + fun `H3 a boost that is also a tip keeps the boost line`() { + val d = map(sent, text = "Tipped") + assertHeader(d, "Sally The Streamer", "You boosted $fire") + } + + @Test + fun `H4 a split boost headed by its stored group`() = + assertHeader( + map(MessageMetadata.DirectlySentCrypto(boostedReaction = boost), chatName = "Design Team"), + "Design Team", "You boosted $fire", + ) + + @Test + fun `H5 a split boost whose chat is not stored is headed by the line alone`() = + assertHeader(map(MessageMetadata.DirectlySentCrypto(boostedReaction = boost)), "You boosted $fire", null) + + @Test + fun `H6 a received boost in a stored group`() = + assertHeader( + map(received, chatName = "Design Team"), + "Sally The Streamer", "Boost $fire received in Design Team", + ) + + @Test + fun `H7 a received boost in a DM`() = + assertHeader(map(received), "Sally The Streamer", "Boost $fire received") + + @Test + fun `H8 a boost whose chat is not stored drops the chat`() = + assertHeader(map(sent), "Sally The Streamer", "You boosted $fire") + + @Test + fun `H9 an unresolved counterparty never borrows the group as heading`() = + assertHeader(map(unresolved, chatName = "Design Team"), "You boosted $fire in Design Team", null) + + @Test + fun `H10 without a boost the header is unchanged`() { + val d = map(MessageMetadata.DirectlySentCrypto(userId = knownUserId), chatName = "Design Team") + assertEquals("Sally The Streamer", d.heading) + assertNull(d.subtitle) + assertEquals(TransactionKind.Sent, d.kind) + } } 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 94ca34ae52..bd830104f6 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 @@ -7,7 +7,10 @@ import com.flipcash.app.core.feed.MessageState import com.flipcash.app.core.feed.MessageSubstitution 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.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.Emoji import com.flipcash.shared.transactionhistory.internal.TransactionItemMapper import com.getcode.opencode.model.core.ID import com.getcode.util.resources.FakeResourceHelper @@ -32,6 +35,11 @@ class TransactionItemMapperTest { .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") + .stub(R.string.title_activity_boostedFor, "Boosted %1\$s for %2\$s") + .stub(R.string.title_activity_boostFrom, "Boost %1\$s from %2\$s") + .stub(R.string.title_activity_boostedIn, "Boosted %1\$s in %2\$s") + .stub(R.string.title_activity_boosted, "Boosted %1\$s") + .stub(R.string.title_activity_boostReceived, "Boost %1\$s received") private val mapper = TransactionItemMapper(resources) private val knownUserId: ID = listOf(0x0A, 0x0B, 0x0C) @@ -433,4 +441,113 @@ class TransactionItemMapperTest { assertEquals(null, item.fee) } + + // --- Boosted reaction titles (cases 1-8 of the spec) --- + + private fun boost(emoji: String) = BoostedReaction( + chatId = ChatId(ByteArray(32) { 9 }), + messageId = 7L, + emoji = Emoji(emoji), + ) + + private fun title( + metadata: MessageMetadata?, + serverTitle: String, + profiles: Map = cached, + chatName: String? = null, + substitutions: List = emptyList(), + ): String { + val msg = feedMessage(metadata = metadata) + .copy(text = serverTitle, textSubstitutions = substitutions) + return mapper.map( + ActivityFeedMessageWithToken(msg, token = null, boostChatName = chatName) to profiles + ).title + } + + private val fire = "\uD83D\uDD25" + private val thumbs = "\uD83D\uDC4D\uD83C\uDFFD" + + @Test + fun `1 sent boost names the payee`() { + val meta = MessageMetadata.DirectlySentCrypto(userId = knownUserId, boostedReaction = boost(fire)) + assertEquals("Boosted $fire for Sally The Streamer", title(meta, "Sent to Sally The Streamer")) + } + + @Test + fun `2 received boost names the payer`() { + val meta = MessageMetadata.ReceivedCrypto(userId = knownUserId, boostedReaction = boost(fire)) + assertEquals("Boost $fire from Sally The Streamer", title(meta, "Received from Sally The Streamer")) + } + + @Test + fun `3 split boost is named for its chat`() { + val meta = MessageMetadata.DirectlySentCrypto(boostedReaction = boost(fire)) + assertEquals( + "Boosted $fire in Design Team", + title(meta, "Sent", profiles = emptyMap(), chatName = "Design Team"), + ) + } + + @Test + fun `4 split boost with no chat in the store is just the emoji`() { + val meta = MessageMetadata.DirectlySentCrypto(boostedReaction = boost(fire)) + assertEquals("Boosted $fire", title(meta, "Sent", profiles = emptyMap(), chatName = null)) + } + + @Test + fun `5 received boost with an unresolved name reads as received`() { + val meta = MessageMetadata.ReceivedCrypto(userId = knownUserId, boostedReaction = boost(fire)) + assertEquals("Boost $fire received", title(meta, "Received", profiles = emptyMap())) + } + + @Test + fun `6 a boost wins over the tip verb`() { + val meta = MessageMetadata.DirectlySentCrypto(userId = knownUserId, boostedReaction = boost(thumbs)) + assertEquals("Boosted $thumbs for Sally The Streamer", title(meta, "Tipped Sally The Streamer")) + } + + @Test + fun `7 a tip without a boost is unchanged`() { + val meta = MessageMetadata.DirectlySentCrypto(userId = knownUserId) + assertEquals("Tipped Sally The Streamer", title(meta, "Tipped")) + } + + @Test + fun `8 a receive without a boost is unchanged`() { + val meta = MessageMetadata.ReceivedCrypto(userId = knownUserId) + assertEquals("Received from Sally The Streamer", title(meta, "Received")) + } + + @Test + fun `a boost never shows the server title while the name is unresolved`() { + val meta = MessageMetadata.DirectlySentCrypto(userId = knownUserId, boostedReaction = boost(fire)) + assertEquals("Boosted $fire", title(meta, "Sent to Sally", profiles = emptyMap())) + } + + @Test + fun `a boost wins over server substitutions`() { + val meta = MessageMetadata.ReceivedCrypto(userId = knownUserId, boostedReaction = boost(fire)) + val subs = listOf(MessageSubstitution.UserId(fallback = "Sally", userId = knownUserId)) + assertEquals( + "Boost $fire from Sally The Streamer", + title(meta, "{0} sent you", substitutions = subs), + ) + } + + @Test + fun `a one-payee boost whose name has not landed does not borrow the chat name`() { + val meta = MessageMetadata.DirectlySentCrypto(userId = knownUserId, boostedReaction = boost(fire)) + assertEquals("Boosted $fire", title(meta, "Sent", profiles = emptyMap(), chatName = "Design Team")) + } + + @Test + fun `a split boost keeps the amount, sign and no-counterparty avatar`() { + val meta = MessageMetadata.DirectlySentCrypto(boostedReaction = boost(fire)) + val msg = feedMessage(metadata = meta) + val item = mapper.map(ActivityFeedMessageWithToken(msg, token = usdfToken()) to emptyMap()) + + assertEquals("-", item.signedAmountPrefix) + assertEquals(TransactionAvatar.TokenIcon(usdfToken()), item.avatar) + assertEquals(msg.amount, item.amount) + } }