diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index c435fee4..5564399b 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -13,5 +13,15 @@ class BitchatApplication : Application() { // Initialize relay directory (loads assets/nostr_relays.csv) RelayDirectory.initialize(this) + + // Initialize favorites persistence early so MessageRouter/NostrTransport can use it on startup + try { + com.bitchat.android.favorites.FavoritesPersistenceService.initialize(this) + } catch (_: Exception) { } + + // Warm up Nostr identity to ensure npub is available for favorite notifications + try { + com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(this) + } catch (_: Exception) { } } } diff --git a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt index ddb96a6c..f1366243 100644 --- a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt +++ b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt @@ -47,6 +47,11 @@ data class FavoriteRelationship( } } +interface FavoritesChangeListener { + fun onFavoriteChanged(noiseKeyHex: String) + fun onAllCleared() +} + /** * Manages favorites with Noise↔Nostr mapping * Singleton pattern matching iOS implementation @@ -77,6 +82,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte private val stateManager = SecureIdentityStateManager(context) private val gson = Gson() private val favorites = mutableMapOf() // noiseKeyHex -> relationship + private val listeners = mutableListOf() init { loadFavorites() @@ -133,6 +139,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte } saveFavorites() + notifyChanged(keyHex) Log.d(TAG, "Updated Nostr pubkey association for ${keyHex.take(16)}...") } @@ -164,7 +171,8 @@ class FavoritesPersistenceService private constructor(private val context: Conte favorites[keyHex] = updated saveFavorites() - + notifyChanged(keyHex) + Log.d(TAG, "Updated favorite status for $nickname: $isFavorite") } @@ -182,6 +190,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte ) favorites[keyHex] = updated saveFavorites() + notifyChanged(keyHex) Log.d(TAG, "Updated peer favorited us for ${keyHex.take(16)}...: $theyFavoritedUs") } @@ -208,13 +217,19 @@ class FavoritesPersistenceService private constructor(private val context: Conte favorites.clear() saveFavorites() Log.i(TAG, "Cleared all favorites") + notifyAllCleared() } /** * Find Noise key by Nostr pubkey */ fun findNoiseKey(forNostrPubkey: String): ByteArray? { - return favorites.values.firstOrNull { it.peerNostrPublicKey == forNostrPubkey }?.peerNoisePublicKey + val targetHex = normalizeNostrKeyToHex(forNostrPubkey) ?: return null + return favorites.values.firstOrNull { rel -> + rel.peerNostrPublicKey?.let { stored -> + normalizeNostrKeyToHex(stored) + } == targetHex + }?.peerNoisePublicKey } /** @@ -265,6 +280,40 @@ class FavoritesPersistenceService private constructor(private val context: Conte Log.e(TAG, "Failed to save favorites: ${e.message}") } } + + // MARK: - Listeners + fun addListener(listener: FavoritesChangeListener) { + synchronized(listeners) { + if (!listeners.contains(listener)) listeners.add(listener) + } + } + fun removeListener(listener: FavoritesChangeListener) { + synchronized(listeners) { listeners.remove(listener) } + } + private fun notifyChanged(noiseKeyHex: String) { + val snapshot = synchronized(listeners) { listeners.toList() } + snapshot.forEach { runCatching { it.onFavoriteChanged(noiseKeyHex) } } + } + private fun notifyAllCleared() { + val snapshot = synchronized(listeners) { listeners.toList() } + snapshot.forEach { runCatching { it.onAllCleared() } } + } + + /** + * Normalize a Nostr public key string (npub bech32 or hex) to lowercase hex for comparison + */ + private fun normalizeNostrKeyToHex(value: String): String? { + return try { + if (value.startsWith("npub1")) { + val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(value) + if (hrp != "npub") return null + data.joinToString("") { "%02x".format(it) } + } else { + // Assume hex + value.lowercase() + } + } catch (_: Exception) { null } + } } /** diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index 5d934e70..809cd1e6 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -78,6 +78,15 @@ class MessageHandler(private val myPeerID: String) { val privateMessage = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) if (privateMessage != null) { Log.d(TAG, "πŸ”“ Decrypted TLV PM from $peerID: ${privateMessage.content.take(30)}...") + + // Handle favorite/unfavorite notifications embedded as PMs + val pmContent = privateMessage.content + if (pmContent.startsWith("[FAVORITED]") || pmContent.startsWith("[UNFAVORITED]")) { + handleFavoriteNotificationFromMesh(pmContent, peerID) + // Acknowledge delivery for UX parity + sendDeliveryAck(privateMessage.messageID, peerID) + return + } // Create BitchatMessage - use local system time for incoming messages val message = BitchatMessage( @@ -426,6 +435,52 @@ class MessageHandler(private val myPeerID: String) { fun shutdown() { handlerScope.cancel() } + + /** + * Handle favorite/unfavorite notification received over mesh as a private message. + * Content format: "[FAVORITED]:npub..." or "[UNFAVORITED]:npub..." + */ + private fun handleFavoriteNotificationFromMesh(content: String, fromPeerID: String) { + try { + val isFavorite = content.startsWith("[FAVORITED]") + val npub = content.substringAfter(":", "").trim().takeIf { it.startsWith("npub1") } + + // Update mutual favorite status in persistence + // Resolve full Noise key if available via delegate peer info + val peerInfo = delegate?.getPeerInfo(fromPeerID) + val noiseKey = peerInfo?.noisePublicKey + if (noiseKey != null) { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, isFavorite) + if (npub != null) { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, npub) + } + + // Determine iOS-style guidance text + val rel = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) + val guidance = if (isFavorite) { + if (rel?.isFavorite == true) { + " β€” mutual! You can continue DMs via Nostr when out of mesh." + } else { + " β€” favorite back to continue DMs later." + } + } else { + ". DMs over Nostr will pause unless you both favorite again." + } + + // Emit system message via delegate callback + val action = if (isFavorite) "favorited" else "unfavorited" + val sys = com.bitchat.android.model.BitchatMessage( + sender = "system", + content = "${peerInfo.nickname} $action you$guidance", + timestamp = java.util.Date(), + isRelay = false + ) + delegate?.onMessageReceived(sys) + } + } catch (_: Exception) { + // Best-effort; ignore errors + } + } } /** diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt b/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt index d0fc3c0a..c845ac0e 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt @@ -16,6 +16,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.util.* /** @@ -75,7 +77,7 @@ class NostrGeohashService( private val processedNostrEvents = mutableSetOf() private val processedNostrEventOrder = mutableListOf() private val maxProcessedNostrEvents = 2000 - private val processedNostrAcks = mutableSetOf() + // removed unused processedNostrAcks private val nostrKeyMapping = mutableMapOf() // senderPeerID -> nostrPubkey // MARK: - Geohash Participant Tracking Properties @@ -221,9 +223,11 @@ class NostrGeohashService( * Handle incoming Nostr message (gift wrap) */ private fun handleNostrMessage(giftWrap: NostrEvent) { - // Simple deduplication - if (processedNostrEvents.contains(giftWrap.id)) return - processedNostrEvents.add(giftWrap.id) + // Offload processing to avoid blocking UI + coroutineScope.launch(kotlinx.coroutines.Dispatchers.Default) { + // Simple deduplication + if (processedNostrEvents.contains(giftWrap.id)) return@launch + processedNostrEvents.add(giftWrap.id) // Manage deduplication cache size processedNostrEventOrder.add(giftWrap.id) @@ -235,16 +239,18 @@ class NostrGeohashService( // Client-side filtering: ignore messages older than 24 hours + 15 minutes buffer val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt if (messageAge > 173700) { // 48 hours + 15 minutes - return + return@launch } - Log.d(TAG, "Processing Nostr message: ${giftWrap.id.take(16)}...") + Log.d(TAG, "Processing Nostr message: ${giftWrap.id.take(16)}...") + + // Removed legacy NostrReadStore usage; rely on SeenMessageStore by message ID - val currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(application) - if (currentIdentity == null) { - Log.w(TAG, "No Nostr identity available for decryption") - return - } + val currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(application) + if (currentIdentity == null) { + Log.w(TAG, "No Nostr identity available for decryption") + return@launch + } try { val decryptResult = NostrProtocol.decryptPrivateMessage( @@ -254,7 +260,7 @@ class NostrGeohashService( if (decryptResult == null) { Log.w(TAG, "Failed to decrypt Nostr message") - return + return@launch } val (content, senderPubkey, rumorTimestamp) = decryptResult @@ -262,26 +268,26 @@ class NostrGeohashService( // Expect embedded BitChat packet content if (!content.startsWith("bitchat1:")) { Log.d(TAG, "Ignoring non-embedded Nostr DM content") - return + return@launch } val base64Content = content.removePrefix("bitchat1:") val packetData = base64URLDecode(base64Content) if (packetData == null) { Log.e(TAG, "Failed to decode base64url BitChat packet") - return + return@launch } val packet = com.bitchat.android.protocol.BitchatPacket.fromBinaryData(packetData) if (packet == null) { Log.e(TAG, "Failed to parse embedded BitChat packet from Nostr DM") - return + return@launch } // Only process noiseEncrypted envelope for private messages/receipts if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) { Log.w(TAG, "Unsupported embedded packet type: ${packet.type}") - return + return@launch } // Validate recipient if present @@ -295,24 +301,50 @@ class NostrGeohashService( val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) if (noisePayload == null) { Log.e(TAG, "Failed to parse embedded NoisePayload") - return + return@launch } // Map sender by Nostr pubkey to Noise key when possible val senderNoiseKey = findNoiseKeyForNostrPubkey(senderPubkey) val messageTimestamp = Date(rumorTimestamp * 1000L) - val senderNickname = if (senderNoiseKey != null) { - // Get nickname from favorites - getFavoriteNickname(senderNoiseKey) ?: "Unknown" + + // If we know the Noise key, try to resolve a currently connected mesh peer ID for it + val targetPeerID: String = if (senderNoiseKey != null) { + val meshPeerId = resolveMeshPeerIdForNoiseKey(senderNoiseKey) + if (meshPeerId != null) { + // Also unify existing noise-hex/nostr-temp chats into this mesh peer + val noiseHex = senderNoiseKey.joinToString("") { b -> "%02x".format(b) } + val tempKey = "nostr_${senderPubkey.take(16)}" + unifyChatsIntoPeer(meshPeerId, listOf(noiseHex, tempKey)) + + // If currently viewing the temporary or noise-hex chat, auto-switch to mesh peer + val selected = state.getSelectedPrivateChatPeerValue() + if (selected == noiseHex || selected == tempKey) { + state.setSelectedPrivateChatPeer(meshPeerId) + } + meshPeerId + } else { + senderNoiseKey.joinToString("") { b -> "%02x".format(b) } + } + } else { + "nostr_${senderPubkey.take(16)}" + } + + // Prefer nickname from mesh when connected; else fallback to favorites + val senderNickname: String = if (senderNoiseKey != null) { + val meshPeerId = resolveMeshPeerIdForNoiseKey(senderNoiseKey) + if (meshPeerId != null) { + // Use live mesh nickname if available + meshDelegateHandler.getPeerInfo(meshPeerId)?.nickname + ?: getFavoriteNickname(senderNoiseKey) + ?: "Unknown" + } else { + getFavoriteNickname(senderNoiseKey) ?: "Unknown" + } } else { "Unknown" } - // Stable target ID if we know Noise key; otherwise temporary Nostr-based peer - val targetPeerID = senderNoiseKey?.let { - it.joinToString("") { byte -> "%02x".format(byte) } - } ?: "nostr_${senderPubkey.take(16)}" - // Store Nostr key mapping nostrKeyMapping[targetPeerID] = senderPubkey @@ -323,17 +355,19 @@ class NostrGeohashService( if (noisePayload.type == com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE) { val pm = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) pm?.let { pmsg -> - val nostrTransport = NostrTransport.getInstance(application) - // Prefer mapped peer route; fallback to direct Nostr using sender pubkey - if (senderNoiseKey != null) { - val peerIdHex = senderNoiseKey.joinToString("") { b -> "%02x".format(b) } - nostrTransport.sendDeliveryAck(pmsg.messageID, peerIdHex) - } else { - // Fallback: direct to sender’s Nostr pubkey (geohash-style) - val identity = NostrIdentityBridge.getCurrentNostrIdentity(application) - if (identity != null) { - nostrTransport.sendDeliveryAckGeohash(pmsg.messageID, senderPubkey, identity) + val seen = com.bitchat.android.services.SeenMessageStore.getInstance(application) + if (!seen.hasDelivered(pmsg.messageID)) { + val nostrTransport = NostrTransport.getInstance(application) + if (senderNoiseKey != null) { + val peerIdHex = senderNoiseKey.joinToString("") { b -> "%02x".format(b) } + nostrTransport.sendDeliveryAck(pmsg.messageID, peerIdHex) + } else { + val identity = NostrIdentityBridge.getCurrentNostrIdentity(application) + if (identity != null) { + nostrTransport.sendDeliveryAckGeohash(pmsg.messageID, senderPubkey, identity) + } } + seen.markDelivered(pmsg.messageID) } } } @@ -341,12 +375,13 @@ class NostrGeohashService( } catch (e: Exception) { Log.e(TAG, "Error processing Nostr message: ${e.message}") } + } } /** * Process NoisePayload from Nostr message */ - private fun processNoisePayload( + private suspend fun processNoisePayload( noisePayload: com.bitchat.android.model.NoisePayload, targetPeerID: String, senderNickname: String, @@ -362,6 +397,8 @@ class NostrGeohashService( val messageId = pm.messageID val messageContent = pm.content + val seen = com.bitchat.android.services.SeenMessageStore.getInstance(application) + val suppressUnread = seen.hasRead(messageId) // Handle favorite/unfavorite notifications if (messageContent.startsWith("[FAVORITED]") || messageContent.startsWith("[UNFAVORITED]")) { @@ -399,13 +436,18 @@ class NostrGeohashService( ) ) - // Add to private chats - privateChatManager.handleIncomingPrivateMessage(message) - - // Send read receipt if viewing - if (isViewingThisChat) { - // Note: meshService needs to be passed as parameter - // privateChatManager.sendReadReceiptsForPeer(targetPeerID, meshService) + // Add to private chats on Main + withContext(Dispatchers.Main) { + privateChatManager.handleIncomingPrivateMessage(message, suppressUnread) + } + + // Send read receipt if viewing (only once across restarts) + if (isViewingThisChat && !seen.hasRead(messageId)) { + try { + val rr = com.bitchat.android.model.ReadReceipt(originalMessageID = messageId) + NostrTransport.getInstance(application).sendReadReceipt(rr, targetPeerID) + seen.markRead(messageId) + } catch (_: Exception) { } } Log.i(TAG, "πŸ“₯ Processed Nostr private message from $senderNickname") @@ -413,15 +455,19 @@ class NostrGeohashService( com.bitchat.android.model.NoisePayloadType.DELIVERED -> { val messageId = String(noisePayload.data, Charsets.UTF_8) - // Use the existing delegate to handle delivery acknowledgment - meshDelegateHandler.didReceiveDeliveryAck(messageId, targetPeerID) + // Use the existing delegate to handle delivery acknowledgment on Main + withContext(Dispatchers.Main) { + meshDelegateHandler.didReceiveDeliveryAck(messageId, targetPeerID) + } Log.d(TAG, "πŸ“₯ Processed Nostr delivery ACK for message $messageId") } com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> { val messageId = String(noisePayload.data, Charsets.UTF_8) - // Use the existing delegate to handle read receipt - meshDelegateHandler.didReceiveReadReceipt(messageId, targetPeerID) + // Use the existing delegate to handle read receipt on Main + withContext(Dispatchers.Main) { + meshDelegateHandler.didReceiveReadReceipt(messageId, targetPeerID) + } Log.d(TAG, "πŸ“₯ Processed Nostr read receipt for message $messageId") } } @@ -440,6 +486,28 @@ class NostrGeohashService( private fun getFavoriteNickname(noiseKey: ByteArray): String? { return com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)?.peerNickname } + + /** + * Resolve a currently-connected mesh peer ID for a given Noise public key + * by matching against the mesh service's peer info noisePublicKey values. + */ + private fun resolveMeshPeerIdForNoiseKey(noiseKey: ByteArray): String? { + return try { + val peers: List = state.getConnectedPeersValue() + peers.firstOrNull { peerId: String -> + val info = meshDelegateHandler.getPeerInfo(peerId) + info?.noisePublicKey?.contentEquals(noiseKey) == true + } + } catch (_: Exception) { null } + } + + /** + * Merge any chats stored under the given keys into the target peer's chat entry + * so messages received while offline appear in the same chat when the peer connects. + */ + private fun unifyChatsIntoPeer(targetPeerID: String, keysToMerge: List) { + com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, targetPeerID, keysToMerge) + } /** * Handle favorite/unfavorite notification @@ -447,16 +515,72 @@ class NostrGeohashService( private fun handleFavoriteNotification(content: String, fromPeerID: String, senderNickname: String) { val isFavorite = content.startsWith("[FAVORITED]") val action = if (isFavorite) "favorited" else "unfavorited" - + + // Try to extract npub after colon, if present + val npub = content.substringAfter(":", "").trim().takeIf { it.startsWith("npub1") } + + // Resolve noise key if possible and persist relationship + npub mapping + try { + var noiseKey: ByteArray? = null + // If fromPeerID looks like hex (noise key), decode + val hexRegex = Regex("^[0-9a-fA-F]+$") + if (fromPeerID.matches(hexRegex) && (fromPeerID.length % 2 == 0)) { + // Expect 64 hex chars for full Curve25519 key; accept others best-effort + val bytes = fromPeerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + // Use only if length is plausible + if (bytes.isNotEmpty()) noiseKey = bytes + } else { + // fromPeerID likely a temporary key like "nostr_..."; map to Nostr pubkey + val senderPubkey = nostrKeyMapping[fromPeerID] + if (senderPubkey != null) { + noiseKey = findNoiseKeyForNostrPubkey(senderPubkey) + } + } + + if (noiseKey != null) { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, isFavorite) + if (npub != null) { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, npub) + } + } + } catch (_: Exception) { + // Best-effort + } + + // Determine guidance text based on mutual status (iOS-style) + val guidance = try { + val rel = run { + // Try to resolve via noise key directly or mapping + var key: ByteArray? = null + val hexRegex = Regex("^[0-9a-fA-F]+$") + if (fromPeerID.matches(hexRegex) && (fromPeerID.length % 2 == 0)) { + key = fromPeerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } else { + val mappedNostr = nostrKeyMapping[fromPeerID] + key = mappedNostr?.let { findNoiseKeyForNostrPubkey(it) } + } + key?.let { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(it) } + } + if (isFavorite) { + if (rel?.isFavorite == true) { + " β€” mutual! You can continue DMs via Nostr when out of mesh." + } else { + " β€” favorite back to continue DMs later." + } + } else { + ". DMs over Nostr will pause unless you both favorite again." + } + } catch (_: Exception) { "" } + // Show system message val systemMessage = BitchatMessage( sender = "system", - content = "$senderNickname $action you", + content = "$senderNickname $action you$guidance", timestamp = Date(), isRelay = false ) messageManager.addMessage(systemMessage) - + Log.i(TAG, "πŸ“₯ Processed favorite notification: $senderNickname $action you") } @@ -953,25 +1077,26 @@ class NostrGeohashService( * Handles participant tracking, nickname caching, message display, and teleport state */ private fun handleUnifiedGeohashEvent(event: NostrEvent, geohash: String) { + coroutineScope.launch(Dispatchers.Default) { try { Log.v(TAG, "πŸ” handleUnifiedGeohashEvent called - eventGeohash: $geohash, currentGeohash: $currentGeohash, eventKind: ${event.kind}, eventId: ${event.id.take(8)}...") // Only handle ephemeral kind 20000 events if (event.kind != 20000) { Log.v(TAG, "❌ Skipping non-ephemeral event (kind ${event.kind})") - return + return@launch } // Check if this user is blocked in geohash channels BEFORE any processing if (isGeohashUserBlocked(event.pubkey)) { Log.v(TAG, "🚫 Skipping event from blocked geohash user: ${event.pubkey.take(8)}...") - return + return@launch } // Deduplicate events if (processedNostrEvents.contains(event.id)) { Log.v(TAG, "❌ Skipping duplicate event ${event.id.take(8)}...") - return + return@launch } processedNostrEvents.add(event.id) @@ -984,7 +1109,7 @@ class NostrGeohashService( // STEP 1: Always update participant activity for all geohashes (for location channel list) val timestamp = Date(event.createdAt * 1000L) - updateGeohashParticipant(geohash, event.pubkey, timestamp) + withContext(Dispatchers.Main) { updateGeohashParticipant(geohash, event.pubkey, timestamp) } // STEP 2: Always cache nickname from tag if present (for all geohashes) event.tags.find { it.size >= 2 && it[0] == "n" }?.let { nickTag -> @@ -996,7 +1121,7 @@ class NostrGeohashService( // If this is a new nickname or nickname change for current geohash, refresh people list if (previousNick != nick && currentGeohash == geohash) { - refreshGeohashPeople() + withContext(Dispatchers.Main) { refreshGeohashPeople() } } } @@ -1006,7 +1131,7 @@ class NostrGeohashService( val currentTeleported = state.getTeleportedGeoValue().toMutableSet() if (!currentTeleported.contains(key)) { currentTeleported.add(key) - state.setTeleportedGeo(currentTeleported) + withContext(Dispatchers.Main) { state.setTeleportedGeo(currentTeleported) } Log.d(TAG, "πŸ“ Marked geohash participant as teleported: ${event.pubkey.take(8)}...") } } @@ -1017,7 +1142,7 @@ class NostrGeohashService( context = application ) if (myGeoIdentity.publicKeyHex.lowercase() == event.pubkey.lowercase()) { - return + return@launch } // STEP 5: Store mapping for potential geohash DM initiation @@ -1032,7 +1157,7 @@ class NostrGeohashService( event.content.trim().isEmpty() if (isTeleportPresence) { Log.v(TAG, "Skipping empty teleport presence event") - return + return@launch } val senderName = displayNameForNostrPubkey(event.pubkey) @@ -1065,7 +1190,7 @@ class NostrGeohashService( selectedLocationChannel.channel.geohash == geohash) if (shouldShowMessage) { - messageManager.addMessage(message) + withContext(Dispatchers.Main) { messageManager.addMessage(message) } } // NOTIFICATION LOGIC: Check for mentions and first messages @@ -1076,12 +1201,13 @@ class NostrGeohashService( } catch (e: Exception) { Log.e(TAG, "Error handling unified geohash event: ${e.message}") } + } } /** * Check and trigger geohash notifications for mentions and first messages */ - private fun checkAndTriggerGeohashNotifications( + private suspend fun checkAndTriggerGeohashNotifications( geohash: String, senderName: String, content: String, @@ -1104,13 +1230,15 @@ class NostrGeohashService( if (isMention || isFirstMessage) { Log.d(TAG, "πŸ”” Triggering geohash notification - geohash: $geohash, mention: $isMention, first: $isFirstMessage") - notificationManager.showGeohashNotification( - geohash = geohash, - senderNickname = senderName, - messageContent = content, - isMention = isMention, - isFirstMessage = isFirstMessage - ) + withContext(Dispatchers.Main) { + notificationManager.showGeohashNotification( + geohash = geohash, + senderNickname = senderName, + messageContent = content, + isMention = isMention, + isFirstMessage = isFirstMessage + ) + } } } catch (e: Exception) { Log.e(TAG, "Error checking geohash notifications: ${e.message}") @@ -1184,10 +1312,13 @@ class NostrGeohashService( geohash: String, identity: NostrIdentity ) { + coroutineScope.launch(kotlinx.coroutines.Dispatchers.Default) { try { // Deduplicate - if (processedNostrEvents.contains(giftWrap.id)) return + if (processedNostrEvents.contains(giftWrap.id)) return@launch processedNostrEvents.add(giftWrap.id) + + // Removed legacy NostrReadStore usage; rely on SeenMessageStore by message ID // Decrypt with per-geohash identity val decryptResult = NostrProtocol.decryptPrivateMessage( @@ -1197,28 +1328,28 @@ class NostrGeohashService( if (decryptResult == null) { Log.d(TAG, "Skipping geohash DM: unwrap/open failed (non-fatal)") - return + return@launch } val (content, senderPubkey, rumorTimestamp) = decryptResult // Only process BitChat embedded messages - if (!content.startsWith("bitchat1:")) return + if (!content.startsWith("bitchat1:")) return@launch val base64Content = content.removePrefix("bitchat1:") - val packetData = base64URLDecode(base64Content) ?: return - val packet = com.bitchat.android.protocol.BitchatPacket.fromBinaryData(packetData) ?: return + val packetData = base64URLDecode(base64Content) ?: return@launch + val packet = com.bitchat.android.protocol.BitchatPacket.fromBinaryData(packetData) ?: return@launch - if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return + if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch - val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return + val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return@launch val messageTimestamp = Date(rumorTimestamp * 1000L) val convKey = "nostr_${senderPubkey.take(16)}" nostrKeyMapping[convKey] = senderPubkey when (noisePayload.type) { com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> { - val pm = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) ?: return + val pm = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) ?: return@launch val messageId = pm.messageID Log.d(TAG, "πŸ“₯ Received geohash DM from ${senderPubkey.take(8)}...") @@ -1232,7 +1363,7 @@ class NostrGeohashService( break } } - if (messageExists) return + if (messageExists) return@launch val senderName = displayNameForNostrPubkey(senderPubkey) val isViewingThisChat = state.getSelectedPrivateChatPeerValue() == convKey @@ -1252,17 +1383,23 @@ class NostrGeohashService( ) ) - // Add to private chats - privateChatManager.handleIncomingPrivateMessage(message) + // Add to private chats (suppress unread if already read) + val seen = com.bitchat.android.services.SeenMessageStore.getInstance(application) + privateChatManager.handleIncomingPrivateMessage(message, suppressUnread = seen.hasRead(messageId)) - // Always send delivery ACK for geohash DMs - val nostrTransport = NostrTransport.getInstance(application) - nostrTransport.sendDeliveryAckGeohash(messageId, senderPubkey, identity) + // Send delivery ACK for geohash DMs only once + if (!seen.hasDelivered(messageId)) { + val nostrTransport = NostrTransport.getInstance(application) + nostrTransport.sendDeliveryAckGeohash(messageId, senderPubkey, identity) + seen.markDelivered(messageId) + } // Send read receipt if viewing this chat - if (isViewingThisChat) { + if (isViewingThisChat && !seen.hasRead(messageId)) { // Send read receipt via Nostr for geohash DM sendGeohashReadReceipt(messageId, senderPubkey, geohash) + // Mark message as read persistently + try { seen.markRead(messageId) } catch (_: Exception) { } } } @@ -1280,6 +1417,7 @@ class NostrGeohashService( } catch (e: Exception) { Log.e(TAG, "Error handling geohash DM event: ${e.message}") } + } } /** diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt index 5942e0a7..5b646088 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt @@ -70,7 +70,7 @@ object NostrProtocol { return null } - Log.v(TAG, "Successfully unwrapped gift wrap") + Log.v(TAG, "Successfully unwrapped gift wrap from: ${seal.pubkey.take(16)}...") // 2. Open the seal val rumor = openSeal(seal, recipientIdentity.privateKeyHex) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt index d98d445f..217e081d 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt @@ -479,28 +479,11 @@ class NostrTransport( } /** - * Convert hex string to byte array (8 bytes) + * Convert full hex string to byte array */ private fun hexStringToByteArray(hexString: String): ByteArray { - if (hexString.length % 2 != 0) { - return ByteArray(8) // Return 8-byte array filled with zeros - } - - val result = ByteArray(8) { 0 } - var tempID = hexString - var index = 0 - - while (tempID.length >= 2 && index < 8) { - val hexByte = tempID.substring(0, 2) - val byte = hexByte.toIntOrNull(16)?.toByte() - if (byte != null) { - result[index] = byte - } - tempID = tempID.substring(2) - index++ - } - - return result + val clean = if (hexString.length % 2 == 0) hexString else "0$hexString" + return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray() } fun cleanup() { diff --git a/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt b/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt new file mode 100644 index 00000000..b8a91218 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt @@ -0,0 +1,82 @@ +package com.bitchat.android.services + +import com.bitchat.android.ui.ChatState + +object ConversationAliasResolver { + + fun resolveCanonicalPeerID( + selectedPeerID: String, + connectedPeers: List, + meshNoiseKeyForPeer: (String) -> ByteArray?, + meshHasPeer: (String) -> Boolean, + nostrPubHexForAlias: (String) -> String?, + findNoiseKeyForNostr: (String) -> ByteArray? + ): String { + var peer = selectedPeerID + try { + if (peer.startsWith("nostr_")) { + val pubHex = nostrPubHexForAlias(peer) + if (pubHex != null) { + val noiseKey = findNoiseKeyForNostr(pubHex) + if (noiseKey != null) { + val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) } + // Prefer a connected mesh peer that matches this noise key + val meshPeer = connectedPeers.firstOrNull { pid -> + meshNoiseKeyForPeer(pid)?.contentEquals(noiseKey) == true + } + peer = meshPeer ?: noiseHex + } + } + } else if (peer.length == 64 && peer.matches(Regex("^[0-9a-fA-F]+$"))) { + // Peer is full noise key hex: upgrade to active mesh peer if available + val noiseKey = peer.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + val meshPeer = connectedPeers.firstOrNull { pid -> + meshNoiseKeyForPeer(pid)?.contentEquals(noiseKey) == true + } + if (meshPeer != null) { + peer = meshPeer + } + } + } catch (_: Exception) { /* no-op */ } + return peer + } + + fun unifyChatsIntoPeer( + state: ChatState, + targetPeerID: String, + keysToMerge: List + ) { + if (keysToMerge.isEmpty()) return + val currentChats = state.getPrivateChatsValue().toMutableMap() + val targetList = currentChats[targetPeerID]?.toMutableList() ?: mutableListOf() + var didMerge = false + keysToMerge.distinct().forEach { key -> + if (key == targetPeerID) return@forEach + val list = currentChats[key] + if (!list.isNullOrEmpty()) { + targetList.addAll(list) + currentChats.remove(key) + didMerge = true + } + } + if (didMerge) { + targetList.sortBy { it.timestamp } + currentChats[targetPeerID] = targetList + state.setPrivateChats(currentChats) + + // Move unread flags + val unread = state.getUnreadPrivateMessagesValue().toMutableSet() + var hadUnread = false + keysToMerge.forEach { key -> if (unread.remove(key)) hadUnread = true } + if (hadUnread) unread.add(targetPeerID) + state.setUnreadPrivateMessages(unread) + + // Switch selection if currently viewing an alias that got merged + val selected = state.getSelectedPrivateChatPeerValue() + if (selected != null && keysToMerge.contains(selected)) { + state.setSelectedPrivateChatPeer(targetPeerID) + } + } + } +} + diff --git a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt new file mode 100644 index 00000000..bdf3e126 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -0,0 +1,192 @@ +package com.bitchat.android.services + +import android.content.Context +import android.util.Log +import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.model.ReadReceipt +import com.bitchat.android.nostr.NostrTransport + +/** + * Routes messages between BLE mesh and Nostr transports, matching iOS behavior. + */ +class MessageRouter private constructor( + private val context: Context, + private val mesh: BluetoothMeshService, + private val nostr: NostrTransport +) { + companion object { + private const val TAG = "MessageRouter" + @Volatile private var INSTANCE: MessageRouter? = null + fun tryGetInstance(): MessageRouter? = INSTANCE + fun getInstance(context: Context, mesh: BluetoothMeshService): MessageRouter { + return INSTANCE ?: synchronized(this) { + val nostr = NostrTransport.getInstance(context) + INSTANCE?.also { + // Update mesh reference if needed and keep senderPeerID in sync + it.nostr.senderPeerID = mesh.myPeerID + return it + } + MessageRouter(context.applicationContext, mesh, nostr).also { instance -> + instance.nostr.senderPeerID = mesh.myPeerID + // Register for favorites changes to flush outbox + try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener(instance.favoriteListener) + } catch (_: Exception) {} + INSTANCE = instance + } + } + } + } + + // Outbox: peerID -> queued (content, nickname, messageID) + private val outbox = mutableMapOf>>() + + // Listener for favorites changes to flush outbox when npub mapping appears/changes + private val favoriteListener = object: com.bitchat.android.favorites.FavoritesChangeListener { + override fun onFavoriteChanged(noiseKeyHex: String) { + flushOutboxFor(noiseKeyHex) + // Also try 16-hex short id commonly used in UI if any client used that + val shortId = noiseKeyHex.take(16) + flushOutboxFor(shortId) + } + override fun onAllCleared() { + // Nothing special; leave queued items until routing becomes possible + } + } + + fun sendPrivate(content: String, toPeerID: String, recipientNickname: String, messageID: String) { + val hasMesh = mesh.getPeerInfo(toPeerID)?.isConnected == true + val hasEstablished = mesh.hasEstablishedSession(toPeerID) + if (hasMesh && hasEstablished) { + Log.d(TAG, "Routing PM via mesh to ${toPeerID.take(8)}… id=${messageID.take(8)}…") + mesh.sendPrivateMessage(content, toPeerID, recipientNickname, messageID) + } else if (canSendViaNostr(toPeerID)) { + Log.d(TAG, "Routing PM via Nostr to ${toPeerID.take(8)}… id=${messageID.take(8)}…") + nostr.sendPrivateMessage(content, toPeerID, recipientNickname, messageID) + } else { + Log.d(TAG, "Queued PM for ${toPeerID.take(8)}… (no mesh, no Nostr mapping) id=${messageID.take(8)}…") + val q = outbox.getOrPut(toPeerID) { mutableListOf() } + q.add(Triple(content, recipientNickname, messageID)) + } + } + + fun sendReadReceipt(receipt: ReadReceipt, toPeerID: String) { + if ((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID)) { + Log.d(TAG, "Routing READ via mesh to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}…") + mesh.sendReadReceipt(receipt.originalMessageID, toPeerID, mesh.getPeerNicknames()[toPeerID] ?: mesh.myPeerID) + } else { + Log.d(TAG, "Routing READ via Nostr to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}…") + nostr.sendReadReceipt(receipt, toPeerID) + } + } + + fun sendDeliveryAck(messageID: String, toPeerID: String) { + // Mesh delivery ACKs are sent by the receiver automatically. + // Only route via Nostr when mesh path isn't available. + if (!((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID))) { + nostr.sendDeliveryAck(messageID, toPeerID) + } + } + + fun sendFavoriteNotification(toPeerID: String, isFavorite: Boolean) { + if (mesh.getPeerInfo(toPeerID)?.isConnected == true) { + val myNpub = try { + com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)?.npub + } catch (_: Exception) { null } + val content = if (isFavorite) "[FAVORITED]:${myNpub ?: ""}" else "[UNFAVORITED]:${myNpub ?: ""}" + val nickname = mesh.getPeerNicknames()[toPeerID] ?: toPeerID + mesh.sendPrivateMessage(content, toPeerID, nickname) + } else { + nostr.sendFavoriteNotification(toPeerID, isFavorite) + } + } + + // Flush any queued messages for a specific peerID + fun flushOutboxFor(peerID: String) { + val queued = outbox[peerID] ?: return + if (queued.isEmpty()) return + Log.d(TAG, "Flushing outbox for ${peerID.take(8)}… count=${queued.size}") + val iterator = queued.iterator() + while (iterator.hasNext()) { + val (content, nickname, messageID) = iterator.next() + var hasMesh = mesh.getPeerInfo(peerID)?.isConnected == true && mesh.hasEstablishedSession(peerID) + // If this is a noiseHex key, see if there is a connected mesh peer for this identity + if (!hasMesh && peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { + val meshPeer = resolveMeshPeerForNoiseHex(peerID) + if (meshPeer != null && mesh.getPeerInfo(meshPeer)?.isConnected == true && mesh.hasEstablishedSession(meshPeer)) { + mesh.sendPrivateMessage(content, meshPeer, nickname, messageID) + iterator.remove() + continue + } + } + val canNostr = canSendViaNostr(peerID) + if (hasMesh) { + mesh.sendPrivateMessage(content, peerID, nickname, messageID) + iterator.remove() + } else if (canNostr) { + nostr.sendPrivateMessage(content, peerID, nickname, messageID) + iterator.remove() + } + } + if (queued.isEmpty()) { + outbox.remove(peerID) + } + } + + // Flush everything (rarely used) + fun flushAllOutbox() { + outbox.keys.toList().forEach { flushOutboxFor(it) } + } + + private fun canSendViaNostr(peerID: String): Boolean { + return try { + // Full Noise key hex + if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { + val noiseKey = hexToBytes(peerID) + val fav = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) + fav?.isMutual == true && fav.peerNostrPublicKey != null + } else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { + // Ephemeral 16-hex mesh ID: resolve via prefix match in favorites + val fav = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID) + fav?.isMutual == true && fav.peerNostrPublicKey != null + } else { + false + } + } catch (_: Exception) { false } + } + + private fun hexToBytes(hex: String): ByteArray { + val clean = if (hex.length % 2 == 0) hex else "0$hex" + return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } + + private fun resolveMeshPeerForNoiseHex(noiseHex: String): String? { + return try { + mesh.getPeerNicknames().keys.firstOrNull { pid -> + val info = mesh.getPeerInfo(pid) + val keyHex = info?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } + keyHex != null && keyHex.equals(noiseHex, ignoreCase = true) + } + } catch (_: Exception) { null } + } + + // Called when mesh peer list changes; attempt to flush any matching outbox entries + fun onPeersUpdated(peers: List) { + peers.forEach { pid -> + flushOutboxFor(pid) + val noiseHex = try { + mesh.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } + } catch (_: Exception) { null } + noiseHex?.let { flushOutboxFor(it) } + } + } + + // Called when a Noise session becomes established; flush both the mesh peerID and its noiseHex alias + fun onSessionEstablished(peerID: String) { + flushOutboxFor(peerID) + val noiseHex = try { + mesh.getPeerInfo(peerID)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } + } catch (_: Exception) { null } + noiseHex?.let { flushOutboxFor(it) } + } +} diff --git a/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt new file mode 100644 index 00000000..469525d7 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt @@ -0,0 +1,89 @@ +package com.bitchat.android.services + +import android.content.Context +import android.util.Log +import com.bitchat.android.identity.SecureIdentityStateManager +import com.google.gson.Gson + +/** + * Persistent store for message IDs we've already acknowledged (DELIVERED) or READ. + * Limits to last MAX_IDS entries per set to avoid memory bloat. + */ +class SeenMessageStore private constructor(private val context: Context) { + companion object { + private const val TAG = "SeenMessageStore" + private const val STORAGE_KEY = "seen_message_store_v1" + private const val MAX_IDS = 10_000 + + @Volatile private var INSTANCE: SeenMessageStore? = null + fun getInstance(appContext: Context): SeenMessageStore { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: SeenMessageStore(appContext.applicationContext).also { INSTANCE = it } + } + } + } + + private val gson = Gson() + private val secure = SecureIdentityStateManager(context) + + private val delivered = LinkedHashSet(MAX_IDS) + private val read = LinkedHashSet(MAX_IDS) + + init { load() } + + @Synchronized fun hasDelivered(id: String) = delivered.contains(id) + @Synchronized fun hasRead(id: String) = read.contains(id) + + @Synchronized fun markDelivered(id: String) { + if (delivered.remove(id)) delivered.add(id) else { + delivered.add(id) + trim(delivered) + } + persist() + } + + @Synchronized fun markRead(id: String) { + if (read.remove(id)) read.add(id) else { + read.add(id) + trim(read) + } + persist() + } + + private fun trim(set: LinkedHashSet) { + if (set.size <= MAX_IDS) return + val it = set.iterator() + while (set.size > MAX_IDS && it.hasNext()) { + it.next(); it.remove() + } + } + + @Synchronized private fun load() { + try { + val json = secure.getSecureValue(STORAGE_KEY) ?: return + val data = gson.fromJson(json, StorePayload::class.java) ?: return + delivered.clear(); read.clear() + data.delivered.takeLast(MAX_IDS).forEach { delivered.add(it) } + data.read.takeLast(MAX_IDS).forEach { read.add(it) } + Log.d(TAG, "Loaded delivered=${delivered.size}, read=${read.size}") + } catch (e: Exception) { + Log.e(TAG, "Failed to load SeenMessageStore: ${e.message}") + } + } + + @Synchronized private fun persist() { + try { + val payload = StorePayload(delivered.toList(), read.toList()) + val json = gson.toJson(payload) + secure.storeSecureValue(STORAGE_KEY, json) + } catch (e: Exception) { + Log.e(TAG, "Failed to persist SeenMessageStore: ${e.message}") + } + } + + private data class StorePayload( + val delivered: List = emptyList(), + val read: List = emptyList() + ) +} + diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 20cab350..85e5ce32 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -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) 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 2a91c22a..c72ae432 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -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) diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index 81b039ac..824ca81d 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -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_) 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) { + 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) + } } 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 dadb5b64..9d8b3144 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -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() 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 727df6cc..c0308a70 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -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() + + // 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_) + 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() { diff --git a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt index 946a4d65..6c8c7917 100644 --- a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt @@ -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 { !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 = 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 { !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() + 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))