From e6d42c7c4f225bff6bf4b72f0770fd21bd1e3b0a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 26 Oct 2026 11:15:32 -0400 Subject: [PATCH 1/4] feat(history): show boosted reactions in transaction history Keep BoostedReaction on MessageMetadata (DirectlySentCrypto, ReceivedCrypto) so it survives the local database, give boosted payments their own row titles, and add a View Message button on the detail screen that opens the boosted message. --- .../app/core/feed/ActivityFeedMessage.kt | 10 ++ .../transactions/TransactionDetailsScreen.kt | 3 + .../internal/TransactionDetailsViewModel.kt | 14 +++ .../TransactionDetailsViewModelTest.kt | 39 ++++++ .../sources/ChatMetadataDataSource.kt | 22 ++++ .../NotificationToEntityMapper.kt | 4 +- .../NotificationToEntityMapperTest.kt | 92 ++++++++++++++ .../ActivityFeedCoordinator.kt | 61 ++++++--- .../transactionhistory/TransactionDetails.kt | 5 + .../TransactionDetailsContent.kt | 12 +- .../internal/TransactionDetailsMapper.kt | 1 + .../internal/TransactionItemMapper.kt | 47 +++++++ .../src/main/res/values/strings.xml | 7 ++ .../TransactionDetailsMapperTest.kt | 31 +++++ .../TransactionItemMapperTest.kt | 117 ++++++++++++++++++ 15 files changed, 447 insertions(+), 18 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 697db2c7cf..f6434bef72 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,11 @@ data class ActivityFeedMessageWithToken( val message: ActivityFeedMessage, val token: Token?, val toToken: Token? = null, + /** + * The display name of the chat a split boost was sent in, from the local chat store; null when + * the entry isn't a split boost or the chat isn't stored (never fetched to name a row). + */ + val boostChatName: String? = null, ) data class ActivityFeedMessage( @@ -103,6 +109,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 +123,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..22df21ee40 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, _) = resolved val counterparty = counterpartyOf(msg.metadata) counterparty ?.takeUnless { profiles.containsKey(it.hexEncodedString()) } @@ -370,6 +383,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 +401,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..f362c3f56c 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 @@ -105,6 +106,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 +129,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..f00e500521 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( 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..f7692a96a8 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 @@ -80,6 +80,7 @@ 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 = boostedReactionOf(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 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..7c27239379 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 @@ -53,6 +59,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..2f3a381977 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 @@ -290,4 +293,32 @@ 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) + } } 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) + } } From 5a7a1fb5c57af928aa912b88239d1fb4ea81f10d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 26 Oct 2026 11:20:13 -0400 Subject: [PATCH 2/4] feat(history): boost heading and Reaction/Chat rows on the detail screen A boost is headed "You boosted" or "Boost received", the counterparty or a split boost's chat name moves under it, and the receipt gains Reaction and Chat rows after the date. The chat comes from the local store only; a DM is named for its partner. --- .../app/core/feed/ActivityFeedMessage.kt | 5 ++ .../sources/ChatMetadataDataSource.kt | 16 ++++ .../ActivityFeedCoordinator.kt | 19 ++++- .../transactionhistory/TransactionDetails.kt | 7 ++ .../TransactionDetailsContent.kt | 14 ++++ .../internal/TransactionDetailsMapper.kt | 27 +++++- .../src/main/res/values/strings.xml | 4 + .../TransactionDetailsMapperTest.kt | 84 +++++++++++++++++++ 8 files changed, 168 insertions(+), 8 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 f6434bef72..0d772ecf4f 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 @@ -25,6 +25,11 @@ data class ActivityFeedMessageWithToken( * the entry isn't a split boost or the chat isn't stored (never fetched to name a row). */ val boostChatName: String? = null, + /** + * Whether the boosted chat is in the local chat store at all. A DM is stored without a title, + * so [boostChatName] alone can't tell "a DM" from "not stored". + */ + val boostChatLocal: Boolean = false, ) data class ActivityFeedMessage( 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 2db45e47a3..c424594ac3 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 @@ -51,6 +51,22 @@ class ChatMetadataDataSource @Inject constructor( } .distinctUntilChanged() + /** + * Every chat the viewer is a member of, keyed by chat-id hex, with its title (null for a DM, + * whose title is the counterparty's profile). Unlike [observeMemberTitles] a titleless chat is + * present, so a caller can tell "stored, but a DM" from "not stored". + */ + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + fun observeMemberChats(): Flow> = + FlipcashDatabase.observeInstance() + .flatMapLatest { database -> + database?.chatMetadataDao()?.observeAll()?.map { rows -> + rows.filter { it.isMember } + .associate { it.chatIdHex to it.title?.takeIf { t -> t.isNotBlank() } } + } ?: 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/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 22df21ee40..1037be3f58 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 @@ -295,12 +295,18 @@ class ActivityFeedCoordinator @Inject internal constructor( 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? { + 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)] } + /** Whether a boost's chat is stored locally, whatever its kind. */ + private fun boostChatLocalOf(meta: MessageMetadata?, chatNames: Map): Boolean { + val chatId = boostedReactionOf(meta)?.chatId ?: return false + return chatNames.containsKey(chats.chatIdHex(chatId)) + } + private fun counterpartyOf(meta: NotificationMetadata?): ID? = when (meta) { is NotificationMetadata.DirectlySentCrypto -> meta.userId is NotificationMetadata.ReceivedCrypto -> meta.userId @@ -355,7 +361,7 @@ class ActivityFeedCoordinator @Inject internal constructor( msg: ActivityFeedMessage, resolved: Resolvers, ): ResolvedTransaction { - val (profiles, tokens, _) = resolved + val (profiles, tokens, chatNames) = resolved val counterparty = counterpartyOf(msg.metadata) counterparty ?.takeUnless { profiles.containsKey(it.hexEncodedString()) } @@ -375,6 +381,11 @@ 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)] }, + boostChatLocal = boostChatLocalOf(msg.metadata, chatNames), ) to profiles ), message = msg, @@ -390,7 +401,7 @@ class ActivityFeedCoordinator @Inject internal constructor( * * Starts empty so rows render before the chat store answers. */ - private val chatNames: Flow> = chats.observeMemberTitles() + private val chatNames: Flow> = chats.observeMemberChats() .onStart { emit(emptyMap()) } /** @@ -415,7 +426,7 @@ class ActivityFeedCoordinator @Inject internal constructor( private data class Resolvers( val profiles: Map, val tokens: Map, - val chatNames: Map, + val chatNames: Map, ) /** 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 f362c3f56c..1a379183a7 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 @@ -24,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), @@ -109,6 +111,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 reaction The boosted emoji, for the receipt's Reaction row. + * @param chatName The boosted chat's display name from the local store, for the Chat row; null + * (row omitted) when the chat isn't stored. */ data class TransactionDetails( val id: String, @@ -130,4 +135,6 @@ data class TransactionDetails( val canCancel: Boolean = false, val canViewInChat: Boolean = false, val boostedReaction: BoostedReaction? = null, + val reaction: String? = null, + val chatName: String? = 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 f00e500521..23be2cd538 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 @@ -253,6 +253,20 @@ private fun DetailCard(details: TransactionDetails, modifier: Modifier = Modifie label = stringResource(R.string.label_txnDetails_date), amount = details.timestamp.format("M/d/yyyy h:mm a"), ) + details.reaction?.let { + ReceiptLineItem( + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.label_txnDetails_reaction), + amount = it, + ) + } + details.chatName?.let { + ReceiptLineItem( + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.label_txnDetails_chat), + amount = it, + ) + } details.tokenAmount?.let { ReceiptLineItem( 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 f7692a96a8..51a341bd1a 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 @@ -45,6 +45,15 @@ 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() } + // A split boost has nobody to name, so the chat takes the name's place; a one-payee boost + // keeps the person. Not stored locally: nothing, the header as it is for no counterparty. + val boostSubtitle = counterpartyName + ?: source.boostChatName?.takeIf { it.isNotBlank() && isSplitBoost(meta) } + // A DM is stored without a title; its name is the partner's, as in the chat list. + val chatName = source.boostChatName?.takeIf { it.isNotBlank() } + ?: counterpartyName?.takeIf { source.boostChatLocal } return TransactionDetails( // Base58, not the row's hex: this is the value someone pastes into a support ticket or @@ -54,8 +63,14 @@ 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), + // A boost keeps its own kind heading ("You boosted"), so the name moves to the line + // under it rather than replacing it. + heading = counterparty?.displayName?.takeIf { it.isNotBlank() && boost == null }, + subtitle = if (boost != null) { + boostSubtitle ?: subtitleOf(kind, token, source.toToken) + } else { + subtitleOf(kind, token, source.toToken) + }, signedAmountPrefix = when { meta == null -> null meta.isOutgoing -> "-" @@ -80,7 +95,9 @@ 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 = boostedReactionOf(meta), + boostedReaction = boost, + reaction = boost?.emoji?.value, + chatName = chatName.takeIf { boost != null }, ) } @@ -117,12 +134,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/res/values/strings.xml b/apps/flipcash/shared/transaction-history/src/main/res/values/strings.xml index 7c27239379..6d7eb67688 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 @@ -22,6 +22,8 @@ You tipped + You boosted + Boost received You received You sent You gave cash @@ -58,6 +60,8 @@ Unknown + Reaction + Chat View in Chat View Message View Profile 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 2f3a381977..2d41e6b56d 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 @@ -80,11 +80,15 @@ class TransactionDetailsMapperTest { token: Token? = null, toToken: Token? = null, amount: LocalFiat = twentyDollars, + chatName: String? = null, + chatLocal: Boolean = chatName != null, ): TransactionDetails = mapper.map( ActivityFeedMessageWithToken( feedMessage(metadata, text, state, amount), token = token, toToken = toToken, + boostChatName = chatName, + boostChatLocal = chatLocal, ) to cached ) @@ -321,4 +325,84 @@ class TransactionDetailsMapperTest { assertNull(details.boostedReaction) assertTrue(details.canViewInChat) } + + // --- Boosted reaction: header and receipt rows (H1-H6, C1-C4) --- + + @Test + fun `H1 a sent boost is headed You boosted with the counterparty's name beneath`() { + val d = map(MessageMetadata.DirectlySentCrypto(userId = knownUserId, boostedReaction = boost)) + assertEquals(TransactionKind.Boosted, d.kind) + assertEquals(R.string.title_txnDetails_youBoosted, d.kind.headingRes) + assertNull(d.heading) + assertEquals("Sally The Streamer", d.subtitle) + } + + @Test + fun `H2 a received boost is headed Boost received`() { + val d = map(MessageMetadata.ReceivedCrypto(userId = knownUserId, boostedReaction = boost)) + assertEquals(TransactionKind.BoostReceived, d.kind) + assertEquals(R.string.title_txnDetails_boostReceived, d.kind.headingRes) + assertEquals("Sally The Streamer", d.subtitle) + } + + @Test + fun `H3 a boost that is also a tip keeps the boost heading`() { + val d = map( + MessageMetadata.DirectlySentCrypto(userId = knownUserId, boostedReaction = boost), + text = "Tipped", + ) + assertEquals(TransactionKind.Boosted, d.kind) + } + + @Test + fun `H4 a split boost names its chat where a counterparty would go`() { + val d = map(MessageMetadata.DirectlySentCrypto(boostedReaction = boost), chatName = "Design Team") + assertEquals(TransactionKind.Boosted, d.kind) + assertEquals("Design Team", d.subtitle) + } + + @Test + fun `H5 a split boost whose chat is not stored keeps the no-counterparty header`() { + val d = map(MessageMetadata.DirectlySentCrypto(boostedReaction = boost)) + assertEquals(TransactionKind.Boosted, d.kind) + assertNull(d.heading) + assertNull(d.subtitle) + } + + @Test + fun `H6 without a boost the header is unchanged`() { + val d = map(MessageMetadata.DirectlySentCrypto(userId = knownUserId)) + assertEquals(TransactionKind.Sent, d.kind) + assertEquals("Sally The Streamer", d.heading) + } + + @Test + fun `C1 a boost in a stored group shows the Reaction and Chat rows`() { + val d = map(MessageMetadata.DirectlySentCrypto(boostedReaction = boost), chatName = "Design Team") + assertEquals("\uD83D\uDD25", d.reaction) + assertEquals("Design Team", d.chatName) + } + + @Test + fun `C2 a boost whose chat is not stored shows Reaction only`() { + val d = map(MessageMetadata.DirectlySentCrypto(userId = knownUserId, boostedReaction = boost)) + assertEquals("\uD83D\uDD25", d.reaction) + assertNull(d.chatName) + } + + @Test + fun `C3 a DM boost names the chat for the partner`() { + val d = map( + MessageMetadata.DirectlySentCrypto(userId = knownUserId, boostedReaction = boost), + chatLocal = true, + ) + assertEquals("Sally The Streamer", d.chatName) + } + + @Test + fun `C4 without a boost neither row shows`() { + val d = map(MessageMetadata.DirectlySentCrypto(userId = knownUserId), chatName = "Design Team") + assertNull(d.reaction) + assertNull(d.chatName) + } } From 379570619e8cd3d5721d8b02bd3683d63d995053 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 26 Oct 2026 11:23:05 -0400 Subject: [PATCH 3/4] fix(history): keep the name as the detail heading for a boost The counterparty (or a split boost's chat) stays the large heading and the boost heading moves to the line under it; with no name, "You boosted" is the heading. --- .../internal/TransactionDetailsMapper.kt | 14 ++++++------- .../TransactionDetailsMapperTest.kt | 21 +++++++++++-------- 2 files changed, 19 insertions(+), 16 deletions(-) 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 51a341bd1a..d19311e897 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 @@ -48,8 +48,8 @@ internal class TransactionDetailsMapper @Inject constructor( val boost = boostedReactionOf(meta) val counterpartyName = counterparty?.displayName?.takeIf { it.isNotBlank() } // A split boost has nobody to name, so the chat takes the name's place; a one-payee boost - // keeps the person. Not stored locally: nothing, the header as it is for no counterparty. - val boostSubtitle = counterpartyName + // keeps the person. Not stored locally: no name, and the kind heading stands alone. + val boostName = counterpartyName ?: source.boostChatName?.takeIf { it.isNotBlank() && isSplitBoost(meta) } // A DM is stored without a title; its name is the partner's, as in the chat list. val chatName = source.boostChatName?.takeIf { it.isNotBlank() } @@ -63,11 +63,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. - // A boost keeps its own kind heading ("You boosted"), so the name moves to the line - // under it rather than replacing it. - heading = counterparty?.displayName?.takeIf { it.isNotBlank() && boost == null }, - subtitle = if (boost != null) { - boostSubtitle ?: subtitleOf(kind, token, source.toToken) + // The name stays the large heading; a boost's own heading ("You boosted") goes on the + // line under it. With no name, that heading stands alone. + heading = if (boost != null) boostName else counterpartyName, + subtitle = if (boost != null && boostName != null) { + resources.getString(kind.headingRes) } else { subtitleOf(kind, token, source.toToken) }, 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 2d41e6b56d..3c50d89d95 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 @@ -44,6 +44,8 @@ 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") + .stub(R.string.title_txnDetails_boostReceived, "Boost received") private val mapper = TransactionDetailsMapper(resources) private val knownUserId: ID = listOf(0x0A, 0x0B, 0x0C) @@ -329,20 +331,20 @@ class TransactionDetailsMapperTest { // --- Boosted reaction: header and receipt rows (H1-H6, C1-C4) --- @Test - fun `H1 a sent boost is headed You boosted with the counterparty's name beneath`() { + fun `H1 a sent boost keeps the name as heading with You boosted beneath`() { val d = map(MessageMetadata.DirectlySentCrypto(userId = knownUserId, boostedReaction = boost)) assertEquals(TransactionKind.Boosted, d.kind) assertEquals(R.string.title_txnDetails_youBoosted, d.kind.headingRes) - assertNull(d.heading) - assertEquals("Sally The Streamer", d.subtitle) + assertEquals("Sally The Streamer", d.heading) + assertEquals("You boosted", d.subtitle) } @Test - fun `H2 a received boost is headed Boost received`() { + fun `H2 a received boost has Boost received beneath the name`() { val d = map(MessageMetadata.ReceivedCrypto(userId = knownUserId, boostedReaction = boost)) assertEquals(TransactionKind.BoostReceived, d.kind) - assertEquals(R.string.title_txnDetails_boostReceived, d.kind.headingRes) - assertEquals("Sally The Streamer", d.subtitle) + assertEquals("Sally The Streamer", d.heading) + assertEquals("Boost received", d.subtitle) } @Test @@ -355,14 +357,15 @@ class TransactionDetailsMapperTest { } @Test - fun `H4 a split boost names its chat where a counterparty would go`() { + fun `H4 a split boost puts its chat name where a counterparty would go`() { val d = map(MessageMetadata.DirectlySentCrypto(boostedReaction = boost), chatName = "Design Team") assertEquals(TransactionKind.Boosted, d.kind) - assertEquals("Design Team", d.subtitle) + assertEquals("Design Team", d.heading) + assertEquals("You boosted", d.subtitle) } @Test - fun `H5 a split boost whose chat is not stored keeps the no-counterparty header`() { + fun `H5 a split boost whose chat is not stored is headed You boosted alone`() { val d = map(MessageMetadata.DirectlySentCrypto(boostedReaction = boost)) assertEquals(TransactionKind.Boosted, d.kind) assertNull(d.heading) From 99d0f544b18998eb8071de60e153c119f3f99457 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 26 Oct 2026 12:58:55 -0400 Subject: [PATCH 4/4] fix(history): keep the name as the detail heading and put the boost line under it The boost verb moves to the subtitle, and the Reaction and Chat card rows are dropped; the emoji and group now ride in the subtitle sentence. --- .../app/core/feed/ActivityFeedMessage.kt | 10 +- .../sources/ChatMetadataDataSource.kt | 16 --- .../ActivityFeedCoordinator.kt | 13 +-- .../transactionhistory/TransactionDetails.kt | 5 - .../TransactionDetailsContent.kt | 17 +-- .../internal/TransactionDetailsMapper.kt | 57 ++++++--- .../src/main/res/values/strings.xml | 8 +- .../TransactionDetailsMapperTest.kt | 109 ++++++++---------- 8 files changed, 101 insertions(+), 134 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 0d772ecf4f..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 @@ -21,15 +21,11 @@ data class ActivityFeedMessageWithToken( val token: Token?, val toToken: Token? = null, /** - * The display name of the chat a split boost was sent in, from the local chat store; null when - * the entry isn't a split boost or the chat isn't stored (never fetched to name a row). + * 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, - /** - * Whether the boosted chat is in the local chat store at all. A DM is stored without a title, - * so [boostChatName] alone can't tell "a DM" from "not stored". - */ - val boostChatLocal: Boolean = false, ) data class ActivityFeedMessage( 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 c424594ac3..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 @@ -51,22 +51,6 @@ class ChatMetadataDataSource @Inject constructor( } .distinctUntilChanged() - /** - * Every chat the viewer is a member of, keyed by chat-id hex, with its title (null for a DM, - * whose title is the counterparty's profile). Unlike [observeMemberTitles] a titleless chat is - * present, so a caller can tell "stored, but a DM" from "not stored". - */ - @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) - fun observeMemberChats(): Flow> = - FlipcashDatabase.observeInstance() - .flatMapLatest { database -> - database?.chatMetadataDao()?.observeAll()?.map { rows -> - rows.filter { it.isMember } - .associate { it.chatIdHex to it.title?.takeIf { t -> t.isNotBlank() } } - } ?: 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/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 1037be3f58..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 @@ -295,18 +295,12 @@ class ActivityFeedCoordinator @Inject internal constructor( 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? { + 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)] } - /** Whether a boost's chat is stored locally, whatever its kind. */ - private fun boostChatLocalOf(meta: MessageMetadata?, chatNames: Map): Boolean { - val chatId = boostedReactionOf(meta)?.chatId ?: return false - return chatNames.containsKey(chats.chatIdHex(chatId)) - } - private fun counterpartyOf(meta: NotificationMetadata?): ID? = when (meta) { is NotificationMetadata.DirectlySentCrypto -> meta.userId is NotificationMetadata.ReceivedCrypto -> meta.userId @@ -385,7 +379,6 @@ class ActivityFeedCoordinator @Inject internal constructor( // DM boost too. Local store only, never fetched. boostChatName = boostedReactionOf(msg.metadata)?.chatId ?.let { chatNames[chats.chatIdHex(it)] }, - boostChatLocal = boostChatLocalOf(msg.metadata, chatNames), ) to profiles ), message = msg, @@ -401,7 +394,7 @@ class ActivityFeedCoordinator @Inject internal constructor( * * Starts empty so rows render before the chat store answers. */ - private val chatNames: Flow> = chats.observeMemberChats() + private val chatNames: Flow> = chats.observeMemberTitles() .onStart { emit(emptyMap()) } /** @@ -426,7 +419,7 @@ class ActivityFeedCoordinator @Inject internal constructor( private data class Resolvers( val profiles: Map, val tokens: Map, - val chatNames: Map, + val chatNames: Map, ) /** 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 1a379183a7..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 @@ -111,9 +111,6 @@ 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 reaction The boosted emoji, for the receipt's Reaction row. - * @param chatName The boosted chat's display name from the local store, for the Chat row; null - * (row omitted) when the chat isn't stored. */ data class TransactionDetails( val id: String, @@ -135,6 +132,4 @@ data class TransactionDetails( val canCancel: Boolean = false, val canViewInChat: Boolean = false, val boostedReaction: BoostedReaction? = null, - val reaction: String? = null, - val chatName: String? = 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 23be2cd538..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 @@ -183,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, ) } } @@ -253,20 +256,6 @@ private fun DetailCard(details: TransactionDetails, modifier: Modifier = Modifie label = stringResource(R.string.label_txnDetails_date), amount = details.timestamp.format("M/d/yyyy h:mm a"), ) - details.reaction?.let { - ReceiptLineItem( - modifier = Modifier.fillMaxWidth(), - label = stringResource(R.string.label_txnDetails_reaction), - amount = it, - ) - } - details.chatName?.let { - ReceiptLineItem( - modifier = Modifier.fillMaxWidth(), - label = stringResource(R.string.label_txnDetails_chat), - amount = it, - ) - } details.tokenAmount?.let { ReceiptLineItem( 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 d19311e897..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 @@ -47,13 +48,24 @@ internal class TransactionDetailsMapper @Inject constructor( val kind = kindOf(meta, msg.text) val boost = boostedReactionOf(meta) val counterpartyName = counterparty?.displayName?.takeIf { it.isNotBlank() } - // A split boost has nobody to name, so the chat takes the name's place; a one-payee boost - // keeps the person. Not stored locally: no name, and the kind heading stands alone. - val boostName = counterpartyName - ?: source.boostChatName?.takeIf { it.isNotBlank() && isSplitBoost(meta) } - // A DM is stored without a title; its name is the partner's, as in the chat list. - val chatName = source.boostChatName?.takeIf { it.isNotBlank() } - ?: counterpartyName?.takeIf { source.boostChatLocal } + 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 @@ -63,14 +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. - // The name stays the large heading; a boost's own heading ("You boosted") goes on the - // line under it. With no name, that heading stands alone. - heading = if (boost != null) boostName else counterpartyName, - subtitle = if (boost != null && boostName != null) { - resources.getString(kind.headingRes) - } else { - 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 -> "-" @@ -96,11 +105,25 @@ internal class TransactionDetailsMapper @Inject constructor( // open it with. canViewInChat = counterparty != null, boostedReaction = boost, - reaction = boost?.emoji?.value, - chatName = chatName.takeIf { boost != null }, ) } + /** + * 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 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 6d7eb67688..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 @@ -22,8 +22,10 @@ You tipped - You boosted - Boost received + 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 @@ -60,8 +62,6 @@ Unknown - Reaction - Chat View in Chat View Message View Profile 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 3c50d89d95..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 @@ -44,8 +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") - .stub(R.string.title_txnDetails_boostReceived, "Boost received") + .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) @@ -83,14 +85,12 @@ class TransactionDetailsMapperTest { toToken: Token? = null, amount: LocalFiat = twentyDollars, chatName: String? = null, - chatLocal: Boolean = chatName != null, ): TransactionDetails = mapper.map( ActivityFeedMessageWithToken( feedMessage(metadata, text, state, amount), token = token, toToken = toToken, boostChatName = chatName, - boostChatLocal = chatLocal, ) to cached ) @@ -328,84 +328,71 @@ class TransactionDetailsMapperTest { assertTrue(details.canViewInChat) } - // --- Boosted reaction: header and receipt rows (H1-H6, C1-C4) --- + // --- Boosted reaction: detail header (H1-H11) --- - @Test - fun `H1 a sent boost keeps the name as heading with You boosted beneath`() { - val d = map(MessageMetadata.DirectlySentCrypto(userId = knownUserId, boostedReaction = boost)) - assertEquals(TransactionKind.Boosted, d.kind) - assertEquals(R.string.title_txnDetails_youBoosted, d.kind.headingRes) - assertEquals("Sally The Streamer", d.heading) - assertEquals("You boosted", d.subtitle) - } + 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) - @Test - fun `H2 a received boost has Boost received beneath the name`() { - val d = map(MessageMetadata.ReceivedCrypto(userId = knownUserId, boostedReaction = boost)) - assertEquals(TransactionKind.BoostReceived, d.kind) - assertEquals("Sally The Streamer", d.heading) - assertEquals("Boost received", d.subtitle) + private fun assertHeader(d: TransactionDetails, heading: String, subtitle: String?) { + assertEquals(heading, d.heading) + assertEquals(subtitle, d.subtitle) } @Test - fun `H3 a boost that is also a tip keeps the boost heading`() { - val d = map( - MessageMetadata.DirectlySentCrypto(userId = knownUserId, boostedReaction = boost), - text = "Tipped", + 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", ) - assertEquals(TransactionKind.Boosted, d.kind) - } @Test - fun `H4 a split boost puts its chat name where a counterparty would go`() { - val d = map(MessageMetadata.DirectlySentCrypto(boostedReaction = boost), chatName = "Design Team") - assertEquals(TransactionKind.Boosted, d.kind) - assertEquals("Design Team", d.heading) - assertEquals("You boosted", d.subtitle) - } + fun `H2 a sent boost in a DM drops the chat`() = + assertHeader(map(sent), "Sally The Streamer", "You boosted $fire") @Test - fun `H5 a split boost whose chat is not stored is headed You boosted alone`() { - val d = map(MessageMetadata.DirectlySentCrypto(boostedReaction = boost)) - assertEquals(TransactionKind.Boosted, d.kind) - assertNull(d.heading) - assertNull(d.subtitle) + 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 `H6 without a boost the header is unchanged`() { - val d = map(MessageMetadata.DirectlySentCrypto(userId = knownUserId)) - assertEquals(TransactionKind.Sent, d.kind) - assertEquals("Sally The Streamer", d.heading) - } + 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 `C1 a boost in a stored group shows the Reaction and Chat rows`() { - val d = map(MessageMetadata.DirectlySentCrypto(boostedReaction = boost), chatName = "Design Team") - assertEquals("\uD83D\uDD25", d.reaction) - assertEquals("Design Team", d.chatName) - } + 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 `C2 a boost whose chat is not stored shows Reaction only`() { - val d = map(MessageMetadata.DirectlySentCrypto(userId = knownUserId, boostedReaction = boost)) - assertEquals("\uD83D\uDD25", d.reaction) - assertNull(d.chatName) - } + 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 `C3 a DM boost names the chat for the partner`() { - val d = map( - MessageMetadata.DirectlySentCrypto(userId = knownUserId, boostedReaction = boost), - chatLocal = true, - ) - assertEquals("Sally The Streamer", d.chatName) - } + 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 `C4 without a boost neither row shows`() { + fun `H10 without a boost the header is unchanged`() { val d = map(MessageMetadata.DirectlySentCrypto(userId = knownUserId), chatName = "Design Team") - assertNull(d.reaction) - assertNull(d.chatName) + assertEquals("Sally The Streamer", d.heading) + assertNull(d.subtitle) + assertEquals(TransactionKind.Sent, d.kind) } }