Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1359,6 +1359,12 @@
<item quantity="one">%1$d Person Wants To Join</item>
<item quantity="other">%1$d People Want To Join</item>
</plurals>
<!-- A hosted group's Chats row while someone waits to join it (node 12824:117). -->
<string name="subtitle_wantsToJoin">%1$s wants to join</string>
<plurals name="subtitle_peopleWantToJoin">
<item quantity="one">%1$d person wants to join</item>
<item quantity="other">%1$d people want to join</item>
</plurals>
<string name="subtitle_namesAnd">and</string>
<string name="title_requestedJustNow">Requested just now</string>
<string name="title_requestedYesterday">Requested yesterday</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,6 +34,7 @@ internal class ArchivedChatsViewModel @Inject constructor(
resources: ResourceHelper,
dispatchers: DispatcherProvider,
paymentRequests: PaymentRequests,
hostLobbies: HostLobbies,
) : BaseViewModel<ArchivedChatsViewModel.State, ArchivedChatsViewModel.Event>(
initialState = State(),
updateStateForEvent = updateStateForEvent,
Expand All @@ -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))) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<List<ConversationReference>> = Loadable.Loading(),
// The chip selected above the list. Not persisted: a cold start opens on All.
Expand 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. */
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -133,10 +128,11 @@ internal class ChatsViewModel @Inject constructor(
// re-fetch every sender already on disk.
senderProfiles: Map<String, UserProfile>?,
requests: Map<ID, PaymentRequest>,
lobbies: Map<ChatId, List<LobbyMember>>,
): 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
Expand All @@ -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
Expand All @@ -165,6 +163,7 @@ internal class ChatsViewModel @Inject constructor(
tokens = tokenCoordinator.cachedTokens(),
senderProfiles = chatCoordinator.currentSenderProfiles(),
requests = paymentRequests.state.value,
lobbies = hostLobbies.state.value,
)
)
}
Expand All @@ -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)
Expand All @@ -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 {
Expand All @@ -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<ChatSummary>, selfId: ID?): Map<ChatId, String> {
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<ConversationReference>.inOrderOf(ids: List<String>): List<ConversationReference> {
val rank = ids.withIndex().associate { (index, id) -> id to index }
return sortedBy { rank[it.chatId.toString()] ?: Int.MAX_VALUE }
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,6 +33,8 @@ internal fun mapConversations(
paymentRequests: Map<ID, PaymentRequest>,
// 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<ChatId, List<LobbyMember>> = emptyMap(),
): List<ConversationReference> {
val tokensByMint = tokens.associateBy { it.address }
return summaries.map { summary ->
Expand All @@ -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 })
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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<LobbyMember>): 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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading
Loading