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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<TransactionDetails>(relaxed = true) {
every { boostedReaction } returns boost
every { this@mockk.canViewInChat } returns canViewInChat
}
val transaction = mockk<ResolvedTransaction>(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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,6 +32,25 @@ class ChatMetadataDataSource @Inject constructor(
fun observeAll(): Flow<List<ChatMetadataEntity>> =
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<Map<String, String>> =
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<ChatMetadataEntity?> =
db?.chatMetadataDao()?.observeById(mapper.chatIdHex(chatId)) ?: emptyFlow()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ class MetadataMapper @Inject constructor(): Mapper<NotificationMetadata?, Messag
override fun map(from: NotificationMetadata?): MessageMetadata? {
from ?: return null
return when (from) {
is NotificationMetadata.DirectlySentCrypto -> 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())
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
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
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

Expand Down Expand Up @@ -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<Byte>(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<Byte>(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<Byte>(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<Byte>(0x0A)))
as MessageMetadata.DirectlySentCrypto
val received = roundTrip(NotificationMetadata.ReceivedCrypto(userId = listOf<Byte>(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<Byte>(10, 11, 12), sent.userId)
assertNull(sent.boostedReaction)
assertEquals("+15551234567", received.phoneNumber)
assertNull(received.boostedReaction)
}
}
Loading
Loading