mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-29 07:16:08 +00:00
ui: keep unread DM senders visible
This commit is contained in:
parent
67b0ae78a5
commit
8c62e90711
@ -8,7 +8,10 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import com.bitchat.android.mesh.BluetoothMeshDelegate
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
@ -171,6 +174,24 @@ class ChatViewModel(
|
||||
val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = state.privateChats
|
||||
val selectedPrivateChatPeer: StateFlow<String?> = state.selectedPrivateChatPeer
|
||||
val unreadPrivateMessages: StateFlow<Set<String>> = state.unreadPrivateMessages
|
||||
internal val unreadConversations: StateFlow<List<UnreadConversationSummary>> = combine(
|
||||
state.unreadPrivateMessages,
|
||||
state.privateChats,
|
||||
state.nickname
|
||||
) { unreadConversationIDs, chats, currentNickname ->
|
||||
val seenStore = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
|
||||
buildUnreadConversationSummaries(
|
||||
unreadConversationIDs = unreadConversationIDs,
|
||||
privateChats = chats,
|
||||
currentUserIdentifiers = setOf(currentNickname, mesh.myPeerID),
|
||||
canonicalize = ContactDirectory::canonicalConversationId,
|
||||
isMessageRead = { message -> seenStore.hasRead(message.id) }
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = emptyList()
|
||||
)
|
||||
val joinedChannels: StateFlow<Set<String>> = state.joinedChannels
|
||||
val currentChannel: StateFlow<String?> = state.currentChannel
|
||||
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = state.channelMessages
|
||||
|
||||
@ -19,6 +19,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.ui.theme.colorForPeer
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.services.ContactDirectory
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import java.util.*
|
||||
|
||||
@ -36,7 +37,8 @@ data class GeoPerson(
|
||||
fun GeohashPeopleList(
|
||||
viewModel: ChatViewModel,
|
||||
onTapPerson: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
excludedConversationIDs: Set<String> = emptySet()
|
||||
) {
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
@ -77,9 +79,15 @@ fun GeohashPeopleList(
|
||||
geohashPeople
|
||||
}
|
||||
}
|
||||
val sections = remember(peopleIncludingSelf, myHex, isTeleported, teleportedGeo) {
|
||||
val visiblePeople = remember(peopleIncludingSelf, excludedConversationIDs) {
|
||||
peopleIncludingSelf.filterNot { person ->
|
||||
val alias = "nostr_${person.id.take(16)}"
|
||||
ContactDirectory.canonicalConversationId(alias) in excludedConversationIDs
|
||||
}
|
||||
}
|
||||
val sections = remember(visiblePeople, myHex, isTeleported, teleportedGeo) {
|
||||
sectionGeohashPeople(
|
||||
people = peopleIncludingSelf,
|
||||
people = visiblePeople,
|
||||
myId = myHex,
|
||||
selfIsTeleported = isTeleported,
|
||||
teleportedIds = teleportedGeo
|
||||
|
||||
@ -82,9 +82,16 @@ fun MeshPeerListSheet(
|
||||
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
val unreadConversations by viewModel.unreadConversations.collectAsStateWithLifecycle()
|
||||
val geohashPeopleCount = geohashPeople.size
|
||||
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
|
||||
val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() }
|
||||
val unreadConversationIDs = remember(unreadConversations) {
|
||||
unreadConversations.mapTo(mutableSetOf()) { it.conversationID }
|
||||
}
|
||||
val visibleConnectedPeers = connectedPeers.filterNot { peerID ->
|
||||
ContactDirectory.canonicalConversationId(peerID) in unreadConversationIDs
|
||||
}
|
||||
|
||||
// Bottom sheet state
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
@ -117,7 +124,22 @@ fun MeshPeerListSheet(
|
||||
) {
|
||||
val peopleCount = when (selectedLocationChannel) {
|
||||
is ChannelID.Location -> geohashPeopleCount
|
||||
else -> connectedPeers.count { it != viewModel.myPeerID }
|
||||
else -> visibleConnectedPeers.count { it != viewModel.myPeerID }
|
||||
}
|
||||
|
||||
if (unreadConversations.isNotEmpty()) {
|
||||
item(key = "unread_private_messages_section") {
|
||||
UnreadDirectMessagesSection(
|
||||
conversations = unreadConversations,
|
||||
connectedPeers = connectedPeers,
|
||||
viewModel = viewModel,
|
||||
onPrivateChatStart = { conversationID ->
|
||||
viewModel.showPrivateChatSheet(conversationID)
|
||||
onDismiss()
|
||||
},
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Channels section
|
||||
@ -127,7 +149,9 @@ fun MeshPeerListSheet(
|
||||
SheetIconSectionHeader(
|
||||
iconRes = R.drawable.ic_spec_chat_bubbles,
|
||||
title = stringResource(R.string.channels),
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
modifier = Modifier.padding(
|
||||
top = if (unreadConversations.isNotEmpty()) 20.dp else 8.dp
|
||||
)
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
@ -179,8 +203,12 @@ fun MeshPeerListSheet(
|
||||
GeohashPeopleList(
|
||||
viewModel = viewModel,
|
||||
onTapPerson = onDismiss,
|
||||
excludedConversationIDs = unreadConversationIDs,
|
||||
modifier = Modifier.padding(
|
||||
top = if (joinedChannels.isNotEmpty()) 20.dp else 8.dp
|
||||
top = if (
|
||||
joinedChannels.isNotEmpty() ||
|
||||
unreadConversations.isNotEmpty()
|
||||
) 20.dp else 8.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -188,9 +216,12 @@ fun MeshPeerListSheet(
|
||||
else -> {
|
||||
PeopleSection(
|
||||
modifier = Modifier.padding(
|
||||
top = if (joinedChannels.isNotEmpty()) 20.dp else 8.dp
|
||||
top = if (
|
||||
joinedChannels.isNotEmpty() ||
|
||||
unreadConversations.isNotEmpty()
|
||||
) 20.dp else 8.dp
|
||||
),
|
||||
connectedPeers = connectedPeers,
|
||||
connectedPeers = visibleConnectedPeers,
|
||||
peerNicknames = peerNicknames,
|
||||
peerRSSI = peerRSSI,
|
||||
nickname = nickname,
|
||||
@ -198,6 +229,7 @@ fun MeshPeerListSheet(
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
wifiAwarePeerIDs = wifiAwarePeerIDs,
|
||||
peopleCount = peopleCount,
|
||||
excludedConversationIDs = unreadConversationIDs,
|
||||
viewModel = viewModel,
|
||||
onPrivateChatStart = { peerID ->
|
||||
viewModel.showPrivateChatSheet(peerID)
|
||||
@ -312,6 +344,7 @@ fun PeopleSection(
|
||||
selectedPrivatePeer: String?,
|
||||
wifiAwarePeerIDs: Set<String> = emptySet(),
|
||||
peopleCount: Int = 0,
|
||||
excludedConversationIDs: Set<String> = emptySet(),
|
||||
viewModel: ChatViewModel,
|
||||
onPrivateChatStart: (String) -> Unit
|
||||
) {
|
||||
@ -410,8 +443,6 @@ fun PeopleSection(
|
||||
)
|
||||
|
||||
// Build a map of base name counts across all people shown in the list (connected + offline + nostr)
|
||||
val hex64Regex = Regex("^[0-9a-fA-F]{64}$")
|
||||
|
||||
// Helper to compute display name used for a given key
|
||||
fun computeDisplayNameForPeerId(key: String): String {
|
||||
return if (key == nickname) "You" else (peerNicknames[key] ?: (privateChats[key]?.lastOrNull()?.sender ?: key.take(12)))
|
||||
@ -430,33 +461,29 @@ fun PeopleSection(
|
||||
val offlineFavorites = FavoritesPersistenceService.shared.getOurFavorites()
|
||||
offlineFavorites.forEach { fav ->
|
||||
val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey)
|
||||
if (!isFavoriteMappedToConnected(fav)) {
|
||||
val conversationID = ContactDirectory.canonicalConversationId(favPeerID)
|
||||
if (
|
||||
conversationID !in excludedConversationIDs &&
|
||||
!isFavoriteMappedToConnected(fav)
|
||||
) {
|
||||
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
|
||||
val (b, _) = splitSuffix(dn)
|
||||
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
// Nostr-only conversations
|
||||
val connectedIds = sortedPeers.toSet()
|
||||
privateChats.keys
|
||||
.filter { key ->
|
||||
(key.startsWith("nostr_") || hex64Regex.matches(key)) &&
|
||||
!connectedIds.contains(key) &&
|
||||
!connectedNoiseHexes.contains(key.lowercase())
|
||||
}
|
||||
.forEach { convKey ->
|
||||
val dn = peerNicknames[convKey] ?: (privateChats[convKey]?.lastOrNull()?.sender ?: convKey.take(12))
|
||||
val (b, _) = splitSuffix(dn)
|
||||
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
|
||||
}
|
||||
|
||||
// Every row this card will show, in final order, so the animated list can key on identity
|
||||
// and animate reordering. Offline favourites are appended after the connected peers.
|
||||
// Collected once for the whole card rather than once per row.
|
||||
val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
|
||||
|
||||
val offlineFavoriteRows = offlineFavorites.filterNot { isFavoriteMappedToConnected(it) }
|
||||
val offlineFavoriteRows = offlineFavorites.filterNot { favorite ->
|
||||
val favoriteConversationID = ContactDirectory.canonicalConversationId(
|
||||
ContactIdentityResolver.noiseKeyHex(favorite.peerNoisePublicKey)
|
||||
)
|
||||
favoriteConversationID in excludedConversationIDs ||
|
||||
isFavoriteMappedToConnected(favorite)
|
||||
}
|
||||
val rowKeys: List<String> = sortedPeers +
|
||||
offlineFavoriteRows.map { ContactIdentityResolver.noiseKeyHex(it.peerNoisePublicKey) }
|
||||
|
||||
@ -563,6 +590,139 @@ fun PeopleSection(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UnreadDirectMessagesSection(
|
||||
conversations: List<UnreadConversationSummary>,
|
||||
connectedPeers: List<String>,
|
||||
viewModel: ChatViewModel,
|
||||
onPrivateChatStart: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Column(modifier = modifier) {
|
||||
SheetIconSectionHeader(
|
||||
iconRes = R.drawable.ic_spec_envelope,
|
||||
title = stringResource(R.string.cd_unread_private_messages)
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding)
|
||||
.padding(top = 10.dp),
|
||||
color = colorScheme.surface,
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
AnimatedRowColumn(
|
||||
items = conversations,
|
||||
key = { it.conversationID }
|
||||
) { index, conversation ->
|
||||
Column {
|
||||
if (index > 0) SheetCardDivider()
|
||||
|
||||
val resolution = ContactDirectory.resolve(conversation.conversationID)
|
||||
val connected = resolution.meshPeerID?.let(connectedPeers::contains) == true ||
|
||||
conversation.conversationID in connectedPeers
|
||||
val aliases = ContactDirectory.aliasesForConversation(
|
||||
conversation.conversationID
|
||||
)
|
||||
val sourceGeohash = aliases
|
||||
.asSequence()
|
||||
.mapNotNull(GeohashConversationRegistry::get)
|
||||
.firstOrNull()
|
||||
val displayName = resolution.displayName
|
||||
?.takeUnless { it.isBlank() || it.equals("Unknown", ignoreCase = true) }
|
||||
?: conversation.displayName
|
||||
val subtitle = when {
|
||||
sourceGeohash != null -> "#$sourceGeohash"
|
||||
conversation.transport == DirectMessageTransport.NOSTR ->
|
||||
stringResource(R.string.cd_reachable_via_nostr)
|
||||
!connected -> stringResource(R.string.cd_offline_mesh_chat)
|
||||
else -> null
|
||||
}
|
||||
val peerIdentity = conversation.nostrPubkey
|
||||
?.let(viewModel::peerIdentityForNostrPubkey)
|
||||
?: viewModel.peerIdentityForMeshPeer(conversation.conversationID)
|
||||
val assignedColor = colorForPeer(peerIdentity, palette)
|
||||
val (baseNameRaw, suffix) = splitSuffix(displayName)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onPrivateChatStart(conversation.conversationID)
|
||||
}
|
||||
.padding(
|
||||
horizontal = SheetRowHorizontal,
|
||||
vertical = SheetRowVertical
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(SheetRowLeadingSlot),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_spec_envelope),
|
||||
contentDescription = stringResource(R.string.cd_unread_message),
|
||||
modifier = Modifier.size(PeerRowIconSize),
|
||||
tint = palette.accentOrange
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(SheetRowLeadingGutter))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = truncateNickname(baseNameRaw),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = assignedColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
if (suffix.isNotEmpty()) {
|
||||
Text(
|
||||
text = suffix,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = assignedColor.copy(alpha = SUFFIX_ALPHA)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
color = palette.textTertiary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
UnreadBadge(
|
||||
count = conversation.unreadCount,
|
||||
colorScheme = colorScheme
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PeerItem(
|
||||
peerID: String,
|
||||
|
||||
@ -0,0 +1,86 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
|
||||
internal enum class DirectMessageTransport {
|
||||
MESH,
|
||||
NOSTR
|
||||
}
|
||||
|
||||
/**
|
||||
* Presence-independent presentation state for an unread private conversation.
|
||||
*
|
||||
* A conversation remains in this model until it is read, even when none of its identities are in
|
||||
* the current mesh or geohash participant lists.
|
||||
*/
|
||||
internal data class UnreadConversationSummary(
|
||||
val conversationID: String,
|
||||
val displayName: String,
|
||||
val unreadCount: Int,
|
||||
val latestMessageAt: Long,
|
||||
val transport: DirectMessageTransport,
|
||||
val nostrPubkey: String?
|
||||
)
|
||||
|
||||
internal fun buildUnreadConversationSummaries(
|
||||
unreadConversationIDs: Set<String>,
|
||||
privateChats: Map<String, List<BitchatMessage>>,
|
||||
currentUserIdentifiers: Set<String>,
|
||||
canonicalize: (String) -> String,
|
||||
isMessageRead: (BitchatMessage) -> Boolean
|
||||
): List<UnreadConversationSummary> {
|
||||
if (unreadConversationIDs.isEmpty()) return emptyList()
|
||||
|
||||
val normalizedCurrentUserIdentifiers = currentUserIdentifiers.filterTo(mutableSetOf()) {
|
||||
it.isNotBlank()
|
||||
}
|
||||
val canonicalUnreadIDs = unreadConversationIDs
|
||||
.mapTo(linkedSetOf()) { canonicalize(it) }
|
||||
val unreadAliasesByCanonicalID = unreadConversationIDs.groupBy(canonicalize)
|
||||
val messagesByCanonicalID = linkedMapOf<String, MutableList<BitchatMessage>>()
|
||||
|
||||
privateChats.forEach { (conversationID, messages) ->
|
||||
val canonicalID = canonicalize(conversationID)
|
||||
messagesByCanonicalID.getOrPut(canonicalID) { mutableListOf() }.addAll(messages)
|
||||
}
|
||||
|
||||
return canonicalUnreadIDs.map { conversationID ->
|
||||
val messages = messagesByCanonicalID[conversationID]
|
||||
.orEmpty()
|
||||
.distinctBy { it.id }
|
||||
val incomingMessages = messages.filterNot {
|
||||
it.sender in normalizedCurrentUserIdentifiers
|
||||
}
|
||||
val unreadIncomingMessages = incomingMessages.filterNot(isMessageRead)
|
||||
val latestMessage = (unreadIncomingMessages.ifEmpty { incomingMessages })
|
||||
.maxWithOrNull(compareBy<BitchatMessage> { it.timestamp.time }.thenBy { it.id })
|
||||
val aliases = unreadAliasesByCanonicalID[conversationID].orEmpty()
|
||||
val nostrPubkey = latestMessage?.senderNostrPubkey
|
||||
val isNostrConversation = nostrPubkey != null ||
|
||||
aliases.any(::isNostrConversationID) ||
|
||||
isNostrConversationID(conversationID)
|
||||
|
||||
UnreadConversationSummary(
|
||||
conversationID = conversationID,
|
||||
displayName = latestMessage
|
||||
?.sender
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: conversationID.take(12),
|
||||
unreadCount = unreadIncomingMessages.size.coerceAtLeast(1),
|
||||
latestMessageAt = latestMessage?.timestamp?.time ?: Long.MIN_VALUE,
|
||||
transport = if (isNostrConversation) {
|
||||
DirectMessageTransport.NOSTR
|
||||
} else {
|
||||
DirectMessageTransport.MESH
|
||||
},
|
||||
nostrPubkey = nostrPubkey
|
||||
)
|
||||
}.sortedWith(
|
||||
compareByDescending<UnreadConversationSummary> { it.latestMessageAt }
|
||||
.thenBy { it.displayName.lowercase() }
|
||||
.thenBy { it.conversationID }
|
||||
)
|
||||
}
|
||||
|
||||
private fun isNostrConversationID(value: String): Boolean =
|
||||
value.startsWith("nostr_") || value.startsWith("nostr:")
|
||||
@ -0,0 +1,117 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.Date
|
||||
|
||||
class UnreadConversationSummaryTest {
|
||||
@Test
|
||||
fun `unread conversations survive missing presence and sort by latest unread`() {
|
||||
val older = incoming(
|
||||
id = "older",
|
||||
sender = "alice",
|
||||
timestamp = 100
|
||||
)
|
||||
val newer = incoming(
|
||||
id = "newer",
|
||||
sender = "bob",
|
||||
timestamp = 200
|
||||
)
|
||||
|
||||
val rows = buildUnreadConversationSummaries(
|
||||
unreadConversationIDs = setOf("alice-peer", "bob-peer"),
|
||||
privateChats = mapOf(
|
||||
"alice-peer" to listOf(older),
|
||||
"bob-peer" to listOf(newer)
|
||||
),
|
||||
currentUserIdentifiers = setOf("me"),
|
||||
canonicalize = { it },
|
||||
isMessageRead = { false }
|
||||
)
|
||||
|
||||
assertEquals(listOf("bob-peer", "alice-peer"), rows.map { it.conversationID })
|
||||
assertEquals(listOf("bob", "alice"), rows.map { it.displayName })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canonical aliases produce one unread conversation row`() {
|
||||
val message = incoming(
|
||||
id = "message",
|
||||
sender = "alice",
|
||||
timestamp = 100
|
||||
)
|
||||
|
||||
val rows = buildUnreadConversationSummaries(
|
||||
unreadConversationIDs = setOf("mesh-alias", "nostr_alias"),
|
||||
privateChats = mapOf(
|
||||
"mesh-alias" to listOf(message),
|
||||
"nostr_alias" to listOf(message)
|
||||
),
|
||||
currentUserIdentifiers = setOf("me"),
|
||||
canonicalize = { "contact_alice" },
|
||||
isMessageRead = { false }
|
||||
)
|
||||
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals("contact_alice", rows.single().conversationID)
|
||||
assertEquals(DirectMessageTransport.NOSTR, rows.single().transport)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only unseen incoming messages contribute to unread count`() {
|
||||
val read = incoming(
|
||||
id = "read",
|
||||
sender = "alice",
|
||||
timestamp = 100
|
||||
)
|
||||
val unread = incoming(
|
||||
id = "unread",
|
||||
sender = "alice",
|
||||
timestamp = 200
|
||||
)
|
||||
val outgoing = incoming(
|
||||
id = "outgoing",
|
||||
sender = "me",
|
||||
timestamp = 300
|
||||
)
|
||||
|
||||
val row = buildUnreadConversationSummaries(
|
||||
unreadConversationIDs = setOf("alice-peer"),
|
||||
privateChats = mapOf("alice-peer" to listOf(read, unread, outgoing)),
|
||||
currentUserIdentifiers = setOf("me"),
|
||||
canonicalize = { it },
|
||||
isMessageRead = { it.id == "read" }
|
||||
).single()
|
||||
|
||||
assertEquals(1, row.unreadCount)
|
||||
assertEquals(200, row.latestMessageAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unread key without hydrated messages still produces a row`() {
|
||||
val row = buildUnreadConversationSummaries(
|
||||
unreadConversationIDs = setOf("orphan-peer"),
|
||||
privateChats = emptyMap(),
|
||||
currentUserIdentifiers = setOf("me"),
|
||||
canonicalize = { it },
|
||||
isMessageRead = { false }
|
||||
).single()
|
||||
|
||||
assertEquals("orphan-peer", row.conversationID)
|
||||
assertEquals(1, row.unreadCount)
|
||||
assertTrue(row.displayName.isNotBlank())
|
||||
}
|
||||
|
||||
private fun incoming(
|
||||
id: String,
|
||||
sender: String,
|
||||
timestamp: Long
|
||||
) = BitchatMessage(
|
||||
id = id,
|
||||
sender = sender,
|
||||
content = "hello",
|
||||
timestamp = Date(timestamp)
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user