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