From 4d509ffda517ff1016e4a5664fd350b91e7c5985 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 17 Oct 2025 00:55:50 +0200 Subject: [PATCH] try 1 --- .../java/com/bitchat/android/MainActivity.kt | 58 +++ .../android/geohash/LocationChannel.kt | 1 + .../android/nostr/LocationNotesCounter.kt | 157 +++++++ .../android/nostr/LocationNotesManager.kt | 386 ++++++++++++++++ .../com/bitchat/android/nostr/NostrFilter.kt | 12 + .../bitchat/android/nostr/NostrProtocol.kt | 28 ++ .../java/com/bitchat/android/ui/ChatHeader.kt | 32 +- .../java/com/bitchat/android/ui/ChatScreen.kt | 77 +++- .../com/bitchat/android/ui/ChatViewModel.kt | 67 +++ .../android/ui/LocationChannelsSheet.kt | 4 +- .../bitchat/android/ui/LocationNotesSheet.kt | 433 ++++++++++++++++++ app/src/main/res/values/strings.xml | 1 + 12 files changed, 1251 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/nostr/LocationNotesCounter.kt create mode 100644 app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index f35a403c..9da01527 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -598,6 +598,64 @@ 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 -> + // Use subscribeForGeohash for location notes (finds geo-local relays) + val geohashFromFilter = filter.getDebugDescription().substringAfter("#g=").substringBefore(")").substringBefore(",") + com.bitchat.android.nostr.NostrRelayManager.getInstance(this@MainActivity).subscribeForGeohash( + geohash = geohashFromFilter, + filter = filter, + id = id, + handler = handler, + includeDefaults = true, + nRelays = 5 + ) + }, + 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) + com.bitchat.android.nostr.LocationNotesManager.getInstance().initialize( + relayManager = { com.bitchat.android.nostr.NostrRelayManager.getInstance(this@MainActivity) }, + subscribe = { filter, id, handler -> + // Extract geohash from filter for geo-routing + val geohashFromFilter = filter.getDebugDescription() + .substringAfter("#g=") + .substringBefore(")") + .substringBefore(",") + + Log.d("MainActivity", "Location Notes subscribing to geohash: $geohashFromFilter") + + com.bitchat.android.nostr.NostrRelayManager.getInstance(this@MainActivity).subscribeForGeohash( + geohash = geohashFromFilter, + filter = filter, + id = id, + handler = handler, + includeDefaults = true, + nRelays = 5 + ) + }, + unsubscribe = { id -> + com.bitchat.android.nostr.NostrRelayManager.getInstance(this@MainActivity).unsubscribe(id) + }, + sendEvent = { event, relayUrls -> + if (relayUrls != null) { + com.bitchat.android.nostr.NostrRelayManager.getInstance(this@MainActivity).sendEvent(event, relayUrls) + } else { + com.bitchat.android.nostr.NostrRelayManager.getInstance(this@MainActivity).sendEvent(event) + } + }, + deriveIdentity = { geohash -> + com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity(geohash, this@MainActivity) + } + ) + Log.d("MainActivity", "✅ Location Notes Manager initialized") + // Ensure all permissions are still granted (user might have revoked in settings) if (!permissionManager.areAllPermissionsGranted()) { val missing = permissionManager.getMissingPermissions() diff --git a/app/src/main/java/com/bitchat/android/geohash/LocationChannel.kt b/app/src/main/java/com/bitchat/android/geohash/LocationChannel.kt index aed974b6..41ef168d 100644 --- a/app/src/main/java/com/bitchat/android/geohash/LocationChannel.kt +++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannel.kt @@ -5,6 +5,7 @@ package com.bitchat.android.geohash * Direct port from iOS implementation for 100% compatibility */ enum class GeohashChannelLevel(val precision: Int, val displayName: String) { + BUILDING(8, "Building"), // iOS: precision 8 for building-level (used for Location Notes) BLOCK(7, "Block"), NEIGHBORHOOD(6, "Neighborhood"), CITY(5, "City"), diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesCounter.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesCounter.kt new file mode 100644 index 00000000..3704bf82 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesCounter.kt @@ -0,0 +1,157 @@ +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 + */ + fun subscribe(geohash: String) { + if (_geohash.value == geohash && subscriptionID != null) { + Log.d(TAG, "Already subscribed to geohash: $geohash") + return + } + + Log.d(TAG, "Subscribing to count notes for geohash: $geohash") + + // Cancel existing subscription + cancel() + + // Reset state + _geohash.value = geohash + _count.value = 0 + eventIDs.clear() + _initialLoadComplete.value = false + + // Check relay availability + val relayManager = relayLookup?.invoke() + val hasRelays = relayManager?.getRelayStatuses()?.any { it.isConnected } == true + _relayAvailable.value = hasRelays + + if (!hasRelays) { + Log.w(TAG, "No relays available for counter subscription") + return + } + + val subscribe = subscribeFunc + if (subscribe == null) { + Log.e(TAG, "Cannot subscribe - subscribe function not initialized") + return + } + + val filter = NostrFilter.geohashNotes( + geohash = geohash, + since = null, + limit = 200 // Count up to 200 recent notes + ) + + val subId = "location-notes-count-$geohash" + + subscriptionID = subscribe(filter, subId) { event -> + handleEvent(event, geohash) + } + + // 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") + } + } + } + + /** + * 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/nostr/LocationNotesManager.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt new file mode 100644 index 00000000..b0076e5a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt @@ -0,0 +1,386 @@ +package com.bitchat.android.nostr + +import android.util.Log +import androidx.annotation.MainThread +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import kotlinx.coroutines.* + +/** + * Manages location notes (kind=1 text notes with geohash tags) + * iOS-compatible implementation with LiveData for Android UI binding + */ +@MainThread +class LocationNotesManager private constructor() { + + companion object { + private const val TAG = "LocationNotesManager" + private const val MAX_NOTES_IN_MEMORY = 500 + + @Volatile + private var INSTANCE: LocationNotesManager? = null + + fun getInstance(): LocationNotesManager { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: LocationNotesManager().also { INSTANCE = it } + } + } + } + + /** + * Note data class matching iOS implementation + */ + data class Note( + val id: String, + val pubkey: String, + val content: String, + val createdAt: Int, + val nickname: String? + ) { + /** + * Display name for the note (nickname or truncated pubkey) + */ + val displayName: String + get() = nickname ?: "@${pubkey.take(8)}" + } + + /** + * Manager state enum + */ + enum class State { + IDLE, + LOADING, + READY, + NO_RELAYS + } + + // Published state (LiveData for Android) + private val _notes = MutableLiveData>(emptyList()) + val notes: LiveData> = _notes + + private val _geohash = MutableLiveData(null) + val geohash: LiveData = _geohash + + private val _initialLoadComplete = MutableLiveData(false) + val initialLoadComplete: LiveData = _initialLoadComplete + + private val _state = MutableLiveData(State.IDLE) + val state: LiveData = _state + + private val _errorMessage = MutableLiveData(null) + val errorMessage: LiveData = _errorMessage + + // Private state + private var subscriptionID: String? = null + private val noteIDs = mutableSetOf() // For deduplication + + // Dependencies (injected via setters for flexibility) + private var relayLookup: (() -> NostrRelayManager)? = null + private var subscribeFunc: ((NostrFilter, String, (NostrEvent) -> Unit) -> String)? = null + private var unsubscribeFunc: ((String) -> Unit)? = null + private var sendEventFunc: ((NostrEvent, List?) -> Unit)? = null + private var deriveIdentityFunc: ((String) -> NostrIdentity)? = null + + // Coroutine scope for background operations + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + + /** + * Initialize dependencies + */ + fun initialize( + relayManager: () -> NostrRelayManager, + subscribe: (NostrFilter, String, (NostrEvent) -> Unit) -> String, + unsubscribe: (String) -> Unit, + sendEvent: (NostrEvent, List?) -> Unit, + deriveIdentity: (String) -> NostrIdentity + ) { + this.relayLookup = relayManager + this.subscribeFunc = subscribe + this.unsubscribeFunc = unsubscribe + this.sendEventFunc = sendEvent + this.deriveIdentityFunc = deriveIdentity + } + + /** + * Set geohash and start subscription + */ + fun setGeohash(newGeohash: String) { + if (_geohash.value == newGeohash) { + Log.d(TAG, "Geohash unchanged, skipping: $newGeohash") + return + } + + Log.d(TAG, "Setting geohash: $newGeohash") + + // Cancel existing subscription + cancel() + + // Clear state + _notes.value = emptyList() + noteIDs.clear() + _initialLoadComplete.value = false + _errorMessage.value = null + _geohash.value = newGeohash + + // Start new subscription + subscribe() + } + + /** + * Refresh notes for current geohash + */ + fun refresh() { + val currentGeohash = _geohash.value + if (currentGeohash == null) { + Log.w(TAG, "Cannot refresh - no geohash set") + return + } + + Log.d(TAG, "Refreshing notes for geohash: $currentGeohash") + + // Cancel and restart subscription + cancel() + _notes.value = emptyList() + noteIDs.clear() + _initialLoadComplete.value = false + subscribe() + } + + /** + * Send a new location note + */ + fun send(content: String, nickname: String?) { + val currentGeohash = _geohash.value + if (currentGeohash == null) { + Log.w(TAG, "Cannot send note - no geohash set") + _errorMessage.value = "No location set" + return + } + + val deriveIdentity = deriveIdentityFunc + if (deriveIdentity == null) { + Log.e(TAG, "Cannot send note - deriveIdentity not initialized") + _errorMessage.value = "Not initialized" + return + } + + Log.d(TAG, "Sending note to geohash: $currentGeohash") + + scope.launch { + try { + val identity = withContext(Dispatchers.IO) { + deriveIdentity(currentGeohash) + } + + val event = withContext(Dispatchers.IO) { + NostrProtocol.createGeohashTextNote( + content = content, + geohash = currentGeohash, + senderIdentity = identity, + nickname = nickname + ) + } + + // Optimistic local echo - add note immediately to UI + val localNote = Note( + id = event.id, + pubkey = event.pubkey, + content = content, + createdAt = event.createdAt, + nickname = nickname + ) + + if (!noteIDs.contains(event.id)) { + noteIDs.add(event.id) + val currentNotes = _notes.value ?: emptyList() + _notes.value = (currentNotes + localNote).sortedByDescending { it.createdAt } + + // Trim if exceeds max + if (noteIDs.size > MAX_NOTES_IN_MEMORY) { + trimOldestNotes() + } + } + + // Send to relays + withContext(Dispatchers.IO) { + sendEventFunc?.invoke(event, null) + } + + Log.d(TAG, "✅ Note sent successfully: ${event.id.take(16)}...") + + } catch (e: Exception) { + Log.e(TAG, "Failed to send note: ${e.message}") + _errorMessage.value = "Failed to send: ${e.message}" + } + } + } + + /** + * Subscribe to location notes for current geohash + */ + private fun subscribe() { + val currentGeohash = _geohash.value + if (currentGeohash == null) { + Log.w(TAG, "Cannot subscribe - no geohash set") + _state.value = State.IDLE + return + } + + val subscribe = subscribeFunc + if (subscribe == null) { + Log.e(TAG, "Cannot subscribe - subscribe function not initialized") + _state.value = State.NO_RELAYS + return + } + + // CRITICAL FIX: Get geo-specific relays for this geohash (matching iOS pattern) + // iOS: let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount) + val relays = try { + com.bitchat.android.nostr.RelayDirectory.closestRelaysForGeohash(currentGeohash, 5) + } catch (e: Exception) { + Log.e(TAG, "Failed to lookup relays for geohash $currentGeohash: ${e.message}") + emptyList() + } + + // Check if we have any relays (iOS pattern: guard !relays.isEmpty()) + if (relays.isEmpty()) { + Log.w(TAG, "No geo relays available for geohash: $currentGeohash") + _state.value = State.NO_RELAYS + _initialLoadComplete.value = true + _errorMessage.value = "No relays available" + return + } + + Log.d(TAG, "📡 Found ${relays.size} geo relays for geohash $currentGeohash: ${relays.joinToString()}") + + _state.value = State.LOADING + + val filter = NostrFilter.geohashNotes( + geohash = currentGeohash, + since = null, // Get all notes (relays will limit) + limit = 200 + ) + + val subId = "location-notes-$currentGeohash" + + Log.d(TAG, "📡 Subscribing to location notes: $subId") + + subscriptionID = subscribe(filter, subId) { event -> + handleEvent(event) + } + + // Mark initial load complete after brief delay to allow relay responses + scope.launch { + delay(2000) // Wait 2 seconds for initial batch + if (!_initialLoadComplete.value!!) { + _initialLoadComplete.value = true + _state.value = State.READY + Log.d(TAG, "Initial load complete for geohash: $currentGeohash (${noteIDs.size} notes)") + } + } + } + + /** + * Handle incoming event from subscription + */ + private fun handleEvent(event: NostrEvent) { + // Validate event + if (event.kind != NostrKind.TEXT_NOTE) { + Log.v(TAG, "Ignoring non-text-note event: kind=${event.kind}") + return + } + + // Check for geohash tag + val geohashTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" } + if (geohashTag == null) { + Log.v(TAG, "Ignoring event without geohash tag: ${event.id.take(16)}...") + return + } + + // Check if matches current geohash + val eventGeohash = geohashTag[1] + if (eventGeohash != _geohash.value) { + Log.v(TAG, "Ignoring event for different geohash: $eventGeohash") + return + } + + // Deduplicate + if (noteIDs.contains(event.id)) { + return + } + + // Extract nickname from tags + val nicknameTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "n" } + val nickname = nicknameTag?.get(1) + + // Create note + val note = Note( + id = event.id, + pubkey = event.pubkey, + content = event.content, + createdAt = event.createdAt, + nickname = nickname + ) + + // Add to collection + noteIDs.add(event.id) + val currentNotes = _notes.value ?: emptyList() + _notes.value = (currentNotes + note).sortedByDescending { it.createdAt } + + Log.d(TAG, "📥 Added note: ${note.displayName} - ${note.content.take(50)}") + + // Trim if exceeds max + if (noteIDs.size > MAX_NOTES_IN_MEMORY) { + trimOldestNotes() + } + + // Update state + if (!_initialLoadComplete.value!!) { + _initialLoadComplete.value = true + } + _state.value = State.READY + } + + /** + * Trim oldest notes to stay within memory limit + */ + private fun trimOldestNotes() { + val currentNotes = _notes.value ?: return + if (currentNotes.size <= MAX_NOTES_IN_MEMORY) return + + val trimmed = currentNotes.sortedByDescending { it.createdAt }.take(MAX_NOTES_IN_MEMORY) + _notes.value = trimmed + + // Update note IDs set + noteIDs.clear() + noteIDs.addAll(trimmed.map { it.id }) + + Log.d(TAG, "Trimmed notes to $MAX_NOTES_IN_MEMORY (removed ${currentNotes.size - trimmed.size})") + } + + /** + * Cancel subscription and clear state + */ + fun cancel() { + subscriptionID?.let { subId -> + Log.d(TAG, "🚫 Canceling subscription: $subId") + unsubscribeFunc?.invoke(subId) + subscriptionID = null + } + + _state.value = State.IDLE + } + + /** + * Cleanup resources + */ + fun cleanup() { + cancel() + scope.cancel() + _notes.value = emptyList() + noteIDs.clear() + _geohash.value = null + _initialLoadComplete.value = false + _errorMessage.value = null + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt b/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt index 0525d184..f2cea4fb 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt @@ -55,6 +55,18 @@ data class NostrFilter( ) } + /** + * Create filter for geohash-scoped text notes (kind=1 with g tag) + */ + fun geohashNotes(geohash: String, since: Long? = null, limit: Int = 200): NostrFilter { + return NostrFilter( + kinds = listOf(NostrKind.TEXT_NOTE), + since = since?.let { (it / 1000).toInt() }, + tagFilters = mapOf("g" to listOf(geohash)), + limit = limit + ) + } + /** * Create filter for specific event IDs */ diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt index 26f3c241..0b94bf78 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt @@ -90,6 +90,34 @@ object NostrProtocol { } } + /** + * Create a geohash-scoped text note (kind 1) with optional nickname + * This creates a persistent text note that can be retrieved later + */ + suspend fun createGeohashTextNote( + content: String, + geohash: String, + senderIdentity: NostrIdentity, + nickname: String? = null + ): NostrEvent = withContext(Dispatchers.Default) { + val tags = mutableListOf>() + tags.add(listOf("g", geohash)) + + if (!nickname.isNullOrEmpty()) { + tags.add(listOf("n", nickname)) + } + + val event = NostrEvent( + pubkey = senderIdentity.publicKeyHex, + createdAt = (System.currentTimeMillis() / 1000).toInt(), + kind = NostrKind.TEXT_NOTE, + tags = tags, + content = content + ) + + return@withContext senderIdentity.signEvent(event) + } + /** * Create a geohash-scoped ephemeral public message (kind 20000) * Includes Proof of Work mining if enabled in settings 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 b53f11e3..60570b01 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.text.input.ImeAction 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 /** * Header components for ChatScreen @@ -233,7 +234,8 @@ fun ChatHeaderContent( onSidebarClick: () -> Unit, onTripleClick: () -> Unit, onShowAppInfo: () -> Unit, - onLocationChannelsClick: () -> Unit + onLocationChannelsClick: () -> Unit, + onLocationNotesClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme @@ -289,6 +291,7 @@ fun ChatHeaderContent( onTripleTitleClick = onTripleClick, onSidebarClick = onSidebarClick, onLocationChannelsClick = onLocationChannelsClick, + onLocationNotesClick = onLocationNotesClick, viewModel = viewModel ) } @@ -510,6 +513,7 @@ private fun MainHeader( onTripleTitleClick: () -> Unit, onSidebarClick: () -> Unit, onLocationChannelsClick: () -> Unit, + onLocationNotesClick: () -> Unit, viewModel: ChatViewModel ) { val colorScheme = MaterialTheme.colorScheme @@ -525,6 +529,15 @@ private fun MainHeader( val context = androidx.compose.ui.platform.LocalContext.current 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) + + // Location channel manager for permission state + val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) } + val permissionState by locationManager.permissionState.observeAsState() + val locationPermissionGranted = permissionState == PermissionState.AUTHORIZED Row( modifier = Modifier.fillMaxWidth(), @@ -571,6 +584,23 @@ private fun MainHeader( tint = Color(0xFFFF9500) ) } + + // Location Notes button (mesh only, when location is authorized) + // iOS: Shows to the left of #mesh badge when in mesh mode and location permitted + if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Mesh && locationPermissionGranted) { + val hasNotes = (notesCount ?: 0) > 0 + IconButton( + onClick = onLocationNotesClick, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = Icons.Outlined.Description, // "long.text.page.and.pencil" equivalent + contentDescription = stringResource(R.string.cd_location_notes), + modifier = Modifier.size(16.dp), + tint = if (hasNotes) colorScheme.primary else Color.Gray + ) + } + } // Location channels button (matching iOS implementation) and bookmark grouped tightly Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 14.dp)) { diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index fa7a861d..ee9c0898 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -63,6 +63,7 @@ fun ChatScreen(viewModel: ChatViewModel) { var showPasswordDialog by remember { mutableStateOf(false) } var passwordInput by remember { mutableStateOf("") } var showLocationChannelsSheet by remember { mutableStateOf(false) } + var showLocationNotesSheet by remember { mutableStateOf(false) } var showUserSheet by remember { mutableStateOf(false) } var selectedUserForSheet by remember { mutableStateOf("") } var selectedMessageForSheet by remember { mutableStateOf(null) } @@ -241,7 +242,8 @@ fun ChatScreen(viewModel: ChatViewModel) { onSidebarToggle = { viewModel.showSidebar() }, onShowAppInfo = { viewModel.showAppInfo() }, onPanicClear = { viewModel.panicClearAllData() }, - onLocationChannelsClick = { showLocationChannelsSheet = true } + onLocationChannelsClick = { showLocationChannelsSheet = true }, + onLocationNotesClick = { showLocationNotesSheet = true } ) // Divider under header - positioned after status bar + header height @@ -354,6 +356,8 @@ fun ChatScreen(viewModel: ChatViewModel) { onAppInfoDismiss = { viewModel.hideAppInfo() }, showLocationChannelsSheet = showLocationChannelsSheet, onLocationChannelsSheetDismiss = { showLocationChannelsSheet = false }, + showLocationNotesSheet = showLocationNotesSheet, + onLocationNotesSheetDismiss = { showLocationNotesSheet = false }, showUserSheet = showUserSheet, onUserSheetDismiss = { showUserSheet = false @@ -435,7 +439,8 @@ private fun ChatFloatingHeader( onSidebarToggle: () -> Unit, onShowAppInfo: () -> Unit, onPanicClear: () -> Unit, - onLocationChannelsClick: () -> Unit + onLocationChannelsClick: () -> Unit, + onLocationNotesClick: () -> Unit ) { Surface( modifier = Modifier @@ -460,7 +465,8 @@ private fun ChatFloatingHeader( onSidebarClick = onSidebarToggle, onTripleClick = onPanicClear, onShowAppInfo = onShowAppInfo, - onLocationChannelsClick = onLocationChannelsClick + onLocationChannelsClick = onLocationChannelsClick, + onLocationNotesClick = onLocationNotesClick ) }, colors = TopAppBarDefaults.topAppBarColors( @@ -471,6 +477,7 @@ private fun ChatFloatingHeader( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ChatDialogs( showPasswordDialog: Boolean, @@ -483,6 +490,8 @@ private fun ChatDialogs( onAppInfoDismiss: () -> Unit, showLocationChannelsSheet: Boolean, onLocationChannelsSheetDismiss: () -> Unit, + showLocationNotesSheet: Boolean, + onLocationNotesSheetDismiss: () -> Unit, showUserSheet: Boolean, onUserSheetDismiss: () -> Unit, selectedUserForSheet: String, @@ -523,6 +532,68 @@ private fun ChatDialogs( ) } + // Location notes sheet + if (showLocationNotesSheet) { + val context = androidx.compose.ui.platform.LocalContext.current + val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) } + val availableChannels by locationManager.availableChannels.observeAsState(emptyList()) + val nickname by viewModel.nickname.observeAsState("") + + // Get building-level geohash (precision 8 for iOS compatibility) + val buildingGeohash = availableChannels.firstOrNull { it.level == com.bitchat.android.geohash.GeohashChannelLevel.BUILDING }?.geohash + + if (buildingGeohash != null) { + // Get location name from locationManager + val locationNames by locationManager.locationNames.observeAsState(emptyMap()) + val locationName = locationNames[com.bitchat.android.geohash.GeohashChannelLevel.BUILDING] + ?: locationNames[com.bitchat.android.geohash.GeohashChannelLevel.BLOCK] + + LocationNotesSheet( + geohash = buildingGeohash, + locationName = locationName, + nickname = nickname, + onDismiss = onLocationNotesSheetDismiss + ) + + // Subscribe to notes counter when sheet is shown + LaunchedEffect(buildingGeohash) { + com.bitchat.android.nostr.LocationNotesCounter.subscribe(buildingGeohash) + } + } else { + // No block geohash available - show error state + ModalBottomSheet( + onDismissRequest = onLocationNotesSheetDismiss, + containerColor = MaterialTheme.colorScheme.surface + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Location Unavailable", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Location permission is required for notes", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = { + locationManager.enableLocationChannels() + locationManager.refreshChannels() + }) { + Text("Enable Location") + } + } + } + } + } + // User action sheet if (showUserSheet) { ChatUserSheet( 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 8255eb06..00d25db2 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -147,6 +147,9 @@ class ChatViewModel( mediaSendingManager.handleTransferProgressEvent(evt) } } + + // Initialize location notes counter subscription management (iOS parity) + initializeLocationNotesCounterSubscription() } fun cancelMediaSend(messageId: String) { @@ -591,6 +594,70 @@ class ChatViewModel( } } + /** + * Initialize location notes counter subscription management (iOS parity) + * Subscribes/unsubscribes based on channel selection and location permission + */ + private fun initializeLocationNotesCounterSubscription() { + viewModelScope.launch { + // Observe channel changes + selectedLocationChannel.observeForever { channel -> + updateNotesCounterSubscription(channel) + } + } + } + + /** + * Update notes counter subscription based on current channel and location permission + * iOS pattern: Subscribe when in mesh mode and location is authorized + */ + private fun updateNotesCounterSubscription(channel: com.bitchat.android.geohash.ChannelID?) { + try { + val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) + val permissionState = locationManager.permissionState.value + val locationPermissionGranted = permissionState == com.bitchat.android.geohash.LocationChannelManager.PermissionState.AUTHORIZED + + when (channel) { + is com.bitchat.android.geohash.ChannelID.Mesh -> { + // Mesh mode: subscribe to block-level geohash notes if location authorized + if (locationPermissionGranted) { + // Refresh location to get current block geohash + locationManager.refreshChannels() + + // Get building-level geohash (precision 8, same as iOS) + val buildingGeohash = locationManager.availableChannels.value + ?.firstOrNull { it.level == com.bitchat.android.geohash.GeohashChannelLevel.BUILDING } + ?.geohash + + if (buildingGeohash != null) { + Log.d(TAG, "📍 Subscribing to location notes for building geohash: $buildingGeohash") + com.bitchat.android.nostr.LocationNotesCounter.subscribe(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() + } + } + } else { + com.bitchat.android.nostr.LocationNotesCounter.cancel() + } + } + is com.bitchat.android.geohash.ChannelID.Location -> { + // Location channel mode: cancel counter (only show in mesh mode) + com.bitchat.android.nostr.LocationNotesCounter.cancel() + } + null -> { + // Default to mesh behavior + if (locationPermissionGranted) { + locationManager.refreshChannels() + } + } + } + } catch (e: Exception) { + Log.w(TAG, "updateNotesCounterSubscription failed: ${e.message}") + } + } + /** * Update reactive states for all connected peers (session states, fingerprints, nicknames, RSSI) */ diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 9f42d662..0ecc6035 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -713,6 +713,7 @@ private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int val ctx = androidx.compose.ui.platform.LocalContext.current val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount) val levelName = when (channel.level) { + com.bitchat.android.geohash.GeohashChannelLevel.BUILDING -> "Building" // iOS: precision 8 for location notes com.bitchat.android.geohash.GeohashChannelLevel.BLOCK -> stringResource(com.bitchat.android.R.string.location_level_block) com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD -> stringResource(com.bitchat.android.R.string.location_level_neighborhood) com.bitchat.android.geohash.GeohashChannelLevel.CITY -> stringResource(com.bitchat.android.R.string.location_level_city) @@ -749,7 +750,8 @@ private fun levelForLength(length: Int): GeohashChannelLevel { 5 -> GeohashChannelLevel.CITY 6 -> GeohashChannelLevel.NEIGHBORHOOD 7 -> GeohashChannelLevel.BLOCK - else -> GeohashChannelLevel.BLOCK + 8 -> GeohashChannelLevel.BUILDING // iOS: precision 8 for building-level + else -> if (length > 8) GeohashChannelLevel.BUILDING else GeohashChannelLevel.BLOCK } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt new file mode 100644 index 00000000..e7210a19 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -0,0 +1,433 @@ +package com.bitchat.android.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Send +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +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.* +import kotlin.math.abs + +/** + * Location Notes Sheet - Material Design bottom sheet for location-based notes + * iOS-compatible implementation with Android Material Design + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LocationNotesSheet( + geohash: String, + locationName: String?, + nickname: String?, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + + // 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) + + // Input field state + var messageText by remember { mutableStateOf("") } + val canSend = messageText.isNotBlank() + + // Scroll state + val listState = rememberLazyListState() + + // Effect to set geohash when sheet opens + LaunchedEffect(geohash) { + notesManager.setGeohash(geohash) + counter.subscribe(geohash) + } + + // Cleanup when sheet closes + DisposableEffect(Unit) { + onDispose { + // Don't cancel subscriptions here - they'll be managed by the manager lifecycle + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + modifier = modifier, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.9f) + .padding(horizontal = 16.dp) + ) { + // Header + LocationNotesHeader( + geohash = geohash, + locationName = locationName, + count = count, + onClose = onDismiss + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Content area + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + when { + state == LocationNotesManager.State.NO_RELAYS -> { + EmptyStateMessage( + message = "No relays available", + icon = "🌐" + ) + } + state == LocationNotesManager.State.LOADING && !initialLoadComplete -> { + LoadingState() + } + notes.isEmpty() && initialLoadComplete -> { + EmptyStateMessage( + message = "No notes yet.\nBe the first to share!", + icon = "📝" + ) + } + else -> { + LocationNotesList( + notes = notes, + listState = listState, + modifier = Modifier.fillMaxSize() + ) + } + } + } + + // Error message + errorMessage?.let { error -> + Text( + text = error, + color = MaterialTheme.colorScheme.error, + fontSize = 12.sp, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Input field + LocationNotesInputField( + text = messageText, + onTextChange = { messageText = it }, + onSend = { + if (canSend) { + notesManager.send(messageText, nickname) + messageText = "" + } + }, + enabled = state != LocationNotesManager.State.NO_RELAYS, + canSend = canSend + ) + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +/** + * Header with geohash, count, location name, and close button + */ +@Composable +private fun LocationNotesHeader( + geohash: String, + locationName: String?, + count: Int, + onClose: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = geohash, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "($count)", + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + locationName?.let { name -> + Text( + text = name, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +/** + * List of location notes + */ +@Composable +private fun LocationNotesList( + notes: List, + listState: androidx.compose.foundation.lazy.LazyListState, + modifier: Modifier = Modifier +) { + LazyColumn( + state = listState, + modifier = modifier, + reverseLayout = false, // Normal order (newest at top since sorted desc) + contentPadding = PaddingValues(vertical = 8.dp) + ) { + items(notes, key = { it.id }) { note -> + LocationNoteItem(note = note) + Spacer(modifier = Modifier.height(12.dp)) + } + } +} + +/** + * Individual note item + */ +@Composable +private fun LocationNoteItem(note: LocationNotesManager.Note) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + .padding(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = note.displayName, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = formatTimestamp(note.createdAt), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = note.content, + fontSize = 15.sp, + color = MaterialTheme.colorScheme.onSurface, + lineHeight = 20.sp + ) + } +} + +/** + * Input field with send button + */ +@Composable +private fun LocationNotesInputField( + text: String, + onTextChange: (String) -> Unit, + onSend: () -> Unit, + enabled: Boolean, + canSend: Boolean +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + value = text, + onValueChange = onTextChange, + modifier = Modifier.weight(1f), + placeholder = { + Text( + text = if (enabled) "Share a note..." else "No relays available", + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + }, + enabled = enabled, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent + ), + maxLines = 3 + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background( + if (canSend && enabled) { + Color(0xFFFF8C00) // Orange color when enabled + } else { + MaterialTheme.colorScheme.surfaceVariant + } + ) + .clickable(enabled = canSend && enabled) { onSend() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Send, + contentDescription = "Send", + tint = if (canSend && enabled) Color.White else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + modifier = Modifier.size(20.dp) + ) + } + } +} + +/** + * Loading state indicator + */ +@Composable +private fun LoadingState() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Loading notes...", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +/** + * Empty state message + */ +@Composable +private fun EmptyStateMessage(message: String, icon: String) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = icon, + fontSize = 48.sp + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = message, + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } + } +} + +/** + * Format timestamp - iOS-compatible formatting + * Relative for < 7 days, absolute otherwise + */ +private fun formatTimestamp(createdAt: Int): String { + val now = System.currentTimeMillis() / 1000 + val diff = now - createdAt + + return when { + diff < 60 -> "just now" + diff < 3600 -> { + val minutes = diff / 60 + "${minutes}m ago" + } + diff < 86400 -> { + val hours = diff / 3600 + "${hours}h ago" + } + diff < 604800 -> { // 7 days + val days = diff / 86400 + "${days}d ago" + } + else -> { + // Absolute date for older than 7 days + val date = Date(createdAt * 1000L) + val formatter = SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) + formatter.format(date) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4bd9ae15..78e4d9cc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -73,6 +73,7 @@ Nostr reachable Unread private messages Toggle bookmark + Location notes Teleported Tor status Connected peers