mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
Merge pull request #784 from permissionlesstech/codex/unread-dm-rows
Keep unread DM senders visible in the people sheet
This commit is contained in:
commit
ea1c64d2b3
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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() }
|
||||
}
|
||||
@ -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 }
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user