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(