diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 0aafbf557..58d3e9aee 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -732,6 +732,7 @@ No Contacts Yet Tap the + button to add contacts + Recents On Flipcash Not On Flipcash Yet 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 87989f21e..75b013917 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 @@ -30,7 +30,6 @@ 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 @@ -265,39 +264,33 @@ internal class SendFlowViewModel @Inject constructor( } val selfId = userManager.accountId - val contactsByE164 = contactState.contacts - val flipcashRows = if (messengerEnabled) { + 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() + // Build a reverse lookup: e164 -> chatId string for contacts with DMs + val e164ToChatId = contactState.dmChatIds - // 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, - ) - } + // Recents — driven by the chat feed, enriched with contact info + val recentsE164s = mutableSetOf() + val recentRows = dmChats.mapNotNull { summary -> + val chatId = summary.metadata.chatId + val chatIdStr = chatId.toString() - // 2. Non-contact DMs (chats not matched to a device contact) - val nonContactRows = dmChats - .filter { it.metadata.chatId.toString() !in consumedChatIds } - .mapNotNull { summary -> + // Try to match this chat to a device contact + val e164 = e164ToChatId.entries + .firstOrNull { it.value == chatIdStr }?.key + val deviceContact = e164?.let { contactState.contacts[it] } + + val contact = if (deviceContact != null) { + if (searchString.isNotBlank() && + !deviceContact.displayName.contains(searchString, ignoreCase = true) && + !deviceContact.e164.contains(searchString, ignoreCase = true)) { + return@mapNotNull null + } + recentsE164s += deviceContact.e164 + deviceContact + } else { + // Non-contact DM — build contact from chat member profile val otherMember = summary.metadata.members .firstOrNull { it.userId != selfId } ?: return@mapNotNull null val phone = otherMember.userProfile.verifiedPhoneNumber @@ -306,53 +299,75 @@ internal class SendFlowViewModel @Inject constructor( ?: formattedPhone ?: "Unknown Contact" - val contact = DeviceContact.unknownContact( + val unknown = DeviceContact.unknownContact( e164 = phone.orEmpty(), displayName = displayName, displayNumber = formattedPhone, ) if (searchString.isNotBlank() && - !contact.displayName.contains(searchString, ignoreCase = true)) { + !unknown.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, - ) + unknown } - (deviceContactRows + nonContactRows) - .sortedWith( - compareByDescending { it.lastActivity } - .thenBy(String.CASE_INSENSITIVE_ORDER) { it.contact.displayName } + ContactListItem.ContactRow( + contact = contact, + isOnFlipcash = true, + lastMessagePreview = formatPreview(summary, selfId, tokensByMint), + unreadCount = summary.unreadCount, + chatId = chatId, + lastActivity = summary.metadata.lastActivity, ) + }.sortedWith( + compareByDescending { it.lastActivity } + .thenBy(String.CASE_INSENSITIVE_ORDER) { it.contact.displayName } + ) + + // On Flipcash — contacts that haven't chatted yet + val flipcashRows = filtered + .filter { it.e164 in contactState.flipcashE164s && it.e164 !in recentsE164s } + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName }) + .map { ContactListItem.ContactRow(contact = it, isOnFlipcash = true) } + + val excludedE164s = recentsE164s + contactState.flipcashE164s + val other = filtered + .filter { it.e164 !in excludedE164s } + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName }) + + if (recentRows.isNotEmpty()) { + add(ContactListItem.Header(resources.getString(R.string.title_recents))) + addAll(recentRows) + } + if (flipcashRows.isNotEmpty()) { + add(ContactListItem.Header(resources.getString(R.string.title_flipcashContacts))) + addAll(flipcashRows) + } + if (other.isNotEmpty()) { + add(ContactListItem.Header(resources.getString(R.string.title_nonFlipcashContacts))) + other.forEach { add(ContactListItem.ContactRow(it, isOnFlipcash = false)) } + } } else { - // Driven by device contacts on Flipcash - filtered + val flipcashRows = 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 flipcashE164s = flipcashRows.mapTo(mutableSetOf()) { it.contact.e164 } + .plus(contactState.flipcashE164s) - val other = filtered - .filter { it.e164 !in flipcashE164s } - .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName }) + val other = filtered + .filter { it.e164 !in flipcashE164s } + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName }) - if (flipcashRows.isNotEmpty()) { - add(ContactListItem.Header(resources.getString(R.string.title_flipcashContacts))) - addAll(flipcashRows) - } - if (other.isNotEmpty()) { - add(ContactListItem.Header(resources.getString(R.string.title_nonFlipcashContacts))) - other.forEach { add(ContactListItem.ContactRow(it, isOnFlipcash = false)) } + if (flipcashRows.isNotEmpty()) { + add(ContactListItem.Header(resources.getString(R.string.title_flipcashContacts))) + addAll(flipcashRows) + } + if (other.isNotEmpty()) { + add(ContactListItem.Header(resources.getString(R.string.title_nonFlipcashContacts))) + other.forEach { add(ContactListItem.ContactRow(it, isOnFlipcash = false)) } + } } } 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 db4cc7801..1e88910b9 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 @@ -465,9 +465,11 @@ private fun ContactListPreview() { } val items = buildList { - add(ContactListItem.Header("Flipcash Contacts")) - addAll(flipcashContacts) - add(ContactListItem.Header("Other Contacts")) + add(ContactListItem.Header("Recents")) + addAll(flipcashContacts.take(3)) + add(ContactListItem.Header("On Flipcash")) + addAll(flipcashContacts.drop(3)) + add(ContactListItem.Header("Not On Flipcash Yet")) addAll(otherContacts) } 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 4abece695..767822374 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 @@ -234,15 +234,23 @@ private fun UserControlBottomBar( ) { s -> when (s) { ChatViewModel.UserState.Reading -> { + val isUnknownContact = state.chattingWith?.isUnknown == true || state.chattingWith == null Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { - CodeButton( + AnimatedVisibility( + visible = !isUnknownContact, modifier = Modifier.weight(1f), - buttonState = ButtonState.Filled, - text = stringResource(R.string.action_sendCash), - ) { dispatch(ChatViewModel.Event.OnSendCash) } + enter = expandHorizontally(expandFrom = Alignment.Start) + fadeIn(), + exit = shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut(), + ) { + CodeButton( + modifier = Modifier.weight(1f), + buttonState = ButtonState.Filled, + text = stringResource(R.string.action_sendCash), + ) { dispatch(ChatViewModel.Event.OnSendCash) } + } AnimatedVisibility( visible = state.typingConstraints.enabled, modifier = Modifier.weight(1f), 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 bc0020cad..e925c4e75 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 @@ -77,6 +77,7 @@ class ChatCoordinator @Inject constructor( companion object { private const val TAG = "ChatCoordinator" + private val HEARTBEAT_INTERVAL = 30.seconds } private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -85,6 +86,7 @@ class ChatCoordinator @Inject constructor( private var syncJob: Job? = null private var eventStreamCollectJob: Job? = null private var eventStreamRetryJob: Job? = null + private var heartbeatJob: Job? = null val state: StateFlow get() = _state.asStateFlow() @@ -140,10 +142,12 @@ class ChatCoordinator @Inject constructor( trace(tag = TAG, message = "Lifecycle resumed, syncing chat feed", type = TraceType.Process) syncFeed() openEventStream() + startHeartbeat() } } override fun onStop(owner: LifecycleOwner) { + stopHeartbeat() closeEventStream() } @@ -280,6 +284,7 @@ class ChatCoordinator @Inject constructor( } suspend fun reset() { + stopHeartbeat() closeEventStream() syncJob?.cancel() _state.value = ChatState() @@ -364,6 +369,26 @@ class ChatCoordinator @Inject constructor( eventStreamingController.close() } + private fun startHeartbeat() { + stopHeartbeat() + heartbeatJob = scope.launch { + while (true) { + delay(HEARTBEAT_INTERVAL) + trace(tag = TAG, message = "Heartbeat: syncing feed", type = TraceType.Process) + syncFeed() + if (!eventStreamingController.isConnected) { + trace(tag = TAG, message = "Heartbeat: event stream dead, reconnecting", type = TraceType.Process) + openEventStream() + } + } + } + } + + private fun stopHeartbeat() { + heartbeatJob?.cancel() + heartbeatJob = null + } + private suspend fun applyUpdate(update: ChatUpdate) { val chatId = update.chatId trace( @@ -383,13 +408,20 @@ class ChatCoordinator @Inject constructor( 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 + val exists = state.feed.any { it.chatId == chatId } + val updatedFeed = if (exists) { + state.feed.map { meta -> + if (meta.chatId == chatId) { + meta.copy( + lastMessage = lastMsg, + lastActivity = lastMsg.timestamp, + ) + } else meta + } + } else { + // New chat not yet in feed — trigger a sync to pick up full metadata + syncFeed() + state.feed } state.copy(feed = updatedFeed) } @@ -445,8 +477,13 @@ class ChatCoordinator @Inject constructor( memberDataSource.upsert(metaUpdate.metadata.chatId, metaUpdate.metadata.members) _state.update { state -> - val updatedFeed = state.feed.map { - if (it.chatId == metaUpdate.metadata.chatId) metaUpdate.metadata else it + val exists = state.feed.any { it.chatId == metaUpdate.metadata.chatId } + val updatedFeed = if (exists) { + state.feed.map { + if (it.chatId == metaUpdate.metadata.chatId) metaUpdate.metadata else it + } + } else { + state.feed + metaUpdate.metadata } state.copy(feed = updatedFeed) } 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 8c2af1cf0..c1f76c462 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,6 +26,8 @@ class EventStreamingController @Inject constructor( private var streamRef: EventStreamReference? = null + val isConnected: Boolean get() = streamRef?.isActive == true + fun open( scope: CoroutineScope, onStreamError: (() -> Unit)? = null, diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/bidi/BidirectionalStreamReference.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/bidi/BidirectionalStreamReference.kt index 95b67bee1..146232f94 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/bidi/BidirectionalStreamReference.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/bidi/BidirectionalStreamReference.kt @@ -18,6 +18,8 @@ class BidirectionalStreamReference( private val supervisorJob = SupervisorJob() val coroutineScope = CoroutineScope(supervisorJob + scope.coroutineContext) + val isActive: Boolean get() = isStreamActive + private var isStreamActive: Boolean = false set(value) { if (field != value) {