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/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/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/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/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/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()) } + } } 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,