From fa1308f12b77a465541ce392a338aa43c34fddad Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 17 Oct 2025 23:03:15 +0200 Subject: [PATCH] ui cleanup --- .../android/nostr/LocationNotesCounter.kt | 62 +++++++--- .../android/nostr/LocationNotesManager.kt | 48 ++++++-- .../bitchat/android/ui/LocationNotesSheet.kt | 111 +++++++++++------- 3 files changed, 151 insertions(+), 70 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesCounter.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesCounter.kt index 3704bf82..2f66f4b7 100644 --- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesCounter.kt +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesCounter.kt @@ -51,31 +51,51 @@ object LocationNotesCounter { /** * Subscribe to count notes for a specific geohash + * iOS: Validates building-level precision (8 chars) and checks for relay availability */ fun subscribe(geohash: String) { - if (_geohash.value == geohash && subscriptionID != null) { - Log.d(TAG, "Already subscribed to geohash: $geohash") + 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 } - Log.d(TAG, "Subscribing to count notes for geohash: $geohash") + // 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 + } - // Cancel existing subscription - cancel() + 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 = geohash - _count.value = 0 + _geohash.value = normalized eventIDs.clear() _initialLoadComplete.value = false + _relayAvailable.value = true - // Check relay availability - val relayManager = relayLookup?.invoke() - val hasRelays = relayManager?.getRelayStatuses()?.any { it.isConnected } == true - _relayAvailable.value = hasRelays + // 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() + } - if (!hasRelays) { - Log.w(TAG, "No relays available for counter subscription") + // 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 } @@ -86,15 +106,16 @@ object LocationNotesCounter { } val filter = NostrFilter.geohashNotes( - geohash = geohash, + geohash = normalized, since = null, limit = 200 // Count up to 200 recent notes ) - val subId = "location-notes-count-$geohash" + // 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, geohash) + handleEvent(event, normalized) } // Mark initial load complete after brief delay @@ -107,6 +128,15 @@ object LocationNotesCounter { } } + /** + * 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 */ diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt index fcb49e95..fe4f964a 100644 --- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt @@ -38,10 +38,19 @@ class LocationNotesManager private constructor() { val nickname: String? ) { /** - * Display name for the note (nickname or truncated pubkey) + * Display name for the note - matches iOS exactly + * Format: "nickname#abcd" or "anon#abcd" where abcd is last 4 chars of pubkey */ val displayName: String - get() = nickname ?: "@${pubkey.take(8)}" + get() { + val suffix = pubkey.takeLast(4) + val nick = nickname?.trim() + return if (!nick.isNullOrEmpty()) { + "$nick#$suffix" + } else { + "anon#$suffix" + } + } } /** @@ -103,29 +112,50 @@ class LocationNotesManager private constructor() { /** * Set geohash and start subscription + * iOS: Validates building-level precision (8 characters) */ fun setGeohash(newGeohash: String) { - if (_geohash.value == newGeohash) { - Log.d(TAG, "Geohash unchanged, skipping: $newGeohash") + val normalized = newGeohash.lowercase() + + if (_geohash.value == normalized) { + Log.d(TAG, "Geohash unchanged, skipping: $normalized") return } - Log.d(TAG, "Setting geohash: $newGeohash") + // Validate geohash (building-level precision: 8 chars) - matches iOS + if (!isValidBuildingGeohash(normalized)) { + Log.w(TAG, "LocationNotesManager: rejecting invalid geohash '$normalized' (expected 8 valid base32 chars)") + return + } + + Log.d(TAG, "Setting geohash: $normalized") // Cancel existing subscription cancel() - // Clear state - _notes.value = emptyList() - noteIDs.clear() + // Set loading state before clearing to prevent empty state flicker (iOS pattern) + _state.value = State.LOADING _initialLoadComplete.value = false _errorMessage.value = null - _geohash.value = newGeohash + + // Clear notes + _notes.value = emptyList() + noteIDs.clear() + _geohash.value = normalized // Start new subscription subscribe() } + /** + * 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 } + } + /** * Refresh notes for current geohash */ 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 de83f7b7..f52a8dab 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -454,7 +454,7 @@ private fun ErrorRow(message: String, onDismiss: () -> Unit) { } /** - * Input section - matches iOS inputSection exactly + * Input section - matches main chat input exactly */ @Composable private fun LocationNotesInputSection( @@ -466,61 +466,82 @@ private fun LocationNotesInputSection( onSend: () -> Unit ) { val isDark = isSystemInDarkTheme() + val colorScheme = MaterialTheme.colorScheme Row( modifier = Modifier .fillMaxWidth() .background(backgroundColor) - .padding(horizontal = 16.dp, vertical = 14.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top + .padding(horizontal = 12.dp, vertical = 8.dp), // Match main chat padding + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) // Match main chat spacing ) { - // Text field - TextField( - value = draft, - onValueChange = onDraftChange, - modifier = Modifier.weight(1f), - placeholder = { + // Text input with placeholder overlay (matches main chat exactly) + Box( + modifier = Modifier.weight(1f) + ) { + androidx.compose.foundation.text.BasicTextField( + value = draft, + onValueChange = onDraftChange, + textStyle = MaterialTheme.typography.bodyMedium.copy( + color = colorScheme.primary, + fontFamily = FontFamily.Monospace + ), + cursorBrush = androidx.compose.ui.graphics.SolidColor(colorScheme.primary), + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + imeAction = androidx.compose.ui.text.input.ImeAction.Send + ), + keyboardActions = androidx.compose.foundation.text.KeyboardActions( + onSend = { if (sendButtonEnabled) onSend() } + ), + modifier = Modifier.fillMaxWidth() + ) + + // Placeholder when empty (matches main chat) + if (draft.isEmpty()) { Text( text = "add a note for this place", - fontFamily = FontFamily.Monospace, - fontSize = 14.sp, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace + ), + color = colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth() ) - }, - textStyle = androidx.compose.ui.text.TextStyle( - fontFamily = FontFamily.Monospace, - fontSize = 14.sp, - color = MaterialTheme.colorScheme.onSurface - ), - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - cursorColor = MaterialTheme.colorScheme.primary - ), - maxLines = 3 - ) + } + } - Spacer(modifier = Modifier.width(10.dp)) - - // Send button - iOS arrow.up.circle.fill icon - Box( - modifier = Modifier - .padding(top = 2.dp) - .size(20.dp) - .clickable(enabled = sendButtonEnabled, onClick = onSend), - contentAlignment = Alignment.Center + // Send button - circular with icon (matches main chat exactly) + IconButton( + onClick = { if (sendButtonEnabled) onSend() }, + enabled = sendButtonEnabled, + modifier = Modifier.size(32.dp) ) { - Icon( - imageVector = Icons.Default.ArrowUpward, - contentDescription = "Send", - tint = if (sendButtonEnabled) accentGreen else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), - modifier = Modifier.size(20.dp) - ) + Box( + modifier = Modifier + .size(30.dp) + .background( + color = if (!sendButtonEnabled) { + colorScheme.onSurface.copy(alpha = 0.3f) + } else { + accentGreen.copy(alpha = 0.75f) + }, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.ArrowUpward, + contentDescription = "Send", + modifier = Modifier.size(20.dp), + tint = if (!sendButtonEnabled) { + colorScheme.onSurface.copy(alpha = 0.5f) + } else if (isDark) { + Color.Black // Black arrow on green in dark theme + } else { + Color.White // White arrow on green in light theme + } + ) + } } } }