diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index b45023e1..39d73a67 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -598,36 +598,7 @@ class MainActivity : ComponentActivity() { PoWPreferenceManager.init(this@MainActivity) Log.d("MainActivity", "PoW preferences initialized") - // Initialize Location Notes Counter (iOS parity) - com.bitchat.android.nostr.LocationNotesCounter.initialize( - relayManager = { com.bitchat.android.nostr.NostrRelayManager.getInstance(this@MainActivity) }, - subscribe = { filter, id, handler -> - // CRITICAL FIX: Extract geohash properly from filter (not from debug string!) - val geohashFromFilter = filter.getGeohash() ?: run { - Log.e("MainActivity", "Cannot extract geohash from filter for location notes counter") - "" - } - - if (geohashFromFilter.isNotEmpty()) { - com.bitchat.android.nostr.NostrRelayManager.getInstance(this@MainActivity).subscribeForGeohash( - geohash = geohashFromFilter, - filter = filter, - id = id, - handler = handler, - includeDefaults = true, - nRelays = 5 - ) - } else { - id - } - }, - unsubscribe = { id -> - com.bitchat.android.nostr.NostrRelayManager.getInstance(this@MainActivity).unsubscribe(id) - } - ) - Log.d("MainActivity", "Location Notes Counter initialized") - - // CRITICAL FIX: Initialize Location Notes Manager (iOS parity) + // SIMPLIFIED: Initialize Location Notes Manager only (no separate counter) com.bitchat.android.nostr.LocationNotesManager.getInstance().initialize( relayManager = { com.bitchat.android.nostr.NostrRelayManager.getInstance(this@MainActivity) }, subscribe = { filter, id, handler -> diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesCounter.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesCounter.kt deleted file mode 100644 index 2f66f4b7..00000000 --- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesCounter.kt +++ /dev/null @@ -1,187 +0,0 @@ -package com.bitchat.android.nostr - -import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import kotlinx.coroutines.* - -/** - * Lightweight counter for location notes - * Singleton manager for counting unique notes per geohash - * iOS-compatible implementation - */ -object LocationNotesCounter { - - private const val TAG = "LocationNotesCounter" - - // Published state - private val _geohash = MutableLiveData(null) - val geohash: LiveData = _geohash - - private val _count = MutableLiveData(0) - val count: LiveData = _count - - private val _initialLoadComplete = MutableLiveData(false) - val initialLoadComplete: LiveData = _initialLoadComplete - - private val _relayAvailable = MutableLiveData(false) - val relayAvailable: LiveData = _relayAvailable - - // Private state - private var subscriptionID: String? = null - private val eventIDs = mutableSetOf() // For deduplication - - // Dependencies (injected) - private var relayLookup: (() -> NostrRelayManager)? = null - private var subscribeFunc: ((NostrFilter, String, (NostrEvent) -> Unit) -> String)? = null - private var unsubscribeFunc: ((String) -> Unit)? = null - - /** - * Initialize dependencies - */ - fun initialize( - relayManager: () -> NostrRelayManager, - subscribe: (NostrFilter, String, (NostrEvent) -> Unit) -> String, - unsubscribe: (String) -> Unit - ) { - this.relayLookup = relayManager - this.subscribeFunc = subscribe - this.unsubscribeFunc = unsubscribe - } - - /** - * Subscribe to count notes for a specific geohash - * iOS: Validates building-level precision (8 chars) and checks for relay availability - */ - fun subscribe(geohash: String) { - val normalized = geohash.lowercase() - - // Skip if already subscribed to this geohash - if (_geohash.value == normalized && subscriptionID != null) { - Log.d(TAG, "Already subscribed to geohash: $normalized") - return - } - - // Validate geohash (building-level precision: 8 chars) - matches iOS - if (!isValidBuildingGeohash(normalized)) { - Log.w(TAG, "LocationNotesCounter: rejecting invalid geohash '$normalized' (expected 8 valid base32 chars)") - return - } - - Log.d(TAG, "Subscribing to count notes for geohash: $normalized") - - // Unsubscribe previous without clearing count to avoid flicker (iOS pattern) - subscriptionID?.let { subId -> - unsubscribeFunc?.invoke(subId) - } - subscriptionID = null - - // Reset state - _geohash.value = normalized - eventIDs.clear() - _initialLoadComplete.value = false - _relayAvailable.value = true - - // Get geo-specific relays (iOS pattern: dependencies.relayLookup(norm, TransportConfig.nostrGeoRelayCount)) - val relays = try { - com.bitchat.android.nostr.RelayDirectory.closestRelaysForGeohash(normalized, 5) - } catch (e: Exception) { - Log.e(TAG, "Failed to lookup relays for geohash $normalized: ${e.message}") - emptyList() - } - - // Check if we have relays (iOS pattern: guard !relays.isEmpty()) - if (relays.isEmpty()) { - Log.w(TAG, "LocationNotesCounter: no geo relays for geohash=$normalized") - _relayAvailable.value = false - _initialLoadComplete.value = true - _count.value = 0 - return - } - - val subscribe = subscribeFunc - if (subscribe == null) { - Log.e(TAG, "Cannot subscribe - subscribe function not initialized") - return - } - - val filter = NostrFilter.geohashNotes( - geohash = normalized, - since = null, - limit = 200 // Count up to 200 recent notes - ) - - // iOS format: "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))" - val subId = "locnotes-count-$normalized-${java.util.UUID.randomUUID().toString().take(6)}" - - subscriptionID = subscribe(filter, subId) { event -> - handleEvent(event, normalized) - } - - // Mark initial load complete after brief delay - kotlinx.coroutines.MainScope().launch { - kotlinx.coroutines.delay(1500) - if (!_initialLoadComplete.value!!) { - _initialLoadComplete.value = true - Log.d(TAG, "Initial count load complete: ${_count.value} notes") - } - } - } - - /** - * Validate building-level geohash (precision 8) - matches iOS Geohash.isValidBuildingGeohash - */ - private fun isValidBuildingGeohash(geohash: String): Boolean { - if (geohash.length != 8) return false - val base32Chars = "0123456789bcdefghjkmnpqrstuvwxyz" - return geohash.all { it in base32Chars } - } - - /** - * Handle incoming event - */ - private fun handleEvent(event: NostrEvent, expectedGeohash: String) { - // Validate event - if (event.kind != NostrKind.TEXT_NOTE) { - return - } - - // Check for geohash tag - val geohashTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" } - if (geohashTag == null) { - return - } - - // Check if matches expected geohash - val eventGeohash = geohashTag[1] - if (eventGeohash != expectedGeohash) { - return - } - - // Deduplicate and count - if (!eventIDs.contains(event.id)) { - eventIDs.add(event.id) - _count.value = eventIDs.size - - if (!_initialLoadComplete.value!!) { - _initialLoadComplete.value = true - } - } - } - - /** - * Cancel subscription - */ - fun cancel() { - subscriptionID?.let { subId -> - Log.d(TAG, "🚫 Canceling counter subscription: $subId") - unsubscribeFunc?.invoke(subId) - subscriptionID = null - } - - _geohash.value = null - _count.value = 0 - eventIDs.clear() - _initialLoadComplete.value = false - } -} diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 60570b01..94d8db87 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -29,6 +29,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitchat.android.core.ui.utils.singleOrTripleClickable import com.bitchat.android.geohash.LocationChannelManager.PermissionState +import androidx.compose.foundation.Canvas +import androidx.compose.ui.geometry.Offset /** * Header components for ChatScreen @@ -54,23 +56,27 @@ fun isFavoriteReactive( } @Composable -fun TorStatusIcon( +fun TorStatusDot( modifier: Modifier = Modifier ) { val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState() if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) { - val cableColor = when { - torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500) - torStatus.running && torStatus.bootstrapPercent >= 100 -> Color(0xFF00C851) - else -> Color.Red + val dotColor = when { + torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500) // Orange - bootstrapping + torStatus.running && torStatus.bootstrapPercent >= 100 -> Color(0xFF00C851) // Green - connected + else -> Color.Red // Red - error/disconnected + } + Canvas( + modifier = modifier + ) { + val radius = size.minDimension / 2 + drawCircle( + color = dotColor, + radius = radius, + center = Offset(size.width / 2, size.height / 2) + ) } - Icon( - imageVector = Icons.Outlined.Cable, - contentDescription = stringResource(R.string.cd_tor_status), - modifier = modifier, - tint = cableColor - ) } } @@ -156,6 +162,10 @@ fun NicknameEditor( .widthIn(max = 120.dp) .horizontalScroll(scrollState) ) + + // Tor status dot - constant distance from nickname end + Spacer(modifier = Modifier.width(6.dp)) + TorStatusDot(modifier = Modifier.size(6.dp)) } } @@ -530,9 +540,10 @@ private fun MainHeader( val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) } val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList()) - // Location notes counter (iOS parity) - val notesCounter = remember { com.bitchat.android.nostr.LocationNotesCounter } - val notesCount by notesCounter.count.observeAsState(0) + // SIMPLIFIED: Get notes count directly from LocationNotesManager (no separate counter) + val notesManager = remember { com.bitchat.android.nostr.LocationNotesManager.getInstance() } + val notes by notesManager.notes.observeAsState(emptyList()) + val notesCount = notes.size // Location channel manager for permission state val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) } @@ -632,9 +643,6 @@ private fun MainHeader( } } } - - // Tor status cable icon when Tor is enabled - TorStatusIcon(modifier = Modifier.size(14.dp)) // PoW status indicator PoWStatusIndicator( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 77aeb03e..684d783b 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -148,8 +148,8 @@ class ChatViewModel( } } - // Initialize location notes counter subscription management (iOS parity) - initializeLocationNotesCounterSubscription() + // SIMPLIFIED: Initialize location notes manager subscription (no separate counter) + initializeLocationNotesManagerSubscription() } fun cancelMediaSend(messageId: String) { @@ -595,24 +595,23 @@ class ChatViewModel( } /** - * Initialize location notes counter subscription management (iOS parity) + * SIMPLIFIED: Initialize location notes manager subscription (no separate counter) * Subscribes/unsubscribes based on channel selection and location permission */ - private fun initializeLocationNotesCounterSubscription() { + private fun initializeLocationNotesManagerSubscription() { viewModelScope.launch { // Observe channel changes selectedLocationChannel.observeForever { channel -> - updateNotesCounterSubscription(channel) + updateLocationNotesSubscription(channel) } } } /** - * Update notes counter subscription based on current channel and location permission + * SIMPLIFIED: Update location notes subscription based on current channel and location permission * iOS pattern: Subscribe when in mesh mode and location is authorized - * ENHANCEMENT: Also subscribe LocationNotesManager to load actual notes in background */ - private fun updateNotesCounterSubscription(channel: com.bitchat.android.geohash.ChannelID?) { + private fun updateLocationNotesSubscription(channel: com.bitchat.android.geohash.ChannelID?) { try { val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) val permissionState = locationManager.permissionState.value @@ -620,9 +619,9 @@ class ChatViewModel( when (channel) { is com.bitchat.android.geohash.ChannelID.Mesh -> { - // Mesh mode: subscribe to block-level geohash notes if location authorized + // Mesh mode: subscribe to building-level geohash notes if location authorized if (locationPermissionGranted) { - // Refresh location to get current block geohash + // Refresh location to get current building geohash locationManager.refreshChannels() // Get building-level geohash (precision 8, same as iOS) @@ -632,25 +631,19 @@ class ChatViewModel( if (buildingGeohash != null) { Log.d(TAG, "📍 Subscribing to location notes for building geohash: $buildingGeohash") - // Subscribe counter for badge - com.bitchat.android.nostr.LocationNotesCounter.subscribe(buildingGeohash) - // ALSO subscribe manager to load actual notes in background com.bitchat.android.nostr.LocationNotesManager.getInstance().setGeohash(buildingGeohash) } else { - // Keep existing subscription if we had one (avoid flicker) - if (com.bitchat.android.nostr.LocationNotesCounter.geohash.value == null) { - com.bitchat.android.nostr.LocationNotesCounter.cancel() + // Cancel if no building geohash available + if (com.bitchat.android.nostr.LocationNotesManager.getInstance().geohash.value == null) { com.bitchat.android.nostr.LocationNotesManager.getInstance().cancel() } } } else { - com.bitchat.android.nostr.LocationNotesCounter.cancel() com.bitchat.android.nostr.LocationNotesManager.getInstance().cancel() } } is com.bitchat.android.geohash.ChannelID.Location -> { - // Location channel mode: cancel counter (only show in mesh mode) - com.bitchat.android.nostr.LocationNotesCounter.cancel() + // Location channel mode: cancel (only show in mesh mode) com.bitchat.android.nostr.LocationNotesManager.getInstance().cancel() } null -> { @@ -661,7 +654,7 @@ class ChatViewModel( } } } catch (e: Exception) { - Log.w(TAG, "updateNotesCounterSubscription failed: ${e.message}") + Log.w(TAG, "updateLocationNotesSubscription failed: ${e.message}") } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt index f52a8dab..715ce90a 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -24,7 +24,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.LocationChannelManager -import com.bitchat.android.nostr.LocationNotesCounter import com.bitchat.android.nostr.LocationNotesManager import java.text.SimpleDateFormat import java.util.* @@ -52,16 +51,17 @@ fun LocationNotesSheet( // Managers val notesManager = remember { LocationNotesManager.getInstance() } - val counter = remember { LocationNotesCounter } val locationManager = remember { LocationChannelManager.getInstance(context) } // State val notes by notesManager.notes.observeAsState(emptyList()) val state by notesManager.state.observeAsState(LocationNotesManager.State.IDLE) val errorMessage by notesManager.errorMessage.observeAsState() - val count by counter.count.observeAsState(0) val initialLoadComplete by notesManager.initialLoadComplete.observeAsState(false) + // SIMPLIFIED: Get count directly from notes list (no separate counter needed) + val count = notes.size + // Get location name (building or block) - matches iOS locationNames lookup val locationNames by locationManager.locationNames.observeAsState(emptyMap()) val displayLocationName = locationNames[GeohashChannelLevel.BUILDING]?.takeIf { it.isNotEmpty() } @@ -77,7 +77,6 @@ fun LocationNotesSheet( // Effect to set geohash when sheet opens LaunchedEffect(geohash) { notesManager.setGeohash(geohash) - counter.subscribe(geohash) } // Cleanup when sheet closes