ui: keep unread DM senders visible

This commit is contained in:
callebtc 2026-07-27 18:00:59 +02:00
parent 67b0ae78a5
commit 8c62e90711
5 changed files with 418 additions and 26 deletions

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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:")

View File

@ -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)
)
}