Merge pull request #784 from permissionlesstech/codex/unread-dm-rows

Keep unread DM senders visible in the people sheet
This commit is contained in:
callebtc 2026-07-27 22:26:31 +02:00 committed by GitHub
commit ea1c64d2b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 560 additions and 54 deletions

View File

@ -6,9 +6,14 @@ 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
import com.bitchat.android.mesh.MeshService
@ -16,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
@ -117,8 +124,12 @@ class ChatViewModel(
messageManager,
dataManager,
noiseSessionDelegate,
hasReadReceiptBeenSent = seenMessageStore::hasReadReceiptBeenSent,
markMessageReadLocally = seenMessageStore::markReadLocally
hasReadReceiptBeenSent = { messageID ->
seenMessageStore.hasReadReceiptBeenSent(messageID)
},
markMessageReadLocally = { messageID ->
seenMessageStore.markReadLocally(messageID)
}
)
private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager)
private val notificationManager = NotificationManager(
@ -157,7 +168,9 @@ class ChatViewModel(
onHapticFeedback = { ChatViewModelUtils.triggerHapticFeedback(application.applicationContext) },
getMyPeerID = { mesh.myPeerID },
getMeshService = { mesh },
markMessageReadLocally = seenMessageStore::markReadLocally
markMessageReadLocally = { messageID ->
seenMessageStore.markReadLocally(messageID)
}
)
// New Geohash architecture ViewModel (replaces God object service usage in UI path)
@ -182,6 +195,57 @@ 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,
state.connectedPeers
) { unreadConversationIDs, chats, currentNickname, connectedPeerIDs ->
val seenStore = seenMessageStore
val connectedPeerIDSet = connectedPeerIDs.mapTo(mutableSetOf()) { it.lowercase() }
buildUnreadConversationSummaries(
unreadConversationIDs = unreadConversationIDs,
privateChats = chats,
currentUserIdentifiers = setOf(currentNickname, mesh.myPeerID),
canonicalize = ContactDirectory::canonicalConversationId,
isMessageRead = { message -> seenStore.hasBeenReadLocally(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()
)
val joinedChannels: StateFlow<Set<String>> = state.joinedChannels
val currentChannel: StateFlow<String?> = state.currentChannel
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = state.channelMessages
@ -247,24 +311,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 myNick = state.getNicknameValue().ifBlank { mesh.myPeerID }
canonical
.filterValues { messages ->
messages.any { message ->
message.sender != myNick &&
message.sender != "system" &&
!seenMessageStore.hasBeenReadLocally(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 myNick = state.getNicknameValue() ?: mesh.myPeerID
val unread = mutableSetOf<String>()
canonicalChats.forEach { (peer, list) ->
if (list.any { msg ->
msg.sender != myNick &&
msg.sender != "system" &&
!seenMessageStore.hasBeenReadLocally(msg.id)
}
) {
unread.add(peer)
}
}
state.setUnreadPrivateMessages(unread)
} catch (_: Exception) { }
state.setUnreadPrivateMessages(unreadConversationIDs)
} } catch (_: Exception) { }
}
viewModelScope.launch {
@ -405,15 +472,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, success) = withContext(Dispatchers.IO) {
val canonicalID = ContactDirectory.canonicalConversationId(peerID)
val unreadAliases = matchingUnreadAliases(
unreadConversationIDs = state.getUnreadPrivateMessagesValue(),
canonicalConversationID = canonicalID,
canonicalize = ContactDirectory::canonicalConversationId
)
canonicalID to privateChatManager.startPrivateChat(
peerID = canonicalID,
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

View File

@ -36,7 +36,8 @@ data class GeoPerson(
fun GeohashPeopleList(
viewModel: ChatViewModel,
onTapPerson: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
excludedIdentityAliases: Set<String> = emptySet()
) {
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
@ -77,9 +78,15 @@ fun GeohashPeopleList(
geohashPeople
}
}
val sections = remember(peopleIncludingSelf, myHex, isTeleported, teleportedGeo) {
val visiblePeople = remember(peopleIncludingSelf, excludedIdentityAliases) {
peopleIncludingSelf.filterNot { person ->
val alias = "nostr_${person.id.take(16)}".lowercase()
alias in excludedIdentityAliases
}
}
val sections = remember(visiblePeople, myHex, isTeleported, teleportedGeo) {
sectionGeohashPeople(
people = peopleIncludingSelf,
people = visiblePeople,
myId = myHex,
selfIsTeleported = isTeleported,
teleportedIds = teleportedGeo

View File

@ -88,9 +88,17 @@ 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 unreadIdentityAliases = remember(unreadConversations) {
unreadConversations
.flatMapTo(mutableSetOf()) { it.identityAliases }
}
val visibleConnectedPeers = connectedPeers.filterNot { peerID ->
peerID.lowercase() in unreadIdentityAliases
}
// Bottom sheet state
val sheetState = rememberModalBottomSheetState(
@ -123,7 +131,21 @@ 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,
viewModel = viewModel,
onPrivateChatStart = { conversationID ->
viewModel.showPrivateChatSheet(conversationID)
onDismiss()
},
modifier = Modifier.padding(top = 8.dp)
)
}
}
// Channels section
@ -133,7 +155,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
@ -185,8 +209,12 @@ fun MeshPeerListSheet(
GeohashPeopleList(
viewModel = viewModel,
onTapPerson = onDismiss,
excludedIdentityAliases = unreadIdentityAliases,
modifier = Modifier.padding(
top = if (joinedChannels.isNotEmpty()) 20.dp else 8.dp
top = if (
joinedChannels.isNotEmpty() ||
unreadConversations.isNotEmpty()
) 20.dp else 8.dp
)
)
}
@ -194,9 +222,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,
@ -204,6 +235,7 @@ fun MeshPeerListSheet(
selectedPrivatePeer = selectedPrivatePeer,
wifiAwarePeerIDs = wifiAwarePeerIDs,
peopleCount = peopleCount,
excludedIdentityAliases = unreadIdentityAliases,
viewModel = viewModel,
onPrivateChatStart = { peerID ->
viewModel.showPrivateChatSheet(peerID)
@ -318,6 +350,7 @@ fun PeopleSection(
selectedPrivatePeer: String?,
wifiAwarePeerIDs: Set<String> = emptySet(),
peopleCount: Int = 0,
excludedIdentityAliases: Set<String> = emptySet(),
viewModel: ChatViewModel,
onPrivateChatStart: (String) -> Unit
) {
@ -433,8 +466,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)))
@ -453,33 +484,28 @@ fun PeopleSection(
val offlineFavorites = FavoritesPersistenceService.shared.getOurFavorites()
offlineFavorites.forEach { fav ->
val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey)
if (!isFavoriteMappedToConnected(fav)) {
if (
favPeerID.lowercase() !in excludedIdentityAliases &&
!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 favoriteNoiseKey = ContactIdentityResolver.noiseKeyHex(
favorite.peerNoisePublicKey
)
favoriteNoiseKey.lowercase() in excludedIdentityAliases ||
isFavoriteMappedToConnected(favorite)
}
val rowKeys: List<String> = sortedPeers +
offlineFavoriteRows.map { ContactIdentityResolver.noiseKeyHex(it.peerNoisePublicKey) }
@ -589,6 +615,126 @@ fun PeopleSection(
}
}
@Composable
private fun UnreadDirectMessagesSection(
conversations: List<UnreadConversationSummary>,
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 subtitle = when {
conversation.sourceGeohash != null -> "#${conversation.sourceGeohash}"
conversation.transport == DirectMessageTransport.NOSTR ->
stringResource(R.string.cd_reachable_via_nostr)
!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(conversation.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

@ -153,11 +153,17 @@ class MessageManager(private val state: ChatState) {
state.setPrivateChats(updatedChats)
}
fun clearPrivateUnreadMessages(peerID: String) {
fun clearPrivateUnreadMessages(
peerID: String,
aliases: Set<String> = 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)
}

View File

@ -49,7 +49,11 @@ class PrivateChatManager(
// MARK: - Private Chat Lifecycle
fun startPrivateChat(peerID: String, meshService: MeshService): Boolean {
fun startPrivateChat(
peerID: String,
meshService: MeshService,
unreadAliases: Set<String> = emptySet()
): Boolean {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val route = ContactDirectory.resolve(conversationID)
val meshPeerID = route.meshPeerID ?: peerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
@ -77,7 +81,7 @@ class PrivateChatManager(
state.setSelectedPrivateChatPeer(conversationID)
// Clear unread
messageManager.clearPrivateUnreadMessages(conversationID)
messageManager.clearPrivateUnreadMessages(conversationID, unreadAliases)
// Initialize chat if needed
messageManager.initializePrivateChat(conversationID)

View File

@ -0,0 +1,105 @@
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?,
val identityAliases: Set<String>,
val isConnected: Boolean = false,
val sourceGeohash: String? = null
)
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,
identityAliases = (aliases + conversationID)
.mapTo(mutableSetOf()) { it.lowercase() }
)
}.sortedWith(
compareByDescending<UnreadConversationSummary> { it.latestMessageAt }
.thenBy { it.displayName.lowercase() }
.thenBy { it.conversationID }
)
}
private fun isNostrConversationID(value: String): Boolean =
value.startsWith("nostr_") || value.startsWith("nostr:")
internal fun matchingUnreadAliases(
unreadConversationIDs: Set<String>,
canonicalConversationID: String,
canonicalize: (String) -> String
): Set<String> {
val normalizedCanonicalID = canonicalConversationID.lowercase()
return unreadConversationIDs
.filterTo(mutableSetOf()) { unreadID ->
canonicalize(unreadID).equals(normalizedCanonicalID, ignoreCase = true)
}
.plus(canonicalConversationID)
.mapTo(mutableSetOf()) { it.lowercase() }
}

View File

@ -107,6 +107,29 @@ 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<MeshService>()
state.setUnreadPrivateMessages(
setOf(canonicalID, nostrAlias, meshAlias, unrelatedConversation)
)
manager.startPrivateChat(
peerID = canonicalID,
meshService = meshService,
unreadAliases = setOf(canonicalID, nostrAlias, meshAlias)
)
assertEquals(
setOf(unrelatedConversation),
state.getUnreadPrivateMessagesValue()
)
}
@Test
fun `opening chat skips messages whose receipt send already completed`() {
val noiseKey = ByteArray(32) { 8 }

View File

@ -0,0 +1,137 @@
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)
assertEquals(
setOf("mesh-alias", "nostr_alias", "contact_alice"),
rows.single().identityAliases
)
}
@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())
}
@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,
timestamp: Long
) = BitchatMessage(
id = id,
sender = sender,
content = "hello",
timestamp = Date(timestamp)
)
}