diff --git a/apps/flipcash/app/src/main/AndroidManifest.xml b/apps/flipcash/app/src/main/AndroidManifest.xml
index 0c8b07619..547302aab 100644
--- a/apps/flipcash/app/src/main/AndroidManifest.xml
+++ b/apps/flipcash/app/src/main/AndroidManifest.xml
@@ -162,6 +162,18 @@
android:scheme="https" />
+
+
+
+
+
+
+
+
+
diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt
index fb643991d..31ae98d68 100644
--- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt
+++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt
@@ -31,7 +31,7 @@ import com.flipcash.app.currency.RegionSelectionScreen
import com.flipcash.app.deposit.DepositFlowScreen
import com.flipcash.app.directsend.SendFlowScreen
import com.flipcash.app.invite.InviteContactScreen
-import com.flipcash.app.messenger.MessengerScreen
+import com.flipcash.app.messenger.ChatFlowScreen
import com.flipcash.app.messenger.ChatAmountEntryScreen
import com.flipcash.app.discovery.TokenDiscoveryScreen
import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEntryDecorator
@@ -96,10 +96,10 @@ fun appEntryProvider(
// Messaging
annotatedEntry { key ->
- MessengerScreen(key.e164, key.displayName)
+ ChatFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
}
annotatedEntry { key ->
- ChatAmountEntryScreen(key.e164, key.displayName)
+ ChatAmountEntryScreen(key.identifier)
}
// Tokens
diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt
index 4d04c9fca..531284166 100644
--- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt
+++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt
@@ -14,10 +14,12 @@ import com.flipcash.app.core.verification.VerificationResult
import com.flipcash.app.core.verification.VerificationStep
import com.flipcash.app.core.withdrawal.WithdrawalResult
import com.flipcash.app.core.withdrawal.WithdrawalStep
+import com.flipcash.app.core.chat.ChatStep
import com.flipcash.app.core.onboarding.OnboardingStep
import com.getcode.navigation.flow.FlowRoute
import com.getcode.navigation.flow.FlowRouteWithResult
import com.getcode.opencode.model.financial.Fiat
+import com.flipcash.services.models.chat.ChatId
import com.getcode.solana.keys.Mint
import com.getcode.ui.core.RestrictionType
import kotlinx.parcelize.Parcelize
@@ -236,20 +238,35 @@ sealed interface AppRoute : NavKey, Parcelable {
data object NavBarSettings : Menu, com.getcode.navigation.Sheet, com.getcode.navigation.WrapContentSheet
}
+ @Serializable
+ @Parcelize
+ sealed interface ChatIdentifier : Parcelable {
+ val key: String
+
+ @Serializable
+ @Parcelize
+ data class ByChatId(val chatId: ChatId) : ChatIdentifier {
+ override val key: String get() = chatId.toString()
+ }
+
+ @Serializable
+ @Parcelize
+ data class ByContact(val e164: String, val displayName: String, val chatId: ChatId? = null) : ChatIdentifier {
+ override val key: String get() = e164
+ }
+ }
+
@Serializable
@Parcelize
sealed interface Messaging : AppRoute {
@Serializable
- data class Chat(
- val e164: String,
- val displayName: String,
- ) : Messaging
+ data class Chat(val identifier: ChatIdentifier) : Messaging, FlowRoute {
+ override val initialStack: List
+ get() = listOf(ChatStep.Conversation)
+ }
@Serializable
- data class AmountEntry(
- val e164: String,
- val displayName: String,
- ) : Messaging
+ data class AmountEntry(val identifier: ChatIdentifier) : Messaging
}
@Serializable
diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt
new file mode 100644
index 000000000..ab86d3645
--- /dev/null
+++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt
@@ -0,0 +1,17 @@
+package com.flipcash.app.core.chat
+
+import android.os.Parcelable
+import com.getcode.navigation.flow.FlowStep
+import kotlinx.parcelize.Parcelize
+import kotlinx.serialization.Serializable
+
+@Serializable
+sealed interface ChatStep : FlowStep, Parcelable {
+ @Parcelize
+ @Serializable
+ data object Conversation : ChatStep
+
+ @Parcelize
+ @Serializable
+ data object AmountEntry : ChatStep
+}
diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt
index 3cd7319a1..70999b9ad 100644
--- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt
+++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt
@@ -2,6 +2,7 @@ package com.flipcash.app.core.navigation
import android.net.Uri
import android.os.Parcelable
+import com.flipcash.services.models.chat.ChatId
import com.getcode.solana.keys.Mint
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@@ -15,6 +16,8 @@ sealed interface DeeplinkType: Parcelable {
@Serializable data class TokenInfo(val mint: Mint): DeeplinkType, Navigatable
+ @Serializable data class Chat(val chatId: ChatId): DeeplinkType, Navigatable
+
@Serializable
data class EmailVerification(
val email: String,
diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt
index 0ba107fbf..23adbc0d7 100644
--- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt
+++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt
@@ -1,8 +1,10 @@
package com.flipcash.app.core.util
+import com.flipcash.services.models.chat.ChatId
import com.getcode.opencode.model.financial.Token
import com.getcode.solana.keys.Mint
import com.getcode.solana.keys.base58
+import com.getcode.utils.encodeBase64
import com.getcode.utils.urlEncode
object Linkify {
@@ -13,4 +15,5 @@ object Linkify {
fun tweet(message: String): String = "https://www.twitter.com/intent/tweet?text=${message.urlEncode()}"
fun tokenInfo(token: Token): String = tokenInfo(token.address)
fun tokenInfo(mint: Mint): String = "https://app.flipcash.com/token/${mint.base58()}"
+ fun chat(chatId: ChatId): String = "https://app.flipcash.com/chat/${chatId.bytes.encodeBase64(urlSafe = true)}"
}
\ No newline at end of file
diff --git a/apps/flipcash/features/direct-send/build.gradle.kts b/apps/flipcash/features/direct-send/build.gradle.kts
index a3629f34e..3401bdbc7 100644
--- a/apps/flipcash/features/direct-send/build.gradle.kts
+++ b/apps/flipcash/features/direct-send/build.gradle.kts
@@ -21,7 +21,9 @@ dependencies {
implementation(project(":apps:flipcash:shared:featureflags"))
implementation(project(":apps:flipcash:shared:payments"))
implementation(project(":apps:flipcash:shared:permissions"))
+ implementation(project(":apps:flipcash:shared:chat"))
implementation(project(":apps:flipcash:shared:contacts"))
+ implementation(project(":apps:flipcash:shared:phone"))
implementation(project(":apps:flipcash:shared:tokens"))
implementation(project(":services:flipcash"))
}
diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListItem.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListItem.kt
index 6e1e1fdd2..0b3de51f0 100644
--- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListItem.kt
+++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListItem.kt
@@ -1,8 +1,17 @@
package com.flipcash.app.directsend.internal
import com.flipcash.app.contacts.device.DeviceContact
+import com.flipcash.services.models.chat.ChatId
+import kotlin.time.Instant
internal sealed interface ContactListItem {
data class Header(val title: String) : ContactListItem
- data class ContactRow(val contact: DeviceContact, val isOnFlipcash: Boolean) : ContactListItem
+ data class ContactRow(
+ val contact: DeviceContact,
+ val isOnFlipcash: Boolean,
+ val lastMessagePreview: String? = null,
+ val unreadCount: Int = 0,
+ val chatId: ChatId? = null,
+ val lastActivity: Instant? = null,
+ ) : ContactListItem
}
diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt
index 00b56688b..7fd918c14 100644
--- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt
+++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt
@@ -13,13 +13,23 @@ import com.flipcash.app.core.send.SendStep
import com.flipcash.app.featureflags.FeatureFlag
import com.flipcash.app.featureflags.FeatureFlagController
import com.flipcash.app.payments.PurchaseMethodController
+import com.flipcash.app.phone.PhoneUtils
import com.flipcash.app.permissions.PickedContact
import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.features.directsend.R
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.chat.ChatType
+import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.user.UserManager
+import com.flipcash.shared.chat.ChatCoordinator
+import com.flipcash.shared.chat.ChatSummary
+import com.getcode.opencode.model.financial.Token
+import com.getcode.solana.keys.Mint
import com.getcode.manager.BottomBarAction
import com.getcode.manager.BottomBarManager
+import com.getcode.opencode.model.core.ID
import com.getcode.util.resources.ResourceHelper
+import com.getcode.utils.decodeBase58
import com.getcode.view.BaseViewModel
import com.getcode.view.LoadingSuccessState
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -42,7 +52,9 @@ internal class SendFlowViewModel @Inject constructor(
private val userManager: UserManager,
featureFlags: FeatureFlagController,
private val contactCoordinator: ContactCoordinator,
+ chatCoordinator: ChatCoordinator,
private val tokenCoordinator: TokenCoordinator,
+ private val phoneUtils: PhoneUtils,
private val resources: ResourceHelper,
purchaseMethodController: PurchaseMethodController,
) : BaseViewModel(
@@ -77,14 +89,12 @@ internal class SendFlowViewModel @Inject constructor(
data class ContactRemoved(val e164: String) : Event
data class SendInvite(val contact: DeviceContact) : Event
- data class NavigateToChat(val contact: DeviceContact) : Event
+ data class NavigateToChat(val identifier: AppRoute.ChatIdentifier) : Event
data class NavigateToDirectSend(val contact: DeviceContact) : Event
data object PresentDepositOptions : Event
data class NavigateToUsdfDepositOption(val route: AppRoute): Event
}
- private val messengerEnabled = featureFlags.observe(FeatureFlag.Messenger)
-
init {
combine(
userManager.state,
@@ -110,9 +120,13 @@ internal class SendFlowViewModel @Inject constructor(
combine(
contactCoordinator.state,
- stateFlow.map { it.searchState }.distinctUntilChanged().flatMapLatest { snapshotFlow { it.text } }
- ) { contactState, searchText ->
- generateListItems(contactState, searchText.toString())
+ stateFlow.map { it.searchState }.distinctUntilChanged().flatMapLatest { snapshotFlow { it.text } },
+ chatCoordinator.feed,
+ tokenCoordinator.tokens,
+ featureFlags.observe(FeatureFlag.Messenger),
+ ) { contactState, searchText, chatFeed, tokens, messengerOn ->
+ val tokensByMint = tokens.associateBy { it.address }
+ generateListItems(contactState, searchText.toString(), chatFeed, tokensByMint, messengerOn)
}.onEach { items ->
dispatchEvent(Event.OnItemsPopulated(items))
}.launchIn(viewModelScope)
@@ -163,10 +177,16 @@ internal class SendFlowViewModel @Inject constructor(
eventFlow
.filterIsInstance()
.map { it.contact }
- .onEach { (contact, isOnFlipcash) ->
+ .onEach { row ->
+ val (contact, isOnFlipcash) = row
if (isOnFlipcash) {
- if (messengerEnabled.value) {
- dispatchEvent(Event.NavigateToChat(contact))
+ if (featureFlags.get(FeatureFlag.Messenger)) {
+ val identifier = if (contact.e164.isNotEmpty()) {
+ AppRoute.ChatIdentifier.ByContact(contact.e164, contact.displayName, row.chatId)
+ } else {
+ AppRoute.ChatIdentifier.ByChatId(row.chatId!!)
+ }
+ dispatchEvent(Event.NavigateToChat(identifier))
} else {
if (!tokenCoordinator.hasGiveableBalance()) {
BottomBarManager.showInfo(
@@ -230,6 +250,9 @@ internal class SendFlowViewModel @Inject constructor(
private fun generateListItems(
contactState: ContactState,
searchString: String,
+ chatFeed: List,
+ tokensByMint: Map,
+ messengerEnabled: Boolean,
): List = buildList {
val allContacts = contactState.contacts.values.toList()
val filtered = if (searchString.isBlank()) {
@@ -241,16 +264,91 @@ internal class SendFlowViewModel @Inject constructor(
}
}
- val flipcash = filtered
- .filter { it.e164 in contactState.flipcashE164s }
- .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName })
+ val selfId = userManager.accountId
+ val contactsByE164 = contactState.contacts
+
+ val flipcashRows = if (messengerEnabled) {
+ val dmChats = chatFeed.filter { it.metadata.type == ChatType.DM }
+ // Index chat feed by chatId (base58) for reliable lookup via dmChatIds
+ val chatById = dmChats.associateBy { it.metadata.chatId.toString() }
+ // Track which chat IDs are consumed by device-contact rows
+ val consumedChatIds = mutableSetOf()
+
+ // 1. Device contacts on Flipcash — enriched with chat preview when a DM exists
+ val deviceContactRows = filtered
+ .filter { it.e164 in contactState.flipcashE164s }
+ .map { contact ->
+ val chatIdStr = contactState.dmChatIds[contact.e164]
+ val chat = chatIdStr?.let { chatById[it] }
+ if (chatIdStr != null) consumedChatIds += chatIdStr
+ val preview = chat?.let { formatPreview(it, selfId, tokensByMint) }
+ val chatId = chat?.metadata?.chatId
+ ?: chatIdStr?.let { runCatching { ChatId(it.decodeBase58()) }.getOrNull() }
+ ContactListItem.ContactRow(
+ contact = contact,
+ isOnFlipcash = true,
+ lastMessagePreview = preview,
+ unreadCount = chat?.unreadCount ?: 0,
+ chatId = chatId,
+ lastActivity = chat?.metadata?.lastActivity,
+ )
+ }
+
+ // 2. Non-contact DMs (chats not matched to a device contact)
+ val nonContactRows = dmChats
+ .filter { it.metadata.chatId.toString() !in consumedChatIds }
+ .mapNotNull { summary ->
+ val otherMember = summary.metadata.members
+ .firstOrNull { it.userId != selfId } ?: return@mapNotNull null
+ val phone = otherMember.userProfile.verifiedPhoneNumber
+ val formattedPhone = phone?.let { phoneUtils.formatNumber(it) }
+ val displayName = otherMember.userProfile.displayName?.takeIf { it.isNotBlank() }
+ ?: formattedPhone
+ ?: "Unknown Contact"
+
+ val contact = DeviceContact.unknownContact(
+ e164 = phone.orEmpty(),
+ displayName = displayName,
+ displayNumber = formattedPhone,
+ )
+ if (searchString.isNotBlank() &&
+ !contact.displayName.contains(searchString, ignoreCase = true)) {
+ return@mapNotNull null
+ }
+ val preview = formatPreview(summary, selfId, tokensByMint)
+ ContactListItem.ContactRow(
+ contact = contact,
+ isOnFlipcash = true,
+ lastMessagePreview = preview,
+ unreadCount = summary.unreadCount,
+ chatId = summary.metadata.chatId,
+ lastActivity = summary.metadata.lastActivity,
+ )
+ }
+
+ (deviceContactRows + nonContactRows)
+ .sortedWith(
+ compareByDescending { it.lastActivity }
+ .thenBy(String.CASE_INSENSITIVE_ORDER) { it.contact.displayName }
+ )
+ } else {
+ // Driven by device contacts on Flipcash
+ filtered
+ .filter { it.e164 in contactState.flipcashE164s }
+ .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName })
+ .map { ContactListItem.ContactRow(contact = it, isOnFlipcash = true) }
+ }
+
+ val flipcashE164s = flipcashRows.mapTo(mutableSetOf()) { it.contact.e164 }
+ .plus(contactState.flipcashE164s)
+
val other = filtered
- .filter { it.e164 !in contactState.flipcashE164s }
+ .filter { it.e164 !in flipcashE164s }
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName })
- if (flipcash.isNotEmpty()) {
+ if (flipcashRows.isNotEmpty()) {
add(ContactListItem.Header(resources.getString(R.string.title_flipcashContacts)))
- flipcash.forEach { add(ContactListItem.ContactRow(it, isOnFlipcash = true)) }
+ addAll(flipcashRows)
}
if (other.isNotEmpty()) {
add(ContactListItem.Header(resources.getString(R.string.title_nonFlipcashContacts)))
@@ -258,6 +356,28 @@ internal class SendFlowViewModel @Inject constructor(
}
}
+ private fun formatPreview(
+ summary: ChatSummary,
+ selfId: ID?,
+ tokensByMint: Map,
+ ): String? {
+ val lastMsg = summary.metadata.lastMessage ?: return null
+ val sentBySelf = lastMsg.senderId != null && lastMsg.senderId == selfId
+ return lastMsg.content.firstOrNull()?.let { content ->
+ when (content) {
+ is MessageContent.Text -> content.text.takeIf { it.isNotEmpty() }
+ is MessageContent.Cash -> {
+ val formatted = content.amount.formatted()
+ val name = content.tokenName.ifBlank {
+ tokensByMint[content.mint]?.name.orEmpty()
+ }
+ val label = if (name.isNotBlank()) "$formatted of $name" else formatted
+ if (sentBySelf) "You sent $label" else "You received $label"
+ }
+ }
+ }
+ }
+
companion object {
val updateStateForEvent: (Event) -> ((State) -> State) = { event ->
when (event) {
diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt
index bdfdac540..87c521421 100644
--- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt
+++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt
@@ -23,6 +23,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.GroupAdd
+import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.rounded.PersonRemove
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -102,13 +103,9 @@ internal fun ContactListScreen() {
LaunchedEffect(viewModel) {
viewModel.eventFlow
.filterIsInstance()
- .map { it.contact }
- .collect { contact ->
+ .collect { event ->
flowNavigator.navigate(
- AppRoute.Messaging.Chat(
- e164 = contact.e164,
- displayName = contact.displayName,
- )
+ AppRoute.Messaging.Chat(identifier = event.identifier)
)
}
}
@@ -120,8 +117,10 @@ internal fun ContactListScreen() {
.collect { contact ->
flowNavigator.navigate(
AppRoute.Messaging.AmountEntry(
- e164 = contact.e164,
- displayName = contact.displayName,
+ identifier = AppRoute.ChatIdentifier.ByContact(
+ e164 = contact.e164,
+ displayName = contact.displayName,
+ )
)
)
}
@@ -261,7 +260,7 @@ private fun ContactList(
key = { _, item ->
when (item) {
is ContactListItem.Header -> item.title
- is ContactListItem.ContactRow -> item.contact.e164
+ is ContactListItem.ContactRow -> item.chatId?.toString() ?: item.contact.e164
}
}
) { index, item ->
@@ -279,6 +278,9 @@ private fun ContactList(
ContactRowItem(
contact = item.contact,
isOnFlipcash = item.isOnFlipcash,
+ isNonContactDm = item.contact.isUnknown,
+ lastMessagePreview = item.lastMessagePreview,
+ unreadCount = item.unreadCount,
showDivider = !isLastInSection,
) {
onItemClick(item)
@@ -322,6 +324,9 @@ private fun ContactRowItem(
contact: DeviceContact,
isOnFlipcash: Boolean,
modifier: Modifier = Modifier,
+ isNonContactDm: Boolean = false,
+ lastMessagePreview: String? = null,
+ unreadCount: Int = 0,
showDivider: Boolean = true,
onClick: () -> Unit,
) {
@@ -341,13 +346,30 @@ private fun ContactRowItem(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3),
) {
- ContactAvatar(
- photoUri = contact.photoUri,
- displayName = contact.displayName,
- modifier = Modifier
- .requiredSize(CodeTheme.dimens.staticGrid.x8)
- .clip(CircleShape),
- )
+ if (isNonContactDm && contact.photoUri == null) {
+ Box(
+ modifier = Modifier
+ .requiredSize(CodeTheme.dimens.staticGrid.x8)
+ .clip(CircleShape)
+ .background(Brush.linearGradient(CodeTheme.colors.contactAvatar.colors)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Person,
+ contentDescription = null,
+ tint = CodeTheme.colors.textSecondary,
+ modifier = Modifier.size(CodeTheme.dimens.staticGrid.x5),
+ )
+ }
+ } else {
+ ContactAvatar(
+ photoUri = contact.photoUri,
+ displayName = contact.displayName,
+ modifier = Modifier
+ .requiredSize(CodeTheme.dimens.staticGrid.x8)
+ .clip(CircleShape),
+ )
+ }
Column(modifier = Modifier.weight(1f)) {
Text(
text = contact.displayName,
@@ -355,18 +377,28 @@ private fun ContactRowItem(
color = CodeTheme.colors.textMain,
)
Text(
- text = contact.displayNumber.ifEmpty { contact.e164 },
+ text = if (isOnFlipcash && !lastMessagePreview.isNullOrEmpty()) {
+ lastMessagePreview
+ } else {
+ contact.displayNumber.ifEmpty { contact.e164 }
+ },
style = CodeTheme.typography.textSmall,
color = CodeTheme.colors.textSecondary,
+ maxLines = 1,
+ overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
}
if (isOnFlipcash) {
- Icon(
- painter = painterResource(id = R.drawable.ic_chevron_right),
- contentDescription = null,
- tint = CodeTheme.colors.textSecondary,
- )
+ if (unreadCount > 0) {
+ UnreadBadge(count = unreadCount)
+ } else {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_chevron_right),
+ contentDescription = null,
+ tint = CodeTheme.colors.textSecondary,
+ )
+ }
} else {
Text(
modifier = Modifier
@@ -399,6 +431,18 @@ private fun ContactRowItem(
}
}
+@Composable
+private fun UnreadBadge(count: Int, modifier: Modifier = Modifier) {
+ Box(
+ modifier = modifier
+ .size(CodeTheme.dimens.grid.x4)
+ .background(
+ color = CodeTheme.colors.indicator,
+ shape = CircleShape,
+ ),
+ )
+}
+
@Preview
@PreviewWrapper(FlipcashThemeWrapper::class)
@Composable
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatAmountEntryScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatAmountEntryScreen.kt
index 1c1678a14..6b5a4303e 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatAmountEntryScreen.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatAmountEntryScreen.kt
@@ -3,39 +3,65 @@ package com.flipcash.app.messenger
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.tokens.TokenPurpose
import com.flipcash.app.core.ui.TokenSelectionPill
import com.flipcash.app.messenger.internal.ChatViewModel
import com.flipcash.features.messenger.R
+import com.flipcash.shared.amountentry.AmountEntryDelegate
import com.flipcash.shared.amountentry.AmountEntryScreen
import com.getcode.manager.BottomBarManager
import com.getcode.navigation.core.LocalCodeNavigator
-import com.getcode.navigation.extensions.flowScopedViewModel
import com.getcode.opencode.model.financial.Fiat
+import com.getcode.opencode.model.financial.Token
import com.getcode.ui.components.AppBarDefaults
import com.getcode.ui.components.AppBarWithTitle
import com.getcode.util.resources.LocalResources
+import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+/**
+ * Standalone amount entry screen — used when navigating directly from the contact list
+ * (messenger disabled). Creates its own [ChatViewModel] scoped to this nav entry.
+ */
@Composable
-fun ChatAmountEntryScreen(e164: String, displayName: String) {
- val viewModel = flowScopedViewModel(e164)
+fun ChatAmountEntryScreen(identifier: AppRoute.ChatIdentifier) {
+ val viewModel = hiltViewModel()
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
- val navigator = LocalCodeNavigator.current
- val resources = LocalResources.current
- LaunchedEffect(Unit) {
- if (state.chattingWith == null) {
- viewModel.dispatchEvent(ChatViewModel.Event.OnChatOpened(e164, displayName))
- }
+ LaunchedEffect(viewModel, identifier) {
+ viewModel.dispatchEvent(ChatViewModel.Event.OnChatOpened(identifier))
}
- LaunchedEffect(state.resolveState) {
- if (state.resolveState is ChatViewModel.ResolveState.Failed) {
+ ChatAmountEntryContent(
+ amountDelegate = viewModel.amountDelegate,
+ resolveState = state.resolveState,
+ chattingWithName = state.chattingWith?.displayName,
+ token = state.token,
+ eventFlow = viewModel.eventFlow,
+ onConfirm = { viewModel.dispatchEvent(ChatViewModel.Event.OnConfirmRequested) },
+ )
+}
+
+@Composable
+internal fun ChatAmountEntryContent(
+ amountDelegate: AmountEntryDelegate,
+ resolveState: ChatViewModel.ResolveState,
+ chattingWithName: String?,
+ token: Token?,
+ eventFlow: Flow,
+ onConfirm: () -> Unit,
+ onSendComplete: (() -> Unit)? = null,
+) {
+ val navigator = LocalCodeNavigator.current
+ val resources = LocalResources.current
+
+ LaunchedEffect(resolveState) {
+ if (resolveState is ChatViewModel.ResolveState.Failed) {
BottomBarManager.showAlert(
title = resources.getString(R.string.error_title_contactNotOnFlipcash),
message = resources.getString(R.string.error_description_contactNotOnFlipcash),
@@ -44,8 +70,8 @@ fun ChatAmountEntryScreen(e164: String, displayName: String) {
}
}
- LaunchedEffect(viewModel) {
- viewModel.eventFlow
+ LaunchedEffect(eventFlow) {
+ eventFlow
.filterIsInstance()
.onEach { event ->
BottomBarManager.showInfo(
@@ -53,22 +79,22 @@ fun ChatAmountEntryScreen(e164: String, displayName: String) {
message = resources.getString(
R.string.prompt_description_fundsSentToContact,
event.amount.formatted(rule = Fiat.FormattingRule.Truncated),
- state.chattingWith?.displayName ?: resources.getString(R.string.subtitle_yourSelectedRecipient),
+ chattingWithName ?: resources.getString(R.string.subtitle_yourSelectedRecipient),
),
- onDismiss = { navigator.pop() }
+ onDismiss = { onSendComplete?.invoke() ?: navigator.pop() }
)
}.launchIn(this)
}
AmountEntryScreen(
- controller = viewModel.amountDelegate,
- onConfirm = { viewModel.dispatchEvent(ChatViewModel.Event.OnConfirmRequested) },
+ controller = amountDelegate,
+ onConfirm = onConfirm,
onChangeCurrency = { navigator.push(AppRoute.Main.RegionSelection) },
appBar = {
AppBarWithTitle(
isInModal = true,
title = {
- TokenSelectionPill(state.token) {
+ TokenSelectionPill(token) {
navigator.push(
AppRoute.Sheets.TokenSelection(TokenPurpose.Select)
)
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt
new file mode 100644
index 000000000..e47bb7cc4
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt
@@ -0,0 +1,97 @@
+package com.flipcash.app.messenger
+
+import android.os.Parcelable
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation3.runtime.NavEntry
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.entryProvider
+import com.flipcash.app.core.AppRoute
+import com.flipcash.app.core.chat.ChatStep
+import com.flipcash.app.messenger.internal.ChatViewModel
+import com.flipcash.app.messenger.internal.screens.MessengerScreen
+import com.getcode.navigation.annotatedEntry
+import com.getcode.navigation.flowAnnotatedEntry
+import com.getcode.navigation.core.LocalCodeNavigator
+import com.getcode.navigation.flow.FlowHost
+import com.getcode.navigation.flow.flowSharedViewModel
+import com.getcode.navigation.flow.rememberFlowNavigator
+import com.getcode.navigation.flow.rememberInitialStack
+import com.getcode.navigation.results.NavResultStateRegistry
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.map
+
+@Composable
+fun ChatFlowScreen(
+ route: AppRoute.Messaging.Chat,
+ resultStateRegistry: NavResultStateRegistry,
+) {
+ val navigator = LocalCodeNavigator.current
+
+ FlowHost(
+ initialStack = route.rememberInitialStack(),
+ resultStateRegistry = resultStateRegistry,
+ onExit = { _, _ -> navigator.pop() },
+ entryProvider = chatEntryProvider(route.identifier),
+ )
+}
+
+@Composable
+private fun chatEntryProvider(
+ identifier: AppRoute.ChatIdentifier,
+): (NavKey) -> NavEntry = entryProvider {
+ annotatedEntry {
+ FlowConversationScreen(identifier)
+ }
+ flowAnnotatedEntry {
+ FlowAmountEntryScreen()
+ }
+}
+
+@Composable
+private fun FlowConversationScreen(identifier: AppRoute.ChatIdentifier) {
+ val viewModel = flowSharedViewModel()
+ val flowNavigator = rememberFlowNavigator()
+
+ LaunchedEffect(viewModel, identifier) {
+ viewModel.dispatchEvent(ChatViewModel.Event.OnChatOpened(identifier))
+ }
+
+ LaunchedEffect(viewModel) {
+ viewModel.eventFlow
+ .filterIsInstance()
+ .collect {
+ flowNavigator.navigateTo(ChatStep.AmountEntry)
+ }
+ }
+
+ LaunchedEffect(viewModel) {
+ viewModel.eventFlow
+ .filterIsInstance()
+ .map { it.route }
+ .collect { route ->
+ flowNavigator.navigate(route)
+ }
+ }
+
+ MessengerScreen(viewModel)
+}
+
+@Composable
+private fun FlowAmountEntryScreen() {
+ val viewModel = flowSharedViewModel()
+ val state by viewModel.stateFlow.collectAsStateWithLifecycle()
+ val flowNavigator = rememberFlowNavigator()
+
+ ChatAmountEntryContent(
+ amountDelegate = viewModel.amountDelegate,
+ resolveState = state.resolveState,
+ chattingWithName = state.chattingWith?.displayName,
+ token = state.token,
+ eventFlow = viewModel.eventFlow,
+ onConfirm = { viewModel.dispatchEvent(ChatViewModel.Event.OnConfirmRequested) },
+ onSendComplete = { flowNavigator.back() },
+ )
+}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MessengerScreen.kt
deleted file mode 100644
index 370e9be1b..000000000
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MessengerScreen.kt
+++ /dev/null
@@ -1,44 +0,0 @@
-package com.flipcash.app.messenger
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import com.flipcash.app.core.AppRoute
-import com.flipcash.app.messenger.internal.ChatViewModel
-import com.flipcash.app.messenger.internal.screens.MessengerScreen
-import com.getcode.navigation.core.LocalCodeNavigator
-import com.getcode.navigation.extensions.flowScopedViewModel
-import kotlinx.coroutines.flow.filterIsInstance
-import kotlinx.coroutines.flow.map
-
-@Composable
-fun MessengerScreen(e164: String, displayName: String) {
- val viewModel = flowScopedViewModel(e164)
- val navigator = LocalCodeNavigator.current
-
- LaunchedEffect(Unit) {
- viewModel.dispatchEvent(ChatViewModel.Event.OnChatOpened(e164, displayName))
- }
-
- LaunchedEffect(viewModel) {
- viewModel.eventFlow
- .filterIsInstance()
- .map { it.contact }
- .collect { contact ->
- navigator.push(AppRoute.Messaging.AmountEntry(
- e164 = contact.e164,
- displayName = contact.displayName,
- ))
- }
- }
-
- LaunchedEffect(viewModel) {
- viewModel.eventFlow
- .filterIsInstance()
- .map { it.route }
- .collect { route ->
- navigator.push(route)
- }
- }
-
- MessengerScreen(viewModel)
-}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
index 8af76d6b7..b14257351 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
@@ -10,16 +10,19 @@ import androidx.paging.insertSeparators
import com.flipcash.app.contacts.ContactCoordinator
import com.flipcash.app.contacts.device.DeviceContact
import com.flipcash.app.core.AppRoute
+import com.flipcash.app.core.AppRoute.ChatIdentifier
import com.flipcash.app.core.extensions.onResult
import com.flipcash.app.core.ui.ConfirmationStyle
import com.flipcash.app.messenger.internal.screens.components.ChatListItem
+import com.flipcash.app.messenger.internal.screens.components.ReceiptStatus
import com.flipcash.app.messenger.internal.screens.components.SeparatorConfig
import com.flipcash.app.payments.PurchaseMethodController
import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.features.messenger.R
+import com.flipcash.services.models.buildDmPaymentMetadata
import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.chat.DeliveryStatus
import com.flipcash.services.models.chat.MessageContent
-import com.flipcash.services.models.chat.TypingState
import com.flipcash.services.user.UserManager
import com.flipcash.shared.amountentry.AmountEntryDelegate
import com.flipcash.shared.amountentry.AmountEntryStyle
@@ -32,6 +35,7 @@ import com.getcode.opencode.exchange.Exchange
import com.getcode.opencode.exchange.VerifiedFiatCalculator
import com.getcode.opencode.model.core.errors.ComputeVerifiedFiatError
import com.getcode.opencode.model.financial.Fiat
+import com.getcode.opencode.model.financial.Limits
import com.getcode.opencode.model.financial.SendLimit
import com.getcode.opencode.model.financial.Token
import com.getcode.solana.keys.PublicKey
@@ -97,11 +101,12 @@ internal class ChatViewModel @Inject constructor(
val resolveState: ResolveState = ResolveState.Pending,
val sendProgress: LoadingSuccessState = LoadingSuccessState(),
val token: Token? = null,
- val limits: com.getcode.opencode.model.financial.Limits? = null,
+ val limits: Limits? = null,
+ val hasPayment: Boolean = false,
)
sealed interface Event {
- data class OnChatOpened(val e164: String, val displayName: String) : Event
+ data class OnChatOpened(val identifier: ChatIdentifier) : Event
data class OnContactFound(val contact: DeviceContact): Event
data class ChatFound(val chatId: ChatId) : Event
data object OnSendCash: Event
@@ -130,28 +135,52 @@ internal class ChatViewModel @Inject constructor(
data class SendComplete(val amount: Fiat) : Event
data class TokenUpdated(val token: Token) : Event
- data class LimitsChanged(val limits: com.getcode.opencode.model.financial.Limits?) : Event
+ data class LimitsChanged(val limits: Limits?) : Event
+ data class HasPaymentBeenMade(val hasPayment: Boolean) : Event
+ data class AdvanceReadPointer(val messageId: Long) : Event
}
private val separatorConfig = SeparatorConfig.TimeGap()
@OptIn(ExperimentalCoroutinesApi::class)
- val messages: Flow> = stateFlow
+ private val messageStream = stateFlow.mapNotNull { it.chatId }.distinctUntilChanged()
+ .flatMapLatest { chatCoordinator.observeMessagesPaged(it) }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val otherReadPointer = stateFlow
.map { it.chatId }
.filterNotNull()
.distinctUntilChanged()
- .flatMapLatest { chatId ->
- chatCoordinator.observeMessagesPaged(chatId)
- }
+ .flatMapLatest { chatCoordinator.observeOtherReadPointer(it) }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0L)
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val messages: Flow> = messageStream
.map { pagingData ->
pagingData.flatMap { message ->
message.content.mapIndexed { index, content ->
+ val enriched = if (content is MessageContent.Cash && content.tokenName.isBlank()) {
+ val token = tokenCoordinator.getTokenMetadata(content.mint).getOrNull()?.token
+ if (token != null) {
+ content.copy(tokenName = token.name, tokenImageUrl = token.imageUrl)
+ } else content
+ } else content
+
+ val receiptStatus = if (message.isFromSelf) {
+ when (message.deliveryStatus) {
+ DeliveryStatus.SENDING -> ReceiptStatus.SENDING
+ DeliveryStatus.FAILED -> ReceiptStatus.FAILED
+ DeliveryStatus.SENT -> ReceiptStatus.SENT
+ }
+ } else null
+
ChatListItem.ContentBubble(
messageId = message.messageId,
contentIndex = index,
- content = content,
+ content = enriched,
isFromSelf = message.isFromSelf,
timestamp = message.timestamp,
+ receiptStatus = receiptStatus,
)
}
}.insertSeparators { before: ChatListItem.ContentBubble?, after: ChatListItem.ContentBubble? ->
@@ -188,18 +217,74 @@ internal class ChatViewModel @Inject constructor(
)
init {
+ // Token observation
+ tokenCoordinator.observeSelectedTokenMint()
+ .flatMapLatest { mint ->
+ tokenCoordinator.tokenBalances.map { tokens ->
+ tokens.find { it.token.address == mint }
+ }
+ }
+ .filterNotNull()
+ .onEach { tokenWithBalance ->
+ dispatchEvent(Event.TokenUpdated(tokenWithBalance.token))
+ }.launchIn(viewModelScope)
+
+ exchange.observePreferredRate()
+ .onEach { rate ->
+ val currency = exchange.getCurrency(rate.currency.name)
+ if (currency != null) {
+ amountDelegate.onCurrencyChanged(currency)
+ }
+ }.launchIn(viewModelScope)
+
+ transactionController.limits
+ .onEach { dispatchEvent(Event.LimitsChanged(it)) }
+ .launchIn(viewModelScope)
+
+ // Unified chat open handler — resolves chatId and contact from the identifier
eventFlow
.filterIsInstance()
- .map { (e164, _) -> contactCoordinator.lookupContact(e164) }
- .onResult(
- onSuccess = { contact ->
- dispatchEvent(Event.OnContactFound(contact))
- },
- onError = {
- // TODO:
+ .onEach { event ->
+ val identifier = event.identifier
+
+ // 1. Resolve chatId
+ val chatId = when (identifier) {
+ is ChatIdentifier.ByContact -> identifier.chatId
+ ?: chatCoordinator.getChatId(
+ DeviceContact.unknownContact(identifier.e164)
+ ).getOrNull()
+ is ChatIdentifier.ByChatId -> identifier.chatId
}
- ).launchIn(viewModelScope)
+ if (chatId != null) {
+ dispatchEvent(Event.ChatFound(chatId))
+ chatCoordinator.dismissNotifications(chatId)
+ }
+
+ // 2. Resolve contact
+ when (identifier) {
+ is ChatIdentifier.ByContact -> {
+ val contact = contactCoordinator.lookupContact(identifier.e164).getOrElse {
+ DeviceContact.unknownContact(
+ e164 = identifier.e164,
+ displayName = identifier.displayName.takeIf { it.isNotBlank() },
+ )
+ }
+ dispatchEvent(Event.OnContactFound(contact))
+ }
+ is ChatIdentifier.ByChatId -> {
+ val contact = contactCoordinator.lookupContactByDmChatId(
+ identifier.chatId.toString()
+ )
+ if (contact != null) {
+ dispatchEvent(Event.OnContactFound(contact))
+ }
+ }
+ }
+ }
+ .launchIn(viewModelScope)
+
+ // Resolve owner authority for sending cash
eventFlow
.filterIsInstance()
.map { it.contact }
@@ -214,24 +299,22 @@ internal class ChatViewModel @Inject constructor(
}
).launchIn(viewModelScope)
- stateFlow
- .mapNotNull { it.chattingWith }
- .map { chatCoordinator.getChatId(it) }
- .onResult(
- onSuccess = { chatId ->
- trace("chatID found => $chatId")
- dispatchEvent(Event.ChatFound(chatId))
- },
- onError = {
- // TODO:
- }
- ).launchIn(viewModelScope)
-
+ // trigger message update fetch on open
stateFlow.map { it.chatId }
-
.filterNotNull()
+ .distinctUntilChanged()
+ .onEach { chatId ->
+ chatCoordinator.loadMessages(chatId)
+ }
+ .launchIn(viewModelScope)
- .onEach { chatCoordinator.loadMessages(it) }
+ // Advance read pointer when user scrolls to messages
+ eventFlow
+ .filterIsInstance()
+ .onEach { event ->
+ val chatId = stateFlow.value.chatId ?: return@onEach
+ chatCoordinator.advanceReadPointer(chatId, event.messageId)
+ }
.launchIn(viewModelScope)
// Observe typing indicators once chatId is known
@@ -241,6 +324,20 @@ internal class ChatViewModel @Inject constructor(
.onEach { typists -> dispatchEvent(Event.TypistsUpdated(typists)) }
.launchIn(viewModelScope)
+ // Track whether any payment has been exchanged in this chat
+ stateFlow.map { it.chatId }
+ .filterNotNull()
+ .distinctUntilChanged()
+ .flatMapLatest { chatId ->
+ chatCoordinator.observeMessages(chatId)
+ .map { messages ->
+ messages.any { msg -> msg.content.any { it is MessageContent.Cash } }
+ }
+ .distinctUntilChanged()
+ }
+ .onEach { dispatchEvent(Event.HasPaymentBeenMade(it)) }
+ .launchIn(viewModelScope)
+
// Send text message
eventFlow.filterIsInstance()
.map { stateFlow.value.chatInputState }
@@ -259,30 +356,7 @@ internal class ChatViewModel @Inject constructor(
)
.launchIn(viewModelScope)
- // Token observation
- tokenCoordinator.observeSelectedTokenMint()
- .flatMapLatest { mint ->
- tokenCoordinator.tokenBalances.map { tokens ->
- tokens.find { it.token.address == mint }
- }
- }
- .filterNotNull()
- .onEach { tokenWithBalance ->
- dispatchEvent(Event.TokenUpdated(tokenWithBalance.token))
- }.launchIn(viewModelScope)
-
- exchange.observePreferredRate()
- .onEach { rate ->
- val currency = exchange.getCurrency(rate.currency.name)
- if (currency != null) {
- amountDelegate.onCurrencyChanged(currency)
- }
- }.launchIn(viewModelScope)
-
- transactionController.limits
- .onEach { dispatchEvent(Event.LimitsChanged(it)) }
- .launchIn(viewModelScope)
-
+ // confirmation of amount and checks
eventFlow.filterIsInstance()
.onEach { onConfirmRequested() }
.launchIn(viewModelScope)
@@ -305,6 +379,7 @@ internal class ChatViewModel @Inject constructor(
)
return@onEach
}
+ amountDelegate.reset()
dispatchEvent(Event.NavigateToAmountEntry(contact))
}.launchIn(viewModelScope)
@@ -350,11 +425,18 @@ internal class ChatViewModel @Inject constructor(
return@launch
}
+ val appMetadataBytes = buildDmPaymentMetadata(
+ chatId = stateFlow.value.chatId,
+ sourcePhone = contactCoordinator.selfPhone,
+ destinationPhone = stateFlow.value.chattingWith?.e164,
+ )
+
transactionController.directTransfer(
amount = verifiedFiat,
token = token,
source = source,
destinationOwner = destination,
+ appMetadata = appMetadataBytes,
).fold(
onSuccess = {
tokenCoordinator.subtract(token, verifiedFiat.localFiat)
@@ -363,6 +445,7 @@ internal class ChatViewModel @Inject constructor(
onFailure = { Result.failure(it) }
).onSuccess { amount ->
dispatchEvent(Event.SendStateUpdated(success = true))
+ stateFlow.value.chatId?.let { chatCoordinator.loadMessages(it) }
delay(400)
dispatchEvent(
Dispatchers.Main,
@@ -472,6 +555,8 @@ internal class ChatViewModel @Inject constructor(
is Event.SendComplete -> { state -> state }
is Event.TokenUpdated -> { state -> state.copy(token = event.token) }
is Event.LimitsChanged -> { state -> state.copy(limits = event.limits) }
+ is Event.HasPaymentBeenMade -> { state -> state.copy(hasPayment = event.hasPayment) }
+ is Event.AdvanceReadPointer -> { state -> state }
}
}
}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt
index 28cad8086..6052d81a8 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt
@@ -1,12 +1,16 @@
package com.flipcash.app.messenger.internal.screens
import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -29,6 +33,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.layout.onSizeChanged
@@ -45,7 +50,9 @@ import com.flipcash.app.messenger.internal.screens.components.SeparatorConfig
import com.flipcash.features.messenger.R
import com.getcode.navigation.core.CodeNavigator
import com.getcode.navigation.core.LocalCodeNavigator
+import com.getcode.navigation.results.key
import com.getcode.theme.CodeTheme
+import com.getcode.theme.White10
import com.getcode.ui.components.AppBarDefaults
import com.getcode.ui.components.AppBarWithTitle
import com.getcode.ui.components.chat.ChatInput
@@ -56,29 +63,43 @@ import com.getcode.ui.theme.ButtonState
import com.getcode.ui.theme.CodeButton
import com.getcode.ui.utils.rememberKeyboardController
import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.blur.HazeBlurStyle
+import dev.chrisbanes.haze.blur.blurEffect
+import dev.chrisbanes.haze.blur.materials.HazeMaterials
+import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
+import dev.chrisbanes.haze.rememberHazeState
@Composable
internal fun MessengerScreen(viewModel: ChatViewModel) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val messages = viewModel.messages.collectAsLazyPagingItems()
+ val otherReadPointer by viewModel.otherReadPointer.collectAsStateWithLifecycle()
val navigator = LocalCodeNavigator.current
+ val hazeState = rememberHazeState()
+
ChatInputScaffold(
topBar = { ChatTopBar(navigator, state.chattingWith) },
bottomBar = {
UserControlBottomBar(
state = state,
+ hazeState = hazeState,
dispatch = viewModel::dispatchEvent,
)
},
) { overlapPadding ->
MessageList(
modifier = Modifier
- .fillMaxSize(),
+ .fillMaxSize()
+ .hazeSource(hazeState),
contentPadding = overlapPadding,
messages = messages,
separatorConfig = SeparatorConfig.TimeGap(),
+ otherReadPointer = otherReadPointer,
+ onAdvanceReadPointer = { messageId ->
+ viewModel.dispatchEvent(ChatViewModel.Event.AdvanceReadPointer(messageId))
+ },
)
}
}
@@ -143,17 +164,19 @@ private fun ChatTopBar(
@Composable
private fun UserControlBottomBar(
state: ChatViewModel.State,
+ hazeState: HazeState,
dispatch: (ChatViewModel.Event) -> Unit,
) {
val keyboard = rememberKeyboardController()
val focusRequester = remember { FocusRequester() }
var buttonHeight by remember { mutableStateOf(0.dp) }
- val fadeMultiplier by animateFloatAsState(
- when (state.userState) {
- ChatViewModel.UserState.Reading -> 0.20f
- ChatViewModel.UserState.Typing -> 0.9f
+
+ LaunchedEffect(keyboard.visible) {
+ if (!keyboard.visible) {
+ dispatch(ChatViewModel.Event.OnStopMessageInput)
}
- )
+ }
+
Box(
modifier = Modifier
.fillMaxWidth(),
@@ -162,10 +185,10 @@ private fun UserControlBottomBar(
modifier = Modifier
.fillMaxWidth()
.height(buttonHeight)
+ .align(Alignment.BottomCenter)
.drawWithGradient(
color = CodeTheme.colors.background,
startY = { 0f },
- endY = { size.height * fadeMultiplier }
),
)
AnimatedContent(
@@ -196,11 +219,26 @@ private fun UserControlBottomBar(
buttonState = ButtonState.Filled,
text = stringResource(R.string.action_sendCash),
) { dispatch(ChatViewModel.Event.OnSendCash) }
- CodeButton(
+ AnimatedVisibility(
+ visible = state.hasPayment,
modifier = Modifier.weight(1f),
- buttonState = ButtonState.Filled10,
- text = stringResource(R.string.action_sendMessage),
- ) { dispatch(ChatViewModel.Event.OnStartMessageInput) }
+ enter = expandHorizontally(expandFrom = Alignment.Start) + fadeIn(),
+ exit = shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut(),
+ ) {
+ val material = HazeMaterials.ultraThin(
+ containerColor = CodeTheme.colors.background
+ )
+ CodeButton(
+ modifier = Modifier
+ .hazeEffect(hazeState) {
+ blurEffect {
+ style = material
+ }
+ },
+ buttonState = ButtonState.Filled10,
+ text = stringResource(R.string.action_sendMessage),
+ ) { dispatch(ChatViewModel.Event.OnStartMessageInput) }
+ }
}
}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageBubble.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageBubble.kt
index 8e7a3871c..b1728abfe 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageBubble.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageBubble.kt
@@ -4,6 +4,9 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -11,36 +14,62 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewWrapper
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.paging.compose.LazyPagingItems
+import com.flipcash.app.core.ui.TokenIconWithName
+import com.flipcash.app.theme.FlipcashThemeWrapper
import com.flipcash.services.models.chat.MessageContent
+import com.getcode.opencode.compose.ExchangeStub
+import com.getcode.opencode.compose.LocalExchange
+import com.getcode.opencode.model.financial.Fiat
import com.getcode.theme.CodeTheme
import com.getcode.theme.extraSmall
+import com.getcode.ui.components.PriceWithFlag
import com.getcode.ui.core.addIf
internal enum class BubblePosition { Solo, First, Middle, Last }
+private const val BUBBLE_MAX_WIDTH_FRACTION = 0.78f
+
@Composable
internal fun ContentBubble(
item: ChatListItem.ContentBubble,
position: BubblePosition,
modifier: Modifier = Modifier,
) {
- Row(
- modifier = modifier.fillMaxWidth(),
- horizontalArrangement = if (item.isFromSelf) Arrangement.End else Arrangement.Start,
- ) {
- when (val content = item.content) {
- is MessageContent.Text -> TextBubble(
- text = content.text,
- isFromSelf = item.isFromSelf,
- position = position,
- )
- is MessageContent.Cash -> Unit // TODO
+ BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
+ val bubbleMaxWidth = maxWidth * BUBBLE_MAX_WIDTH_FRACTION
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = if (item.isFromSelf) Arrangement.End else Arrangement.Start,
+ ) {
+ when (val content = item.content) {
+ is MessageContent.Text -> TextBubble(
+ text = content.text,
+ isFromSelf = item.isFromSelf,
+ position = position,
+ maxWidth = bubbleMaxWidth,
+ )
+
+ is MessageContent.Cash -> CashBubble(
+ amount = content.amount,
+ tokenName = content.tokenName,
+ tokenImageUrl = content.tokenImageUrl,
+ isFromSelf = item.isFromSelf,
+ position = position,
+ maxWidth = bubbleMaxWidth,
+ )
+ }
}
}
}
@@ -50,6 +79,70 @@ private fun TextBubble(
text: String,
isFromSelf: Boolean,
position: BubblePosition,
+ maxWidth: Dp,
+) {
+ Bubble(isFromSelf, position, maxWidth) {
+ Text(
+ text = text,
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.textMain,
+ )
+ }
+}
+
+@Composable
+private fun CashBubble(
+ amount: Fiat,
+ tokenName: String,
+ tokenImageUrl: String,
+ isFromSelf: Boolean,
+ position: BubblePosition,
+ maxWidth: Dp,
+) {
+ Bubble(isFromSelf = isFromSelf, position = position, minWidth = maxWidth, maxWidth = maxWidth) {
+ val exchange = LocalExchange.current
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ if (tokenName.isNotBlank()) {
+ TokenIconWithName(
+ modifier = Modifier.align(Alignment.Start),
+ tokenName = tokenName,
+ tokenImage = tokenImageUrl,
+ imageSize = 20.dp,
+ spacing = CodeTheme.dimens.grid.x1,
+ textStyle = CodeTheme.typography.textSmall,
+ )
+ }
+
+ PriceWithFlag(
+ modifier = Modifier
+ .padding(vertical = CodeTheme.dimens.grid.x5),
+ amount = amount.formatted(),
+ currencyCode = amount.currencyCode.name,
+ flag = exchange.getFlagByCurrency(amount.currencyCode.name),
+ text = { text ->
+ Text(
+ text = text,
+ style = CodeTheme.typography.displayMedium,
+ color = CodeTheme.colors.textMain,
+ )
+ }
+ )
+ }
+ }
+}
+
+@Composable
+private fun Bubble(
+ isFromSelf: Boolean,
+ position: BubblePosition,
+ maxWidth: Dp,
+ minWidth: Dp = 0.dp,
+ content: @Composable BoxScope.() -> Unit,
) {
val bubble = if (isFromSelf) {
CodeTheme.colors.chat.outgoingBubble
@@ -59,7 +152,7 @@ private fun TextBubble(
val shape = bubbleShape(position, isFromSelf)
Box(
modifier = Modifier
- .widthIn(max = 280.dp)
+ .widthIn(min = minWidth, max = maxWidth)
.clip(shape)
.addIf(bubble.hasBorder) {
Modifier.border(1.dp, bubble.border, shape)
@@ -67,11 +160,7 @@ private fun TextBubble(
.background(bubble.background)
.padding(horizontal = 16.dp, vertical = 10.dp),
) {
- Text(
- text = text,
- style = CodeTheme.typography.textSmall,
- color = CodeTheme.colors.textMain,
- )
+ content()
}
}
@@ -88,11 +177,13 @@ private fun bubbleShape(position: BubblePosition, isFromSelf: Boolean): Shape {
} else {
RoundedCornerShape(topStart = l, topEnd = l, bottomEnd = l, bottomStart = s)
}
+
BubblePosition.Middle -> if (isFromSelf) {
RoundedCornerShape(topStart = l, topEnd = s, bottomEnd = s, bottomStart = l)
} else {
RoundedCornerShape(topStart = s, topEnd = l, bottomEnd = l, bottomStart = s)
}
+
BubblePosition.Last -> if (isFromSelf) {
// Bottom of group, outgoing: top-end connects to item above
RoundedCornerShape(topStart = l, topEnd = s, bottomEnd = l, bottomStart = l)
@@ -150,3 +241,137 @@ internal fun DateSeparatorRow(
)
}
}
+
+// region Previews
+
+@Preview
+@PreviewWrapper(FlipcashThemeWrapper::class)
+@Composable
+private fun Preview_TextBubble_Outgoing() {
+ TextBubble(
+ text = "Hey! How's it going?",
+ isFromSelf = true,
+ position = BubblePosition.Solo,
+ maxWidth = 300.dp,
+ )
+}
+
+@Preview
+@PreviewWrapper(FlipcashThemeWrapper::class)
+@Composable
+private fun Preview_TextBubble_Incoming() {
+ TextBubble(
+ text = "Not bad, just shipped a new feature!",
+ isFromSelf = false,
+ position = BubblePosition.Solo,
+ maxWidth = 300.dp,
+ )
+}
+
+@Preview
+@PreviewWrapper(FlipcashThemeWrapper::class)
+@Composable
+private fun Preview_TextBubble_LongMessage() {
+ TextBubble(
+ text = "This is a much longer message that should wrap across multiple lines to show how the bubble handles overflow text content gracefully.",
+ isFromSelf = false,
+ position = BubblePosition.Solo,
+ maxWidth = 300.dp,
+ )
+}
+
+@Preview
+@PreviewWrapper(FlipcashThemeWrapper::class)
+@Composable
+private fun Preview_CashBubble_Outgoing() {
+ CompositionLocalProvider(
+ LocalExchange provides ExchangeStub(context = LocalContext.current)
+ ) {
+ CashBubble(
+ amount = Fiat(fiat = 5.0),
+ tokenName = "USDF",
+ tokenImageUrl = "",
+ isFromSelf = true,
+ position = BubblePosition.Solo,
+ maxWidth = 300.dp,
+ )
+ }
+}
+
+@Preview
+@PreviewWrapper(FlipcashThemeWrapper::class)
+@Composable
+private fun Preview_CashBubble_Incoming() {
+ CompositionLocalProvider(
+ LocalExchange provides ExchangeStub(context = LocalContext.current)
+ ) {
+ CashBubble(
+ amount = Fiat(fiat = 1.0),
+ tokenName = "Waylon",
+ tokenImageUrl = "",
+ isFromSelf = false,
+ position = BubblePosition.Solo,
+ maxWidth = 300.dp,
+ )
+ }
+}
+
+@Preview
+@PreviewWrapper(FlipcashThemeWrapper::class)
+@Composable
+private fun Preview_CashBubble_NoTokenName() {
+ CompositionLocalProvider(
+ LocalExchange provides ExchangeStub(context = LocalContext.current)
+ ) {
+ CashBubble(
+ amount = Fiat(fiat = 25.0),
+ tokenName = "",
+ tokenImageUrl = "",
+ isFromSelf = true,
+ position = BubblePosition.Solo,
+ maxWidth = 300.dp,
+ )
+ }
+}
+
+@Preview
+@PreviewWrapper(FlipcashThemeWrapper::class)
+@Composable
+private fun Preview_GroupedBubbles() {
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp), horizontalAlignment = Alignment.End) {
+ TextBubble("First message", true, BubblePosition.First, 300.dp)
+ TextBubble("Second message", true, BubblePosition.Middle, 300.dp)
+ TextBubble("Third message", true, BubblePosition.Last, 300.dp)
+ }
+}
+
+@Preview
+@PreviewWrapper(FlipcashThemeWrapper::class)
+@Composable
+private fun Preview_Conversation() {
+ CompositionLocalProvider(
+ LocalExchange provides ExchangeStub(context = LocalContext.current)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
+ TextBubble("Hey!", false, BubblePosition.Solo, 300.dp)
+ }
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+ TextBubble("What's up?", true, BubblePosition.Solo, 300.dp)
+ }
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
+ CashBubble(Fiat(fiat = 5.0), "USDF", "", false, BubblePosition.Solo, 300.dp)
+ }
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+ TextBubble("Thanks!", true, BubblePosition.Solo, 300.dp)
+ }
+ }
+ }
+}
+
+// endregion
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt
index c1840e883..3dc1404bc 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt
@@ -1,15 +1,29 @@
package com.flipcash.app.messenger.internal.screens.components
+import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.Snapshot
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.Dp
@@ -18,10 +32,12 @@ import androidx.paging.compose.itemKey
import com.flipcash.services.models.chat.MessageContent
import androidx.compose.ui.unit.dp
import com.getcode.theme.CodeTheme
-import com.getcode.ui.core.drawWithGradient
-import com.getcode.ui.core.verticalScrollStateGradient
import com.getcode.ui.utils.sheetResignmentBehavior
import com.getcode.util.toLocalDate
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.mapNotNull
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
@@ -50,6 +66,8 @@ sealed interface SeparatorConfig {
}
}
+internal enum class ReceiptStatus { SENDING, SENT, READ, FAILED }
+
internal sealed interface ChatListItem {
val itemKey: Any
@@ -63,6 +81,7 @@ internal sealed interface ChatListItem {
val content: MessageContent,
val isFromSelf: Boolean,
val timestamp: Instant,
+ val receiptStatus: ReceiptStatus? = null,
) : ChatListItem {
override val itemKey: Any = "$messageId-$contentIndex"
}
@@ -75,6 +94,8 @@ internal fun MessageList(
contentPadding: PaddingValues,
messages: LazyPagingItems,
separatorConfig: SeparatorConfig,
+ otherReadPointer: Long = 0L,
+ onAdvanceReadPointer: ((Long) -> Unit)? = null,
) {
val listAlpha by animateFloatAsState(
targetValue = if (messages.itemCount > 0) 1f else 0f,
@@ -82,6 +103,11 @@ internal fun MessageList(
)
val listState = rememberLazyListState()
+
+ if (onAdvanceReadPointer != null) {
+ HandleMessageReads(listState, messages, onAdvanceReadPointer)
+ }
+
LazyColumn(
modifier = modifier
.alpha(listAlpha)
@@ -103,13 +129,30 @@ internal fun MessageList(
val item = messages[index] ?: return@items
val bottomSpacing = bottomSpacingFor(index, item, messages, separatorConfig)
- Box(modifier = Modifier.padding(bottom = bottomSpacing)) {
+ Box(
+ modifier = Modifier
+ .padding(bottom = bottomSpacing)
+ .animateItem(),
+ ) {
when (item) {
is ChatListItem.DateSeparator -> DateSeparatorRow(item.label)
- is ChatListItem.ContentBubble -> ContentBubble(
- item = item,
- position = bubblePositionOf(index, item, messages, separatorConfig),
- )
+ is ChatListItem.ContentBubble -> {
+ val effectiveStatus = effectiveReceiptStatus(item, otherReadPointer)
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = if (item.isFromSelf) Alignment.End else Alignment.Start,
+ ) {
+ ContentBubble(
+ item = item,
+ position = bubblePositionOf(index, item, messages, separatorConfig),
+ )
+ val showReceipt =
+ shouldShowReceiptLabel(index, item, messages, otherReadPointer)
+ if (showReceipt && effectiveStatus != null) {
+ ReceiptLabel(effectiveStatus)
+ }
+ }
+ }
}
}
}
@@ -158,4 +201,124 @@ private fun bottomSpacingFor(
// Same sender, close together → tight
else -> tight
}
+}
+
+@Composable
+private fun HandleMessageReads(
+ listState: LazyListState,
+ messages: LazyPagingItems,
+ onAdvanceReadPointer: (Long) -> Unit,
+) {
+ var lastAdvanced by remember { mutableLongStateOf(0L) }
+
+ LaunchedEffect(listState, messages) {
+ snapshotFlow {
+ val layout = listState.layoutInfo
+ val count = messages.itemCount
+ val visibleRange = layout.visibleItemsInfo
+ if (visibleRange.isEmpty() || count == 0) return@snapshotFlow null
+
+ var highestId = 0L
+ for (info in visibleRange) {
+ if (info.index !in 0 until count) continue
+ val bubble = messages.peek(info.index) as? ChatListItem.ContentBubble ?: continue
+ if (!bubble.isFromSelf && bubble.messageId > highestId) {
+ highestId = bubble.messageId
+ }
+ }
+ if (highestId > 0L) highestId else null
+ }
+ .filterNotNull()
+ .distinctUntilChanged()
+ .collectLatest { messageId ->
+ if (messageId > lastAdvanced) {
+ lastAdvanced = messageId
+ onAdvanceReadPointer(messageId)
+ }
+ }
+ }
+}
+
+private fun effectiveReceiptStatus(
+ bubble: ChatListItem.ContentBubble,
+ otherReadPointer: Long,
+): ReceiptStatus? {
+ val base = bubble.receiptStatus ?: return null
+ if (base == ReceiptStatus.SENT && bubble.messageId in 1..otherReadPointer) {
+ return ReceiptStatus.READ
+ }
+ return base
+}
+
+/**
+ * Show a receipt label below a self-message only within the last 2 contiguous
+ * self-message groups. In reverseLayout, index 0 is the newest message.
+ *
+ * Within a group, labels appear at status boundaries (SENT↔READ).
+ * At group boundaries, labels are suppressed when the nearest self-group
+ * below already shows the same status (avoids duplicate "Read" labels).
+ */
+private fun shouldShowReceiptLabel(
+ index: Int,
+ item: ChatListItem.ContentBubble,
+ messages: LazyPagingItems,
+ otherReadPointer: Long,
+): Boolean {
+ if (!item.isFromSelf) return false
+ val status = effectiveReceiptStatus(item, otherReadPointer) ?: return false
+ if (status != ReceiptStatus.SENT && status != ReceiptStatus.READ) return false
+
+ // index - 1 is the item below (newer) in reverseLayout
+ val below = if (index > 0) messages.peek(index - 1) else null
+ val belowBubble = below as? ChatListItem.ContentBubble
+
+ // Within a self-group: show at intra-group status boundaries only
+ if (belowBubble != null && belowBubble.isFromSelf) {
+ return effectiveReceiptStatus(belowBubble, otherReadPointer) != status
+ }
+
+ // At a group boundary — count which self-group this is.
+ var selfGroups = 0
+ var prevWasSelf = false
+ for (i in 0 until index) {
+ val peek = messages.peek(i) ?: break
+ val bubble = peek as? ChatListItem.ContentBubble
+ val isSelf = bubble != null && bubble.isFromSelf
+ if (isSelf && !prevWasSelf) selfGroups++
+ prevWasSelf = isSelf
+ }
+ if (!prevWasSelf) selfGroups++ // current item starts a new group
+ if (selfGroups > 2) return false
+
+ // Bottommost self-group always shows its label
+ if (selfGroups == 1) return true
+
+ // For group 2: show only if status differs from the nearest self-group below
+ for (i in (index - 1) downTo 0) {
+ val peek = messages.peek(i) ?: break
+ val bubble = peek as? ChatListItem.ContentBubble ?: continue
+ if (bubble.isFromSelf) return effectiveReceiptStatus(bubble, otherReadPointer) != status
+ }
+ return true
+}
+
+@Composable
+private fun ReceiptLabel(status: ReceiptStatus, modifier: Modifier = Modifier) {
+ AnimatedContent(
+ targetState = status,
+ modifier = modifier.padding(top = CodeTheme.dimens.grid.x1),
+ transitionSpec = { fadeIn() togetherWith fadeOut() },
+ label = "receiptStatus",
+ ) { animatedStatus ->
+ val text = when (animatedStatus) {
+ ReceiptStatus.SENT -> "Delivered"
+ ReceiptStatus.READ -> "Read"
+ else -> return@AnimatedContent
+ }
+ Text(
+ text = text,
+ style = CodeTheme.typography.caption,
+ color = CodeTheme.colors.textSecondary,
+ )
+ }
}
\ No newline at end of file
diff --git a/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegate.kt b/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegate.kt
index 599f29d8e..f5f4a76e3 100644
--- a/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegate.kt
+++ b/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegate.kt
@@ -13,8 +13,10 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.scan
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlin.math.roundToInt
@@ -80,7 +82,17 @@ class AmountEntryDelegate(
loadingState = loading,
),
)
- }.stateIn(
+ }.scan(null as AmountEntryConfig?) { prev, current ->
+ // Freeze hint and confirm state while send is in progress;
+ // only the action's loadingState is allowed to update.
+ val loading = current.action.loadingState
+ if (prev != null && (loading.loading || loading.success)) {
+ prev.copy(action = current.action)
+ } else {
+ current
+ }
+ }.filterNotNull()
+ .stateIn(
scope,
SharingStarted.WhileSubscribed(5000),
AmountEntryConfig(
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt
index 62efd4684..f82f28b2a 100644
--- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt
@@ -2,6 +2,7 @@
package com.flipcash.shared.chat
+import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
@@ -13,11 +14,14 @@ import com.flipcash.app.contacts.device.DeviceContact
import com.flipcash.app.persistence.sources.ChatMemberDataSource
import com.flipcash.app.persistence.sources.ChatMessageDataSource
import com.flipcash.app.persistence.sources.ChatMetadataDataSource
+import com.flipcash.app.persistence.sources.ContactDataSource
import com.flipcash.services.controllers.ChatController
import com.flipcash.services.controllers.ChatMessagingController
import com.flipcash.services.controllers.EventStreamingController
import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.chat.ChatMember
import com.flipcash.services.models.chat.ChatMessage
+import com.flipcash.services.models.chat.MessagePointer
import com.flipcash.services.models.chat.ChatUpdate
import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.models.chat.MetadataUpdate
@@ -29,12 +33,14 @@ import com.getcode.opencode.model.accounts.AccountCluster
import com.getcode.opencode.providers.SessionListener
import com.getcode.utils.TraceType
import com.getcode.utils.network.NetworkConnectivityListener
+import com.getcode.utils.decodeBase58
import com.getcode.utils.trace
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -63,7 +69,9 @@ class ChatCoordinator @Inject constructor(
private val metadataDataSource: ChatMetadataDataSource,
private val messageDataSource: ChatMessageDataSource,
private val memberDataSource: ChatMemberDataSource,
+ private val contactDataSource: ContactDataSource,
private val networkObserver: NetworkConnectivityListener,
+ private val notificationManager: NotificationManagerCompat,
private val userManager: UserManager,
) : SessionListener, DefaultLifecycleObserver {
@@ -77,6 +85,7 @@ class ChatCoordinator @Inject constructor(
private var syncJob: Job? = null
private var eventStreamCollectJob: Job? = null
+ private var eventStreamRetryJob: Job? = null
val state: StateFlow
get() = _state.asStateFlow()
@@ -90,8 +99,9 @@ class ChatCoordinator @Inject constructor(
?.firstOrNull { it.type == PointerType.READ }
?.value ?: 0L
+ val selfId = userManager.accountId
val unreadCount = metadata.lastMessage?.let { lastMsg ->
- if (lastMsg.messageId > readPointer) 1 else 0
+ if (lastMsg.messageId > readPointer && lastMsg.senderId != selfId) 1 else 0
} ?: 0
ChatSummary(metadata = metadata, unreadCount = unreadCount)
@@ -142,9 +152,12 @@ class ChatCoordinator @Inject constructor(
// region Public API
- fun getChatId(contact: DeviceContact): Result {
- // TODO:
- return Result.success(ChatId("b7e8cc8ac48cf661b1a32b09dfd4561c7c230c3b3c5cd65a85cc55381442ea0f"))
+ suspend fun getChatId(contact: DeviceContact): Result {
+ val raw = contactDataSource.getDmChatId(contact.e164)
+ if (raw.isNullOrEmpty()) {
+ return Result.failure(NoDmChatInitializedException(contact.e164))
+ }
+ return runCatching { ChatId(raw.decodeBase58()) }
}
fun observeMessages(chatId: ChatId): Flow> {
@@ -152,11 +165,10 @@ class ChatCoordinator @Inject constructor(
}
fun observeMessagesPaged(chatId: ChatId): Flow> {
- messageDataSource.setActiveChatId(chatId)
return Pager(
config = PagingConfig(pageSize = 50),
) {
- messageDataSource.observe()
+ messageDataSource.observeForChat(chatId)
}.flow.map { page ->
page.map { entity -> messageDataSource.toChatMessage(entity) }
}
@@ -166,6 +178,18 @@ class ChatCoordinator @Inject constructor(
return _state.map { it.typingIndicators[chatId] ?: emptySet() }
}
+ fun observeOtherReadPointer(chatId: ChatId): Flow {
+ val selfId = userManager.accountId
+ return memberDataSource.observeMembers(chatId)
+ .map { members ->
+ members.firstOrNull { it.userId != selfId }
+ ?.pointers
+ ?.firstOrNull { it.type == PointerType.READ }
+ ?.value ?: 0L
+ }
+ .distinctUntilChanged()
+ }
+
suspend fun loadMessages(chatId: ChatId, limit: Int = 100) {
messagingController.getMessages(chatId)
.onSuccess { messages ->
@@ -186,7 +210,23 @@ class ChatCoordinator @Inject constructor(
return messagingController.sendMessage(chatId, content, clientMessageId)
.onSuccess { serverMessage ->
- messageDataSource.confirmPending(chatId, clientMessageId, serverMessage.messageId)
+ messageDataSource.confirmPending(chatId, clientMessageId, serverMessage)
+ advanceReadPointer(chatId, serverMessage.messageId)
+
+ // Update feed metadata so the contact list shows the latest message
+ metadataDataSource.updateLastMessageId(chatId, serverMessage.messageId)
+ metadataDataSource.updateLastActivity(chatId, serverMessage.timestamp.toEpochMilliseconds())
+ _state.update { state ->
+ val updatedFeed = state.feed.map { meta ->
+ if (meta.chatId == chatId) {
+ meta.copy(
+ lastMessage = serverMessage,
+ lastActivity = serverMessage.timestamp,
+ )
+ } else meta
+ }
+ state.copy(feed = updatedFeed)
+ }
}
.onFailure {
messageDataSource.failPending(chatId, clientMessageId)
@@ -194,9 +234,40 @@ class ChatCoordinator @Inject constructor(
}
suspend fun advanceReadPointer(chatId: ChatId, messageId: Long): Result {
+ val selfId = userManager.accountId ?: return Result.failure(
+ IllegalStateException("No account")
+ )
+
+ // Optimistically update local pointer so the feed unread count clears immediately
+ val pointer = MessagePointer(
+ type = PointerType.READ,
+ userId = selfId,
+ value = messageId,
+ )
+ memberDataSource.updatePointers(chatId, pointer)
+ _state.update { state ->
+ val updatedFeed = state.feed.map { meta ->
+ if (meta.chatId == chatId) {
+ meta.copy(members = meta.members.map { member ->
+ if (member.userId == selfId) {
+ val updated = member.pointers
+ .filter { it.type != PointerType.READ }
+ .plus(pointer)
+ member.copy(pointers = updated)
+ } else member
+ })
+ } else meta
+ }
+ state.copy(feed = updatedFeed)
+ }
+
return messagingController.advancePointer(chatId, PointerType.READ, messageId)
}
+ fun dismissNotifications(chatId: ChatId) {
+ notificationManager.cancel(chatId.hashCode())
+ }
+
suspend fun notifyTyping(chatId: ChatId, typingState: TypingState): Result {
return messagingController.notifyIsTyping(chatId, typingState)
}
@@ -257,14 +328,30 @@ class ChatCoordinator @Inject constructor(
}
private fun openEventStream() {
- eventStreamingController.open(scope)
- eventStreamCollectJob?.cancel()
- eventStreamCollectJob = scope.launch {
- eventStreamingController.chatUpdates.collect { applyUpdate(it) }
+ eventStreamRetryJob?.cancel()
+ val opened = eventStreamingController.open(scope) {
+ // Stream died after exhausting retries — schedule a re-open
+ eventStreamRetryJob = scope.launch {
+ delay(5_000)
+ trace(tag = TAG, message = "Retrying event stream after failure", type = TraceType.Process)
+ openEventStream()
+ }
+ }
+ if (!opened) {
+ trace(tag = TAG, message = "Event stream failed to open", type = TraceType.Error)
+ }
+ // Always ensure a collector is running so events are processed
+ // as soon as the stream (re)connects.
+ if (eventStreamCollectJob?.isActive != true) {
+ eventStreamCollectJob = scope.launch {
+ eventStreamingController.chatUpdates.collect { applyUpdate(it) }
+ }
}
}
private fun closeEventStream() {
+ eventStreamRetryJob?.cancel()
+ eventStreamRetryJob = null
eventStreamCollectJob?.cancel()
eventStreamCollectJob = null
eventStreamingController.close()
@@ -272,15 +359,33 @@ class ChatCoordinator @Inject constructor(
private suspend fun applyUpdate(update: ChatUpdate) {
val chatId = update.chatId
+ trace(
+ tag = TAG,
+ message = "applyUpdate: chatId=$chatId, newMessages=${update.newMessages.size}, pointers=${update.pointerUpdates.size}, typing=${update.typingNotifications.size}",
+ type = TraceType.Process,
+ )
// New messages
if (update.newMessages.isNotEmpty()) {
+ trace(tag = TAG, message = "Upserting ${update.newMessages.size} new messages for $chatId", type = TraceType.Process)
messageDataSource.upsert(chatId, update.newMessages)
val lastMsg = update.newMessages.maxByOrNull { it.messageId }
if (lastMsg != null) {
metadataDataSource.updateLastMessageId(chatId, lastMsg.messageId)
metadataDataSource.updateLastActivity(chatId, lastMsg.timestamp.toEpochMilliseconds())
+
+ _state.update { state ->
+ val updatedFeed = state.feed.map { meta ->
+ if (meta.chatId == chatId) {
+ meta.copy(
+ lastMessage = lastMsg,
+ lastActivity = lastMsg.timestamp,
+ )
+ } else meta
+ }
+ state.copy(feed = updatedFeed)
+ }
}
}
@@ -289,6 +394,28 @@ class ChatCoordinator @Inject constructor(
memberDataSource.updatePointers(chatId, pointer)
}
+ if (update.pointerUpdates.isNotEmpty()) {
+ _state.update { state ->
+ val updatedFeed = state.feed.map { meta ->
+ if (meta.chatId == chatId) {
+ meta.copy(members = meta.members.map { member ->
+ val memberPointerUpdates = update.pointerUpdates
+ .filter { it.userId == member.userId }
+ if (memberPointerUpdates.isNotEmpty()) {
+ val updated = member.pointers.toMutableList()
+ for (p in memberPointerUpdates) {
+ updated.removeAll { it.type == p.type }
+ updated.add(p)
+ }
+ member.copy(pointers = updated)
+ } else member
+ })
+ } else meta
+ }
+ state.copy(feed = updatedFeed)
+ }
+ }
+
// Typing notifications (ephemeral, in-memory only)
if (update.typingNotifications.isNotEmpty()) {
_state.update { state ->
@@ -346,3 +473,5 @@ class ChatCoordinator @Inject constructor(
// endregion
}
+
+class NoDmChatInitializedException(e164: String) : Exception("No DM chat for $e164")
diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt
index 26a525524..851c155cc 100644
--- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt
+++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt
@@ -93,6 +93,7 @@ class ContactCoordinator @Inject constructor(
data class ContactState(
val contacts: Map = emptyMap(),
val flipcashE164s: Set = emptySet(),
+ val dmChatIds: Map = emptyMap(),
val syncState: SyncState = SyncState.Idle,
val hasEverSynced: Boolean = false,
val hasDiscoveredFlipcashContacts: Boolean = false,
@@ -112,6 +113,9 @@ class ContactCoordinator @Inject constructor(
val isLinkedForPayment: StateFlow
get() = _isLinkedForPayment.asStateFlow()
+ val selfPhone: String?
+ get() = userManager.profile?.verifiedPhoneNumber
+
// region SessionListener
override suspend fun onUserLoggedIn(cluster: AccountCluster) {
@@ -198,6 +202,18 @@ class ContactCoordinator @Inject constructor(
return Result.success(contact)
}
+ suspend fun lookupContactByDmChatId(dmChatId: String): DeviceContact? {
+ val entity = contactDataSource.getContactByDmChatId(dmChatId) ?: return null
+ // Prefer the in-memory contact (has latest device data), fall back to entity
+ return _state.value.contacts[entity.e164] ?: DeviceContact(
+ e164 = entity.e164,
+ androidContactId = entity.androidContactId,
+ displayName = entity.displayName,
+ photoUri = entity.photoUri,
+ displayNumber = entity.displayNumber,
+ )
+ }
+
/**
* Ensures the user's verified phone number is linked for payment, calling the
* server RPC at most once per account lifetime (persisted via DataStore).
@@ -329,11 +345,15 @@ class ContactCoordinator @Inject constructor(
)
}
val flipcashE164s = mappings.filter { it.isOnFlipcash }.map { it.e164 }.toSet()
+ val dmChatIds = mappings
+ .filter { it.dmChatId.isNotEmpty() }
+ .associate { it.e164 to it.dmChatId }
_state.update {
it.copy(
contacts = contacts,
flipcashE164s = flipcashE164s,
+ dmChatIds = dmChatIds,
hasEverSynced = true,
hasDiscoveredFlipcashContacts = hasDiscoveredFlipcashContacts,
)
@@ -498,8 +518,8 @@ class ContactCoordinator @Inject constructor(
val result = contactListController.getFlipcashContacts(checksum)
.firstOrNull()
- result?.onSuccess { phones ->
- val flipcashE164s = phones.map { it.phoneNumber }.toSet()
+ result?.onSuccess { entries ->
+ val flipcashE164s = entries.map { it.phoneNumber }.toSet()
contactDataSource.clearFlipcashStatus()
if (flipcashE164s.isNotEmpty()) {
contactDataSource.markAsFlipcash(flipcashE164s.toList())
@@ -508,7 +528,13 @@ class ContactCoordinator @Inject constructor(
_state.update { it.copy(hasDiscoveredFlipcashContacts = true) }
}
}
- _state.update { it.copy(flipcashE164s = flipcashE164s) }
+ val dmChatIds = mutableMapOf()
+ entries.forEach { entry ->
+ val chatIdStr = entry.dmChatId?.toString() ?: return@forEach
+ contactDataSource.updateDmChatId(entry.phoneNumber, chatIdStr)
+ dmChatIds[entry.phoneNumber] = chatIdStr
+ }
+ _state.update { it.copy(flipcashE164s = flipcashE164s, dmChatIds = it.dmChatIds + dmChatIds) }
trace(tag = TAG, message = "Found ${flipcashE164s.size} contacts on Flipcash", type = TraceType.Process)
}?.onFailure { error ->
when (error) {
diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactReader.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactReader.kt
index 4f6554357..e06f69cbf 100644
--- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactReader.kt
+++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactReader.kt
@@ -12,7 +12,22 @@ data class DeviceContact(
val displayName: String,
val photoUri: String?,
val displayNumber: String = "",
-)
+) {
+ val isUnknown = androidContactId == -1L
+ companion object {
+ fun unknownContact(
+ e164: String = "",
+ displayName: String? = null,
+ displayNumber: String? = null,
+ ) = DeviceContact(
+ e164 = e164,
+ androidContactId = -1,
+ displayName = displayName ?: e164,
+ displayNumber = displayNumber ?: e164,
+ photoUri = null,
+ )
+ }
+}
data class PickedContactData(
val phoneNumber: String,
diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt
index dc7d82689..89581aa14 100644
--- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt
+++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt
@@ -51,7 +51,6 @@ class NotificationService : FirebaseMessagingService(),
private const val KEY_TITLE = "push_notification_title"
private const val KEY_BODY = "push_notification_body"
private const val KEY_PAYLOAD = "flipcash_payload"
- private const val CONVERSATION_ID_OFFSET = 0x10000000
}
@Inject
@@ -142,8 +141,8 @@ class NotificationService : FirebaseMessagingService(),
val channel = NotificationChannels.channelFor(this, category)
notificationManager.createNotificationChannel(channel)
+ val chatId = (payload?.navigation as? NavigationTrigger.Chat)?.chatId
val groupKey = payload?.groupKey?.takeIf { it.isNotEmpty() }
- val isContactChat = groupKey?.startsWith("+") == true
val builder = NotificationCompat.Builder(this, channel.id)
.setPriority(NotificationCompat.PRIORITY_HIGH)
@@ -156,8 +155,8 @@ class NotificationService : FirebaseMessagingService(),
if (groupKey != null) setGroup(groupKey)
}
- val notificationId = if (isContactChat) {
- builder.applyContactChatStyle(groupKey, body)
+ val notificationId = if (chatId != null) {
+ builder.applyContactChatStyle(chatId.hashCode(), groupKey, body)
} else {
builder.setContentTitle(title).setContentText(body)
SecureRandom().nextInt(Int.MAX_VALUE)
@@ -179,11 +178,13 @@ class NotificationService : FirebaseMessagingService(),
}
private suspend fun NotificationCompat.Builder.applyContactChatStyle(
- groupKey: String,
+ notificationId: Int,
+ groupKey: String?,
body: String?,
): Int {
- val contactPhoto = resolveContactPhoto(groupKey)
- val senderName = contactResolver.resolveName(groupKey)
+ val e164 = groupKey?.takeIf { it.startsWith("+") }
+ val contactPhoto = e164?.let { resolveContactPhoto(it) }
+ val senderName = e164?.let { contactResolver.resolveName(it) } ?: ""
val profile = userManager.profile
val selfPerson = Person.Builder()
@@ -197,14 +198,12 @@ class NotificationService : FirebaseMessagingService(),
val senderPerson = Person.Builder()
.setName(senderName)
- .setKey(groupKey)
+ .setKey(groupKey ?: "unknown")
.apply {
if (contactPhoto != null) setIcon(IconCompat.createWithBitmap(contactPhoto.toCircularBitmap()))
}
.build()
- val notificationId = groupKey.hashCode() xor CONVERSATION_ID_OFFSET
-
val style = notificationManager.activeNotifications
.firstOrNull { it.id == notificationId }
?.notification
@@ -286,6 +285,10 @@ class NotificationService : FirebaseMessagingService(),
data = Linkify.tokenInfo(navigation.mint).toUri()
}
+ is NavigationTrigger.Chat -> Intent(Intent.ACTION_VIEW).apply {
+ data = Linkify.chat(navigation.chatId).toUri()
+ }
+
else -> packageManager.getLaunchIntentForPackage(packageName)
}
diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/20.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/20.json
new file mode 100644
index 000000000..b80579eb6
--- /dev/null
+++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/20.json
@@ -0,0 +1,630 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 20,
+ "identityHash": "3cb2a9c732497c23b9b6311e3b9baa35",
+ "entities": [
+ {
+ "tableName": "messages",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', PRIMARY KEY(`idBase58`))",
+ "fields": [
+ {
+ "fieldPath": "idBase58",
+ "columnName": "idBase58",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "text",
+ "columnName": "text",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "amountUsdc",
+ "columnName": "amountUsdc",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "amountNative",
+ "columnName": "amountNative",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "nativeCurrency",
+ "columnName": "nativeCurrency",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "rate",
+ "columnName": "rate",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "state",
+ "columnName": "state",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "metadata",
+ "columnName": "metadata",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mintBase58",
+ "columnName": "mintBase58",
+ "affinity": "TEXT",
+ "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "idBase58"
+ ]
+ }
+ },
+ {
+ "tableName": "tokens",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))",
+ "fields": [
+ {
+ "fieldPath": "address",
+ "columnName": "address",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "decimals",
+ "columnName": "decimals",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "symbol",
+ "columnName": "symbol",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "image_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "socialLinks",
+ "columnName": "social_links",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "billCustomizationsJson",
+ "columnName": "bill_customizations",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "holderMetricsJson",
+ "columnName": "holder_metrics",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "vmMetadata.vm",
+ "columnName": "vm_vm",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "vmMetadata.authority",
+ "columnName": "vm_authority",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "vmMetadata.lockDurationInDays",
+ "columnName": "vm_lock_duration_days",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "launchpadMetadata.currencyConfig",
+ "columnName": "lp_currency_config",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "launchpadMetadata.liquidityPool",
+ "columnName": "lp_liquidity_pool",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "launchpadMetadata.seed",
+ "columnName": "lp_seed",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "launchpadMetadata.authority",
+ "columnName": "lp_authority",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "launchpadMetadata.mintVault",
+ "columnName": "lp_mint_vault",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "launchpadMetadata.coreMintVault",
+ "columnName": "lp_core_mint_vault",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks",
+ "columnName": "lp_circulating_supply_quarks",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "launchpadMetadata.sellFeeBps",
+ "columnName": "lp_sell_fee_bps",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "launchpadMetadata.priceAmount",
+ "columnName": "lp_price_amount_usd",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "launchpadMetadata.marketCapAmount",
+ "columnName": "lp_market_cap_amount_usd",
+ "affinity": "REAL"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "address"
+ ]
+ }
+ },
+ {
+ "tableName": "token_social_links",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tokenAddress",
+ "columnName": "token_address",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "value",
+ "columnName": "value",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_token_social_links_token_address",
+ "unique": false,
+ "columnNames": [
+ "token_address"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "tokens",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "token_address"
+ ],
+ "referencedColumns": [
+ "address"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "token_valuation",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "tokenAddress",
+ "columnName": "token_address",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "balanceQuarks",
+ "columnName": "balance_quarks",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "costBasis",
+ "columnName": "cost_basis",
+ "affinity": "REAL",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "token_address"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_token_valuation_token_address",
+ "unique": false,
+ "columnNames": [
+ "token_address"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "tokens",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "token_address"
+ ],
+ "referencedColumns": [
+ "address"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "currency_creator_draft",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "iconUri",
+ "columnName": "icon_uri",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "billCustomizations",
+ "columnName": "bill_customizations",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "attestations",
+ "columnName": "attestations",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "currentStep",
+ "columnName": "current_step",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdMint",
+ "columnName": "created_mint",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "saved_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "contact_sync_state",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "checksumBytes",
+ "columnName": "checksumBytes",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTimestamp",
+ "columnName": "lastSyncTimestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "needsFullUpload",
+ "columnName": "needsFullUpload",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasDiscoveredFlipcashContacts",
+ "columnName": "hasDiscoveredFlipcashContacts",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "contact_mapping",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`e164`))",
+ "fields": [
+ {
+ "fieldPath": "e164",
+ "columnName": "e164",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "androidContactId",
+ "columnName": "androidContactId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "displayName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "photoUri",
+ "columnName": "photoUri",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "isOnFlipcash",
+ "columnName": "isOnFlipcash",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayNumber",
+ "columnName": "displayNumber",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "dmChatId",
+ "columnName": "dmChatId",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "e164"
+ ]
+ }
+ },
+ {
+ "tableName": "chat_metadata",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, PRIMARY KEY(`chat_id_hex`))",
+ "fields": [
+ {
+ "fieldPath": "chatIdHex",
+ "columnName": "chat_id_hex",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatType",
+ "columnName": "chat_type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastActivityEpochMs",
+ "columnName": "last_activity_epoch_ms",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastMessageId",
+ "columnName": "last_message_id",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "chat_id_hex"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_chat_metadata_last_activity_epoch_ms",
+ "unique": false,
+ "columnNames": [
+ "last_activity_epoch_ms"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)"
+ }
+ ]
+ },
+ {
+ "tableName": "chat_messages",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))",
+ "fields": [
+ {
+ "fieldPath": "chatIdHex",
+ "columnName": "chat_id_hex",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "messageId",
+ "columnName": "message_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "senderIdHex",
+ "columnName": "sender_id_hex",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "contentJson",
+ "columnName": "content_json",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "timestampEpochMs",
+ "columnName": "timestamp_epoch_ms",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "unreadSeq",
+ "columnName": "unread_seq",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'SENT'"
+ },
+ {
+ "fieldPath": "pendingClientIdHex",
+ "columnName": "pending_client_id_hex",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "chat_id_hex",
+ "message_id"
+ ]
+ }
+ },
+ {
+ "tableName": "chat_members",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `user_profile_json` TEXT, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))",
+ "fields": [
+ {
+ "fieldPath": "chatIdHex",
+ "columnName": "chat_id_hex",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userIdHex",
+ "columnName": "user_id_hex",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userProfileJson",
+ "columnName": "user_profile_json",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "pointersJson",
+ "columnName": "pointers_json",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "chat_id_hex",
+ "user_id_hex"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3cb2a9c732497c23b9b6311e3b9baa35')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt
index d71576082..20c1771de 100644
--- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt
+++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt
@@ -67,8 +67,9 @@ import com.getcode.utils.subByteArray
AutoMigration(from = 16, to = 17),
AutoMigration(from = 17, to = 18),
AutoMigration(from = 18, to = 19),
+ AutoMigration(from = 19, to = 20),
],
- version = 19,
+ version = 20,
)
@TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class)
abstract class FlipcashDatabase : RoomDatabase() {
diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt
index e124e89a9..b55abb5a2 100644
--- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt
+++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt
@@ -75,7 +75,14 @@ sealed interface MessageContentSerialized {
@Serializable
@SerialName("cash")
- data class Cash(val intentId: String, val quarks: Long) : MessageContentSerialized
+ data class Cash(
+ val intentId: String,
+ val quarks: Long,
+ val currencyCode: String = "USD",
+ val mint: String = "",
+ val tokenName: String = "",
+ val tokenImageUrl: String = "",
+ ) : MessageContentSerialized
}
@Serializable
diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt
index 25d3d4e36..552dcf2b0 100644
--- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt
+++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt
@@ -22,6 +22,9 @@ interface ChatMemberDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(entities: List)
+ @Query("SELECT * FROM chat_members WHERE chat_id_hex = :chatIdHex AND user_id_hex = :userIdHex LIMIT 1")
+ suspend fun getMember(chatIdHex: String, userIdHex: String): ChatMemberEntity?
+
@Query("UPDATE chat_members SET pointers_json = :pointersJson WHERE chat_id_hex = :chatIdHex AND user_id_hex = :userIdHex")
suspend fun updatePointers(chatIdHex: String, userIdHex: String, pointersJson: String)
diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt
index 2fa796d10..7eb8816de 100644
--- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt
+++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt
@@ -5,6 +5,7 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
+import androidx.room.Transaction
import com.flipcash.app.persistence.entities.ChatMessageEntity
import com.flipcash.app.persistence.entities.MessageStatus
import kotlinx.coroutines.flow.Flow
@@ -30,12 +31,23 @@ interface ChatMessageDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(entities: List)
- @Query(
- """UPDATE chat_messages
- SET message_id = :serverMessageId, pending_client_id_hex = NULL, status = 'SENT'
- WHERE chat_id_hex = :chatIdHex AND pending_client_id_hex = :clientIdHex"""
- )
- suspend fun confirmPendingMessage(chatIdHex: String, clientIdHex: String, serverMessageId: Long)
+ @Query("DELETE FROM chat_messages WHERE chat_id_hex = :chatIdHex AND pending_client_id_hex = :clientIdHex")
+ suspend fun deletePending(chatIdHex: String, clientIdHex: String)
+
+ @Query("DELETE FROM chat_messages WHERE chat_id_hex = :chatIdHex AND status = 'SENDING'")
+ suspend fun deleteAllPending(chatIdHex: String)
+
+ @Transaction
+ suspend fun confirmPendingMessage(chatIdHex: String, clientIdHex: String, serverMessage: ChatMessageEntity) {
+ deletePending(chatIdHex, clientIdHex)
+ upsert(serverMessage)
+ }
+
+ @Transaction
+ suspend fun upsertAndClearPending(chatIdHex: String, entities: List) {
+ deleteAllPending(chatIdHex)
+ upsert(entities)
+ }
@Query("UPDATE chat_messages SET status = :status WHERE chat_id_hex = :chatIdHex AND pending_client_id_hex = :clientIdHex")
suspend fun updatePendingStatus(chatIdHex: String, clientIdHex: String, status: MessageStatus)
diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ContactDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ContactDao.kt
index d88c5f7b9..8f420168f 100644
--- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ContactDao.kt
+++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ContactDao.kt
@@ -63,6 +63,15 @@ interface ContactDao {
@Query("UPDATE contact_mapping SET isOnFlipcash = 1 WHERE e164 IN (:e164s)")
suspend fun markAsFlipcash(e164s: List)
+ @Query("SELECT dmChatId FROM contact_mapping WHERE e164 = :e164 LIMIT 1")
+ suspend fun getDmChatId(e164: String): String?
+
+ @Query("UPDATE contact_mapping SET dmChatId = :dmChatId WHERE e164 = :e164")
+ suspend fun updateDmChatId(e164: String, dmChatId: String)
+
+ @Query("SELECT * FROM contact_mapping WHERE dmChatId = :dmChatId LIMIT 1")
+ suspend fun getContactByDmChatId(dmChatId: String): ContactMappingEntity?
+
// endregion
@Query("DELETE FROM contact_mapping")
diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ContactMappingEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ContactMappingEntity.kt
index 6f0f2ad8f..82ad8bc25 100644
--- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ContactMappingEntity.kt
+++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ContactMappingEntity.kt
@@ -14,4 +14,6 @@ data class ContactMappingEntity(
val isOnFlipcash: Boolean = false,
@ColumnInfo(defaultValue = "")
val displayNumber: String = "",
+ @ColumnInfo(defaultValue = "")
+ val dmChatId: String = "",
)
diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt
index 1987d9068..273da49f7 100644
--- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt
+++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt
@@ -24,6 +24,9 @@ class ChatMemberDataSource @Inject constructor(
entities.map { mapper.toMember(it) }
} ?: emptyFlow()
+ suspend fun getMembersForChat(chatId: ChatId): List =
+ getMembersForChat(mapper.chatIdHex(chatId))
+
suspend fun getMembersForChat(chatIdHex: String): List =
db?.chatMemberDao()?.getMembersForChat(chatIdHex)?.map { mapper.toMember(it) } ?: emptyList()
@@ -33,11 +36,18 @@ class ChatMemberDataSource @Inject constructor(
}
suspend fun updatePointers(chatId: ChatId, pointer: MessagePointer) {
- db?.chatMemberDao()?.updatePointers(
- mapper.chatIdHex(chatId),
- mapper.userIdHex(pointer.userId),
- mapper.pointerToJson(pointer),
- )
+ val dao = db?.chatMemberDao() ?: return
+ val chatIdHex = mapper.chatIdHex(chatId)
+ val userIdHex = mapper.userIdHex(pointer.userId)
+
+ val existing = dao.getMember(chatIdHex, userIdHex)
+ val existingPointers = existing?.pointersJson ?: emptyList()
+
+ val merged = existingPointers
+ .filter { it.type != pointer.type.name }
+ .plus(mapper.pointerSerialized(pointer))
+
+ dao.updatePointers(chatIdHex, userIdHex, mapper.pointersToJson(merged))
}
suspend fun deleteForChat(chatId: ChatId) {
diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt
index 77ae51504..2256f61a9 100644
--- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt
+++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt
@@ -79,6 +79,11 @@ class ChatMessageDataSource @Inject constructor(
// endregion
+ fun observeForChat(chatId: ChatId): PagingSource {
+ return db?.chatMessageDao()?.observeMessagesPaged(mapper.chatIdHex(chatId))
+ ?: emptyPagingSource()
+ }
+
fun observeMessages(chatId: ChatId): Flow> =
db?.chatMessageDao()?.observeMessages(mapper.chatIdHex(chatId))?.map { entities ->
entities.map { toChatMessage(it) }
@@ -89,7 +94,14 @@ class ChatMessageDataSource @Inject constructor(
suspend fun upsert(chatId: ChatId, messages: List) {
val hex = mapper.chatIdHex(chatId)
- db?.chatMessageDao()?.upsert(messages.map { mapper.toEntity(hex, it) })
+ val entities = messages.map { mapper.toEntity(hex, it) }
+ val selfId = userManager.accountId
+ val hasSelfMessage = selfId != null && messages.any { it.senderId == selfId }
+ if (hasSelfMessage) {
+ db?.chatMessageDao()?.upsertAndClearPending(hex, entities)
+ } else {
+ db?.chatMessageDao()?.upsert(entities)
+ }
}
suspend fun insertPending(
@@ -111,11 +123,12 @@ class ChatMessageDataSource @Inject constructor(
)
}
- suspend fun confirmPending(chatId: ChatId, clientMessageId: ClientMessageId, serverMessageId: Long) {
+ suspend fun confirmPending(chatId: ChatId, clientMessageId: ClientMessageId, serverMessage: ChatMessage) {
+ val hex = mapper.chatIdHex(chatId)
db?.chatMessageDao()?.confirmPendingMessage(
- mapper.chatIdHex(chatId),
+ hex,
mapper.clientMessageIdHex(clientMessageId),
- serverMessageId,
+ mapper.toEntity(hex, serverMessage),
)
}
diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ContactDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ContactDataSource.kt
index 0cbc727ba..6954f4d9e 100644
--- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ContactDataSource.kt
+++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ContactDataSource.kt
@@ -101,5 +101,15 @@ class ContactDataSource @Inject constructor(
db?.contactDao()?.updatePhotoUri(e164, photoUri)
}
+ suspend fun getDmChatId(e164: String): String? =
+ db?.contactDao()?.getDmChatId(e164)
+
+ suspend fun updateDmChatId(e164: String, dmChatId: String) {
+ db?.contactDao()?.updateDmChatId(e164, dmChatId)
+ }
+
+ suspend fun getContactByDmChatId(dmChatId: String): ContactMappingEntity? =
+ db?.contactDao()?.getContactByDmChatId(dmChatId)
+
// endregion
}
diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt
index aaf3403a3..3dcbbb5fc 100644
--- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt
+++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt
@@ -11,6 +11,7 @@ import com.flipcash.app.persistence.entities.MessageStatus
import com.flipcash.services.models.SocialAccount
import com.flipcash.services.models.UserProfile
import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.chat.DeliveryStatus
import com.flipcash.services.models.chat.ChatMember
import com.flipcash.services.models.chat.ChatMessage
import com.flipcash.services.models.chat.ChatMetadata
@@ -20,7 +21,10 @@ import com.flipcash.services.models.chat.MessagePointer
import com.flipcash.services.models.chat.PointerType
import com.flipcash.services.models.chat.ClientMessageId
import com.getcode.opencode.model.core.ID
+import com.getcode.opencode.model.financial.CurrencyCode
import com.getcode.opencode.model.financial.Fiat
+import com.getcode.solana.keys.Mint
+import com.getcode.solana.keys.base58
import com.getcode.utils.base58
import com.getcode.utils.base64
import com.getcode.utils.decodeBase58
@@ -81,6 +85,11 @@ class ChatEntityMapper @Inject constructor() {
content = entity.contentJson?.map { it.toDomain() } ?: emptyList(),
timestamp = Instant.fromEpochMilliseconds(entity.timestampEpochMs),
unreadSeq = entity.unreadSeq,
+ deliveryStatus = when (entity.status) {
+ MessageStatus.SENDING -> DeliveryStatus.SENDING
+ MessageStatus.SENT -> DeliveryStatus.SENT
+ MessageStatus.FAILED -> DeliveryStatus.FAILED
+ },
)
}
@@ -146,6 +155,12 @@ class ChatEntityMapper @Inject constructor() {
)
}
+ fun pointerSerialized(pointer: MessagePointer): MessagePointerSerialized =
+ pointer.toSerialized()
+
+ fun pointersToJson(pointers: List): String =
+ kotlinx.serialization.json.Json.encodeToString(pointers)
+
fun userIdHex(userId: ID): String = userId.hexEncodedString()
private fun String.hexToByteArray(): ByteArray {
@@ -171,6 +186,10 @@ private fun MessageContent.toSerialized(): MessageContentSerialized = when (this
is MessageContent.Cash -> MessageContentSerialized.Cash(
intentId = intentId.base58,
quarks = amount.quarks,
+ currencyCode = amount.currencyCode.name,
+ mint = mint.base58(),
+ tokenName = tokenName,
+ tokenImageUrl = tokenImageUrl,
)
}
@@ -178,7 +197,13 @@ private fun MessageContentSerialized.toDomain(): MessageContent = when (this) {
is MessageContentSerialized.Text -> MessageContent.Text(text)
is MessageContentSerialized.Cash -> MessageContent.Cash(
intentId = intentId.decodeBase58().toList(),
- amount = Fiat(quarks = quarks),
+ amount = Fiat(
+ quarks = quarks,
+ currencyCode = CurrencyCode.tryValueOf(currencyCode) ?: CurrencyCode.USD,
+ ),
+ mint = Mint(mint.decodeBase58().toList()),
+ tokenName = tokenName,
+ tokenImageUrl = tokenImageUrl,
)
}
diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt
index 0e8c80d33..64bf778ca 100644
--- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt
+++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt
@@ -4,18 +4,21 @@ import androidx.core.net.toUri
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.navigation.DeeplinkAction
import com.flipcash.app.core.navigation.DeeplinkType
+import com.flipcash.services.models.chat.ChatId
import com.flipcash.app.core.navigation.Key
import com.flipcash.app.core.navigation.fragments
import com.flipcash.app.core.tokens.SwapPurpose
import com.flipcash.app.core.verification.email.EmailDeeplinkOrigin
import com.flipcash.app.router.Router
import com.flipcash.app.router.internal.AppRouter.Companion.cashLink
+import com.flipcash.app.router.internal.AppRouter.Companion.chat
import com.flipcash.app.router.internal.AppRouter.Companion.login
import com.flipcash.app.router.internal.AppRouter.Companion.token
import com.flipcash.app.router.internal.AppRouter.Companion.verification
import com.flipcash.services.user.AuthState
import com.getcode.solana.keys.Mint
import com.getcode.utils.decodeBase64
+import com.getcode.utils.decodeBase64UrlSafe
import com.getcode.utils.urlDecode
import dev.theolm.rinku.DeepLink
import org.json.JSONObject
@@ -28,6 +31,7 @@ internal class AppRouter(
val cashLink = listOf("c", "cash")
val verification = listOf("verify")
val token = listOf("token")
+ val chat = listOf("chat")
}
override fun dispatch(deepLink: DeepLink): DeeplinkAction {
@@ -54,6 +58,9 @@ internal class AppRouter(
)
is DeeplinkType.EmailVerification -> resolveEmailVerification(type)
+ is DeeplinkType.Chat -> DeeplinkAction.Navigate(
+ listOf(AppRoute.Sheets.Send(), AppRoute.Messaging.Chat(AppRoute.ChatIdentifier.ByChatId(type.chatId)))
+ )
}
}
@@ -63,6 +70,7 @@ internal class AppRouter(
deepLink.isCashLink() -> deepLink.handleCashLink()
deepLink.isToken() -> deepLink.handleTokenLink()
deepLink.isEmailVerification() -> deepLink.handleEmailVerification()
+ deepLink.isChat() -> deepLink.handleChat()
else -> null
}
}
@@ -116,6 +124,8 @@ private fun DeepLink.isToken(): Boolean = token.contains(pathSegments.getOrNull(
private fun DeepLink.isEmailVerification(): Boolean = verification.contains(pathSegments.getOrNull(0))
&& data.toUri().getQueryParameter("email") != null
+private fun DeepLink.isChat(): Boolean = chat.contains(pathSegments.getOrNull(0))
+
private fun DeepLink.handleLoginLink(): DeeplinkType.Login? {
val uri = data.toUri()
var entropy = uri.fragments[Key.entropy]
@@ -140,6 +150,13 @@ private fun DeepLink.handleTokenLink(): DeeplinkType.TokenInfo? {
return DeeplinkType.TokenInfo(Mint(mint))
}
+private fun DeepLink.handleChat(): DeeplinkType.Chat? {
+ val uri = data.toUri()
+ val chatIdBase64 = uri.pathSegments.getOrNull(1) ?: return null
+ val chatId = ChatId(chatIdBase64.decodeBase64UrlSafe().toList())
+ return DeeplinkType.Chat(chatId)
+}
+
// https://app.flipcash.com/verify?email={email}&code={code}&client_data={data}
private fun DeepLink.handleEmailVerification(): DeeplinkType.EmailVerification? {
val uri = data.toUri()
diff --git a/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt b/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt
index 46a1277ee..d8e158ff1 100644
--- a/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt
+++ b/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt
@@ -32,6 +32,7 @@ object Flipcash2ColorSpec {
val secondary = Color(115, 129, 121)
val secondaryText = Color.White.copy(alpha = 0.5f)
val cashBill = Color(0xFF06450F)
+ val notification = Color(0xFF009EE7)
val trackColor = Color.White.copy(alpha = 0.07f)
val bannerThemed = Color(0xFF252526)
val success = Color(0xFF1AC86A)
@@ -86,7 +87,7 @@ private val colors = with(Flipcash2ColorSpec) {
brandContainer = primary,
secondary = secondary,
tertiary = BrandAccent,
- indicator = BrandIndicator,
+ indicator = notification,
action = Gray50,
onAction = White,
background = primary,
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 95c7be83f..136671c65 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -90,7 +90,7 @@ bugsnag = "6.26.0"
bugsnag-agp = "8.2.0"
bugsnag-gradle-plugin = "1.1.0"
rinku = "1.6.0"
-haze = "2.0.0-alpha02"
+haze = "2.0.0-alpha03"
cloudy = "0.5.0"
phantom-connect-kmp = "2.0.2-1.0.0"
vico = "3.2.2"
@@ -258,7 +258,7 @@ cloudy = { module = "com.github.skydoves:cloudy", version.ref = "cloudy" }
fingerprint-pro = { module = "com.fingerprint.android:pro", version = "2.4.0" }
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
haze-blur = { module = "dev.chrisbanes.haze:haze-blur", version.ref = "haze" }
-haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
+haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" }
phantom-connect = { module = "dev.bmcreations:phantom-connect", version.ref = "phantom-connect-kmp" }
phantom-connect-wallet = { module = "dev.bmcreations:phantom-connect-wallet", version.ref = "phantom-connect-kmp" }
rinku = { module = "dev.theolm:rinku", version.ref = "rinku" }
@@ -306,7 +306,7 @@ androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", vers
compose = ["compose-ui", "compose-foundation", "compose-material", "compose-material-icons-extended", "compose-animation", "compose-activities"]
hilt = ["hilt-android", "javax-inject"]
hilt-compiler = ["hilt-android-compiler", "hilt-compiler"]
-haze = ["haze", "haze-blur"]
+haze = ["haze", "haze-blur", "haze-materials"]
room = ["androidx-room-runtime", "androidx-room-ktx", "androidx-room-paging"]
firebase = ["firebase-messaging", "firebase-installations"]
kotlinx-serialization = ["kotlinx-serialization-core", "kotlinx-serialization-json"]
diff --git a/libs/encryption/utils/src/main/kotlin/com/getcode/utils/Extensions.kt b/libs/encryption/utils/src/main/kotlin/com/getcode/utils/Extensions.kt
index 71a691c8e..fd4ab24fd 100644
--- a/libs/encryption/utils/src/main/kotlin/com/getcode/utils/Extensions.kt
+++ b/libs/encryption/utils/src/main/kotlin/com/getcode/utils/Extensions.kt
@@ -30,6 +30,10 @@ fun String.decodeBase64(): ByteArray {
return Base64.decode(this, Base64.NO_WRAP)
}
+fun String.decodeBase64UrlSafe(): ByteArray {
+ return Base64.decode(this, Base64.NO_WRAP or Base64.URL_SAFE)
+}
+
fun ByteArray.decodeBase64(): ByteArray {
return Base64.decode(this, Base64.NO_WRAP)
}
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactListController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactListController.kt
index a1ec51bc6..267a6f911 100644
--- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactListController.kt
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactListController.kt
@@ -1,6 +1,7 @@
package com.flipcash.services.controllers
import com.flipcash.services.models.ContactMethod
+import com.flipcash.services.models.FlipcashContactEntry
import com.flipcash.services.repository.ContactListRepository
import com.flipcash.services.user.UserManager
import com.getcode.solana.keys.Checksum
@@ -37,7 +38,7 @@ class ContactListController @Inject constructor(
return repository.fullUpload(owner, phones, expectedChecksum)
}
- fun getFlipcashContacts(checksum: Checksum): Flow>> {
+ fun getFlipcashContacts(checksum: Checksum): Flow>> {
val owner = userManager.accountCluster?.authority?.keyPair
?: throw IllegalStateException("No account cluster in UserManager")
return repository.getFlipcashContacts(owner, checksum)
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/EventStreamingController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/EventStreamingController.kt
index 6cc5d9eee..8c2af1cf0 100644
--- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/EventStreamingController.kt
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/EventStreamingController.kt
@@ -26,10 +26,13 @@ class EventStreamingController @Inject constructor(
private var streamRef: EventStreamReference? = null
- fun open(scope: CoroutineScope) {
+ fun open(
+ scope: CoroutineScope,
+ onStreamError: (() -> Unit)? = null,
+ ): Boolean {
val owner = userManager.accountCluster?.authority?.keyPair ?: run {
trace("EventStreamingController: No account cluster, cannot open stream")
- return
+ return false
}
close()
@@ -38,12 +41,15 @@ class EventStreamingController @Inject constructor(
scope = scope,
owner = owner,
onEvent = { update ->
+ trace("EventStreamingController: Received chat update, messages=${update.newMessages.size}")
_chatUpdates.tryEmit(update)
},
onError = { error ->
trace("EventStreamingController: Stream error: ${error.message}")
+ onStreamError?.invoke()
},
)
+ return true
}
fun close() {
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt
index 42e9275c3..ba1a3f6dc 100644
--- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt
@@ -117,6 +117,7 @@ internal fun MessageContent.asContent(): MessagingModel.Content {
.setAmount(
Common.CryptoPaymentAmount.newBuilder()
.setQuarks(amount.quarks)
+ .setMint(Common.PublicKey.newBuilder().setValue(mint.bytes.toByteString()))
)
)
.build()
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt
index 0a2b5000b..107742113 100644
--- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt
@@ -14,8 +14,11 @@ import com.flipcash.services.models.NotificationCategory
import com.flipcash.services.models.NotificationPayload
import com.flipcash.services.models.PagingToken
import com.flipcash.services.models.Substitution
+import com.flipcash.services.models.UserProfile
import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.chat.ChatMember
import com.flipcash.services.models.chat.ChatMessage
+import com.flipcash.services.models.chat.ChatMetadata
import com.flipcash.services.models.chat.ChatType
import com.flipcash.services.models.chat.ChatUpdate
import com.flipcash.services.models.chat.MessageContent
@@ -25,6 +28,7 @@ import com.flipcash.services.models.chat.PointerType
import com.flipcash.services.models.chat.TypingNotification
import com.flipcash.services.models.chat.TypingState
import com.getcode.opencode.model.core.ID
+import com.getcode.opencode.model.financial.CurrencyCode
import com.getcode.opencode.model.financial.Fiat
import com.getcode.solana.keys.Checksum
import com.getcode.solana.keys.Mint
@@ -108,7 +112,11 @@ internal fun MessagingModel.Content.toMessageContent(): MessageContent {
MessagingModel.Content.TypeCase.TEXT -> MessageContent.Text(text.text)
MessagingModel.Content.TypeCase.CASH -> MessageContent.Cash(
intentId = cash.intentId.value.toByteArray().toList(),
- amount = Fiat(quarks = cash.amount.quarks),
+ amount = Fiat(
+ fiat = cash.amount.nativeAmount,
+ currencyCode = CurrencyCode.tryValueOf(cash.amount.currency) ?: CurrencyCode.USD,
+ ),
+ mint = cash.amount.mint.value.toByteArray().toMint(),
)
else -> MessageContent.Text("")
}
@@ -161,7 +169,7 @@ internal fun MessagingModel.IsTypingNotification.State.toTypingState(): TypingSt
// -- Chat metadata updates --
internal fun ChatModel.MetadataUpdate.toMetadataUpdate(
- metadataMapper: (ChatModel.Metadata) -> com.flipcash.services.models.chat.ChatMetadata,
+ metadataMapper: (ChatModel.Metadata) -> ChatMetadata,
): MetadataUpdate {
return when (kindCase) {
ChatModel.MetadataUpdate.KindCase.FULL_REFRESH ->
@@ -188,19 +196,21 @@ internal fun ChatModel.Metadata.ChatType.toChatType(): ChatType {
// -- Chat metadata (simple, no injected mapper) --
-internal fun ChatModel.Metadata.toChatMetadata(): com.flipcash.services.models.chat.ChatMetadata {
- return com.flipcash.services.models.chat.ChatMetadata(
+internal fun ChatModel.Metadata.toChatMetadata(): ChatMetadata {
+ return ChatMetadata(
chatId = chatId.toChatId(),
type = type.toChatType(),
members = membersList.map { member ->
- com.flipcash.services.models.chat.ChatMember(
+ ChatMember(
userId = member.userId.toId(),
- userProfile = com.flipcash.services.models.UserProfile(
- displayName = member.userProfile.displayName,
- socialAccounts = emptyList(),
- verifiedPhoneNumber = null,
- verifiedEmailAddress = null,
- ),
+ userProfile = with (member.userProfile) {
+ UserProfile(
+ displayName = displayName,
+ socialAccounts = emptyList(),
+ verifiedPhoneNumber = phoneNumber.value.takeIf { it.isNotEmpty() },
+ verifiedEmailAddress = emailAddress.value.takeIf { it.isNotEmpty() },
+ )
+ },
pointers = member.pointersList.map { it.toPointer() },
)
},
@@ -212,7 +222,7 @@ internal fun ChatModel.Metadata.toChatMetadata(): com.flipcash.services.models.c
// -- EventModel.ChatUpdate --
internal fun EventModel.ChatUpdate.toChatUpdate(
- metadataMapper: (ChatModel.Metadata) -> com.flipcash.services.models.chat.ChatMetadata = { it.toChatMetadata() },
+ metadataMapper: (ChatModel.Metadata) -> ChatMetadata = { it.toChatMetadata() },
): ChatUpdate {
return ChatUpdate(
chatId = chat.toChatId(),
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ContactListService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ContactListService.kt
index 24a926e54..465706367 100644
--- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ContactListService.kt
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ContactListService.kt
@@ -1,8 +1,10 @@
package com.flipcash.services.internal.network.services
import com.flipcash.services.models.ContactMethod
+import com.flipcash.services.models.FlipcashContactEntry
import com.flipcash.services.internal.network.api.ContactListApi
import com.flipcash.services.internal.network.extensions.toChecksum
+import com.flipcash.services.internal.network.extensions.toChatId
import com.flipcash.services.models.CheckSyncError
import com.flipcash.services.models.DeltaUploadError
import com.flipcash.services.models.FullUploadError
@@ -110,11 +112,18 @@ internal class ContactListService @Inject constructor(
fun getContacts(
owner: KeyPair,
checksum: Checksum,
- ): Flow>> {
+ ): Flow>> {
return api.getFlipcashContacts(owner, checksum).map { response ->
when (response.result) {
RpcContactListService.GetFlipcashContactsResponse.Result.OK ->
- Result.success(response.contactsList.map { ContactMethod.Phone(it.phone.value) })
+ Result.success(response.contactsList.map { proto ->
+ FlipcashContactEntry(
+ phoneNumber = proto.phone.value,
+ dmChatId = proto.dmChatId
+ .takeIf { !it.value.isEmpty }
+ ?.toChatId(),
+ )
+ })
RpcContactListService.GetFlipcashContactsResponse.Result.DENIED ->
Result.failure(GetContactsError.Denied())
RpcContactListService.GetFlipcashContactsResponse.Result.NOT_FOUND ->
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalContactListRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalContactListRepository.kt
index 4f4986505..6f8373081 100644
--- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalContactListRepository.kt
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalContactListRepository.kt
@@ -2,6 +2,7 @@ package com.flipcash.services.internal.repositories
import com.flipcash.services.internal.network.services.ContactListService
import com.flipcash.services.models.ContactMethod
+import com.flipcash.services.models.FlipcashContactEntry
import com.flipcash.services.repository.ContactListRepository
import com.getcode.ed25519.Ed25519
import com.getcode.solana.keys.Checksum
@@ -36,5 +37,5 @@ internal class InternalContactListRepository(
override fun getFlipcashContacts(
owner: Ed25519.KeyPair,
checksum: Checksum,
- ): Flow>> = service.getContacts(owner, checksum)
+ ): Flow>> = service.getContacts(owner, checksum)
}
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/DmPaymentMetadata.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/DmPaymentMetadata.kt
new file mode 100644
index 000000000..0212fe056
--- /dev/null
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/DmPaymentMetadata.kt
@@ -0,0 +1,31 @@
+package com.flipcash.services.models
+
+import com.codeinc.flipcash.gen.common.v1.Common
+import com.codeinc.flipcash.gen.intent.v1.Model as FlipcashIntentModel
+import com.codeinc.flipcash.gen.phone.v1.Model as PhoneModel
+import com.flipcash.services.models.chat.ChatId
+import com.getcode.utils.toByteString
+
+/**
+ * Builds the serialized Flipcash `AppMetadata` proto bytes for a contact DM payment.
+ *
+ * Returns `null` if any required parameter is missing, so the caller can simply
+ * pass the result through without conditional logic.
+ */
+fun buildDmPaymentMetadata(
+ chatId: ChatId?,
+ sourcePhone: String?,
+ destinationPhone: String?,
+): ByteArray? {
+ if (chatId == null || sourcePhone == null || destinationPhone == null) return null
+ return FlipcashIntentModel.AppMetadata.newBuilder()
+ .setChat(
+ FlipcashIntentModel.ChatMetadata.newBuilder()
+ .setChatId(Common.ChatId.newBuilder().setValue(chatId.bytes.toByteString()))
+ .setContactDmPayment(
+ FlipcashIntentModel.ChatMetadata.ContactDmPayment.newBuilder()
+ .setSource(PhoneModel.PhoneNumber.newBuilder().setValue(sourcePhone))
+ .setDestination(PhoneModel.PhoneNumber.newBuilder().setValue(destinationPhone))
+ )
+ ).build().toByteArray()
+}
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/FlipcashContactEntry.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/FlipcashContactEntry.kt
new file mode 100644
index 000000000..d14936631
--- /dev/null
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/FlipcashContactEntry.kt
@@ -0,0 +1,8 @@
+package com.flipcash.services.models
+
+import com.flipcash.services.models.chat.ChatId
+
+data class FlipcashContactEntry(
+ val phoneNumber: String,
+ val dmChatId: ChatId?,
+)
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt
index df1a3a917..e5b4f46c5 100644
--- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt
@@ -2,6 +2,7 @@ package com.flipcash.services.models
import com.codeinc.flipcash.gen.push.v1.Model
import com.flipcash.services.internal.network.extensions.asPayload
+import com.flipcash.services.models.chat.ChatId
import com.getcode.solana.keys.Mint
import com.getcode.utils.decodeBase64
@@ -43,5 +44,5 @@ data class NotificationPayload(
sealed interface NavigationTrigger {
data class CurrencyInfo(val mint: Mint) : NavigationTrigger
- data class Chat(val chatId: com.flipcash.services.models.chat.ChatId) : NavigationTrigger
+ data class Chat(val chatId: ChatId) : NavigationTrigger
}
\ No newline at end of file
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatId.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatId.kt
index 9ac65010a..c8e5fb00d 100644
--- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatId.kt
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatId.kt
@@ -1,13 +1,39 @@
package com.flipcash.services.models.chat
+import android.os.Parcel
+import android.os.Parcelable
import com.getcode.utils.base58
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
-data class ChatId(val bytes: ByteArray) {
+object ChatIdAsHexSerializer : KSerializer {
+ override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ChatId", PrimitiveKind.STRING)
+
+ @OptIn(ExperimentalStdlibApi::class)
+ override fun serialize(encoder: Encoder, value: ChatId) {
+ encoder.encodeString(value.bytes.toHexString())
+ }
+
+ @OptIn(ExperimentalStdlibApi::class)
+ override fun deserialize(decoder: Decoder): ChatId {
+ return ChatId(decoder.decodeString().hexToByteArray())
+ }
+}
+
+@Serializable(with = ChatIdAsHexSerializer::class)
+data class ChatId(val bytes: ByteArray) : Parcelable {
constructor(bytes: List): this(bytes.toByteArray())
@OptIn(ExperimentalStdlibApi::class)
constructor(hex: String): this(hex.hexToByteArray())
+ private constructor(parcel: Parcel): this(parcel.readString().orEmpty())
+
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ChatId) return false
@@ -17,4 +43,20 @@ data class ChatId(val bytes: ByteArray) {
override fun hashCode(): Int = bytes.contentHashCode()
override fun toString(): String = bytes.base58
+
+ override fun describeContents(): Int = 0
+
+ @OptIn(ExperimentalStdlibApi::class)
+ override fun writeToParcel(dest: Parcel, flags: Int) {
+ dest.writeString(bytes.toHexString())
+ }
+
+ companion object {
+ @JvmField
+ val CREATOR: Parcelable.Creator =
+ object : Parcelable.Creator {
+ override fun createFromParcel(parcel: Parcel) = ChatId(parcel)
+ override fun newArray(size: Int) = arrayOfNulls(size)
+ }
+ }
}
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt
index e0e1d0ff7..5df08f32c 100644
--- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt
@@ -10,4 +10,5 @@ data class ChatMessage(
val timestamp: Instant,
val unreadSeq: Long,
val isFromSelf: Boolean = false,
+ val deliveryStatus: DeliveryStatus = DeliveryStatus.SENT,
)
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/DeliveryStatus.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/DeliveryStatus.kt
new file mode 100644
index 000000000..809be07ac
--- /dev/null
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/DeliveryStatus.kt
@@ -0,0 +1,3 @@
+package com.flipcash.services.models.chat
+
+enum class DeliveryStatus { SENDING, SENT, FAILED }
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt
index 076b74c1f..9d8dac8ee 100644
--- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt
@@ -2,8 +2,15 @@ package com.flipcash.services.models.chat
import com.getcode.opencode.model.core.ID
import com.getcode.opencode.model.financial.Fiat
+import com.getcode.solana.keys.Mint
sealed interface MessageContent {
data class Text(val text: String) : MessageContent
- data class Cash(val intentId: ID, val amount: Fiat) : MessageContent
+ data class Cash(
+ val intentId: ID,
+ val amount: Fiat,
+ val mint: Mint,
+ val tokenName: String = "",
+ val tokenImageUrl: String = "",
+ ) : MessageContent
}
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ContactListRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ContactListRepository.kt
index c166720a3..d637bf49f 100644
--- a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ContactListRepository.kt
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ContactListRepository.kt
@@ -1,6 +1,7 @@
package com.flipcash.services.repository
import com.flipcash.services.models.ContactMethod
+import com.flipcash.services.models.FlipcashContactEntry
import com.getcode.ed25519.Ed25519.KeyPair
import com.getcode.solana.keys.Checksum
import kotlinx.coroutines.flow.Flow
@@ -28,5 +29,5 @@ interface ContactListRepository {
fun getFlipcashContacts(
owner: KeyPair,
checksum: Checksum,
- ): Flow>>
+ ): Flow>>
}
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt
index d3ff451e3..291154fa1 100644
--- a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt
@@ -131,6 +131,7 @@ class TransactionController @Inject constructor(
token: Token,
source: AccountCluster,
destinationOwner: PublicKey,
+ appMetadata: ByteArray? = null,
scope: CoroutineScope = this.scope,
): Result {
val verifiedState = amount.verifiedState
@@ -146,6 +147,7 @@ class TransactionController @Inject constructor(
destination = destinationVault,
destinationOwner = destinationOwner,
verifiedState = verifiedState,
+ appMetadata = appMetadata,
)
return submitIntent(scope, intent, source.authority.keyPair)
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentTransfer.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentTransfer.kt
index d0ac15a21..933155b23 100644
--- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentTransfer.kt
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentTransfer.kt
@@ -76,6 +76,7 @@ internal class IntentTransfer(
destination: PublicKey,
destinationOwner: PublicKey,
verifiedState: VerifiedState,
+ appMetadata: ByteArray? = null,
): IntentTransfer {
val transfer = ActionPublicTransfer.newInstance(
owner = sourceCluster.authority.keyPair,
@@ -96,6 +97,7 @@ internal class IntentTransfer(
mint = mint,
isIndirect = false,
isWithdrawal = false,
+ appMetadata = appMetadata,
),
actionGroup = ActionGroup().apply {
actions = listOf(transfer)
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/LocalToProtobuf.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/LocalToProtobuf.kt
index 06b87227a..e02185ad0 100644
--- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/LocalToProtobuf.kt
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/LocalToProtobuf.kt
@@ -165,6 +165,12 @@ internal fun TransactionMetadata.asProtobufMetadata(): OcpTransactionService.Met
.setIsWithdrawal(isWithdrawal)
.build()
)
+ if (appMetadata != null) {
+ builder.setAppMetadata(
+ OcpTransactionService.AppMetadata.newBuilder()
+ .setValue(appMetadata.toByteString())
+ )
+ }
}
is TransactionMetadata.PublicDistribution -> {
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/TransactionMetadata.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/TransactionMetadata.kt
index 0c2f77ab1..4fa1c3a90 100644
--- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/TransactionMetadata.kt
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/TransactionMetadata.kt
@@ -54,6 +54,7 @@ sealed interface TransactionMetadata {
override val verifiedExchangeData: ExchangeData.Verified? = null,
val isIndirect: Boolean,
val isWithdrawal: Boolean,
+ val appMetadata: ByteArray? = null,
): PublicPayment {
constructor(
source: PublicKey,
@@ -87,6 +88,7 @@ sealed interface TransactionMetadata {
mint: Mint,
isIndirect: Boolean,
isWithdrawal: Boolean,
+ appMetadata: ByteArray? = null,
) : this(
source = source,
destination = destination,
@@ -106,6 +108,7 @@ sealed interface TransactionMetadata {
),
isIndirect = isIndirect,
isWithdrawal = isWithdrawal,
+ appMetadata = appMetadata,
)
constructor(