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 @@ -78,10 +78,10 @@ internal fun AppNavigationBar(
session?.billState?.map { it.bill != null } ?: flowOf(false)
}.collectAsStateWithLifecycle(initialValue = false)

// Unread tip-DM count, badged onto the Chat tab, so the badge appears, updates and clears as
// conversations are read.
val tipUnreadCount by remember(session) {
session?.state?.map { it.tipsUnreadCount } ?: flowOf(0)
// Unread chats in the Chats list (tip DMs and groups), badged onto the Chats tab, so the badge
// appears, updates and clears as conversations are read.
val chatListUnreadCount by remember(session) {
session?.state?.map { it.chatListUnreadCount } ?: flowOf(0)
}.collectAsStateWithLifecycle(initialValue = 0)

val avatar = rememberProfileAvatar()
Expand Down Expand Up @@ -119,7 +119,7 @@ internal fun AppNavigationBar(
if (!fadedOut) {
val state = rememberNavigationBarState(
selectedTab = selectedTab ?: NavBarButton.Wallet,
tipUnreadCount = tipUnreadCount,
chatListUnreadCount = chatListUnreadCount,
)
NavigationBar(
modifier = Modifier
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,30 +57,30 @@ data class NavigationBarState(
// Route-driven: the caller derives this from the current backstack tab so the highlighted tab
// is correct on launch and persists while a sheet/modal is open (not tap-managed).
val selectedTab: NavBarButton = NavBarButton.Wallet,
val tipUnreadCount: Int = 0,
val chatListUnreadCount: Int = 0,
) {
/** Unread count to badge [button] with, or 0 for none. Only the tip-DM inbox badges. */
/** Unread count to badge [button] with, or 0 for none. Only the Chats tab badges. */
fun badgeCount(button: NavBarButton): Int = when (button) {
NavBarButton.Chats -> tipUnreadCount
NavBarButton.Chats -> chatListUnreadCount
else -> 0
}
}

@Composable
fun rememberNavigationBarState(
selectedTab: NavBarButton = NavBarButton.Wallet,
tipUnreadCount: Int = 0,
chatListUnreadCount: Int = 0,
): NavigationBarState {
return produceState(
initialValue = NavigationBarState(
selectedTab = selectedTab,
tipUnreadCount = tipUnreadCount,
chatListUnreadCount = chatListUnreadCount,
),
selectedTab, tipUnreadCount,
selectedTab, chatListUnreadCount,
) {
value = NavigationBarState(
selectedTab = selectedTab,
tipUnreadCount = tipUnreadCount,
chatListUnreadCount = chatListUnreadCount,
)
}.value
}
Expand Down Expand Up @@ -291,7 +291,7 @@ private fun NavBarButton.icon(selected: Boolean): Painter = when (this) {
@Composable
private fun NavigationBarPreview() {
NavigationBar(
state = rememberNavigationBarState(tipUnreadCount = 100),
state = rememberNavigationBarState(chatListUnreadCount = 100),
onButtonClick = { }
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flipcash.features.messenger.R
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatType
import com.flipcash.services.models.chat.MuteState
import com.flipcash.services.models.chat.ViewerState
import com.flipcash.shared.chat.ChatCoordinator
import com.flipcash.shared.chat.currentChatListFeed
import com.getcode.manager.BottomBarManager
import com.getcode.util.resources.ResourceHelper
import com.getcode.utils.trace
Expand Down Expand Up @@ -40,7 +40,7 @@ internal class MuteChatViewModel @Inject constructor(
* from `null` would open it without the row and then grow it by one as the store answered.
*/
fun currentViewerState(chatId: ChatId): ViewerState? =
chatCoordinator.currentFeed(ChatType.TIP_DM, ChatType.GROUP)
chatCoordinator.currentChatListFeed()
?.firstOrNull { it.metadata.chatId == chatId }
?.metadata?.viewerState

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import com.flipcash.app.core.data.Loadable
import com.flipcash.libs.coroutines.DispatcherProvider
import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.services.models.chat.ChatType
import com.flipcash.services.user.UserManager
import com.flipcash.shared.chat.ChatCoordinator
import com.flipcash.shared.chat.ChatSummary
import com.flipcash.shared.chat.chatListFeed
import com.flipcash.shared.chat.currentChatListFeed
import com.flipcash.shared.chat.ui.ConversationReference
import com.flipcash.shared.chat.ui.toConversationReference
import com.getcode.opencode.model.financial.Token
Expand Down Expand Up @@ -52,14 +53,14 @@ internal class ChatsViewModel @Inject constructor(

// On a cold launch the feed is usually built before this screen is, so draw it on the first
// frame rather than waiting for the collector below to get a turn on the main thread.
chatCoordinator.currentFeed(ChatType.TIP_DM, ChatType.GROUP)?.let { summaries ->
chatCoordinator.currentChatListFeed()?.let { summaries ->
dispatchEvent(
Event.ChatsUpdated(Loadable.Loaded(conversations(summaries, tokenCoordinator.cachedTokens())))
)
}

combine(
chatCoordinator.feed(ChatType.TIP_DM, ChatType.GROUP),
chatCoordinator.chatListFeed(),
tokenCoordinator.tokens,
::conversations,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.flipcash.shared.chat

import com.flipcash.services.models.chat.ChatType
import kotlinx.coroutines.flow.Flow

/**
* The chat types the Chats list shows: tip DMs and groups. Contact DMs have their own inbox.
*
* The list and the Chats tab badge both read through the functions below, so the badge cannot
* count a different set of chats than the rows it sits above.
*/
private val chatListTypes = arrayOf(ChatType.TIP_DM, ChatType.GROUP)

/** [FeedOperations.feed] for the chats the Chats list shows. */
fun FeedOperations.chatListFeed(): Flow<List<ChatSummary>> = feed(*chatListTypes)

/** [FeedOperations.currentFeed] for the chats the Chats list shows. */
fun FeedOperations.currentChatListFeed(): List<ChatSummary>? = currentFeed(*chatListTypes)

/** How many of the chats the Chats list shows have unread messages; the Chats tab badge. */
fun FeedOperations.observeUnreadChatListCount(): Flow<Int> = observeUnreadConversations(*chatListTypes)
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package com.flipcash.shared.chat

import com.flipcash.app.persistence.entities.ChatMetadataEntity
import com.flipcash.app.persistence.sources.ChatMemberDataSource
import com.flipcash.app.persistence.sources.ChatMessageDataSource
import com.flipcash.app.persistence.sources.ChatMetadataDataSource
import com.flipcash.services.controllers.ChatController
import com.flipcash.services.models.UserProfile
import com.flipcash.services.models.VerifiableContactMethod
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.MessageContent
import com.flipcash.services.models.chat.MessagePointer
import com.flipcash.services.models.chat.PointerType
import com.flipcash.services.models.chat.RosterSummary
import com.flipcash.services.user.UserManager
import com.flipcash.shared.chat.internal.ChatStateHolder
import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.time.Instant

/**
* The Chats tab badge counts the chats the Chats list shows: tip DMs and groups. It used to count
* tip DMs only, so a list whose only unread rows were groups sat under a tab with no badge.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class ChatListUnreadCountTest {

private val selfId = listOf<Byte>(1, 2, 3)
private val otherId = listOf<Byte>(4, 5, 6)

private val tipHex = "aabbccdd"
private val contactHex = "55667788"
private val groupHex = "11223344"

private fun entity(chatIdHex: String, type: ChatType) = ChatMetadataEntity(
chatIdHex = chatIdHex,
chatType = type.name,
lastActivityEpochMs = 2_000,
lastMessageId = 2,
isMember = true,
)

private fun message(messageId: Long) = ChatMessage(
messageId = messageId,
senderId = otherId,
content = listOf(MessageContent.Text("hi")),
timestamp = Instant.fromEpochSeconds(messageId),
unreadSeq = messageId,
)

/** Read up to message 1 of 2, so each chat below has one unread message. */
private val selfUnread = ChatMember(
userId = selfId,
userProfile = UserProfile.Empty,
pointers = listOf(
MessagePointer(
type = PointerType.READ,
userId = selfId,
value = 1,
timestamp = Instant.fromEpochSeconds(1_000),
)
),
)

private val addressableOther = ChatMember(
userId = otherId,
userProfile = UserProfile.Empty.copy(
displayName = "Ada",
phoneNumber = VerifiableContactMethod("+15551234567", verified = true),
),
pointers = emptyList(),
)

private suspend fun TestScope.unreadChatListCount(vararg unread: Pair<String, ChatType>): Int {
val entities = unread.map { (hex, type) -> entity(hex, type) }
val members = unread.associate { (hex, type) ->
hex to if (type == ChatType.GROUP) listOf(selfUnread) else listOf(selfUnread, addressableOther)
}

val messageDataSource = mockk<ChatMessageDataSource>(relaxed = true).also {
coEvery { it.getLatestVisibleByChat() } returns entities.associate { e -> e.chatIdHex to message(2) }
coEvery { it.getUnreadSeq(any(), any()) } answers { secondArg() }
}
val metadataDataSource = mockk<ChatMetadataDataSource>(relaxed = true).also { source ->
every { source.observeAll() } returns flowOf(entities)
every { source.toMetadata(any(), any(), any()) } answers {
val row = firstArg<ChatMetadataEntity>()
ChatMetadata(
chatId = ChatId(row.chatIdHex),
type = ChatType.valueOf(row.chatType),
members = secondArg(),
lastMessage = thirdArg(),
lastActivity = Instant.fromEpochMilliseconds(row.lastActivityEpochMs),
title = row.title,
rosterSummary = RosterSummary(memberCount = row.memberCount, version = row.rosterVersion),
)
}
}
val memberDataSource = mockk<ChatMemberDataSource>(relaxed = true).also { source ->
every { source.observeAll() } returns flowOf(members)
}

val delegate = FeedSyncDelegate(
messagingController = mockk(relaxed = true),
chatController = mockk<ChatController>(relaxed = true),
metadataDataSource = metadataDataSource,
messageDataSource = messageDataSource,
memberDataSource = memberDataSource,
stateHolder = ChatStateHolder().apply { update { it.copy(feedSyncState = FeedSyncState.Synced) } },
userManager = mockk<UserManager>(relaxed = true).also {
every { it.accountId } returns selfId
every { it.profile } returns null
},
)
delegate.initialize(backgroundScope)
delegate.observeFeedFromDb()
runCurrent()
return delegate.observeUnreadChatListCount().first()
}

@Test
fun `an unread group counts toward the badge`() = runTest {
assertEquals(1, unreadChatListCount(groupHex to ChatType.GROUP))
}

@Test
fun `an unread contact DM does not count toward the badge`() = runTest {
assertEquals(0, unreadChatListCount(contactHex to ChatType.CONTACT_DM))
}

@Test
fun `the badge counts unread tip DMs and groups but not contact DMs`() = runTest {
val count = unreadChatListCount(
tipHex to ChatType.TIP_DM,
groupHex to ChatType.GROUP,
contactHex to ChatType.CONTACT_DM,
)

assertEquals(2, count)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ data class SessionState(
val restrictionType: RestrictionType? = null,
val isRemoteSendLoading: Boolean = false,
val contactDmUnreadCount: Int = 0,
val tipsUnreadCount: Int = 0,
val chatListUnreadCount: Int = 0,
val tokens: List<Token> = emptyList(),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.flipcash.app.blob.BlobStorageCoordinator
import com.flipcash.app.core.media.MediaUrlResolver
import com.flipcash.services.models.chat.ChatType
import com.flipcash.shared.chat.ChatCoordinator
import com.flipcash.shared.chat.observeUnreadChatListCount
import com.flipcash.app.core.bill.Scannable
import com.flipcash.app.core.internal.bill.BillController
import com.flipcash.app.core.internal.updater.ProfileUpdater
Expand Down Expand Up @@ -244,9 +245,9 @@ class RealSessionController @Inject constructor(
.map { it.authState }
.filter { it.isAtLeastRegistered }
.distinctUntilChanged()
.flatMapLatest { chatCoordinator.observeUnreadConversations(ChatType.TIP_DM) }
.flatMapLatest { chatCoordinator.observeUnreadChatListCount() }
.distinctUntilChanged()
.onEach { count -> stateHolder.update { it.copy(tipsUnreadCount = count) } }
.onEach { count -> stateHolder.update { it.copy(chatListUnreadCount = count) } }
.launchIn(scope)

// Preload the blob upload policy once registered so profile-photo selection can filter and
Expand Down
Loading