From 8c62e90711b941e4bf84caa96995dac186580844 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:00:59 +0200 Subject: [PATCH 1/2] ui: keep unread DM senders visible --- .../com/bitchat/android/ui/ChatViewModel.kt | 21 ++ .../bitchat/android/ui/GeohashPeopleList.kt | 14 +- .../bitchat/android/ui/MeshPeerListSheet.kt | 206 ++++++++++++++++-- .../android/ui/UnreadConversationSummary.kt | 86 ++++++++ .../ui/UnreadConversationSummaryTest.kt | 117 ++++++++++ 5 files changed, 418 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/ui/UnreadConversationSummary.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/ui/UnreadConversationSummaryTest.kt diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 3017b20c..6ef0ff81 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -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>> = state.privateChats val selectedPrivateChatPeer: StateFlow = state.selectedPrivateChatPeer val unreadPrivateMessages: StateFlow> = state.unreadPrivateMessages + internal val unreadConversations: StateFlow> = 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> = state.joinedChannels val currentChannel: StateFlow = state.currentChannel val channelMessages: StateFlow>> = state.channelMessages diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 0cf88b74..e8f87668 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -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 = 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 diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index c496938e..7ec2fc0a 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -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 = emptySet(), peopleCount: Int = 0, + excludedConversationIDs: Set = 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 = sortedPeers + offlineFavoriteRows.map { ContactIdentityResolver.noiseKeyHex(it.peerNoisePublicKey) } @@ -563,6 +590,139 @@ fun PeopleSection( } } +@Composable +private fun UnreadDirectMessagesSection( + conversations: List, + connectedPeers: List, + 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, diff --git a/app/src/main/java/com/bitchat/android/ui/UnreadConversationSummary.kt b/app/src/main/java/com/bitchat/android/ui/UnreadConversationSummary.kt new file mode 100644 index 00000000..bad2c43c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/UnreadConversationSummary.kt @@ -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, + privateChats: Map>, + currentUserIdentifiers: Set, + canonicalize: (String) -> String, + isMessageRead: (BitchatMessage) -> Boolean +): List { + 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>() + + 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 { 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 { it.latestMessageAt } + .thenBy { it.displayName.lowercase() } + .thenBy { it.conversationID } + ) +} + +private fun isNostrConversationID(value: String): Boolean = + value.startsWith("nostr_") || value.startsWith("nostr:") diff --git a/app/src/test/kotlin/com/bitchat/android/ui/UnreadConversationSummaryTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/UnreadConversationSummaryTest.kt new file mode 100644 index 00000000..e054d819 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/UnreadConversationSummaryTest.kt @@ -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) + ) +} From 21cfa62cc2102764957198ea1c1ecc25382ec242 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:23:08 +0200 Subject: [PATCH 2/2] fix: address unread DM review feedback --- .../com/bitchat/android/ui/ChatViewModel.kt | 120 ++++++++++++++---- .../bitchat/android/ui/GeohashPeopleList.kt | 9 +- .../bitchat/android/ui/MeshPeerListSheet.kt | 44 +++---- .../com/bitchat/android/ui/MessageManager.kt | 12 +- .../bitchat/android/ui/PrivateChatManager.kt | 8 +- .../android/ui/UnreadConversationSummary.kt | 23 +++- .../android/ui/PrivateChatManagerTest.kt | 23 ++++ .../ui/UnreadConversationSummaryTest.kt | 20 +++ 8 files changed, 190 insertions(+), 69 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 6ef0ff81..c00504fb 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -6,11 +6,13 @@ import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.bitchat.android.favorites.FavoritesPersistenceService +import kotlinx.coroutines.Dispatchers 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.flowOn import kotlinx.coroutines.flow.stateIn import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.mesh.BluetoothMeshService @@ -19,10 +21,12 @@ import com.bitchat.android.service.MeshServiceHolder import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType import com.bitchat.android.nostr.NostrIdentityBridge +import com.bitchat.android.nostr.GeohashConversationRegistry import com.bitchat.android.protocol.BitchatPacket import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import com.bitchat.android.util.NotificationIntervalManager import kotlinx.coroutines.delay import java.util.Date @@ -177,21 +181,54 @@ class ChatViewModel( internal val unreadConversations: StateFlow> = combine( state.unreadPrivateMessages, state.privateChats, - state.nickname - ) { unreadConversationIDs, chats, currentNickname -> + state.nickname, + state.connectedPeers + ) { unreadConversationIDs, chats, currentNickname, connectedPeerIDs -> val seenStore = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) + val connectedPeerIDSet = connectedPeerIDs.mapTo(mutableSetOf()) { it.lowercase() } buildUnreadConversationSummaries( unreadConversationIDs = unreadConversationIDs, privateChats = chats, currentUserIdentifiers = setOf(currentNickname, mesh.myPeerID), canonicalize = ContactDirectory::canonicalConversationId, isMessageRead = { message -> seenStore.hasRead(message.id) } + ).map { summary -> + val resolution = ContactDirectory.resolve(summary.conversationID) + val resolvedNostrPubkey = summary.nostrPubkey + ?: resolution.nostrPubkey?.let(ContactIdentityResolver::nostrPubkeyHex) + val aliases = buildSet { + addAll(summary.identityAliases) + add(summary.conversationID) + add(resolution.conversationID) + resolution.meshPeerID?.let(::add) + resolution.noiseKeyHex?.let(::add) + resolvedNostrPubkey + ?.let(ContactIdentityResolver::nostrAliasForPubkey) + ?.let(::add) + }.mapTo(mutableSetOf()) { it.lowercase() } + + summary.copy( + displayName = resolution.displayName + ?.takeUnless { + it.isBlank() || it.equals("Unknown", ignoreCase = true) + } + ?: summary.displayName, + nostrPubkey = resolvedNostrPubkey, + identityAliases = aliases, + isConnected = aliases.any(connectedPeerIDSet::contains), + sourceGeohash = aliases + .asSequence() + .mapNotNull(GeohashConversationRegistry::get) + .firstOrNull() + ) + } + } + .flowOn(Dispatchers.IO) + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = emptyList() ) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.Eagerly, - initialValue = emptyList() - ) val joinedChannels: StateFlow> = state.joinedChannels val currentChannel: StateFlow = state.currentChannel val channelMessages: StateFlow>> = state.channelMessages @@ -256,18 +293,27 @@ class ChatViewModel( } viewModelScope.launch { try { com.bitchat.android.services.AppStateStore.privateMessages.collect { byPeer -> - val canonicalChats = ContactDirectory.canonicalizePrivateChats(byPeer) + val (canonicalChats, unreadConversationIDs) = withContext(Dispatchers.IO) { + val canonical = ContactDirectory.canonicalizePrivateChats(byPeer) + val unread = try { + val seen = com.bitchat.android.services.SeenMessageStore + .getInstance(getApplication()) + val myNick = state.getNicknameValue().ifBlank { mesh.myPeerID } + canonical + .filterValues { messages -> + messages.any { message -> + message.sender != myNick && !seen.hasRead(message.id) + } + } + .keys + } catch (_: Exception) { + state.getUnreadPrivateMessagesValue() + } + canonical to unread + } state.setPrivateChats(canonicalChats) // Recompute unread set using SeenMessageStore for robustness across Activity recreation - try { - val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) - val myNick = state.getNicknameValue() ?: mesh.myPeerID - val unread = mutableSetOf() - canonicalChats.forEach { (peer, list) -> - if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer) - } - state.setUnreadPrivateMessages(unread) - } catch (_: Exception) { } + state.setUnreadPrivateMessages(unreadConversationIDs) } } catch (_: Exception) { } } viewModelScope.launch { @@ -397,15 +443,26 @@ class ChatViewModel( // MARK: - Private Chat Management (delegated) - fun startPrivateChat(peerID: String) { + suspend fun startPrivateChat(peerID: String) { // For geohash conversation keys, ensure DM subscription is active if (peerID.startsWith("nostr_")) { ensureGeohashDMSubscriptionIfNeeded(peerID) } - - val success = privateChatManager.startPrivateChat(peerID, mesh) + + val (conversationID, unreadAliases) = withContext(Dispatchers.IO) { + val canonicalID = ContactDirectory.canonicalConversationId(peerID) + canonicalID to matchingUnreadAliases( + unreadConversationIDs = state.getUnreadPrivateMessagesValue(), + canonicalConversationID = canonicalID, + canonicalize = ContactDirectory::canonicalConversationId + ) + } + val success = privateChatManager.startPrivateChat( + peerID = conversationID, + meshService = mesh, + unreadAliases = unreadAliases + ) if (success) { - val conversationID = ContactDirectory.canonicalConversationId(peerID) // Notify notification manager about current private chat setCurrentPrivateChatPeer(conversationID) // Clear notifications for this sender since user is now viewing the chat @@ -413,14 +470,21 @@ class ChatViewModel( // Persistently mark all messages in this conversation as read so Nostr fetches // after app restarts won't re-mark them as unread. - try { - val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) - val chats = state.getPrivateChatsValue() - val messages = chats[conversationID] ?: emptyList() - messages.forEach { msg -> - try { seen.markRead(msg.id) } catch (_: Exception) { } + withContext(Dispatchers.IO) { + try { + val seen = + com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) + val chats = state.getPrivateChatsValue() + val messages = chats[conversationID] ?: emptyList() + messages.forEach { msg -> + try { + seen.markRead(msg.id) + } catch (_: Exception) { + } + } + } catch (_: Exception) { } - } catch (_: Exception) { } + } } } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index e8f87668..d784610a 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -19,7 +19,6 @@ 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.* @@ -38,7 +37,7 @@ fun GeohashPeopleList( viewModel: ChatViewModel, onTapPerson: () -> Unit, modifier: Modifier = Modifier, - excludedConversationIDs: Set = emptySet() + excludedIdentityAliases: Set = emptySet() ) { val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() @@ -79,10 +78,10 @@ fun GeohashPeopleList( geohashPeople } } - val visiblePeople = remember(peopleIncludingSelf, excludedConversationIDs) { + val visiblePeople = remember(peopleIncludingSelf, excludedIdentityAliases) { peopleIncludingSelf.filterNot { person -> - val alias = "nostr_${person.id.take(16)}" - ContactDirectory.canonicalConversationId(alias) in excludedConversationIDs + val alias = "nostr_${person.id.take(16)}".lowercase() + alias in excludedIdentityAliases } } val sections = remember(visiblePeople, myHex, isTeleported, teleportedGeo) { diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 7ec2fc0a..a5777ccf 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -86,11 +86,12 @@ fun MeshPeerListSheet( 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 unreadIdentityAliases = remember(unreadConversations) { + unreadConversations + .flatMapTo(mutableSetOf()) { it.identityAliases } } val visibleConnectedPeers = connectedPeers.filterNot { peerID -> - ContactDirectory.canonicalConversationId(peerID) in unreadConversationIDs + peerID.lowercase() in unreadIdentityAliases } // Bottom sheet state @@ -131,7 +132,6 @@ fun MeshPeerListSheet( item(key = "unread_private_messages_section") { UnreadDirectMessagesSection( conversations = unreadConversations, - connectedPeers = connectedPeers, viewModel = viewModel, onPrivateChatStart = { conversationID -> viewModel.showPrivateChatSheet(conversationID) @@ -203,7 +203,7 @@ fun MeshPeerListSheet( GeohashPeopleList( viewModel = viewModel, onTapPerson = onDismiss, - excludedConversationIDs = unreadConversationIDs, + excludedIdentityAliases = unreadIdentityAliases, modifier = Modifier.padding( top = if ( joinedChannels.isNotEmpty() || @@ -229,7 +229,7 @@ fun MeshPeerListSheet( selectedPrivatePeer = selectedPrivatePeer, wifiAwarePeerIDs = wifiAwarePeerIDs, peopleCount = peopleCount, - excludedConversationIDs = unreadConversationIDs, + excludedIdentityAliases = unreadIdentityAliases, viewModel = viewModel, onPrivateChatStart = { peerID -> viewModel.showPrivateChatSheet(peerID) @@ -344,7 +344,7 @@ fun PeopleSection( selectedPrivatePeer: String?, wifiAwarePeerIDs: Set = emptySet(), peopleCount: Int = 0, - excludedConversationIDs: Set = emptySet(), + excludedIdentityAliases: Set = emptySet(), viewModel: ChatViewModel, onPrivateChatStart: (String) -> Unit ) { @@ -461,9 +461,8 @@ fun PeopleSection( val offlineFavorites = FavoritesPersistenceService.shared.getOurFavorites() offlineFavorites.forEach { fav -> val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey) - val conversationID = ContactDirectory.canonicalConversationId(favPeerID) if ( - conversationID !in excludedConversationIDs && + favPeerID.lowercase() !in excludedIdentityAliases && !isFavoriteMappedToConnected(fav) ) { val dn = peerNicknames[favPeerID] ?: fav.peerNickname @@ -478,10 +477,10 @@ fun PeopleSection( val directMap by viewModel.peerDirect.collectAsStateWithLifecycle() val offlineFavoriteRows = offlineFavorites.filterNot { favorite -> - val favoriteConversationID = ContactDirectory.canonicalConversationId( - ContactIdentityResolver.noiseKeyHex(favorite.peerNoisePublicKey) + val favoriteNoiseKey = ContactIdentityResolver.noiseKeyHex( + favorite.peerNoisePublicKey ) - favoriteConversationID in excludedConversationIDs || + favoriteNoiseKey.lowercase() in excludedIdentityAliases || isFavoriteMappedToConnected(favorite) } val rowKeys: List = sortedPeers + @@ -593,7 +592,6 @@ fun PeopleSection( @Composable private fun UnreadDirectMessagesSection( conversations: List, - connectedPeers: List, viewModel: ChatViewModel, onPrivateChatStart: (String) -> Unit, modifier: Modifier = Modifier @@ -622,31 +620,19 @@ private fun UnreadDirectMessagesSection( 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.sourceGeohash != null -> "#${conversation.sourceGeohash}" conversation.transport == DirectMessageTransport.NOSTR -> stringResource(R.string.cd_reachable_via_nostr) - !connected -> stringResource(R.string.cd_offline_mesh_chat) + !conversation.isConnected -> + 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) + val (baseNameRaw, suffix) = splitSuffix(conversation.displayName) Row( modifier = Modifier diff --git a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt index e93ef39a..0c7b64b2 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -153,11 +153,17 @@ class MessageManager(private val state: ChatState) { state.setPrivateChats(updatedChats) } - fun clearPrivateUnreadMessages(peerID: String) { + fun clearPrivateUnreadMessages( + peerID: String, + aliases: Set = emptySet() + ) { val conversationID = ContactDirectory.canonicalConversationId(peerID) val updatedUnread = state.getUnreadPrivateMessagesValue().toMutableSet() - updatedUnread.remove(peerID) - updatedUnread.remove(conversationID) + val normalizedAliases = (aliases + peerID + conversationID) + .mapTo(mutableSetOf()) { it.lowercase() } + updatedUnread.removeAll { unreadID -> + unreadID.lowercase() in normalizedAliases + } state.setUnreadPrivateMessages(updatedUnread) } diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index 1af48a04..8d7c5e1d 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -47,7 +47,11 @@ class PrivateChatManager( // MARK: - Private Chat Lifecycle - fun startPrivateChat(peerID: String, meshService: MeshService): Boolean { + fun startPrivateChat( + peerID: String, + meshService: MeshService, + unreadAliases: Set = emptySet() + ): Boolean { val conversationID = ContactDirectory.canonicalConversationId(peerID) val route = ContactDirectory.resolve(conversationID) val meshPeerID = route.meshPeerID ?: peerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) } @@ -75,7 +79,7 @@ class PrivateChatManager( state.setSelectedPrivateChatPeer(conversationID) // Clear unread - messageManager.clearPrivateUnreadMessages(conversationID) + messageManager.clearPrivateUnreadMessages(conversationID, unreadAliases) // Initialize chat if needed messageManager.initializePrivateChat(conversationID) diff --git a/app/src/main/java/com/bitchat/android/ui/UnreadConversationSummary.kt b/app/src/main/java/com/bitchat/android/ui/UnreadConversationSummary.kt index bad2c43c..5d0ad81a 100644 --- a/app/src/main/java/com/bitchat/android/ui/UnreadConversationSummary.kt +++ b/app/src/main/java/com/bitchat/android/ui/UnreadConversationSummary.kt @@ -19,7 +19,10 @@ internal data class UnreadConversationSummary( val unreadCount: Int, val latestMessageAt: Long, val transport: DirectMessageTransport, - val nostrPubkey: String? + val nostrPubkey: String?, + val identityAliases: Set, + val isConnected: Boolean = false, + val sourceGeohash: String? = null ) internal fun buildUnreadConversationSummaries( @@ -73,7 +76,9 @@ internal fun buildUnreadConversationSummaries( } else { DirectMessageTransport.MESH }, - nostrPubkey = nostrPubkey + nostrPubkey = nostrPubkey, + identityAliases = (aliases + conversationID) + .mapTo(mutableSetOf()) { it.lowercase() } ) }.sortedWith( compareByDescending { it.latestMessageAt } @@ -84,3 +89,17 @@ internal fun buildUnreadConversationSummaries( private fun isNostrConversationID(value: String): Boolean = value.startsWith("nostr_") || value.startsWith("nostr:") + +internal fun matchingUnreadAliases( + unreadConversationIDs: Set, + canonicalConversationID: String, + canonicalize: (String) -> String +): Set { + val normalizedCanonicalID = canonicalConversationID.lowercase() + return unreadConversationIDs + .filterTo(mutableSetOf()) { unreadID -> + canonicalize(unreadID).equals(normalizedCanonicalID, ignoreCase = true) + } + .plus(canonicalConversationID) + .mapTo(mutableSetOf()) { it.lowercase() } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt index 852411e8..8434cab1 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt @@ -104,4 +104,27 @@ class PrivateChatManagerTest { verify(meshService).sendReadReceipt(message.id, meshPeerID, "bob") } + + @Test + fun `opening canonical unread conversation clears all source aliases`() { + val canonicalID = "contact_alice" + val nostrAlias = "nostr_0123456789abcdef" + val meshAlias = "0123456789abcdef" + val unrelatedConversation = "other-contact" + val meshService = mock() + state.setUnreadPrivateMessages( + setOf(canonicalID, nostrAlias, meshAlias, unrelatedConversation) + ) + + manager.startPrivateChat( + peerID = canonicalID, + meshService = meshService, + unreadAliases = setOf(canonicalID, nostrAlias, meshAlias) + ) + + assertEquals( + setOf(unrelatedConversation), + state.getUnreadPrivateMessagesValue() + ) + } } diff --git a/app/src/test/kotlin/com/bitchat/android/ui/UnreadConversationSummaryTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/UnreadConversationSummaryTest.kt index e054d819..2b5f4613 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/UnreadConversationSummaryTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/UnreadConversationSummaryTest.kt @@ -57,6 +57,10 @@ class UnreadConversationSummaryTest { assertEquals(1, rows.size) assertEquals("contact_alice", rows.single().conversationID) assertEquals(DirectMessageTransport.NOSTR, rows.single().transport) + assertEquals( + setOf("mesh-alias", "nostr_alias", "contact_alice"), + rows.single().identityAliases + ) } @Test @@ -104,6 +108,22 @@ class UnreadConversationSummaryTest { assertTrue(row.displayName.isNotBlank()) } + @Test + fun `canonical unread lookup returns every matching source alias`() { + val aliases = matchingUnreadAliases( + unreadConversationIDs = setOf("mesh-alias", "nostr_alias", "other-contact"), + canonicalConversationID = "contact_alice", + canonicalize = { unreadID -> + if (unreadID == "other-contact") unreadID else "contact_alice" + } + ) + + assertEquals( + setOf("mesh-alias", "nostr_alias", "contact_alice"), + aliases + ) + } + private fun incoming( id: String, sender: String,