From a7186b61d2c788782678491dd4907c32c99f9d30 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 16:02:21 -0400 Subject: [PATCH 1/2] feat(chats): show pending join requests on a hosted group's row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A private group you host now shows who waits to join on its own Chats row, the way unread messages show (node 12824:117). The pill counts requests plus unread messages, the timestamp turns blue, and the subtitle reads "Jasper wants to join" or "3 people want to join" in the unread colour. With unread messages too, " · " and the last message follow in grey, cut off at the end. Someone typing keeps the slot until they stop. The row sorts by the later of its last activity and its newest request, so ChatListEntry gains pendingRequests and newestRequestMs and the projection sorts by both. ChatsViewModel now draws rows in the projection's order; until now feed order and projection order were the same, so nothing read projection.main for ordering. Requests count as unread for the Unread chip, the Archived row and the tab badge. The badge has its own source, so observeUnreadChatListCount takes the lobbies, and RealSessionController fetches each hosted group's lobby as it appears; otherwise the badge would miss requests until Chats was first opened. The Archived list reads the same lobbies. State.needsDecision had no reader and goes. hostedPrivateGroups moves to shared/chat for the session to use. --- .../core/src/main/res/values/strings.xml | 6 + .../internal/ArchivedChatsViewModel.kt | 9 +- .../app/tipping/internal/ChatsViewModel.kt | 49 +++--- .../tipping/internal/ConversationMapping.kt | 6 + .../app/tipping/internal/RequestsViewModel.kt | 1 + .../tipping/internal/components/TipChatRow.kt | 43 +++++- .../tipping/internal/JoinRequestRowTest.kt | 57 +++++++ .../internal/RequestsProjectionTest.kt | 1 + .../internal/TipChatRowScreenshotTest.kt | 44 ++++++ .../flipcash/shared/chat/ui/ChatRowContent.kt | 28 ++++ .../shared/chat/ui/ConversationReference.kt | 19 +++ .../com/flipcash/shared/chat/ChatList.kt | 19 ++- .../shared/chat/ChatListProjection.kt | 24 ++- .../com/flipcash/shared/chat/HostedGroups.kt | 23 +++ .../shared/chat/ChatListJoinRequestsTest.kt | 140 ++++++++++++++++++ .../shared/chat/ChatListUnreadCountTest.kt | 61 +++++++- .../session/internal/RealSessionController.kt | 28 +++- .../SessionControllerEventRoutingTest.kt | 1 + .../SessionControllerGiftCardErrorTest.kt | 1 + .../SessionControllerShareCheckTest.kt | 1 + 20 files changed, 513 insertions(+), 48 deletions(-) create mode 100644 apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/JoinRequestRowTest.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostedGroups.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatListJoinRequestsTest.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index e3be076c73..6a02a9bb9f 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1359,6 +1359,12 @@ %1$d Person Wants To Join %1$d People Want To Join + + %1$s wants to join + + %1$d person wants to join + %1$d people want to join + and Requested just now Requested yesterday diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt index c32cd5962d..84308978a9 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt @@ -15,6 +15,7 @@ import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.combine +import com.flipcash.shared.chat.HostLobbies import com.flipcash.shared.chat.PaymentRequests import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn @@ -33,6 +34,7 @@ internal class ArchivedChatsViewModel @Inject constructor( resources: ResourceHelper, dispatchers: DispatcherProvider, paymentRequests: PaymentRequests, + hostLobbies: HostLobbies, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -56,10 +58,13 @@ internal class ArchivedChatsViewModel @Inject constructor( tokenCoordinator.tokens, chatCoordinator.observeSenderProfiles(), paymentRequests.state, - ) { summaries, tokens, senderProfiles, requests -> + hostLobbies.state, + ) { summaries, tokens, senderProfiles, requests, lobbies -> mapConversations( summaries, tokens, senderProfiles, userManager.accountId, resources, chatCoordinator, exchange, requests, - ) { id -> viewModelScope.launch { paymentRequests.fetchIfAbsent(id) } } + requestPaymentRequest = { id -> viewModelScope.launch { paymentRequests.fetchIfAbsent(id) } }, + lobbies = lobbies, + ).sortedByDescending { it.rowActivity } } .flowOn(dispatchers.Default) .onEach { dispatchEvent(Event.ChatsUpdated(Loadable.Loaded(it))) } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt index dea471f2c9..14b71af54f 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt @@ -13,7 +13,6 @@ import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatArchiveStore import com.flipcash.shared.chat.LobbyRequests import com.flipcash.shared.chat.HostLobbies -import com.flipcash.shared.chat.ChatSummary import com.flipcash.services.models.chat.LobbyMember import com.getcode.opencode.model.core.ID import kotlinx.coroutines.flow.distinctUntilChanged @@ -24,6 +23,7 @@ import com.flipcash.shared.chat.ChatListEntry import com.flipcash.shared.chat.ChatListFilter import com.flipcash.shared.chat.ChatListProjection import com.flipcash.shared.chat.chatListFeeds +import com.flipcash.shared.chat.hostedPrivateGroups import com.flipcash.shared.chat.currentChatListFeeds import com.flipcash.shared.chat.projectChatList import com.flipcash.shared.chat.ui.ConversationReference @@ -58,7 +58,7 @@ internal class ChatsViewModel @Inject constructor( ) { data class State( - // The main list (not archived, not hidden), in the feed's order. Loading until the feed + // The main list (not archived, not hidden), in the projection's order. Loading until the feed // emits, so the empty state doesn't flash on top of a list that's about to arrive. val chats: Loadable> = Loadable.Loading(), // The chip selected above the list. Not persisted: a cold start opens on All. @@ -68,9 +68,6 @@ internal class ChatsViewModel @Inject constructor( // Whether a pull from the top has brought the chips on. Held here rather than in the // screen so it survives opening a chat and coming back. val chipsRevealed: Boolean = false, - // Someone waits in a private group the viewer hosts: the dot on the overflow menu and on - // its Requests item. - val needsDecision: Boolean = false, ) { /** The chips stay once revealed, and always show under a filter other than All. */ @@ -103,8 +100,6 @@ internal class ChatsViewModel @Inject constructor( data class FilterSelected(val filter: ChatListFilter) : Event data object ChipsRevealed : Event - - data class RequestsUpdated(val needsDecision: Boolean) : Event } fun selectFilter(filter: ChatListFilter) = dispatchEvent(Event.FilterSelected(filter)) @@ -133,10 +128,11 @@ internal class ChatsViewModel @Inject constructor( // re-fetch every sender already on disk. senderProfiles: Map?, requests: Map, + lobbies: Map>, ): Event.ChatsUpdated { val fetchRequest: (ID) -> Unit = { id -> viewModelScope.launch { paymentRequests.fetchIfAbsent(id) } } - val mainRows = mapConversations(feeds.main, tokens, senderProfiles, selfId(), resources, chatCoordinator, exchange, requests, fetchRequest) - val archivedRows = mapConversations(feeds.archived, tokens, senderProfiles, selfId(), resources, chatCoordinator, exchange, requests, fetchRequest) + val mainRows = mapConversations(feeds.main, tokens, senderProfiles, selfId(), resources, chatCoordinator, exchange, requests, fetchRequest, lobbies) + val archivedRows = mapConversations(feeds.archived, tokens, senderProfiles, selfId(), resources, chatCoordinator, exchange, requests, fetchRequest, lobbies) // The projection sees every chat so the chips, the Archived row and the badge number // come from the one function the fixture tests. Hidden chats never reach the feeds, so @@ -149,11 +145,13 @@ internal class ChatsViewModel @Inject constructor( muted = row.viewerState.isMutedAt(), hidden = false, unread = row.unreadCount, + pendingRequests = row.joinRequests.size, + newestRequestMs = row.joinRequests.maxOfOrNull { it.enteredAt.toEpochMilliseconds() } ?: 0L, ) val projection = projectChatList( mainRows.map { entry(it, isArchived = false) } + archivedRows.map { entry(it, isArchived = true) } ) - return Event.ChatsUpdated(Loadable.Loaded(mainRows), projection) + return Event.ChatsUpdated(Loadable.Loaded(mainRows.inOrderOf(projection.main)), projection) } // On a cold launch the feed is usually built before this screen is, so draw it on the first @@ -165,6 +163,7 @@ internal class ChatsViewModel @Inject constructor( tokens = tokenCoordinator.cachedTokens(), senderProfiles = chatCoordinator.currentSenderProfiles(), requests = paymentRequests.state.value, + lobbies = hostLobbies.state.value, ) ) } @@ -177,8 +176,11 @@ internal class ChatsViewModel @Inject constructor( tokenCoordinator.tokens, chatCoordinator.observeSenderProfiles(), exchange.observePreferredRate(), - paymentRequests.state, - ) { feeds, tokens, senderProfiles, _, requests -> content(feeds, tokens, senderProfiles, requests) } + // Paired so the combine stays at five typed flows. + combine(paymentRequests.state, hostLobbies.state, ::Pair), + ) { feeds, tokens, senderProfiles, _, (requests, lobbies) -> + content(feeds, tokens, senderProfiles, requests, lobbies) + } // Off the main thread: on a cold launch the first mapping lands while the main thread // is drawing the app's first screens, and waiting for it holds the Chats tab blank. .flowOn(dispatchers.Default) @@ -193,12 +195,6 @@ internal class ChatsViewModel @Inject constructor( hostedGroups .onEach { groups -> groups.keys.forEach { chatId -> viewModelScope.launch { hostLobbies.load(chatId) } } } .launchIn(viewModelScope) - combine(hostedGroups, hostLobbies.state) { hosted, lobbies -> - Event.RequestsUpdated(needsDecision = hostedRequests(hosted.keys, lobbies).isNotEmpty()) - } - .distinctUntilChanged() - .onEach { dispatchEvent(it) } - .launchIn(viewModelScope) } internal companion object { @@ -210,21 +206,18 @@ internal class ChatsViewModel @Inject constructor( // A chip can only be tapped once the row is on screen, so choosing one reveals it. is Event.FilterSelected -> { state -> state.copy(filter = event.filter, chipsRevealed = true) } Event.ChipsRevealed -> { state -> state.copy(chipsRevealed = true) } - is Event.RequestsUpdated -> { state -> - state.copy(needsDecision = event.needsDecision) - } } } } } -/** Private groups [selfId] created, by id, with their titles. */ -internal fun hostedPrivateGroups(summaries: List, selfId: ID?): Map { - selfId ?: return emptyMap() - return summaries - .map { it.metadata } - .filter { it.isPrivate && it.creator == selfId } - .associate { it.chatId to it.title.orEmpty() } +/** + * [this] rows in the order of [ids] (the projection's order). A row the projection doesn't list + * keeps its place after the ones it does; there shouldn't be one. + */ +internal fun List.inOrderOf(ids: List): List { + val rank = ids.withIndex().associate { (index, id) -> id to index } + return sortedBy { rank[it.chatId.toString()] ?: Int.MAX_VALUE } } /** diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt index 16936175c6..11abe7559f 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt @@ -2,8 +2,11 @@ package com.flipcash.app.tipping.internal import com.getcode.opencode.exchange.Exchange import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.LobbyMember import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatSummary +import com.flipcash.shared.chat.waitingFor import com.flipcash.services.models.paymentrequest.PaymentRequest import com.flipcash.shared.chat.ui.ConversationReference import com.flipcash.shared.chat.ui.previewedPaymentRequestId @@ -30,6 +33,8 @@ internal fun mapConversations( paymentRequests: Map, // Asked for each previewed request the store doesn't hold yet; the row redraws when it lands. requestPaymentRequest: (ID) -> Unit, + // Hosted groups' lobbies; a row shows the ones waiting on a group the viewer hosts. + lobbies: Map> = emptyMap(), ): List { val tokensByMint = tokens.associateBy { it.address } return summaries.map { summary -> @@ -43,5 +48,6 @@ internal fun mapConversations( ?.takeIf { it !in paymentRequests } ?.let(requestPaymentRequest) summary.toConversationReference(selfId, tokensByMint, resources, senderProfiles.orEmpty(), exchange, paymentRequests) + .copy(joinRequests = lobbies.waitingFor(summary.metadata, selfId).sortedByDescending { it.enteredAt }) } } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/RequestsViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/RequestsViewModel.kt index fb6c2c9397..e4b135db79 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/RequestsViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/RequestsViewModel.kt @@ -8,6 +8,7 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.hostedPrivateGroups import com.flipcash.shared.chat.ChatSummary import com.flipcash.shared.chat.HostLobbies import com.flipcash.shared.chat.LobbyRequests diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt index 090de079d3..dd6a70f6c9 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt @@ -10,18 +10,24 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.features.tipping.R import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.LobbyMember import com.flipcash.shared.chat.ui.ChatListRow import com.flipcash.shared.chat.ui.ChatRowSubtitle import com.flipcash.shared.chat.ui.ChatRowTrailing import com.flipcash.shared.chat.ui.ConversationReference +import com.flipcash.shared.chat.ui.JoinRequestSubtitle import com.flipcash.shared.chat.ui.MutedIndicator import com.flipcash.shared.chat.ui.SubtitleText import com.flipcash.shared.common.ui.ContactAvatar +import com.flipcash.shared.common.ui.profile.chatterName import com.getcode.theme.CodeTheme import kotlin.time.Clock @@ -70,13 +76,23 @@ internal fun TipChatRow( } ChatRowTrailing( - lastActivity = chat.lastActivity, - unreadCount = chat.unreadCount, + lastActivity = chat.rowActivity, + unreadCount = chat.rowUnreadCount, canOpen = true, ) }, subtitle = { - ChatRowSubtitle( + val request = joinRequestText(chat.joinRequests) + // Someone typing is live and passes in seconds, so it keeps the slot; the request + // comes back when they stop. + if (request != null && !chat.isTyping) { + JoinRequestSubtitle( + request = request, + // A read message stays out of the way until every request is answered; only + // unread ones share the line. + preview = chat.lastMessagePreview.takeIf { chat.unreadCount != 0 }, + ) + } else ChatRowSubtitle( isTyping = chat.isTyping, preview = chat.lastMessagePreview, hasMessages = chat.hasMessages, @@ -93,6 +109,27 @@ internal fun TipChatRow( ) } +/** + * "Jasper wants to join" for one requester, "3 people want to join" for several, or null with + * nobody waiting. Named the way the in-chat Join Requests anchor names them. + */ +@Composable +private fun joinRequestText(waiting: List): String? { + val only = waiting.singleOrNull() + return when { + waiting.isEmpty() -> null + only != null -> stringResource( + R.string.subtitle_wantsToJoin, + chatterName( + only.userProfile.displayName, + only.userProfile.username, + stringResource(R.string.title_unnamedUser), + ), + ) + else -> pluralStringResource(R.plurals.subtitle_peopleWantToJoin, waiting.size, waiting.size) + } +} + @Composable @Preview @PreviewWrapper(FlipcashThemeWrapper::class) diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/JoinRequestRowTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/JoinRequestRowTest.kt new file mode 100644 index 0000000000..2403867e06 --- /dev/null +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/JoinRequestRowTest.kt @@ -0,0 +1,57 @@ +package com.flipcash.app.tipping.internal + +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.LobbyMember +import com.flipcash.shared.chat.ui.ConversationReference +import com.getcode.solana.keys.PublicKey +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import kotlin.time.Instant + +/** What a hosted group's row draws while someone waits to join it, and where it sits. */ +class JoinRequestRowTest { + + private fun waiting(enteredAtMs: Long) = LobbyMember( + userProfile = UserProfile.Empty, + publicKey = PublicKey("11111111111111111111111111111111"), + enteredAt = Instant.fromEpochMilliseconds(enteredAtMs), + ) + + private val group = ConversationReference( + chatId = ChatId(byteArrayOf(1)), + isGroup = true, + lastActivity = Instant.fromEpochMilliseconds(500), + unreadCount = 3, + ) + + @Test + fun `the pill counts unread messages and requests together`() { + assertEquals(4, group.copy(joinRequests = listOf(waiting(100))).rowUnreadCount) + assertEquals(3, group.rowUnreadCount) + } + + @Test + fun `an unknown unread count stays unknown with requests`() { + assertNull(group.copy(unreadCount = null, joinRequests = listOf(waiting(100))).rowUnreadCount) + } + + @Test + fun `the row shows the later of its last activity and its newest request`() { + assertEquals(Instant.fromEpochMilliseconds(900), group.copy(joinRequests = listOf(waiting(100), waiting(900))).rowActivity) + assertEquals(Instant.fromEpochMilliseconds(500), group.copy(joinRequests = listOf(waiting(100))).rowActivity) + assertEquals(Instant.fromEpochMilliseconds(100), group.copy(lastActivity = null, joinRequests = listOf(waiting(100))).rowActivity) + } + + @Test + fun `rows draw in the projection's order, not the feed's`() { + val a = ConversationReference(chatId = ChatId(byteArrayOf(1))) + val b = ConversationReference(chatId = ChatId(byteArrayOf(2))) + val c = ConversationReference(chatId = ChatId(byteArrayOf(3))) + + val ordered = listOf(a, b, c).inOrderOf(listOf(c.chatId.toString(), a.chatId.toString(), b.chatId.toString())) + + assertEquals(listOf(c, a, b), ordered) + } +} diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/RequestsProjectionTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/RequestsProjectionTest.kt index e03414b1a8..86456f70dc 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/RequestsProjectionTest.kt +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/RequestsProjectionTest.kt @@ -1,6 +1,7 @@ package com.flipcash.app.tipping.internal import com.flipcash.services.models.UserProfile +import com.flipcash.shared.chat.hostedPrivateGroups import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt index cbca38523a..384384e7ea 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt @@ -12,8 +12,11 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.unit.dp import com.flipcash.app.theme.FlipcashPreview import com.flipcash.app.tipping.internal.components.TipChatRow +import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.LobbyMember import com.flipcash.shared.chat.ui.ConversationReference +import com.getcode.solana.keys.PublicKey import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -21,6 +24,7 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode import java.io.File +import kotlin.time.Duration.Companion.minutes import kotlin.time.Instant /** @@ -123,6 +127,46 @@ class TipChatRowScreenshotTest { capture("tip_chat_row_empty.png") } + @Test + fun rendersJoinRequestStates() { + val at = Instant.fromEpochMilliseconds(1_700_000_000_000) + fun waiting(name: String, minutesAgo: Long) = LobbyMember( + userProfile = UserProfile.Empty.copy(displayName = name), + publicKey = PublicKey("11111111111111111111111111111111"), + enteredAt = at - minutesAgo.minutes, + ) + val group = ConversationReference( + chatId = ChatId(byteArrayOf(6)), + title = "Friday Night Poker", + isGroup = true, + lastMessagePreview = "Leo: who's bringing chips?", + lastActivity = at - 30.minutes, + unreadCount = 0, + ) + val rows = listOf( + // One request, no unread messages. + group.copy(joinRequests = listOf(waiting("Jasper", 1))), + // Several requests. + group.copy(joinRequests = listOf(waiting("Jasper", 1), waiting("Mia", 2), waiting("Sam", 3))), + // One request and three unread messages: pill 4, request then the preview. + group.copy(unreadCount = 3, joinRequests = listOf(waiting("Jasper", 1))), + // Nobody waiting: the row as it was. + group, + ) + + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Column(modifier = Modifier.width(360.dp).padding(vertical = 8.dp)) { + rows.forEach { TipChatRow(chat = it, onClick = {}) } + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("tip_chat_row_join_requests.png") + } + private fun capture(name: String) { val root: View = composeRule.activity.findViewById(android.R.id.content) val width = root.width.takeIf { it > 0 } ?: 1080 diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt index aa84e4a7cd..ed64b5d73f 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt @@ -11,7 +11,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.style.TextOverflow import com.flipcash.core.R import com.getcode.theme.CodeTheme @@ -101,3 +104,28 @@ fun SubtitleText(text: String, fontStyle: FontStyle = FontStyle.Normal) { overflow = TextOverflow.Ellipsis, ) } + +/** + * Subtitle of a hosted group's row while someone waits to join: [request] in the unread colour, + * then, when the chat also has unread messages, " · " and the last message [preview] in the + * secondary colour. One line, cut off at the end, so the request is what stays readable. + */ +@Composable +fun JoinRequestSubtitle(request: String, preview: String?) { + val requestColor = CodeTheme.colors.indicator + val previewColor = CodeTheme.colors.textSecondary + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(color = requestColor)) { append(request) } + if (!preview.isNullOrEmpty()) { + withStyle(SpanStyle(color = previewColor)) { + append(" · ") + append(preview) + } + } + }, + style = CodeTheme.typography.textSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt index 513a914406..5dcf7581b1 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt @@ -4,6 +4,7 @@ import com.getcode.opencode.model.core.ID import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.LobbyMember import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.nameOrHandle @@ -55,7 +56,25 @@ data class ConversationReference( * the deadline and stops drawing at it. */ val viewerState: ViewerState? = null, + /** + * Who waits to join, newest first, when this is a private group the viewer hosts; empty + * otherwise. Requests read as unread on the row: they add to its count and move its time. + */ + val joinRequests: List = emptyList(), ) { + /** The time the row shows and sorts by: the later of [lastActivity] and the newest request. */ + val rowActivity: Instant? + get() { + val newestRequest = joinRequests.maxOfOrNull { it.enteredAt } ?: return lastActivity + return if (lastActivity == null || newestRequest > lastActivity) newestRequest else lastActivity + } + + /** + * The row's unread pill: unread messages plus pending requests. A null [unreadCount] stays + * null (unread by an unknown count), since adding to an unknown number gives an unknown one. + */ + val rowUnreadCount: Int? get() = unreadCount?.plus(joinRequests.size) + /** * What to call this row: the chat's [title] for a group, otherwise the counterparty's * [displayName], or their [handle] when they have no name. diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatList.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatList.kt index ab9e03048e..ee2fdc8246 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatList.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatList.kt @@ -1,7 +1,11 @@ package com.flipcash.shared.chat +import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.LobbyMember +import com.getcode.opencode.model.core.ID import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine /** * The chat types the Chats list shows: tip DMs and groups. Contact DMs have their own inbox. @@ -17,9 +21,22 @@ fun FeedOperations.chatListFeed(): Flow> = feed(*chatListTypes /** [FeedOperations.currentFeed] for the chats the Chats list shows. */ fun FeedOperations.currentChatListFeed(): List? = currentFeed(*chatListTypes) -/** How many of the chats the Chats list shows have unread messages; the Chats tab badge. */ +/** How many of the chats the Chats list shows have unread messages. */ fun FeedOperations.observeUnreadChatListCount(): Flow = observeUnreadConversations(*chatListTypes) +/** + * The Chats tab badge: the chats the list shows that read as unread, which is unread messages or, + * on a group [selfId] hosts, someone waiting in [lobbies] to join. Counted the way + * [projectChatList] counts its Unread chip, so the badge and the chip agree. + */ +fun FeedOperations.observeUnreadChatListCount( + lobbies: Flow>>, + selfId: () -> ID?, +): Flow = combine(feed(*chatListTypes), lobbies) { summaries, waiting -> + val self = selfId() + summaries.count { it.unreadCount != 0 || waiting.waitingFor(it.metadata, self).isNotEmpty() } +} + /** [FeedOperations.archivedFeed] for the chats the Chats list shows. */ fun FeedOperations.archivedChatListFeed(): Flow> = archivedFeed(*chatListTypes) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatListProjection.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatListProjection.kt index 6abf462251..d13a15e691 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatListProjection.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatListProjection.kt @@ -7,6 +7,10 @@ enum class ChatListFilter { All, Unread, Groups } * One chat as the list projection sees it. [id] is opaque here (the app passes the chat id's * `toString()`, its base58 form; the fixture passes its own ids). [unread] is the chat's unread count, with null meaning "unread * by an unknown count", which counts as unread. + * + * [pendingRequests] is how many people wait to join a private group the viewer hosts, and + * [newestRequestMs] when the newest of them asked. Zero for every other chat. Requests read as + * unread, and a new one moves the row up the way a new message would. */ data class ChatListEntry( val id: String, @@ -16,18 +20,24 @@ data class ChatListEntry( val muted: Boolean, val hidden: Boolean, val unread: Int?, + val pendingRequests: Int = 0, + val newestRequestMs: Long = 0L, ) { - val isUnread: Boolean get() = unread != 0 + val isUnread: Boolean get() = unread != 0 || pendingRequests > 0 + + /** What the list sorts by: the later of the last activity and the newest join request. */ + val sortTimeMs: Long get() = maxOf(lastActivityMs, newestRequestMs) } /** * What the Chats list shows, derived from every chat the viewer has. * - * Every id list is ordered by last activity, newest first. Hidden chats (blocked) appear nowhere, + * Every id list is ordered by [ChatListEntry.sortTimeMs], newest first. Hidden chats (blocked) appear nowhere, * including [archived]; their archive record stays in storage in case they are unblocked. * * @property main every chat that is neither archived nor hidden: the "All" list. - * @property unreadChip [main] filtered to unread chats, unknown counts included. + * @property unreadChip [main] filtered to unread chats, unknown counts and pending join requests + * included. * @property groupsChip [main] filtered to groups. The chip's *number* is [groupsChipCount], the * unread groups among them. * @property archived the chats under the Archived row, muted ones included. @@ -37,7 +47,7 @@ data class ChatListEntry( * toward nothing, as muted chats count toward nothing there today. * @property tabBadge unread chats in [main], muted ones included, as the Chats tab badge counts * today. No Android screen reads it: the badge comes from - * [observeUnreadChatListCount], which counts the same chats. It stays because the shared + * [observeUnreadChatListCount], which counts the same chats, requests included. It stays because the shared * fixture's `list` cases check it on both platforms, and iOS draws its badge from it. */ data class ChatListProjection( @@ -76,12 +86,12 @@ data class ChatListProjection( * Splits chats into the main list and the Archived row (an archived chat leaves the list, every * chip and the tab badge together), and counts the numbers the list draws. Pure, so the fixture's `list` cases drive it directly. * - * Ties in last activity keep their input order (`sortedByDescending` is stable). + * Ties in sort time keep their input order (`sortedByDescending` is stable). */ fun projectChatList(entries: List): ChatListProjection { val (archived, active) = entries.filterNot { it.hidden }.partition { it.archived } - val main = active.sortedByDescending { it.lastActivityMs } - val archivedSorted = archived.sortedByDescending { it.lastActivityMs } + val main = active.sortedByDescending { it.sortTimeMs } + val archivedSorted = archived.sortedByDescending { it.sortTimeMs } val unread = main.filter { it.isUnread } val groups = main.filter { it.isGroup } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostedGroups.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostedGroups.kt new file mode 100644 index 0000000000..c078cef003 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostedGroups.kt @@ -0,0 +1,23 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.LobbyMember +import com.getcode.opencode.model.core.ID + +/** Whether [selfId] created this private group, so its lobby is theirs to answer. */ +fun ChatMetadata.isHostedBy(selfId: ID?): Boolean = selfId != null && isPrivate && creator == selfId + +/** Private groups [selfId] created, by id, with their titles. */ +fun hostedPrivateGroups(summaries: List, selfId: ID?): Map = + summaries + .map { it.metadata } + .filter { it.isHostedBy(selfId) } + .associate { it.chatId to it.title.orEmpty() } + +/** + * Who waits to join [metadata]'s group, newest first, when [selfId] hosts it; empty for any other + * chat, and for a hosted group whose lobby isn't loaded. + */ +fun Map>.waitingFor(metadata: ChatMetadata, selfId: ID?): List = + if (metadata.isHostedBy(selfId)) this[metadata.chatId].orEmpty() else emptyList() diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatListJoinRequestsTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatListJoinRequestsTest.kt new file mode 100644 index 0000000000..9d0577b07d --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatListJoinRequestsTest.kt @@ -0,0 +1,140 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.LobbyMember +import com.getcode.solana.keys.PublicKey +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * A hosted private group with someone waiting to join reads as unread on the Chats list: it sorts + * by its newest request, counts toward the Unread chip and the Archived row, and the badge. + */ +class ChatListJoinRequestsTest { + + private fun entry( + id: String, + lastActivityMs: Long, + unread: Int? = 0, + archived: Boolean = false, + muted: Boolean = false, + pendingRequests: Int = 0, + newestRequestMs: Long = 0, + ) = ChatListEntry( + id = id, + isGroup = true, + lastActivityMs = lastActivityMs, + archived = archived, + muted = muted, + hidden = false, + unread = unread, + pendingRequests = pendingRequests, + newestRequestMs = newestRequestMs, + ) + + @Test + fun `a request newer than every message moves the row to the top`() { + val projection = projectChatList( + listOf( + entry("recent", lastActivityMs = 500), + entry("hosted", lastActivityMs = 100, pendingRequests = 1, newestRequestMs = 900), + ) + ) + + assertEquals(listOf("hosted", "recent"), projection.main) + } + + @Test + fun `a request older than the last message leaves the row where its message put it`() { + val projection = projectChatList( + listOf( + entry("recent", lastActivityMs = 500), + entry("hosted", lastActivityMs = 300, pendingRequests = 1, newestRequestMs = 200), + ) + ) + + assertEquals(listOf("recent", "hosted"), projection.main) + } + + @Test + fun `requests alone put the row in the Unread chip and the badge`() { + val projection = projectChatList( + listOf( + entry("read", lastActivityMs = 500), + entry("hosted", lastActivityMs = 100, pendingRequests = 2, newestRequestMs = 200), + ) + ) + + assertEquals(listOf("hosted"), projection.unreadChip) + assertEquals(listOf("hosted"), projection.idsFor(ChatListFilter.Unread)) + assertEquals(1, projection.tabBadge) + } + + @Test + fun `requests and unread messages count the row once`() { + val projection = projectChatList( + listOf(entry("hosted", lastActivityMs = 100, unread = 3, pendingRequests = 1, newestRequestMs = 200)) + ) + + assertEquals(1, projection.unreadChip.size) + assertEquals(1, projection.tabBadge) + } + + @Test + fun `an archived hosted group stays archived and its requests count on the Archived row`() { + val projection = projectChatList( + listOf( + entry("main", lastActivityMs = 500), + entry("hosted", lastActivityMs = 100, archived = true, pendingRequests = 1, newestRequestMs = 900), + ) + ) + + assertEquals(listOf("main"), projection.main) + assertEquals(listOf("hosted"), projection.archived) + assertTrue(projection.unreadChip.isEmpty()) + assertEquals(1, projection.archivedRowCount) + assertEquals(0, projection.tabBadge) + } + + @Test + fun `no requests leaves a read row read`() { + assertFalse(entry("hosted", lastActivityMs = 100).isUnread) + } + + private val self = listOf(1) + private val other = listOf(2) + + private fun group(creator: List?, isPrivate: Boolean) = ChatMetadata( + chatId = ChatId("aa"), + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(0), + creator = creator, + isPrivate = isPrivate, + ) + + private val waiting = mapOf( + ChatId("aa") to listOf( + LobbyMember( + userProfile = UserProfile.Empty, + publicKey = PublicKey("11111111111111111111111111111111"), + enteredAt = Instant.fromEpochSeconds(1), + ) + ) + ) + + @Test + fun `only the host of a private group sees who waits`() { + assertEquals(1, waiting.waitingFor(group(creator = self, isPrivate = true), self).size) + assertTrue(waiting.waitingFor(group(creator = other, isPrivate = true), self).isEmpty()) + assertTrue(waiting.waitingFor(group(creator = self, isPrivate = false), self).isEmpty()) + assertTrue(waiting.waitingFor(group(creator = self, isPrivate = true), null).isEmpty()) + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatListUnreadCountTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatListUnreadCountTest.kt index c3cc20f7d0..a153aaadc0 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatListUnreadCountTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatListUnreadCountTest.kt @@ -12,6 +12,7 @@ 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.LobbyMember import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.PointerType @@ -19,6 +20,7 @@ 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 com.getcode.solana.keys.PublicKey import io.mockk.coEvery import io.mockk.every import io.mockk.mockk @@ -62,15 +64,15 @@ class ChatListUnreadCountTest { unreadSeq = messageId, ) - /** Read up to message 1 of 2, so each chat below has one unread message. */ - private val selfUnread = ChatMember( + /** Read up to message [readThrough] of 2; 1 leaves each chat below one unread message. */ + private fun selfReadThrough(readThrough: Long) = ChatMember( userId = selfId, userProfile = UserProfile.Empty, pointers = listOf( MessagePointer( type = PointerType.READ, userId = selfId, - value = 1, + value = readThrough, timestamp = Instant.fromEpochSeconds(1_000), ) ), @@ -85,10 +87,23 @@ class ChatListUnreadCountTest { pointers = emptyList(), ) - private suspend fun TestScope.unreadChatListCount(vararg unread: Pair): Int { + private suspend fun TestScope.unreadChatListCount(vararg unread: Pair): Int = + feedDelegate(unread.toList()).observeUnreadChatListCount().first() + + /** + * Builds the feed over [chats]. [readThrough] is the viewer's read pointer (2 reads everything); + * [hostedGroups] are private groups the viewer created. + */ + private suspend fun TestScope.feedDelegate( + chats: List>, + readThrough: Long = 1, + hostedGroups: Set = emptySet(), + ): FeedSyncDelegate { + val unread = chats.toTypedArray() 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 self = selfReadThrough(readThrough) + hex to if (type == ChatType.GROUP) listOf(self) else listOf(self, addressableOther) } val messageDataSource = mockk(relaxed = true).also { @@ -107,6 +122,8 @@ class ChatListUnreadCountTest { lastActivity = Instant.fromEpochMilliseconds(row.lastActivityEpochMs), title = row.title, rosterSummary = RosterSummary(memberCount = row.memberCount, version = row.rosterVersion), + creator = if (row.chatIdHex in hostedGroups) selfId else null, + isPrivate = row.chatIdHex in hostedGroups, ) } } @@ -129,7 +146,7 @@ class ChatListUnreadCountTest { delegate.initialize(backgroundScope) delegate.observeFeedFromDb() runCurrent() - return delegate.observeUnreadChatListCount().first() + return delegate } @Test @@ -152,4 +169,36 @@ class ChatListUnreadCountTest { assertEquals(2, count) } + + private fun waiting(chatIdHex: String) = mapOf( + ChatId(chatIdHex) to listOf( + LobbyMember( + userProfile = UserProfile.Empty, + publicKey = PublicKey("11111111111111111111111111111111"), + enteredAt = Instant.fromEpochSeconds(1), + ) + ) + ) + + @Test + fun `a read hosted group with someone waiting counts toward the badge`() = runTest { + val feed = feedDelegate(listOf(groupHex to ChatType.GROUP), readThrough = 2, hostedGroups = setOf(groupHex)) + + assertEquals(0, feed.observeUnreadChatListCount(flowOf(emptyMap())) { selfId }.first()) + assertEquals(1, feed.observeUnreadChatListCount(flowOf(waiting(groupHex))) { selfId }.first()) + } + + @Test + fun `an unread hosted group with someone waiting counts once`() = runTest { + val feed = feedDelegate(listOf(groupHex to ChatType.GROUP), hostedGroups = setOf(groupHex)) + + assertEquals(1, feed.observeUnreadChatListCount(flowOf(waiting(groupHex))) { selfId }.first()) + } + + @Test + fun `a lobby on a group the viewer doesn't host doesn't count`() = runTest { + val feed = feedDelegate(listOf(groupHex to ChatType.GROUP), readThrough = 2) + + assertEquals(0, feed.observeUnreadChatListCount(flowOf(waiting(groupHex))) { selfId }.first()) + } } diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt index f6ca80d2b6..48fe01205d 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt @@ -9,9 +9,13 @@ import com.flipcash.app.blocklist.BlocklistCoordinator import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.blob.BlobStorageCoordinator import com.flipcash.app.core.media.MediaUrlResolver +import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatType import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.FeaturedGroupsStore +import com.flipcash.shared.chat.HostLobbies +import com.flipcash.shared.chat.chatListFeeds +import com.flipcash.shared.chat.hostedPrivateGroups import com.flipcash.shared.chat.observeUnreadChatListCount import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.internal.bill.BillController @@ -58,6 +62,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.runningFold import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @@ -116,6 +121,7 @@ class RealSessionController @Inject constructor( private val blocklistCoordinator: BlocklistCoordinator, private val blobStorageCoordinator: BlobStorageCoordinator, private val mediaUrlResolver: MediaUrlResolver, + private val hostLobbies: HostLobbies, networkObserver: NetworkConnectivityListener, featureFlagController: FeatureFlagController, appSettingsCoordinator: AppSettingsCoordinator, @@ -248,11 +254,31 @@ class RealSessionController @Inject constructor( .map { it.authState } .filter { it.isAtLeastRegistered } .distinctUntilChanged() - .flatMapLatest { chatCoordinator.observeUnreadChatListCount() } + .flatMapLatest { + chatCoordinator.observeUnreadChatListCount(hostLobbies.state) { userManager.accountId } + } .distinctUntilChanged() .onEach { count -> stateHolder.update { it.copy(chatListUnreadCount = count) } } .launchIn(scope) + // The badge counts join requests, and nothing else loads a hosted group's lobby until Chats + // is opened: no push says a request came in, so a host who never opens Chats would never + // see one. Fetched as each hosted group first appears; the stream keeps them live after. + userManager.state + .map { it.authState } + .filter { it.isAtLeastRegistered } + .distinctUntilChanged() + .flatMapLatest { + // Per session, so signing back in fetches every lobby again after logout cleared them. + chatCoordinator.chatListFeeds() + .map { feeds -> hostedPrivateGroups(feeds.main + feeds.archived, userManager.accountId).keys } + .runningFold(emptySet() to emptySet()) { (_, seen), hosted -> + (hosted - seen) to (seen + hosted) + } + } + .onEach { (added, _) -> added.forEach { chatId -> scope.launch { hostLobbies.load(chatId) } } } + .launchIn(scope) + // Preload the blob upload policy once registered so profile-photo selection can filter and // validate against it without a network round-trip. Cached in the BlobStorageCoordinator. userManager.state diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerEventRoutingTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerEventRoutingTest.kt index e363776956..473d9fc7a9 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerEventRoutingTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerEventRoutingTest.kt @@ -123,6 +123,7 @@ class SessionControllerEventRoutingTest { blocklistCoordinator = mockk(relaxed = true), blobStorageCoordinator = mockk(relaxed = true), mediaUrlResolver = mockk(relaxed = true), + hostLobbies = mockk(relaxed = true), featureFlagController = featureFlagController, appSettingsCoordinator = appSettingsCoordinator, dispatchers = dispatchers, diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt index 72210e9135..29ee66b416 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt @@ -149,6 +149,7 @@ class SessionControllerGiftCardErrorTest { blocklistCoordinator = mockk(relaxed = true), blobStorageCoordinator = mockk(relaxed = true), mediaUrlResolver = mockk(relaxed = true), + hostLobbies = mockk(relaxed = true), featureFlagController = mockk(relaxed = true), appSettingsCoordinator = mockk(relaxed = true), dispatchers = dispatchers, diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerShareCheckTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerShareCheckTest.kt index 79bf239d92..37a62460c8 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerShareCheckTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerShareCheckTest.kt @@ -153,6 +153,7 @@ class SessionControllerShareCheckTest { blocklistCoordinator = mockk(relaxed = true), blobStorageCoordinator = mockk(relaxed = true), mediaUrlResolver = mockk(relaxed = true), + hostLobbies = mockk(relaxed = true), featureFlagController = featureFlagController, appSettingsCoordinator = appSettingsCoordinator, dispatchers = dispatchers, From 267d625ae99b7e1f5a1f26e839892e9eba763d90 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 16:03:58 -0400 Subject: [PATCH 2/2] fix(chat): reload hosted lobbies when the event stream reconnects LobbyUpdate only arrives on the event stream, and the server doesn't replay what was sent while the stream was down. A request that came or went in that gap left the lobby, and now the Chats row's count, wrong until something loaded it again. iOS reloads on reconnect (ConversationController.swift:606); Android didn't. LobbyUpdateSink gains reload(), and HostLobbies refetches every lobby it holds. RealChatCoordinator calls it with the feed sync wherever the stream comes back: the network reconnect, the heartbeat finding the stream down, and returning to the foreground. --- .../com/flipcash/shared/chat/HostLobbies.kt | 13 +++++++++++++ .../shared/chat/internal/RealChatCoordinator.kt | 17 +++++++++++++---- .../chat/ChatCoordinatorLifecycleStreamTest.kt | 17 +++++++++++++++++ .../com/flipcash/shared/chat/HostLobbiesTest.kt | 14 ++++++++++++++ 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostLobbies.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostLobbies.kt index 73713c3d78..a8d4e6ca70 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostLobbies.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostLobbies.kt @@ -9,10 +9,12 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.LobbyMember import com.flipcash.services.models.chat.LobbyUpdate import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @@ -23,6 +25,12 @@ fun interface LobbyUpdateSink { /** Drops everything held. For logout: the lobbies belong to the account. */ fun clear() = Unit + /** + * Fetches again every lobby held. For a reopened stream: updates sent while it was down are + * not replayed, so a request that came or went in the gap would otherwise stay wrong. + */ + suspend fun reload() = Unit + object None : LobbyUpdateSink { override fun apply(chatId: ChatId, updates: List) = Unit } @@ -106,6 +114,11 @@ class HostLobbies @Inject constructor( lobbies.value = emptyMap() } + /** Only the chats a host surface already loaded: those are the ones anyone is showing. */ + override suspend fun reload() = coroutineScope { + lobbies.value.keys.forEach { chatId -> launch { load(chatId) } } + } + private fun remove(chatId: ChatId, userId: ID) { lobbies.update { all -> val current = all[chatId] ?: return@update all diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt index 4e7af95252..4558d821fd 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt @@ -177,7 +177,7 @@ class RealChatCoordinator @Inject constructor( // don't get one. scope.launch { messagingDelegate.openKeyPending() } eventStreamDelegate.open() - eventStreamDelegate.startHeartbeat { syncFeeds() } + eventStreamDelegate.startHeartbeat(::resync) } // endregion @@ -255,7 +255,7 @@ class RealChatCoordinator @Inject constructor( .filter { foregrounded.value } .onEach { trace(tag = TAG, message = "Network connected, re-syncing chat feed", type = TraceType.Process) - syncFeeds() + resync() eventStreamDelegate.open() } .launchIn(scope) @@ -286,9 +286,9 @@ class RealChatCoordinator @Inject constructor( scope.launch { if (cluster.value != null) { trace(tag = TAG, message = "Lifecycle resumed, syncing chat feed", type = TraceType.Process) - syncFeeds() + resync() eventStreamDelegate.open() - eventStreamDelegate.startHeartbeat { syncFeeds() } + eventStreamDelegate.startHeartbeat(::resync) } } } @@ -357,6 +357,15 @@ class RealChatCoordinator @Inject constructor( * overlap rather than queue. A group feed that fails is traced and dropped by * [GroupFeedDelegate.performGroupFeedSync]; it cannot take the DM list down with it. */ + /** + * For a stream coming back after a gap: what it missed is not replayed, so the feeds and the + * hosted lobbies are fetched again. A lobby's LobbyUpdates arrive only on the stream. + */ + private fun resync() { + syncFeeds() + scope.launch { lobbyUpdates.reload() } + } + private fun syncFeeds(fresh: Boolean = false) { if (feedSyncJob?.isActive == true) { // Shared with the one in flight, so a foreground, a reconnect or a heartbeat landing diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorLifecycleStreamTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorLifecycleStreamTest.kt index 189856658a..ddfcc5df54 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorLifecycleStreamTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorLifecycleStreamTest.kt @@ -9,7 +9,9 @@ import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.controllers.ChatController import com.flipcash.services.controllers.ChatMessagingController import com.flipcash.services.controllers.EventStreamingController +import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatUpdate +import com.flipcash.services.models.chat.LobbyUpdate import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.internal.ChatIdGenerator import com.flipcash.shared.chat.internal.ChatStateHolder @@ -68,6 +70,16 @@ class ChatCoordinatorLifecycleStreamTest { override val type: ConnectionType get() = mutableState.value.type } + /** Counts reloads: updates the stream dropped while down are only recovered by refetching. */ + private class RecordingLobbies : LobbyUpdateSink { + var reloads = 0 + override fun apply(chatId: ChatId, updates: List) = Unit + override suspend fun reload() { + reloads++ + } + } + + private val lobbies = RecordingLobbies() private lateinit var eventStreamingController: EventStreamingController private var opens = 0 private lateinit var networkObserver: FakeNetworkObserver @@ -150,6 +162,7 @@ class ChatCoordinatorLifecycleStreamTest { dispatchers = testDispatchers, groupFeedDelegate = mockk(relaxed = true), reactionsDelegate = mockk(relaxed = true), + lobbyUpdates = lobbies, ) } @@ -231,6 +244,7 @@ class ChatCoordinatorLifecycleStreamTest { advanceTimeBy(2.seconds) runCurrent() + val reloadsBefore = lobbies.reloads disconnected() advanceTimeBy(2.seconds) connected(SignalStrength.Good) @@ -240,6 +254,7 @@ class ChatCoordinatorLifecycleStreamTest { coordinator.teardown() assertEquals(4, opens) + assertEquals(reloadsBefore + 1, lobbies.reloads) } /** Backgrounding and then returning still reopens — the gate is the lifecycle, not a latch. */ @@ -259,11 +274,13 @@ class ChatCoordinatorLifecycleStreamTest { advanceTimeBy(10.seconds) runCurrent() + val reloadsBefore = lobbies.reloads coordinator.onStart(mockk(relaxed = true)) runCurrent() coordinator.teardown() assertEquals(4, opens) + assertEquals(reloadsBefore + 1, lobbies.reloads) } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostLobbiesTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostLobbiesTest.kt index d2c48bfd32..dd9ac91910 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostLobbiesTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostLobbiesTest.kt @@ -136,4 +136,18 @@ class HostLobbiesTest { assertTrue(lobbies.deny(chat, listOf(1)).isFailure) assertEquals(listOf(listOf(1)), ids()) } + + @Test + fun `reload refetches the lobbies held and no others`() = runTest { + val other = ChatId("bb") + lobbies.apply(chat, listOf(LobbyUpdate.MemberEntered(older), LobbyUpdate.MemberEntered(newer))) + // The server has since admitted `newer`; the stream that would have said so was down. + coEvery { chatController.getLobbyMembers(chat, any()) } returns + Result.success(LobbyPage(listOf(older), null, hasMore = false)) + + lobbies.reload() + + assertEquals(listOf(listOf(1)), ids()) + coVerify(exactly = 0) { chatController.getLobbyMembers(other, any()) } + } }