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) {