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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,7 @@
<string name="title_noContacts">No Contacts Yet</string>
<string name="subtitle_noContacts">Tap the + button to add contacts</string>

<string name="title_recents">Recents</string>
<string name="title_flipcashContacts">On Flipcash</string>
<string name="title_nonFlipcashContacts">Not On Flipcash Yet</string>
<plurals name="prompt_title_contactsAlreadyOnFlipcash">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String>()
// 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<String>()
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
Expand All @@ -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<ContactListItem.ContactRow> { 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<ContactListItem.ContactRow> { 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)) }
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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<ChatState>
get() = _state.asStateFlow()
Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -280,6 +284,7 @@ class ChatCoordinator @Inject constructor(
}

suspend fun reset() {
stopHeartbeat()
closeEventStream()
syncJob?.cancel()
_state.value = ChatState()
Expand Down Expand Up @@ -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(
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class BidirectionalStreamReference<Request, Response>(
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) {
Expand Down
Loading