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
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ import com.getcode.ui.utils.heightOrZero
import com.getcode.ui.utils.widthOrZero

data class NavigationBarState(
val notificationUnreadCount: Int = 0,
val contactDmUnreadCount: Int = 0,
val showToast: Boolean = false,
val toastText: String? = null,
val isPaused: Boolean = false,
Expand Down Expand Up @@ -144,7 +144,7 @@ fun NavigationBar(
NavBarButton.Send -> BottomBarAction(
modifier = buttonModifier,
label = stringResource(R.string.action_send),
badgeCount = state.notificationUnreadCount,
badgeCount = state.contactDmUnreadCount,
painter = painterResource(R.drawable.ic_send_outlined),
onClick = { onButtonClick(NavBarButton.Send) }
)
Expand Down Expand Up @@ -301,7 +301,7 @@ private fun BottomBarAction(
@Composable
private fun NavigationBarPreview() {
NavigationBar(
state = NavigationBarState(notificationUnreadCount = 100),
state = NavigationBarState(contactDmUnreadCount = 100),
)
}
@Preview
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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.ChatSummary
import com.flipcash.shared.chat.ui.ConversationReference
import com.getcode.opencode.model.core.ID
import com.getcode.opencode.model.financial.Token
import com.getcode.solana.keys.Mint
Expand Down Expand Up @@ -109,7 +110,7 @@ internal class ContactListBuilder @Inject constructor(
contact = contact,
isOnFlipcash = true,
lastActivity = summary.metadata.lastActivity,
conversation = Conversation(
conversation = ConversationReference(
chatId = chatId,
lastMessagePreview = formatPreview(summary, selfId, tokensByMint),
unreadCount = summary.unreadCount,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.flipcash.app.directsend.internal

import com.flipcash.app.core.contacts.DeviceContact
import com.flipcash.services.models.chat.ChatId
import com.flipcash.shared.chat.ui.ConversationReference
import kotlin.time.Instant

internal sealed interface ContactListItem {
Expand All @@ -22,14 +23,6 @@ internal sealed interface ContactListItem {
val contact: DeviceContact,
val isOnFlipcash: Boolean,
val lastActivity: Instant? = null,
val conversation: Conversation? = null,
val conversation: ConversationReference? = null,
) : ContactListItem
}

/** Presentation state derived from an existing DM with a contact. */
internal data class Conversation(
val chatId: ChatId,
val lastMessagePreview: String? = null,
val unreadCount: Int = 0,
val isTyping: Boolean = false,
)
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import com.flipcash.app.featureflags.FeatureFlagController
import com.flipcash.app.permissions.PickedContact
import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.features.directsend.R
import com.flipcash.services.models.chat.ChatType
import com.flipcash.services.user.UserManager
import com.flipcash.shared.chat.ChatCoordinator
import com.getcode.manager.BottomBarManager
Expand Down Expand Up @@ -92,7 +93,7 @@ internal class SendFlowViewModel @Inject constructor(
featureFlags.observe(FeatureFlag.PhoneNumberSend),
featureFlags.observe(FeatureFlag.ContactPickerMode),
contactCoordinator.state,
chatCoordinator.feed,
chatCoordinator.feed(ChatType.CONTACT_DM),
) { userState, phoneNumberSendFlag, contactPickerMode, contactState, chats ->
val hasLinkedPhone = userState.userProfile?.verifiedPhoneNumber != null
val phoneNumberSendEnabled = phoneNumberSendFlag ||
Expand All @@ -119,7 +120,7 @@ internal class SendFlowViewModel @Inject constructor(
.map { it.searchState }
.distinctUntilChanged()
.flatMapLatest { snapshotFlow { it.text } },
chatCoordinator.feed,
chatCoordinator.feed(ChatType.CONTACT_DM),
tokenCoordinator.tokens,
) { contactState, searchText, chatFeed, tokens ->
contactListBuilder.build(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Search
Expand All @@ -36,39 +36,33 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
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.sp
import androidx.compose.ui.zIndex
import com.flipcash.app.contacts.ui.ContactAvatar
import com.flipcash.app.core.android.extensions.launchAppSettings
import com.flipcash.app.core.contacts.DeviceContact
import com.flipcash.app.directsend.internal.ContactListItem
import com.flipcash.app.directsend.internal.Conversation
import com.flipcash.app.permissions.ContactAccessHandle
import com.flipcash.app.theme.FlipcashThemeWrapper
import com.flipcash.features.directsend.R
import com.flipcash.services.models.chat.ChatId
import com.flipcash.shared.chat.ui.AnimatedConversationPaymentsPreview
import com.flipcash.shared.chat.ui.ConversationReference
import com.getcode.theme.CodeTheme
import com.getcode.theme.White10
import com.getcode.theme.extraLarge
import com.getcode.theme.extraSmall
import com.getcode.ui.core.verticalScrollStateGradient
import com.getcode.ui.theme.CodeButton
import com.getcode.util.formatLocalized
import com.getcode.util.permissions.PermissionResult
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
Expand Down Expand Up @@ -576,7 +570,7 @@ private fun ContactListPreview() {
contact = flipcashContacts[0].contact,
isOnFlipcash = true,
lastActivity = now - 5.minutes,
conversation = Conversation(
conversation = ConversationReference(
chatId = ChatId(byteArrayOf(1)),
lastMessagePreview = "Sent you $10.00",
unreadCount = 2,
Expand All @@ -587,7 +581,7 @@ private fun ContactListPreview() {
contact = flipcashContacts[1].contact,
isOnFlipcash = true,
lastActivity = now - 1.hours,
conversation = Conversation(
conversation = ConversationReference(
chatId = ChatId(byteArrayOf(2)),
lastMessagePreview = "See you soon",
isTyping = true,
Expand All @@ -598,7 +592,7 @@ private fun ContactListPreview() {
contact = flipcashContacts[2].contact,
isOnFlipcash = true,
lastActivity = now - 26.hours,
conversation = Conversation(
conversation = ConversationReference(
chatId = ChatId(byteArrayOf(3)),
lastMessagePreview = "You: Thanks!",
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ internal fun ScannerNavigationBar(
modifier = modifier,
config = effectiveConfig,
state = NavigationBarState(
notificationUnreadCount = state.notificationUnreadCount,
contactDmUnreadCount = state.contactDmUnreadCount,
showToast = billState.showToast && billState.toast != null,
toastText = billState.toast?.formattedAmount,
isPaused = isPaused,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.flipcash.shared.chat.ui

import com.flipcash.services.models.chat.ChatId

/** Presentation state derived from an existing DM with a contact. */
data class ConversationReference(
val chatId: ChatId,
val lastMessagePreview: String? = null,
val unreadCount: Int = 0,
val isTyping: Boolean = false,
)
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.flipcash.app.core.contacts.DeviceContact
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.ChatType
import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.models.chat.MessagePointer
import com.flipcash.services.models.chat.ReactionSummary
Expand All @@ -21,11 +22,11 @@ import kotlinx.coroutines.flow.StateFlow
* Implemented by [com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate].
*/
interface FeedOperations {
/** Reactive list of all DM conversations, sorted by last activity. */
val feed: Flow<List<ChatSummary>>
/** Reactive list of [chatType] conversations, sorted by last activity. */
fun feed(chatType: ChatType): Flow<List<ChatSummary>>

/** Emits the number of conversations that have unread messages. */
fun observeUnreadConversations(): Flow<Int>
/** Emits the number of [chatType] conversations that have unread messages. */
fun observeUnreadConversations(chatType: ChatType): Flow<Int>

/** Triggers a server-side feed sync. Safe to call redundantly. */
fun refreshFeed()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,37 +72,44 @@ class FeedSyncDelegate @Inject constructor(

// region FeedOperations

override val feed: Flow<List<ChatSummary>>
get() = stateHolder.state.map { state ->
override fun feed(chatType: ChatType): Flow<List<ChatSummary>> =
stateHolder.state.map { state ->
val selfId = userManager.accountId
val selfPhone = userManager.profile?.verifiedPhoneNumber
val isSelf = { member: ChatMember ->
member.userId == selfId || (selfPhone != null && member.userProfile.verifiedPhoneNumber == selfPhone)
}
state.feed.mapNotNull { metadata ->
val otherMember = metadata.members.firstOrNull { !isSelf(it) }
?: return@mapNotNull null
val profile = otherMember.userProfile
val hasIdentity = !profile.displayName.isNullOrBlank() ||
!profile.verifiedPhoneNumber.isNullOrBlank()
if (!hasIdentity) return@mapNotNull null

val readPointer = metadata.members
.firstOrNull { it.userId == selfId }
?.pointers
?.firstOrNull { it.type == PointerType.READ }
?.value ?: 0L

val unreadCount = metadata.lastMessage?.let { lastMsg ->
if (lastMsg.messageId > readPointer && lastMsg.senderId != selfId) 1 else 0
} ?: 0

ChatSummary(metadata = metadata, unreadCount = unreadCount)
}
state.feed
.filter { it.type == chatType }
.mapNotNull { metadata ->
val otherMember = metadata.members.firstOrNull { !isSelf(it) }
?: return@mapNotNull null

// Contact DMs require a resolvable identity (phone / display name). Tip DMs are
// identified by user id and have no phone by design, so they are never dropped.
if (chatType == ChatType.CONTACT_DM) {
val profile = otherMember.userProfile
val hasIdentity = !profile.displayName.isNullOrBlank() ||
!profile.verifiedPhoneNumber.isNullOrBlank()
if (!hasIdentity) return@mapNotNull null
}

val readPointer = metadata.members
.firstOrNull { it.userId == selfId }
?.pointers
?.firstOrNull { it.type == PointerType.READ }
?.value ?: 0L

val unreadCount = metadata.lastMessage?.let { lastMsg ->
if (lastMsg.messageId > readPointer && lastMsg.senderId != selfId) 1 else 0
} ?: 0

ChatSummary(metadata = metadata, unreadCount = unreadCount)
}
}

override fun observeUnreadConversations(): Flow<Int> {
return feed.map { summaries -> summaries.count { it.unreadCount > 0 } }
override fun observeUnreadConversations(chatType: ChatType): Flow<Int> {
return feed(chatType).map { summaries -> summaries.count { it.unreadCount > 0 } }
}

override fun refreshFeed() {
Expand Down Expand Up @@ -155,23 +162,35 @@ class FeedSyncDelegate @Inject constructor(
}
}

/**
* Fetches the CONTACT_DM and TIP_DM feeds and returns their merged chats. The contact feed is
* required (its failure fails the whole sync, preserving prior behaviour); a TIP_DM failure is
* tolerated so tips never break the main DM list. Each chat carries its own [ChatType].
*/
internal suspend fun fetchCombinedFeed(): Result<List<ChatMetadata>> {
val contact = chatController.getDmChatFeed(ChatType.CONTACT_DM)
.getOrElse { return Result.failure(it) }
val tip = chatController.getDmChatFeed(ChatType.TIP_DM).getOrNull()
return Result.success(contact.chats + (tip?.chats ?: emptyList()))
}

private suspend fun performFeedSync() {
stateHolder.update { it.copy(feedSyncState = FeedSyncState.Syncing) }
chatController.getDmChatFeed(ChatType.CONTACT_DM)
.onSuccess { page ->
metadataDataSource.upsert(page.chats)
fetchCombinedFeed()
.onSuccess { chats ->
metadataDataSource.upsert(chats)

for (chat in page.chats) {
for (chat in chats) {
memberDataSource.upsert(chat.chatId, chat.members)
chat.lastMessage?.let { msg ->
messageDataSource.upsert(chat.chatId, listOf(msg))
}
}

stateHolder.update { it.copy(feedSyncState = FeedSyncState.Synced) }
trace(tag = TAG, message = "Feed synced: ${page.chats.size} chats", type = TraceType.Process)
trace(tag = TAG, message = "Feed synced: ${chats.size} chats", type = TraceType.Process)

for (chat in page.chats) {
for (chat in chats) {
if (chat.latestEventSequence > 0) {
val localSeq = metadataDataSource.getLatestEventSequence(chat.chatId)
if (localSeq > 0 && localSeq < chat.latestEventSequence) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.flipcash.shared.chat

import com.flipcash.services.controllers.ChatController
import com.flipcash.services.models.chat.ChatFeedPage
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatMetadata
import com.flipcash.services.models.chat.ChatType
import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import org.junit.Assert.assertEquals
import org.junit.Test

class FeedSyncCombinedFeedTest {

private fun metadata(hex: String, type: ChatType) = ChatMetadata(
chatId = ChatId(hex),
type = type,
members = emptyList(),
lastMessage = null,
lastActivity = Instant.fromEpochSeconds(1000),
)

private fun delegateWith(chatController: ChatController) = FeedSyncDelegate(
chatController = chatController,
metadataDataSource = mockk(relaxed = true),
messageDataSource = mockk(relaxed = true),
memberDataSource = mockk(relaxed = true),
stateHolder = mockk(relaxed = true),
userManager = mockk(relaxed = true),
)

@Test
fun `fetches both contact and tip feeds and merges chats`() = runTest {
val chatController = mockk<ChatController>(relaxed = true)
coEvery { chatController.getDmChatFeed(ChatType.CONTACT_DM, any()) } returns
Result.success(ChatFeedPage(listOf(metadata("aa", ChatType.CONTACT_DM)), null, false))
coEvery { chatController.getDmChatFeed(ChatType.TIP_DM, any()) } returns
Result.success(ChatFeedPage(listOf(metadata("bb", ChatType.TIP_DM)), null, false))

val merged = delegateWith(chatController).fetchCombinedFeed().getOrThrow()

assertEquals(2, merged.size)
coVerify { chatController.getDmChatFeed(ChatType.CONTACT_DM, any()) }
coVerify { chatController.getDmChatFeed(ChatType.TIP_DM, any()) }
}

@Test
fun `contact feed still returned when tip feed fails`() = runTest {
val chatController = mockk<ChatController>(relaxed = true)
coEvery { chatController.getDmChatFeed(ChatType.CONTACT_DM, any()) } returns
Result.success(ChatFeedPage(listOf(metadata("aa", ChatType.CONTACT_DM)), null, false))
coEvery { chatController.getDmChatFeed(ChatType.TIP_DM, any()) } returns
Result.failure(RuntimeException("tip feed unavailable"))

val merged = delegateWith(chatController).fetchCombinedFeed().getOrThrow()

assertEquals(1, merged.size)
assertEquals(ChatType.CONTACT_DM, merged.first().type)
}
}
Loading
Loading