mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 13:25:21 +00:00
Nostr refactor simplify (#390)
* fix bug * geoDM receive works, send doesnt, and incoming message doesnt make sender appear in peer list * fix nostr dm * geohash dms work * Geohash DM UI: stop mixing Nostr DM temp chats into mesh offline list; ensure geohash DM senders are added to geohash people list only. Removed nostr_* sidebar append in PeopleSection; kept 64-hex mesh offline favorites. Verified build. * refactor * nice * works * merging nostr -> mesh works * tripple click to delete all * fix sidebar icon * remove hash * dms have correct recipient * works * wip unread badge * geohash dms wip * dms work
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
@@ -31,6 +32,7 @@ import com.bitchat.android.core.ui.utils.singleOrTripleClickable
|
||||
* Extracted from ChatScreen.kt for better organization
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Reactive helper to compute favorite state from fingerprint mapping
|
||||
* This eliminates the need for static isFavorite parameters and makes
|
||||
@@ -159,7 +161,6 @@ fun PeerCounter(
|
||||
connectedPeers: List<String>,
|
||||
joinedChannels: Set<String>,
|
||||
hasUnreadChannels: Map<String, Int>,
|
||||
hasUnreadPrivateMessages: Set<String>,
|
||||
isConnected: Boolean,
|
||||
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
|
||||
geohashPeople: List<GeoPerson>,
|
||||
@@ -189,33 +190,6 @@ fun PeerCounter(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier.clickable { onClick() }.padding(end = 8.dp) // Added right margin to match "bitchat" logo spacing
|
||||
) {
|
||||
if (hasUnreadChannels.values.any { it > 0 }) {
|
||||
// Channel icon in a Box to ensure consistent size with other icons
|
||||
Box(
|
||||
modifier = Modifier.size(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "#",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color(0xFF0080FF),
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
|
||||
if (hasUnreadPrivateMessages.isNotEmpty()) {
|
||||
// Filled mail icon to match sidebar style
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Email,
|
||||
contentDescription = "Unread private messages",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color(0xFFFF9500) // Orange to match private message theme
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.Group,
|
||||
contentDescription = when (selectedLocationChannel) {
|
||||
@@ -226,6 +200,7 @@ fun PeerCounter(
|
||||
tint = countColor
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
Text(
|
||||
text = "$peopleCount",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -290,7 +265,8 @@ fun ChatHeaderContent(
|
||||
selectedLocationChannel = selectedLocationChannel,
|
||||
geohashPeople = geohashPeople,
|
||||
onBackClick = onBackClick,
|
||||
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) }
|
||||
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) },
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
currentChannel != null -> {
|
||||
@@ -326,7 +302,8 @@ private fun PrivateChatHeader(
|
||||
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
|
||||
geohashPeople: List<GeoPerson>,
|
||||
onBackClick: () -> Unit,
|
||||
onToggleFavorite: () -> Unit
|
||||
onToggleFavorite: () -> Unit,
|
||||
viewModel: ChatViewModel
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
|
||||
@@ -345,12 +322,24 @@ private fun PrivateChatHeader(
|
||||
|
||||
// Compute title text: for NIP-17 chats show "#geohash/@username" (iOS parity)
|
||||
val titleText: String = if (isNostrDM) {
|
||||
val geohash = (selectedLocationChannel as? com.bitchat.android.geohash.ChannelID.Location)?.channel?.geohash
|
||||
val shortId = peerID.removePrefix("nostr_").removePrefix("nostr:")
|
||||
val person = geohashPeople.firstOrNull { it.id.startsWith(shortId, ignoreCase = true) }
|
||||
val baseName = person?.displayName?.substringBefore('#') ?: peerNicknames[peerID] ?: "unknown"
|
||||
val geoPart = geohash?.let { "#$it" } ?: "#geohash"
|
||||
"$geoPart/@$baseName"
|
||||
// For geohash DMs, get the actual source geohash and proper display name
|
||||
val (conversationGeohash, baseName) = try {
|
||||
val repoField = com.bitchat.android.ui.GeohashViewModel::class.java.getDeclaredField("repo")
|
||||
repoField.isAccessible = true
|
||||
val repo = repoField.get(viewModel.geohashViewModel) as com.bitchat.android.nostr.GeohashRepository
|
||||
val gh = repo.getConversationGeohash(peerID) ?: "geohash"
|
||||
val fullPubkey = com.bitchat.android.nostr.GeohashAliasRegistry.get(peerID) ?: ""
|
||||
val displayName = if (fullPubkey.isNotEmpty()) {
|
||||
repo.displayNameForGeohashConversation(fullPubkey, gh)
|
||||
} else {
|
||||
peerNicknames[peerID] ?: "unknown"
|
||||
}
|
||||
Pair(gh, displayName)
|
||||
} catch (e: Exception) {
|
||||
Pair("geohash", peerNicknames[peerID] ?: "unknown")
|
||||
}
|
||||
|
||||
"#$conversationGeohash/@$baseName"
|
||||
} else {
|
||||
// Prefer live mesh nickname; fallback to favorites nickname (supports 16-hex), finally short key
|
||||
peerNicknames[peerID] ?: run {
|
||||
@@ -563,6 +552,19 @@ private fun MainHeader(
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp)
|
||||
) {
|
||||
|
||||
// Unread private messages badge (click to open most recent DM)
|
||||
if (hasUnreadPrivateMessages.isNotEmpty()) {
|
||||
// Render icon directly to avoid symbol resolution issues
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Email,
|
||||
contentDescription = "Unread private messages",
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.clickable { viewModel.openLatestUnreadPrivateChat() },
|
||||
tint = Color(0xFFFF9500)
|
||||
)
|
||||
}
|
||||
|
||||
// Location channels button (matching iOS implementation)
|
||||
LocationChannelsButton(
|
||||
viewModel = viewModel,
|
||||
@@ -582,7 +584,6 @@ private fun MainHeader(
|
||||
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
|
||||
joinedChannels = joinedChannels,
|
||||
hasUnreadChannels = hasUnreadChannels,
|
||||
hasUnreadPrivateMessages = hasUnreadPrivateMessages,
|
||||
isConnected = isConnected,
|
||||
selectedLocationChannel = selectedLocationChannel,
|
||||
geohashPeople = geohashPeople,
|
||||
|
||||
@@ -124,6 +124,9 @@ class ChatState {
|
||||
// Geohash people state (iOS-compatible)
|
||||
private val _geohashPeople = MutableLiveData<List<GeoPerson>>(emptyList())
|
||||
val geohashPeople: LiveData<List<GeoPerson>> = _geohashPeople
|
||||
// For background thread updates by repositories/handlers in their own scopes
|
||||
val geohashPeopleMutable: MutableLiveData<List<GeoPerson>> get() = _geohashPeople
|
||||
|
||||
|
||||
private val _teleportedGeo = MutableLiveData<Set<String>>(emptySet())
|
||||
val teleportedGeo: LiveData<Set<String>> = _teleportedGeo
|
||||
@@ -155,6 +158,16 @@ class ChatState {
|
||||
fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value
|
||||
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value ?: emptySet()
|
||||
fun getJoinedChannelsValue() = _joinedChannels.value ?: emptySet()
|
||||
// Thread-safe posting helpers for background updates
|
||||
fun postGeohashPeople(people: List<GeoPerson>) {
|
||||
_geohashPeople.postValue(people)
|
||||
}
|
||||
|
||||
fun postGeohashParticipantCounts(counts: Map<String, Int>) {
|
||||
_geohashParticipantCounts.postValue(counts)
|
||||
}
|
||||
|
||||
|
||||
fun getCurrentChannelValue() = _currentChannel.value
|
||||
fun getChannelMessagesValue() = _channelMessages.value ?: emptyMap()
|
||||
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value ?: emptyMap()
|
||||
@@ -183,6 +196,10 @@ class ChatState {
|
||||
_connectedPeers.value = peers
|
||||
}
|
||||
|
||||
fun postTeleportedGeo(teleported: Set<String>) {
|
||||
_teleportedGeo.postValue(teleported)
|
||||
}
|
||||
|
||||
fun setNickname(nickname: String) {
|
||||
_nickname.value = nickname
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import com.bitchat.android.mesh.BluetoothMeshDelegate
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.nostr.NostrGeohashService
|
||||
|
||||
|
||||
import kotlinx.coroutines.launch
|
||||
import com.bitchat.android.util.NotificationIntervalManager
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -68,18 +69,20 @@ class ChatViewModel(
|
||||
getMeshService = { meshService }
|
||||
)
|
||||
|
||||
// Nostr and Geohash service - initialize singleton
|
||||
private val nostrGeohashService = NostrGeohashService.initialize(
|
||||
// New Geohash architecture ViewModel (replaces God object service usage in UI path)
|
||||
val geohashViewModel = GeohashViewModel(
|
||||
application = application,
|
||||
state = state,
|
||||
messageManager = messageManager,
|
||||
privateChatManager = privateChatManager,
|
||||
meshDelegateHandler = meshDelegateHandler,
|
||||
coroutineScope = viewModelScope,
|
||||
dataManager = dataManager,
|
||||
notificationManager = notificationManager
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
// Expose state through LiveData (maintaining the same interface)
|
||||
val messages: LiveData<List<BitchatMessage>> = state.messages
|
||||
val connectedPeers: LiveData<List<String>> = state.connectedPeers
|
||||
@@ -168,14 +171,12 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize location channel state
|
||||
nostrGeohashService.initializeLocationChannelState()
|
||||
// Initialize new geohash architecture
|
||||
geohashViewModel.initialize()
|
||||
|
||||
// Initialize favorites persistence service
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication())
|
||||
|
||||
// Initialize Nostr integration
|
||||
nostrGeohashService.initializeNostrIntegration()
|
||||
|
||||
// Ensure NostrTransport knows our mesh peer ID for embedded packets
|
||||
try {
|
||||
@@ -213,6 +214,46 @@ class ChatViewModel(
|
||||
meshService.sendBroadcastAnnounce()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure Nostr DM subscription for a geohash conversation key if known
|
||||
* Minimal-change approach: reflectively access GeohashViewModel internals to reuse pipeline
|
||||
*/
|
||||
private fun ensureGeohashDMSubscriptionIfNeeded(convKey: String) {
|
||||
try {
|
||||
val repoField = GeohashViewModel::class.java.getDeclaredField("repo")
|
||||
repoField.isAccessible = true
|
||||
val repo = repoField.get(geohashViewModel) as com.bitchat.android.nostr.GeohashRepository
|
||||
val gh = repo.getConversationGeohash(convKey)
|
||||
if (!gh.isNullOrEmpty()) {
|
||||
val subMgrField = GeohashViewModel::class.java.getDeclaredField("subscriptionManager")
|
||||
subMgrField.isAccessible = true
|
||||
val subMgr = subMgrField.get(geohashViewModel) as com.bitchat.android.nostr.NostrSubscriptionManager
|
||||
val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity(gh, getApplication())
|
||||
val subId = "geo-dm-$gh"
|
||||
val currentDmSubField = GeohashViewModel::class.java.getDeclaredField("currentDmSubId")
|
||||
currentDmSubField.isAccessible = true
|
||||
val currentId = currentDmSubField.get(geohashViewModel) as String?
|
||||
if (currentId != subId) {
|
||||
(currentId)?.let { subMgr.unsubscribe(it) }
|
||||
currentDmSubField.set(geohashViewModel, subId)
|
||||
subMgr.subscribeGiftWraps(
|
||||
pubkey = identity.publicKeyHex,
|
||||
sinceMs = System.currentTimeMillis() - 172800000L,
|
||||
id = subId,
|
||||
handler = { event ->
|
||||
val dmHandlerField = GeohashViewModel::class.java.getDeclaredField("dmHandler")
|
||||
dmHandlerField.isAccessible = true
|
||||
val dmHandler = dmHandlerField.get(geohashViewModel) as com.bitchat.android.nostr.NostrDirectMessageHandler
|
||||
dmHandler.onGiftWrap(event, gh, identity)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "ensureGeohashDMSubscriptionIfNeeded failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Channel Management (delegated)
|
||||
|
||||
fun joinChannel(channel: String, password: String? = null): Boolean {
|
||||
@@ -231,6 +272,11 @@ class ChatViewModel(
|
||||
// MARK: - Private Chat Management (delegated)
|
||||
|
||||
fun startPrivateChat(peerID: String) {
|
||||
// For geohash conversation keys, ensure DM subscription is active
|
||||
if (peerID.startsWith("nostr_")) {
|
||||
ensureGeohashDMSubscriptionIfNeeded(peerID)
|
||||
}
|
||||
|
||||
val success = privateChatManager.startPrivateChat(peerID, meshService)
|
||||
if (success) {
|
||||
// Notify notification manager about current private chat
|
||||
@@ -258,6 +304,66 @@ class ChatViewModel(
|
||||
// Clear mesh mention notifications since user is now back in mesh chat
|
||||
clearMeshMentionNotifications()
|
||||
}
|
||||
|
||||
// MARK: - Open Latest Unread Private Chat
|
||||
|
||||
fun openLatestUnreadPrivateChat() {
|
||||
try {
|
||||
val unreadKeys = state.getUnreadPrivateMessagesValue()
|
||||
if (unreadKeys.isEmpty()) return
|
||||
|
||||
val me = state.getNicknameValue() ?: meshService.myPeerID
|
||||
val chats = state.getPrivateChatsValue()
|
||||
|
||||
// Pick the latest incoming message among unread conversations
|
||||
var bestKey: String? = null
|
||||
var bestTime: Long = Long.MIN_VALUE
|
||||
|
||||
unreadKeys.forEach { key ->
|
||||
val list = chats[key]
|
||||
if (!list.isNullOrEmpty()) {
|
||||
// Prefer the latest incoming message (sender != me), fallback to last message
|
||||
val latestIncoming = list.lastOrNull { it.sender != me }
|
||||
val candidateTime = (latestIncoming ?: list.last()).timestamp.time
|
||||
if (candidateTime > bestTime) {
|
||||
bestTime = candidateTime
|
||||
bestKey = key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val targetKey = bestKey ?: unreadKeys.firstOrNull() ?: return
|
||||
|
||||
val openPeer: String = if (targetKey.startsWith("nostr_")) {
|
||||
// Use the exact conversation key for geohash DMs and ensure DM subscription
|
||||
ensureGeohashDMSubscriptionIfNeeded(targetKey)
|
||||
targetKey
|
||||
} else {
|
||||
// Resolve to a canonical mesh peer if needed
|
||||
val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
|
||||
selectedPeerID = targetKey,
|
||||
connectedPeers = state.getConnectedPeersValue(),
|
||||
meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey },
|
||||
meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true },
|
||||
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
|
||||
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
||||
)
|
||||
canonical ?: targetKey
|
||||
}
|
||||
|
||||
startPrivateChat(openPeer)
|
||||
|
||||
// If sidebar visible, hide it to focus on the private chat
|
||||
if (state.getShowSidebarValue()) {
|
||||
state.setShowSidebar(false)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "openLatestUnreadPrivateChat failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// END - Open Latest Unread Private Chat
|
||||
|
||||
|
||||
// MARK: - Message Sending
|
||||
|
||||
@@ -270,7 +376,7 @@ class ChatViewModel(
|
||||
commandProcessor.processCommand(content, meshService, meshService.myPeerID, { messageContent, mentions, channel ->
|
||||
if (selectedLocationForCommand is com.bitchat.android.geohash.ChannelID.Location) {
|
||||
// Route command-generated public messages via Nostr in geohash channels
|
||||
nostrGeohashService.sendGeohashMessage(
|
||||
geohashViewModel.sendGeohashMessage(
|
||||
messageContent,
|
||||
selectedLocationForCommand.channel,
|
||||
meshService.myPeerID,
|
||||
@@ -298,7 +404,7 @@ class ChatViewModel(
|
||||
connectedPeers = state.getConnectedPeersValue(),
|
||||
meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey },
|
||||
meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true },
|
||||
nostrPubHexForAlias = { alias -> nostrGeohashService.getNostrKeyMapping()[alias] },
|
||||
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
|
||||
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
||||
).also { canonical ->
|
||||
if (canonical != state.getSelectedPrivateChatPeerValue()) {
|
||||
@@ -323,7 +429,7 @@ class ChatViewModel(
|
||||
val selectedLocationChannel = state.selectedLocationChannel.value
|
||||
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
|
||||
// Send to geohash channel via Nostr ephemeral event
|
||||
nostrGeohashService.sendGeohashMessage(content, selectedLocationChannel.channel, meshService.myPeerID, state.getNicknameValue())
|
||||
geohashViewModel.sendGeohashMessage(content, selectedLocationChannel.channel, meshService.myPeerID, state.getNicknameValue())
|
||||
} else {
|
||||
// Send public/channel message via mesh
|
||||
val message = BitchatMessage(
|
||||
@@ -376,17 +482,36 @@ class ChatViewModel(
|
||||
Log.d("ChatViewModel", "toggleFavorite called for peerID: $peerID")
|
||||
privateChatManager.toggleFavorite(peerID)
|
||||
|
||||
// Persist relationship in FavoritesPersistenceService when we have Noise key
|
||||
// Persist relationship in FavoritesPersistenceService
|
||||
try {
|
||||
var noiseKey: ByteArray? = null
|
||||
var nickname: String = meshService.getPeerNicknames()[peerID] ?: peerID
|
||||
|
||||
// Case 1: Live mesh peer with known info
|
||||
val peerInfo = meshService.getPeerInfo(peerID)
|
||||
val noiseKey = peerInfo?.noisePublicKey
|
||||
val nickname = peerInfo?.nickname ?: (meshService.getPeerNicknames()[peerID] ?: peerID)
|
||||
if (peerInfo?.noisePublicKey != null) {
|
||||
noiseKey = peerInfo.noisePublicKey
|
||||
nickname = peerInfo.nickname
|
||||
} else {
|
||||
// Case 2: Offline favorite entry using 64-hex noise public key as peerID
|
||||
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||
try {
|
||||
noiseKey = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
// Prefer nickname from favorites store if available
|
||||
val rel = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey!!)
|
||||
if (rel != null) nickname = rel.peerNickname
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
if (noiseKey != null) {
|
||||
val isNowFavorite = dataManager.favoritePeers.contains(
|
||||
com.bitchat.android.mesh.PeerFingerprintManager.getInstance().getFingerprintForPeer(peerID) ?: ""
|
||||
)
|
||||
// Determine current favorite state from DataManager using fingerprint
|
||||
val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication())
|
||||
val fingerprint = identityManager.generateFingerprint(noiseKey!!)
|
||||
val isNowFavorite = dataManager.favoritePeers.contains(fingerprint)
|
||||
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateFavoriteStatus(
|
||||
noisePublicKey = noiseKey,
|
||||
noisePublicKey = noiseKey!!,
|
||||
nickname = nickname,
|
||||
isFavorite = isNowFavorite
|
||||
)
|
||||
@@ -596,7 +721,7 @@ class ChatViewModel(
|
||||
|
||||
// Clear Nostr/geohash state, keys, connections, and reinitialize from scratch
|
||||
try {
|
||||
nostrGeohashService.panicResetNostrAndGeohash()
|
||||
geohashViewModel.panicReset()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}")
|
||||
}
|
||||
@@ -638,10 +763,20 @@ class ChatViewModel(
|
||||
try {
|
||||
val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication())
|
||||
identityManager.clearIdentityData()
|
||||
Log.d(TAG, "✅ Cleared secure identity state")
|
||||
// Also clear secure values used by FavoritesPersistenceService (favorites + peerID index)
|
||||
try {
|
||||
identityManager.clearSecureValues("favorite_relationships", "favorite_peerid_index")
|
||||
} catch (_: Exception) { }
|
||||
Log.d(TAG, "✅ Cleared secure identity state and secure favorites store")
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "SecureIdentityStateManager not available or already cleared: ${e.message}")
|
||||
}
|
||||
|
||||
// Clear FavoritesPersistenceService persistent relationships
|
||||
try {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.clearAllFavorites()
|
||||
Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships")
|
||||
} catch (_: Exception) { }
|
||||
|
||||
Log.d(TAG, "✅ Cleared all cryptographic data")
|
||||
} catch (e: Exception) {
|
||||
@@ -653,48 +788,48 @@ class ChatViewModel(
|
||||
* Get participant count for a specific geohash (5-minute activity window)
|
||||
*/
|
||||
fun geohashParticipantCount(geohash: String): Int {
|
||||
return nostrGeohashService.geohashParticipantCount(geohash)
|
||||
return geohashViewModel.geohashParticipantCount(geohash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin sampling multiple geohashes for participant activity
|
||||
*/
|
||||
fun beginGeohashSampling(geohashes: List<String>) {
|
||||
nostrGeohashService.beginGeohashSampling(geohashes)
|
||||
geohashViewModel.beginGeohashSampling(geohashes)
|
||||
}
|
||||
|
||||
/**
|
||||
* End geohash sampling
|
||||
*/
|
||||
fun endGeohashSampling() {
|
||||
nostrGeohashService.endGeohashSampling()
|
||||
// No-op in refactored architecture; sampling subscriptions are short-lived
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a geohash person is teleported (iOS-compatible)
|
||||
*/
|
||||
fun isPersonTeleported(pubkeyHex: String): Boolean {
|
||||
return nostrGeohashService.isPersonTeleported(pubkeyHex)
|
||||
return geohashViewModel.isPersonTeleported(pubkeyHex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start geohash DM with pubkey hex (iOS-compatible)
|
||||
*/
|
||||
fun startGeohashDM(pubkeyHex: String) {
|
||||
nostrGeohashService.startGeohashDM(pubkeyHex) { convKey ->
|
||||
geohashViewModel.startGeohashDM(pubkeyHex) { convKey ->
|
||||
startPrivateChat(convKey)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) {
|
||||
nostrGeohashService.selectLocationChannel(channel)
|
||||
geohashViewModel.selectLocationChannel(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Block a user in geohash channels by their nickname
|
||||
*/
|
||||
fun blockUserInGeohash(targetNickname: String) {
|
||||
nostrGeohashService.blockUserInGeohash(targetNickname)
|
||||
geohashViewModel.blockUserInGeohash(targetNickname)
|
||||
}
|
||||
|
||||
// MARK: - Navigation Management
|
||||
@@ -767,6 +902,6 @@ class ChatViewModel(
|
||||
* Get consistent color for a Nostr pubkey (iOS-compatible)
|
||||
*/
|
||||
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
|
||||
return nostrGeohashService.colorForNostrPubkey(pubkeyHex, isDark)
|
||||
return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +293,7 @@ class CommandProcessor(
|
||||
val actionMessage = "* ${state.getNicknameValue() ?: "someone"} $verb $targetName $object_ *"
|
||||
|
||||
// If we're in a geohash location channel, don't add a local echo here.
|
||||
// NostrGeohashService.sendGeohashMessage() will add the local echo with proper metadata.
|
||||
// GeohashViewModel.sendGeohashMessage() will add the local echo with proper metadata.
|
||||
val isInLocationChannel = state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location
|
||||
|
||||
// Send as regular message
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitchat.android.nostr.GeohashMessageHandler
|
||||
import com.bitchat.android.nostr.GeohashRepository
|
||||
import com.bitchat.android.nostr.NostrDirectMessageHandler
|
||||
import com.bitchat.android.nostr.NostrIdentityBridge
|
||||
import com.bitchat.android.nostr.NostrProtocol
|
||||
import com.bitchat.android.nostr.NostrRelayManager
|
||||
import com.bitchat.android.nostr.NostrSubscriptionManager
|
||||
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
|
||||
class GeohashViewModel(
|
||||
application: Application,
|
||||
private val state: ChatState,
|
||||
private val messageManager: MessageManager,
|
||||
private val privateChatManager: PrivateChatManager,
|
||||
private val meshDelegateHandler: MeshDelegateHandler,
|
||||
private val dataManager: DataManager,
|
||||
private val notificationManager: NotificationManager
|
||||
) : AndroidViewModel(application) {
|
||||
|
||||
companion object { private const val TAG = "GeohashViewModel" }
|
||||
|
||||
private val repo = GeohashRepository(application, state)
|
||||
private val subscriptionManager = NostrSubscriptionManager(application, viewModelScope)
|
||||
private val geohashMessageHandler = GeohashMessageHandler(application, state, messageManager, repo, viewModelScope)
|
||||
private val dmHandler = NostrDirectMessageHandler(application, state, privateChatManager, meshDelegateHandler, viewModelScope, repo)
|
||||
|
||||
private var currentGeohashSubId: String? = null
|
||||
private var currentDmSubId: String? = null
|
||||
private var geoTimer: Job? = null
|
||||
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
|
||||
|
||||
val geohashPeople: LiveData<List<GeoPerson>> = state.geohashPeople
|
||||
val geohashParticipantCounts: LiveData<Map<String, Int>> = state.geohashParticipantCounts
|
||||
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
|
||||
|
||||
fun initialize() {
|
||||
subscriptionManager.connect()
|
||||
val identity = NostrIdentityBridge.getCurrentNostrIdentity(getApplication())
|
||||
if (identity != null) {
|
||||
// Use global chat-messages only for full account DMs (mesh context). For geohash DMs, subscribe per-geohash below.
|
||||
subscriptionManager.subscribeGiftWraps(
|
||||
pubkey = identity.publicKeyHex,
|
||||
sinceMs = System.currentTimeMillis() - 172800000L,
|
||||
id = "chat-messages",
|
||||
handler = { event -> dmHandler.onGiftWrap(event, "", identity) } // geohash="" means global account DM (not geohash identity)
|
||||
)
|
||||
}
|
||||
try {
|
||||
locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
|
||||
locationChannelManager?.selectedChannel?.observeForever { channel ->
|
||||
state.setSelectedLocationChannel(channel)
|
||||
switchLocationChannel(channel)
|
||||
}
|
||||
locationChannelManager?.teleported?.observeForever { teleported ->
|
||||
state.setIsTeleported(teleported)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to initialize location channel state: ${e.message}")
|
||||
state.setSelectedLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh)
|
||||
state.setIsTeleported(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun panicReset() {
|
||||
repo.clearAll()
|
||||
subscriptionManager.disconnect()
|
||||
currentGeohashSubId = null
|
||||
currentDmSubId = null
|
||||
geoTimer?.cancel()
|
||||
geoTimer = null
|
||||
try { NostrIdentityBridge.clearAllAssociations(getApplication()) } catch (_: Exception) {}
|
||||
initialize()
|
||||
}
|
||||
|
||||
fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val tempId = "temp_${System.currentTimeMillis()}_${kotlin.random.Random.nextInt(1000)}"
|
||||
val pow = PoWPreferenceManager.getCurrentSettings()
|
||||
val localMsg = com.bitchat.android.model.BitchatMessage(
|
||||
id = tempId,
|
||||
sender = nickname ?: myPeerID,
|
||||
content = content,
|
||||
timestamp = Date(),
|
||||
isRelay = false,
|
||||
senderPeerID = "geohash:${channel.geohash}",
|
||||
channel = "#${channel.geohash}",
|
||||
powDifficulty = if (pow.enabled) pow.difficulty else null
|
||||
)
|
||||
messageManager.addChannelMessage("geo:${channel.geohash}", localMsg)
|
||||
if (pow.enabled && pow.difficulty > 0) {
|
||||
com.bitchat.android.ui.PoWMiningTracker.startMiningMessage(tempId)
|
||||
}
|
||||
val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication())
|
||||
val teleported = state.isTeleported.value ?: false
|
||||
val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported)
|
||||
val relayManager = NostrRelayManager.getInstance(getApplication())
|
||||
relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send geohash message: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun beginGeohashSampling(geohashes: List<String>) {
|
||||
if (geohashes.isEmpty()) return
|
||||
Log.d(TAG, "🌍 Beginning geohash sampling for ${geohashes.size} geohashes")
|
||||
viewModelScope.launch {
|
||||
geohashes.forEach { geohash ->
|
||||
subscriptionManager.subscribeGeohash(
|
||||
geohash = geohash,
|
||||
sinceMs = System.currentTimeMillis() - 86400000L,
|
||||
limit = 200,
|
||||
id = "sampling-$geohash",
|
||||
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun endGeohashSampling() { Log.d(TAG, "🌍 Ending geohash sampling") }
|
||||
fun geohashParticipantCount(geohash: String): Int = repo.geohashParticipantCount(geohash)
|
||||
fun isPersonTeleported(pubkeyHex: String): Boolean = repo.isPersonTeleported(pubkeyHex)
|
||||
|
||||
fun startGeohashDM(pubkeyHex: String, onStartPrivateChat: (String) -> Unit) {
|
||||
val convKey = "nostr_${pubkeyHex.take(16)}"
|
||||
repo.putNostrKeyMapping(convKey, pubkeyHex)
|
||||
onStartPrivateChat(convKey)
|
||||
Log.d(TAG, "🗨️ Started geohash DM with $pubkeyHex -> $convKey")
|
||||
}
|
||||
|
||||
fun getNostrKeyMapping(): Map<String, String> = repo.getNostrKeyMapping()
|
||||
|
||||
fun blockUserInGeohash(targetNickname: String) {
|
||||
val pubkey = repo.findPubkeyByNickname(targetNickname)
|
||||
if (pubkey != null) {
|
||||
dataManager.addGeohashBlockedUser(pubkey)
|
||||
val sysMsg = com.bitchat.android.model.BitchatMessage(
|
||||
sender = "system",
|
||||
content = "blocked $targetNickname in geohash channels",
|
||||
timestamp = Date(),
|
||||
isRelay = false
|
||||
)
|
||||
fun startGeohashDM(pubkeyHex: String, onStartPrivateChat: (String) -> Unit) {
|
||||
val convKey = "nostr_${'$'}{pubkeyHex.take(16)}"
|
||||
repo.putNostrKeyMapping(convKey, pubkeyHex)
|
||||
// Record the conversation's geohash using the currently selected location channel (if any)
|
||||
val current = state.selectedLocationChannel.value
|
||||
val gh = (current as? com.bitchat.android.geohash.ChannelID.Location)?.channel?.geohash
|
||||
if (!gh.isNullOrEmpty()) {
|
||||
repo.setConversationGeohash(convKey, gh)
|
||||
com.bitchat.android.nostr.GeohashConversationRegistry.set(convKey, gh)
|
||||
}
|
||||
onStartPrivateChat(convKey)
|
||||
Log.d(TAG, "🗨️ Started geohash DM with ${'$'}pubkeyHex -> ${'$'}convKey (geohash=${'$'}gh)")
|
||||
}
|
||||
|
||||
messageManager.addMessage(sysMsg)
|
||||
} else {
|
||||
val sysMsg = com.bitchat.android.model.BitchatMessage(
|
||||
sender = "system",
|
||||
content = "user '$targetNickname' not found in current geohash",
|
||||
timestamp = Date(),
|
||||
isRelay = false
|
||||
)
|
||||
messageManager.addMessage(sysMsg)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) {
|
||||
locationChannelManager?.select(channel) ?: run { Log.w(TAG, "Cannot select location channel - not initialized") }
|
||||
}
|
||||
|
||||
fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex)
|
||||
|
||||
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
|
||||
val seed = "nostr:${pubkeyHex.lowercase()}"
|
||||
return colorForPeerSeed(seed, isDark).copy()
|
||||
}
|
||||
|
||||
private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) {
|
||||
geoTimer?.cancel(); geoTimer = null
|
||||
currentGeohashSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashSubId = null }
|
||||
currentDmSubId?.let { subscriptionManager.unsubscribe(it); currentDmSubId = null }
|
||||
|
||||
when (channel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Mesh -> {
|
||||
Log.d(TAG, "📡 Switched to mesh channel")
|
||||
repo.setCurrentGeohash(null)
|
||||
notificationManager.setCurrentGeohash(null)
|
||||
notificationManager.clearMeshMentionNotifications()
|
||||
repo.refreshGeohashPeople()
|
||||
}
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
Log.d(TAG, "📍 Switching to geohash channel: ${channel.channel.geohash}")
|
||||
repo.setCurrentGeohash(channel.channel.geohash)
|
||||
notificationManager.setCurrentGeohash(channel.channel.geohash)
|
||||
notificationManager.clearNotificationsForGeohash(channel.channel.geohash)
|
||||
try { messageManager.clearChannelUnreadCount("geo:${channel.channel.geohash}") } catch (_: Exception) { }
|
||||
|
||||
try {
|
||||
val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication())
|
||||
repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date())
|
||||
val teleported = state.isTeleported.value ?: false
|
||||
if (teleported) repo.markTeleported(identity.publicKeyHex)
|
||||
} catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") }
|
||||
|
||||
startGeoParticipantsTimer()
|
||||
|
||||
viewModelScope.launch {
|
||||
val geohash = channel.channel.geohash
|
||||
val subId = "geohash-$geohash"; currentGeohashSubId = subId
|
||||
subscriptionManager.subscribeGeohash(
|
||||
geohash = geohash,
|
||||
sinceMs = System.currentTimeMillis() - 3600000L,
|
||||
limit = 200,
|
||||
id = subId,
|
||||
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
|
||||
)
|
||||
val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
|
||||
val dmSubId = "geo-dm-$geohash"; currentDmSubId = dmSubId
|
||||
subscriptionManager.subscribeGiftWraps(
|
||||
pubkey = dmIdentity.publicKeyHex,
|
||||
sinceMs = System.currentTimeMillis() - 172800000L,
|
||||
id = dmSubId,
|
||||
handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) }
|
||||
)
|
||||
// Also register alias in global registry for routing convenience
|
||||
com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex)
|
||||
}
|
||||
}
|
||||
null -> {
|
||||
Log.d(TAG, "📡 No channel selected")
|
||||
repo.setCurrentGeohash(null)
|
||||
repo.refreshGeohashPeople()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startGeoParticipantsTimer() {
|
||||
geoTimer = viewModelScope.launch {
|
||||
while (repo.getCurrentGeohash() != null) {
|
||||
delay(30000)
|
||||
repo.refreshGeohashPeople()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,16 +107,21 @@ class MeshDelegateHandler(
|
||||
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
|
||||
// Use GeohashAliasRegistry for geohash aliases, but for mesh favorites, derive from favorites mapping
|
||||
if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(alias)) {
|
||||
com.bitchat.android.nostr.GeohashAliasRegistry.get(alias)
|
||||
} else {
|
||||
// Best-effort: derive pub hex from favorites mapping for mesh nostr_ aliases
|
||||
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) }
|
||||
}?.takeIf { it.startsWith(prefix, ignoreCase = true) }
|
||||
}
|
||||
},
|
||||
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
||||
)
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.bitchat.android.ui
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.mesh.PeerFingerprintManager
|
||||
import java.security.MessageDigest
|
||||
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import java.util.*
|
||||
import android.util.Log
|
||||
@@ -123,21 +125,40 @@ class PrivateChatManager(
|
||||
}
|
||||
|
||||
fun toggleFavorite(peerID: String) {
|
||||
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return
|
||||
var fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
|
||||
|
||||
// Fallback: if this looks like a 64-hex Noise public key (offline favorite entry),
|
||||
// compute a synthetic fingerprint (SHA-256 of public key) to allow unfollowing offline peers
|
||||
if (fingerprint == null && peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||
try {
|
||||
val pubBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
val digest = java.security.MessageDigest.getInstance("SHA-256")
|
||||
val fpBytes = digest.digest(pubBytes)
|
||||
fingerprint = fpBytes.joinToString("") { "%02x".format(it) }
|
||||
Log.d(TAG, "Computed fingerprint from noise key hex for offline toggle: $fingerprint")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to compute fingerprint from noise key hex: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
if (fingerprint == null) {
|
||||
Log.w(TAG, "toggleFavorite: no fingerprint for peerID=$peerID; ignoring toggle")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "toggleFavorite called for peerID: $peerID, fingerprint: $fingerprint")
|
||||
|
||||
val wasFavorite = dataManager.isFavorite(fingerprint)
|
||||
val wasFavorite = dataManager.isFavorite(fingerprint!!)
|
||||
Log.d(TAG, "Current favorite status: $wasFavorite")
|
||||
|
||||
val currentFavorites = state.getFavoritePeersValue()
|
||||
Log.d(TAG, "Current UI state favorites: $currentFavorites")
|
||||
|
||||
if (wasFavorite) {
|
||||
dataManager.removeFavorite(fingerprint)
|
||||
dataManager.removeFavorite(fingerprint!!)
|
||||
Log.d(TAG, "Removed from favorites: $fingerprint")
|
||||
} else {
|
||||
dataManager.addFavorite(fingerprint)
|
||||
dataManager.addFavorite(fingerprint!!)
|
||||
Log.d(TAG, "Added to favorites: $fingerprint")
|
||||
}
|
||||
|
||||
@@ -149,6 +170,7 @@ class PrivateChatManager(
|
||||
Log.d(TAG, "All peer fingerprints: ${fingerprintManager.getAllPeerFingerprints()}")
|
||||
}
|
||||
|
||||
|
||||
fun isFavorite(peerID: String): Boolean {
|
||||
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return false
|
||||
val isFav = dataManager.isFavorite(fingerprint)
|
||||
|
||||
@@ -413,6 +413,22 @@ fun PeopleSection(
|
||||
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
|
||||
if (isMappedToConnected) return@forEach
|
||||
|
||||
// Resolve potential Nostr conversation key for this favorite (for unread detection)
|
||||
val nostrConvKey: String? = try {
|
||||
val npubOrHex = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey)
|
||||
if (npubOrHex != null) {
|
||||
val hex = if (npubOrHex.startsWith("npub")) {
|
||||
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex)
|
||||
if (hrp == "npub") data.joinToString("") { "%02x".format(it) } else null
|
||||
} else {
|
||||
npubOrHex.lowercase()
|
||||
}
|
||||
hex?.let { "nostr_${it.take(16)}" }
|
||||
} else null
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
val hasUnread = hasUnreadPrivateMessages.contains(favPeerID) || (nostrConvKey != null && hasUnreadPrivateMessages.contains(nostrConvKey))
|
||||
|
||||
// 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
|
||||
@@ -420,13 +436,20 @@ fun PeopleSection(
|
||||
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
|
||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
||||
|
||||
// Compute unreadCount from either noise conversation or Nostr conversation
|
||||
val unreadCount = (
|
||||
privateChats[favPeerID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID) } ?: 0
|
||||
) + (
|
||||
if (nostrConvKey != null) privateChats[nostrConvKey]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(nostrConvKey) } ?: 0 else 0
|
||||
)
|
||||
|
||||
PeerItem(
|
||||
peerID = favPeerID,
|
||||
displayName = dn,
|
||||
isDirect = false,
|
||||
isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
|
||||
isFavorite = true,
|
||||
hasUnreadDM = hasUnreadPrivateMessages.contains(favPeerID),
|
||||
hasUnreadDM = hasUnread,
|
||||
colorScheme = colorScheme,
|
||||
viewModel = viewModel,
|
||||
onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) },
|
||||
@@ -434,21 +457,23 @@ fun PeopleSection(
|
||||
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,
|
||||
unreadCount = if (unreadCount > 0) unreadCount else if (hasUnread) 1 else 0,
|
||||
showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null),
|
||||
showHashSuffix = showHash
|
||||
)
|
||||
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
|
||||
// NOTE: Do NOT append Nostr-only (nostr_*) conversations to the mesh people list.
|
||||
// Geohash DMs should appear in the GeohashPeople list for the active geohash, not in mesh offline contacts.
|
||||
// We intentionally remove previously-added behavior that mixed geohash DMs into mesh sidebar.
|
||||
// If you need to surface non-geohash offline mesh conversations in the future, do it here for 64-hex noise IDs only.
|
||||
/*
|
||||
val alreadyShownIds = connectedIds + appendedOfflineIds
|
||||
privateChats.keys
|
||||
.filter { key ->
|
||||
(key.startsWith("nostr_") || hex64Regex.matches(key)) &&
|
||||
// Only include 64-hex noise IDs (mesh identities); exclude any nostr_* aliases
|
||||
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) }
|
||||
@@ -460,24 +485,27 @@ fun PeopleSection(
|
||||
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
|
||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
||||
|
||||
PeerItem(
|
||||
peerID = convKey,
|
||||
displayName = dn,
|
||||
isDirect = false,
|
||||
isSelected = convKey == selectedPrivatePeer,
|
||||
isFavorite = false,
|
||||
hasUnreadDM = hasUnreadPrivateMessages.contains(convKey),
|
||||
colorScheme = colorScheme,
|
||||
viewModel = viewModel,
|
||||
PeerItem(
|
||||
peerID = convKey,
|
||||
displayName = dn,
|
||||
isDirect = false,
|
||||
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,
|
||||
showNostrGlobe = false,
|
||||
showHashSuffix = showHash
|
||||
)
|
||||
}
|
||||
*/
|
||||
// End intentional removal
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,6 +566,15 @@ private fun PeerItem(
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color(0xFF9C27B0) // Purple
|
||||
)
|
||||
} else if (!isDirect && isFavorite) {
|
||||
// Offline favorited user: show outlined circle icon
|
||||
Icon(
|
||||
//painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_offline_favorite),
|
||||
imageVector = Icons.Outlined.Circle,
|
||||
contentDescription = "Offline favorite",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color.Gray
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = if (isDirect) Icons.Outlined.SettingsInputAntenna else Icons.Filled.Route,
|
||||
|
||||
Reference in New Issue
Block a user