mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 20:05:21 +00:00
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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user