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
@@ -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) { }
}
}
@@ -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<String, FavoriteRelationship>() // noiseKeyHex -> relationship
private val listeners = mutableListOf<FavoritesChangeListener>()
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 }
}
}
/**
@@ -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
}
}
}
/**
@@ -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<String>()
private val processedNostrEventOrder = mutableListOf<String>()
private val maxProcessedNostrEvents = 2000
private val processedNostrAcks = mutableSetOf<String>()
// removed unused processedNostrAcks
private val nostrKeyMapping = mutableMapOf<String, String>() // 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 senders 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<String> = 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<String>) {
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}")
}
}
}
/**
@@ -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)
@@ -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() {
@@ -0,0 +1,82 @@
package com.bitchat.android.services
import com.bitchat.android.ui.ChatState
object ConversationAliasResolver {
fun resolveCanonicalPeerID(
selectedPeerID: String,
connectedPeers: List<String>,
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<String>
) {
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)
}
}
}
}
@@ -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<String, MutableList<Triple<String, String, String>>>()
// 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<String>) {
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) }
}
}
@@ -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<String>(MAX_IDS)
private val read = LinkedHashSet<String>(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<String>) {
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<String> = emptyList(),
val read: List<String> = emptyList()
)
}
@@ -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))