Nip17 dms to extend the mesh (#313)

* favorite each other

* show favorites as system messsages

* wip show glove when offline but mayb edoesnt work

* send and receive works

* wip kinda works but no

* getting there

* kinda

* show offline peers in peer list

* kinda wonky but almost works

* fixes

* nostr user goes offline works

* nostr -> mesh works

* handoff works

* background message processing

* read works

* seen message store was missing
This commit is contained in:
callebtc
2025-08-25 18:38:10 +02:00
committed by GitHub
parent 941be1b98f
commit 6fe6e049ad
15 changed files with 1153 additions and 173 deletions
@@ -309,6 +309,18 @@ private fun PrivateChatHeader(
) {
val colorScheme = MaterialTheme.colorScheme
val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
// Determine mutual favorite state for this peer (supports mesh ephemeral 16-hex via favorites lookup)
val isMutualFavorite = remember(peerID, peerNicknames) {
try {
if (isNostrDM) return@remember false
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
val noiseKeyBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKeyBytes)?.isMutual == true
} else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.isMutual == true
} else false
} catch (_: Exception) { false }
}
// Compute title text: for NIP-17 chats show "#geohash/@username" (iOS parity)
val titleText: String = if (isNostrDM) {
@@ -319,7 +331,18 @@ private fun PrivateChatHeader(
val geoPart = geohash?.let { "#$it" } ?: "#geohash"
"$geoPart/@$baseName"
} else {
peerNicknames[peerID] ?: peerID
// Prefer live mesh nickname; fallback to favorites nickname (supports 16-hex), finally short key
peerNicknames[peerID] ?: run {
val titleFromFavorites = try {
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
val noiseKeyBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKeyBytes)?.peerNickname
} else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.peerNickname
} else null
} catch (_: Exception) { null }
titleFromFavorites ?: peerID.take(12)
}
}
Box(modifier = Modifier.fillMaxWidth()) {
@@ -367,8 +390,16 @@ private fun PrivateChatHeader(
Spacer(modifier = Modifier.width(4.dp))
// For NIP-17 chats, do not show Noise session state icon (remove warning icon)
if (!isNostrDM) {
// Show a globe when chatting via Nostr alias, or when mesh session not established but mutual favorite exists
val showGlobe = isNostrDM || (sessionState != "established" && isMutualFavorite)
if (showGlobe) {
Icon(
imageVector = Icons.Outlined.Public,
contentDescription = "Nostr reachable",
modifier = Modifier.size(14.dp),
tint = Color(0xFF9B59B6) // Purple like iOS
)
} else {
NoiseSessionIcon(
sessionState = sessionState,
modifier = Modifier.size(14.dp)
@@ -152,6 +152,12 @@ class ChatViewModel(
// Initialize Nostr integration
nostrGeohashService.initializeNostrIntegration()
// Ensure NostrTransport knows our mesh peer ID for embedded packets
try {
val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication())
nostrTransport.senderPeerID = meshService.myPeerID
} catch (_: Exception) { }
// Note: Mesh service is now started by MainActivity
@@ -207,6 +213,17 @@ class ChatViewModel(
setCurrentPrivateChatPeer(peerID)
// Clear notifications for this sender since user is now viewing the chat
clearNotificationsForSender(peerID)
// 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[peerID] ?: emptyList()
messages.forEach { msg ->
try { seen.markRead(msg.id) } catch (_: Exception) { }
}
} catch (_: Exception) { }
}
}
@@ -235,10 +252,23 @@ class ChatViewModel(
// REMOVED: Auto-join mentioned channels feature that was incorrectly parsing hashtags from @mentions
// This was causing messages like "test @jack#1234 test" to auto-join channel "#1234"
val selectedPeer = state.getSelectedPrivateChatPeerValue()
var selectedPeer = state.getSelectedPrivateChatPeerValue()
val currentChannelValue = state.getCurrentChannelValue()
if (selectedPeer != null) {
// If the selected peer is a temporary Nostr alias or a noise-hex identity, resolve to a canonical target
selectedPeer = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
selectedPeerID = selectedPeer,
connectedPeers = state.getConnectedPeersValue(),
meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey },
meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true },
nostrPubHexForAlias = { alias -> nostrGeohashService.getNostrKeyMapping()[alias] },
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
).also { canonical ->
if (canonical != state.getSelectedPrivateChatPeerValue()) {
privateChatManager.startPrivateChat(canonical, meshService)
}
}
// Send private message
val recipientNickname = meshService.getPeerNicknames()[selectedPeer]
privateChatManager.sendPrivateMessage(
@@ -248,7 +278,9 @@ class ChatViewModel(
state.getNicknameValue(),
meshService.myPeerID
) { messageContent, peerID, recipientNicknameParam, messageId ->
meshService.sendPrivateMessage(messageContent, peerID, recipientNicknameParam, messageId)
// Route via MessageRouter (mesh when connected+established, else Nostr)
val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), meshService)
router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId)
}
} else {
// Check if we're in a location channel
@@ -309,7 +341,44 @@ class ChatViewModel(
fun toggleFavorite(peerID: String) {
Log.d("ChatViewModel", "toggleFavorite called for peerID: $peerID")
privateChatManager.toggleFavorite(peerID)
// Persist relationship in FavoritesPersistenceService when we have Noise key
try {
val peerInfo = meshService.getPeerInfo(peerID)
val noiseKey = peerInfo?.noisePublicKey
val nickname = peerInfo?.nickname ?: (meshService.getPeerNicknames()[peerID] ?: peerID)
if (noiseKey != null) {
val isNowFavorite = dataManager.favoritePeers.contains(
com.bitchat.android.mesh.PeerFingerprintManager.getInstance().getFingerprintForPeer(peerID) ?: ""
)
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateFavoriteStatus(
noisePublicKey = noiseKey,
nickname = nickname,
isFavorite = isNowFavorite
)
// Send favorite notification via mesh or Nostr with our npub if available
try {
val myNostr = com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(getApplication())
val announcementContent = if (isNowFavorite) "[FAVORITED]:${myNostr?.npub ?: ""}" else "[UNFAVORITED]:${myNostr?.npub ?: ""}"
// Prefer mesh if session established, else try Nostr
if (meshService.hasEstablishedSession(peerID)) {
// Reuse existing private message path for notifications
meshService.sendPrivateMessage(
announcementContent,
peerID,
nickname,
java.util.UUID.randomUUID().toString()
)
} else {
val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication())
nostrTransport.senderPeerID = meshService.myPeerID
nostrTransport.sendFavoriteNotification(peerID, isNowFavorite)
}
} catch (_: Exception) { }
}
} catch (_: Exception) { }
// Log current state after toggle
logCurrentFavoriteState()
}
@@ -341,10 +410,20 @@ class ChatViewModel(
val currentPeers = state.getConnectedPeersValue()
// Update session states
val prevStates = state.getPeerSessionStatesValue()
val sessionStates = currentPeers.associateWith { peerID ->
meshService.getSessionState(peerID).toString()
}
state.setPeerSessionStates(sessionStates)
// Detect new established sessions and flush router outbox for them and their noiseHex aliases
sessionStates.forEach { (peerID, newState) ->
val old = prevStates[peerID]
if (old != "established" && newState == "established") {
com.bitchat.android.services.MessageRouter
.getInstance(getApplication(), meshService)
.onSessionEstablished(peerID)
}
}
// Update fingerprint mappings from centralized manager
val fingerprints = privateChatManager.getAllPeerFingerprints()
state.setPeerFingerprints(fingerprints)
@@ -86,18 +86,100 @@ class MeshDelegateHandler(
coroutineScope.launch {
state.setConnectedPeers(peers)
state.setIsConnected(peers.isNotEmpty())
// Flush router outbox for any peers that just connected (and their noiseHex aliases)
runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(peers) }
// Clean up channel members who disconnected
channelManager.cleanupDisconnectedMembers(peers, getMyPeerID())
// Exit private chat if peer disconnected
// Handle chat view migration based on current selection and new peer list
state.getSelectedPrivateChatPeerValue()?.let { currentPeer ->
if (!peers.contains(currentPeer)) {
privateChatManager.cleanupDisconnectedPeer(currentPeer)
val isNostrAlias = currentPeer.startsWith("nostr_")
val isNoiseHex = currentPeer.length == 64 && currentPeer.matches(Regex("^[0-9a-fA-F]+$"))
val isMeshEphemeral = currentPeer.length == 16 && currentPeer.matches(Regex("^[0-9a-fA-F]+$"))
if (isNostrAlias || isNoiseHex) {
// Reverse case: Nostr/offline chat is open, and peer may have come online on mesh.
// Resolve canonical target (prefer connected mesh peer if available)
val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
selectedPeerID = currentPeer,
connectedPeers = peers,
meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey },
meshHasPeer = { pid -> peers.contains(pid) },
nostrPubHexForAlias = { alias ->
// Best-effort: derive pub hex from favorites mapping
val prefix = alias.removePrefix("nostr_")
val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() }
favs.firstNotNullOfOrNull { rel ->
rel.peerNostrPublicKey?.let { s ->
runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec ->
if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null
}
}
}?.takeIf { it.startsWith(prefix, ignoreCase = true) }
},
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
)
if (canonical != currentPeer) {
// Merge conversations and switch selection to the live mesh peer (or noiseHex)
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, canonical, listOf(currentPeer))
state.setSelectedPrivateChatPeer(canonical)
}
} else if (isMeshEphemeral && !peers.contains(currentPeer)) {
// Forward case: Mesh chat lost connection. If mutual favorite exists, migrate to Nostr (noiseHex)
val favoriteRel = try {
val info = getPeerInfo(currentPeer)
val noiseKey = info?.noisePublicKey
if (noiseKey != null) {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
} else null
} catch (_: Exception) { null }
if (favoriteRel?.isMutual == true) {
val noiseHex = favoriteRel.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
if (noiseHex != currentPeer) {
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(
state = state,
targetPeerID = noiseHex,
keysToMerge = listOf(currentPeer)
)
state.setSelectedPrivateChatPeer(noiseHex)
}
} else {
privateChatManager.cleanupDisconnectedPeer(currentPeer)
}
}
}
// Global unification: for each connected peer, merge any offline/stable conversations
// (noiseHex or nostr_<pub16>) into the connected peer's chat so there is only one chat per identity.
peers.forEach { pid ->
try {
val info = getPeerInfo(pid)
val noiseKey = info?.noisePublicKey ?: return@forEach
val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) }
// Derive temp nostr key from favorites npub
val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKey)
val tempNostrKey: String? = try {
if (npub != null) {
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub)
if (hrp == "npub") "nostr_${data.joinToString("") { b -> "%02x".format(b) }.take(16)}" else null
} else null
} catch (_: Exception) { null }
unifyChatsIntoPeer(pid, listOfNotNull(noiseHex, tempNostrKey))
} catch (_: Exception) { }
}
}
}
/**
* Merge any chats stored under the given keys into the connected peer's chat entry.
*/
private fun unifyChatsIntoPeer(targetPeerID: String, keysToMerge: List<String>) {
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, targetPeerID, keysToMerge)
}
override fun didReceiveChannelLeave(channel: String, fromPeer: String) {
coroutineScope.launch {
@@ -191,4 +273,11 @@ class MeshDelegateHandler(
}
// registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager
/**
* Expose mesh peer info for components that need to resolve identities (e.g., Nostr mapping)
*/
fun getPeerInfo(peerID: String): com.bitchat.android.mesh.PeerInfo? {
return getMeshService().getPeerInfo(peerID)
}
}
@@ -74,7 +74,7 @@ class MessageManager(private val state: ChatState) {
}
// MARK: - Private Message Management
fun addPrivateMessage(peerID: String, message: BitchatMessage) {
val currentPrivateChats = state.getPrivateChatsValue().toMutableMap()
if (!currentPrivateChats.containsKey(peerID)) {
@@ -94,6 +94,19 @@ class MessageManager(private val state: ChatState) {
state.setUnreadPrivateMessages(currentUnread)
}
}
// Variant that does not mark unread (used when we know the message has been read already, e.g., persisted Nostr read store)
fun addPrivateMessageNoUnread(peerID: String, message: BitchatMessage) {
val currentPrivateChats = state.getPrivateChatsValue().toMutableMap()
if (!currentPrivateChats.containsKey(peerID)) {
currentPrivateChats[peerID] = mutableListOf()
}
val chatMessages = currentPrivateChats[peerID]?.toMutableList() ?: mutableListOf()
chatMessages.add(message)
chatMessages.sortBy { it.timestamp }
currentPrivateChats[peerID] = chatMessages
state.setPrivateChats(currentPrivateChats)
}
fun clearPrivateMessages(peerID: String) {
val updatedChats = state.getPrivateChatsValue().toMutableMap()
@@ -56,6 +56,11 @@ class PrivateChatManager(
// Establish Noise session if needed before starting the chat
establishNoiseSessionIfNeeded(peerID, meshService)
// Consolidate any temporary Nostr conversation for this peer into the stable/current peerID
try {
consolidateNostrTempConversationIfNeeded(peerID)
} catch (_: Exception) { }
state.setSelectedPrivateChatPeer(peerID)
// Clear unread
@@ -261,18 +266,30 @@ class PrivateChatManager(
// MARK: - Message Handling
fun handleIncomingPrivateMessage(message: BitchatMessage) {
handleIncomingPrivateMessage(message, suppressUnread = false)
}
fun handleIncomingPrivateMessage(message: BitchatMessage, suppressUnread: Boolean) {
message.senderPeerID?.let { senderPeerID ->
if (!isPeerBlocked(senderPeerID)) {
// Add to private messages
messageManager.addPrivateMessage(senderPeerID, message)
if (suppressUnread) {
messageManager.addPrivateMessageNoUnread(senderPeerID, message)
} else {
messageManager.addPrivateMessage(senderPeerID, message)
}
// Track as unread for read receipt purposes
val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() }
unreadList.add(message)
var unreadCount = 0
if (!suppressUnread) {
val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() }
unreadList.add(message)
unreadCount = unreadList.size
}
Log.d(
TAG,
"Added received message ${message.id} from $senderPeerID to unread list (${unreadList.size} unread)"
"Added received message ${message.id} from $senderPeerID to unread list (${unreadCount} unread)"
)
}
}
@@ -439,6 +456,65 @@ class PrivateChatManager(
return meshService.getPeerNicknames()[peerID] ?: peerID
}
// MARK: - Consolidation
private fun consolidateNostrTempConversationIfNeeded(targetPeerID: String) {
// If target is a mesh/noise-based peerID, merge any messages from its temp Nostr key
if (targetPeerID.startsWith("nostr_")) return
// Find favorites mapping and corresponding temp key
val tryMergeKeys = mutableListOf<String>()
// If we know the sender's Nostr pubkey for this peer via favorites, derive temp key
try {
val noiseKeyBytes = targetPeerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKeyBytes)
if (npub != null) {
// Normalize to hex to match how we formed temp keys (nostr_<pub16>)
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub)
if (hrp == "npub") {
val pubHex = data.joinToString("") { "%02x".format(it) }
tryMergeKeys.add("nostr_${pubHex.take(16)}")
}
}
} catch (_: Exception) { }
// Also merge any directly-addressed temp key used by incoming messages (without mapping yet)
// Search existing chats for keys that begin with "nostr_" and have messages from the same nickname
state.getPrivateChatsValue().keys.filter { it.startsWith("nostr_") }.forEach { tempKey ->
if (!tryMergeKeys.contains(tempKey)) tryMergeKeys.add(tempKey)
}
if (tryMergeKeys.isEmpty()) return
val currentChats = state.getPrivateChatsValue().toMutableMap()
val targetList = currentChats[targetPeerID]?.toMutableList() ?: mutableListOf()
var didMerge = false
tryMergeKeys.forEach { tempKey ->
val tempList = currentChats[tempKey]
if (!tempList.isNullOrEmpty()) {
targetList.addAll(tempList)
currentChats.remove(tempKey)
didMerge = true
}
}
if (didMerge) {
targetList.sortBy { it.timestamp }
currentChats[targetPeerID] = targetList
state.setPrivateChats(currentChats)
// Also remove unread flag from temp keys and apply to target
val unread = state.getUnreadPrivateMessagesValue().toMutableSet()
val hadUnread = tryMergeKeys.any { unread.remove(it) }
if (hadUnread) {
unread.add(targetPeerID)
state.setUnreadPrivateMessages(unread)
}
}
}
// MARK: - Emergency Clear
fun clearAllPrivateChats() {
@@ -285,56 +285,139 @@ fun PeopleSection(
color = colorScheme.onSurface.copy(alpha = 0.5f),
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
)
} else {
// Observe reactive state for favorites and fingerprints
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
val privateChats by viewModel.privateChats.observeAsState(emptyMap())
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet())
val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap())
// Reactive favorite computation for all peers
val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) {
connectedPeers.associateWith { peerID ->
// Reactive favorite computation - same as ChatHeader
val fingerprint = peerFingerprints[peerID]
fingerprint != null && favoritePeers.contains(fingerprint)
}
}
Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates")
// Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical
val sortedPeers = connectedPeers.sortedWith(
compareBy<String> { !hasUnreadPrivateMessages.contains(it) } // Unread DM senders first
.thenByDescending { privateChats[it]?.maxByOrNull { msg -> msg.timestamp }?.timestamp?.time ?: 0L } // Most recent DM (convert Date to Long)
.thenBy { !(peerFavoriteStates[it] ?: false) } // Favorites first
.thenBy { (if (it == nickname) "You" else (peerNicknames[it] ?: it)).lowercase() } // Alphabetical
)
sortedPeers.forEach { peerID ->
val isFavorite = peerFavoriteStates[peerID] ?: false
PeerItem(
peerID = peerID,
displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: peerID),
signalStrength = convertRSSIToSignalStrength(peerRSSI[peerID]),
isSelected = peerID == selectedPrivatePeer,
isFavorite = isFavorite,
hasUnreadDM = hasUnreadPrivateMessages.contains(peerID),
colorScheme = colorScheme,
viewModel = viewModel,
onItemClick = { onPrivateChatStart(peerID) },
onToggleFavorite = {
Log.d("SidebarComponents", "Sidebar toggle favorite: peerID=$peerID, currentFavorite=$isFavorite")
viewModel.toggleFavorite(peerID)
},
unreadCount = privateChats[peerID]?.count { msg ->
// Count unread messages from this peer (messages not from the current user)
msg.sender != nickname && hasUnreadPrivateMessages.contains(peerID)
} ?: if (hasUnreadPrivateMessages.contains(peerID)) 1 else 0
)
}
// Observe reactive state for favorites and fingerprints
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
val privateChats by viewModel.privateChats.observeAsState(emptyMap())
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet())
val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap())
// Reactive favorite computation for all peers
val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) {
connectedPeers.associateWith { peerID ->
// Reactive favorite computation - same as ChatHeader
val fingerprint = peerFingerprints[peerID]
fingerprint != null && favoritePeers.contains(fingerprint)
}
}
// Build mapping of connected peerID -> noise key hex to unify with offline favorites
val noiseHexByPeerID: Map<String, String> = connectedPeers.associateWith { pid ->
try {
viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
} catch (_: Exception) { null }
}.filterValues { it != null }.mapValues { it.value!! }
Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates")
// Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical
val sortedPeers = connectedPeers.sortedWith(
compareBy<String> { !hasUnreadPrivateMessages.contains(it) } // Unread DM senders first
.thenByDescending { privateChats[it]?.maxByOrNull { msg -> msg.timestamp }?.timestamp?.time ?: 0L } // Most recent DM (convert Date to Long)
.thenBy { !(peerFavoriteStates[it] ?: false) } // Favorites first
.thenBy { (if (it == nickname) "You" else (peerNicknames[it] ?: it)).lowercase() } // Alphabetical
)
sortedPeers.forEach { peerID ->
val isFavorite = peerFavoriteStates[peerID] ?: false
// fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below
val noiseHex = noiseHexByPeerID[peerID]
val meshUnread = hasUnreadPrivateMessages.contains(peerID)
val nostrUnread = if (noiseHex != null) hasUnreadPrivateMessages.contains(noiseHex) else false
val combinedHasUnread = meshUnread || nostrUnread
val combinedUnreadCount = (
privateChats[peerID]?.count { msg -> msg.sender != nickname && meshUnread } ?: 0
) + (
if (noiseHex != null) privateChats[noiseHex]?.count { msg -> msg.sender != nickname && nostrUnread } ?: 0 else 0
)
PeerItem(
peerID = peerID,
displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: (privateChats[peerID]?.lastOrNull()?.sender ?: peerID.take(12))),
signalStrength = convertRSSIToSignalStrength(peerRSSI[peerID]),
isSelected = peerID == selectedPrivatePeer,
isFavorite = isFavorite,
hasUnreadDM = combinedHasUnread,
colorScheme = colorScheme,
viewModel = viewModel,
onItemClick = { onPrivateChatStart(peerID) },
onToggleFavorite = {
Log.d("SidebarComponents", "Sidebar toggle favorite: peerID=$peerID, currentFavorite=$isFavorite")
viewModel.toggleFavorite(peerID)
},
unreadCount = if (combinedUnreadCount > 0) combinedUnreadCount else if (combinedHasUnread) 1 else 0,
showNostrGlobe = false
)
}
// Append offline favorites we actively favorite (and not currently connected)
val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites()
val appendedOfflineIds = mutableSetOf<String>()
offlineFavorites.forEach { fav ->
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
// If any connected peer maps to this noise key, skip showing the offline entry
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
if (isMappedToConnected) return@forEach
// If user clicks an offline favorite and the mapped peer is currently connected under a different ID,
// open chat with the connected peerID instead of the noise hex for a seamless window
val mappedConnectedPeerID = noiseHexByPeerID.entries.firstOrNull { it.value.equals(favPeerID, ignoreCase = true) }?.key
PeerItem(
peerID = favPeerID,
displayName = peerNicknames[favPeerID] ?: fav.peerNickname,
signalStrength = 0,
isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
isFavorite = true,
hasUnreadDM = hasUnreadPrivateMessages.contains(favPeerID),
colorScheme = colorScheme,
viewModel = viewModel,
onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) },
onToggleFavorite = {
Log.d("SidebarComponents", "Sidebar toggle favorite (offline): peerID=$favPeerID")
viewModel.toggleFavorite(favPeerID)
},
unreadCount = privateChats[favPeerID]?.count { msg ->
msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID)
} ?: if (hasUnreadPrivateMessages.contains(favPeerID)) 1 else 0,
showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null)
)
appendedOfflineIds.add(favPeerID)
}
// Also show any incoming Nostr chats that exist locally but are not in connected peers or favorites yet
// This ensures a user can open and read Nostr messages while the sender remains offline
val connectedIds = sortedPeers.toSet()
val alreadyShownIds = connectedIds + appendedOfflineIds
val hex64Regex = Regex("^[0-9a-fA-F]{64}$")
privateChats.keys
.filter { key ->
(key.startsWith("nostr_") || hex64Regex.matches(key)) &&
!alreadyShownIds.contains(key) &&
// Skip if this key maps to a connected peer via noiseHex mapping
!noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) }
}
.sortedBy { key -> privateChats[key]?.lastOrNull()?.timestamp }
.forEach { convKey ->
val lastSender = privateChats[convKey]?.lastOrNull()?.sender
PeerItem(
peerID = convKey,
displayName = peerNicknames[convKey] ?: (lastSender ?: convKey.take(12)),
signalStrength = 0,
isSelected = convKey == selectedPrivatePeer,
isFavorite = false,
hasUnreadDM = hasUnreadPrivateMessages.contains(convKey),
colorScheme = colorScheme,
viewModel = viewModel,
onItemClick = { onPrivateChatStart(convKey) },
onToggleFavorite = { viewModel.toggleFavorite(convKey) },
unreadCount = privateChats[convKey]?.count { msg ->
msg.sender != nickname && hasUnreadPrivateMessages.contains(convKey)
} ?: if (hasUnreadPrivateMessages.contains(convKey)) 1 else 0,
showNostrGlobe = true
)
}
}
}
@@ -350,7 +433,8 @@ private fun PeerItem(
viewModel: ChatViewModel,
onItemClick: () -> Unit,
onToggleFavorite: () -> Unit,
unreadCount: Int = 0
unreadCount: Int = 0,
showNostrGlobe: Boolean = false
) {
// Split display name for hashtag suffix support (iOS-compatible)
val (baseName, suffix) = com.bitchat.android.ui.splitSuffix(displayName)
@@ -383,10 +467,20 @@ private fun PeerItem(
)
} else {
// Signal strength indicators
SignalStrengthIndicator(
signalStrength = signalStrength,
colorScheme = colorScheme
)
if (showNostrGlobe) {
// Purple globe to indicate Nostr availability
Icon(
imageVector = Icons.Filled.Public,
contentDescription = "Reachable via Nostr",
modifier = Modifier.size(16.dp),
tint = Color(0xFF9C27B0) // Purple
)
} else {
SignalStrengthIndicator(
signalStrength = signalStrength,
colorScheme = colorScheme
)
}
}
Spacer(modifier = Modifier.width(8.dp))