ui cleanup

This commit is contained in:
callebtc
2025-10-17 23:03:15 +02:00
parent 7e9de140d4
commit fa1308f12b
3 changed files with 151 additions and 70 deletions
@@ -51,31 +51,51 @@ object LocationNotesCounter {
/** /**
* Subscribe to count notes for a specific geohash * Subscribe to count notes for a specific geohash
* iOS: Validates building-level precision (8 chars) and checks for relay availability
*/ */
fun subscribe(geohash: String) { fun subscribe(geohash: String) {
if (_geohash.value == geohash && subscriptionID != null) { val normalized = geohash.lowercase()
Log.d(TAG, "Already subscribed to geohash: $geohash")
// Skip if already subscribed to this geohash
if (_geohash.value == normalized && subscriptionID != null) {
Log.d(TAG, "Already subscribed to geohash: $normalized")
return 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 Log.d(TAG, "Subscribing to count notes for geohash: $normalized")
cancel()
// Unsubscribe previous without clearing count to avoid flicker (iOS pattern)
subscriptionID?.let { subId ->
unsubscribeFunc?.invoke(subId)
}
subscriptionID = null
// Reset state // Reset state
_geohash.value = geohash _geohash.value = normalized
_count.value = 0
eventIDs.clear() eventIDs.clear()
_initialLoadComplete.value = false _initialLoadComplete.value = false
_relayAvailable.value = true
// Check relay availability // Get geo-specific relays (iOS pattern: dependencies.relayLookup(norm, TransportConfig.nostrGeoRelayCount))
val relayManager = relayLookup?.invoke() val relays = try {
val hasRelays = relayManager?.getRelayStatuses()?.any { it.isConnected } == true com.bitchat.android.nostr.RelayDirectory.closestRelaysForGeohash(normalized, 5)
_relayAvailable.value = hasRelays } catch (e: Exception) {
Log.e(TAG, "Failed to lookup relays for geohash $normalized: ${e.message}")
emptyList()
}
if (!hasRelays) { // Check if we have relays (iOS pattern: guard !relays.isEmpty())
Log.w(TAG, "No relays available for counter subscription") if (relays.isEmpty()) {
Log.w(TAG, "LocationNotesCounter: no geo relays for geohash=$normalized")
_relayAvailable.value = false
_initialLoadComplete.value = true
_count.value = 0
return return
} }
@@ -86,15 +106,16 @@ object LocationNotesCounter {
} }
val filter = NostrFilter.geohashNotes( val filter = NostrFilter.geohashNotes(
geohash = geohash, geohash = normalized,
since = null, since = null,
limit = 200 // Count up to 200 recent notes 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 -> subscriptionID = subscribe(filter, subId) { event ->
handleEvent(event, geohash) handleEvent(event, normalized)
} }
// Mark initial load complete after brief delay // 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 * Handle incoming event
*/ */
@@ -38,10 +38,19 @@ class LocationNotesManager private constructor() {
val nickname: String? 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 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 * Set geohash and start subscription
* iOS: Validates building-level precision (8 characters)
*/ */
fun setGeohash(newGeohash: String) { fun setGeohash(newGeohash: String) {
if (_geohash.value == newGeohash) { val normalized = newGeohash.lowercase()
Log.d(TAG, "Geohash unchanged, skipping: $newGeohash")
if (_geohash.value == normalized) {
Log.d(TAG, "Geohash unchanged, skipping: $normalized")
return 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 existing subscription
cancel() cancel()
// Clear state // Set loading state before clearing to prevent empty state flicker (iOS pattern)
_notes.value = emptyList() _state.value = State.LOADING
noteIDs.clear()
_initialLoadComplete.value = false _initialLoadComplete.value = false
_errorMessage.value = null _errorMessage.value = null
_geohash.value = newGeohash
// Clear notes
_notes.value = emptyList()
noteIDs.clear()
_geohash.value = normalized
// Start new subscription // Start new subscription
subscribe() 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 * Refresh notes for current geohash
*/ */
@@ -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 @Composable
private fun LocationNotesInputSection( private fun LocationNotesInputSection(
@@ -466,61 +466,82 @@ private fun LocationNotesInputSection(
onSend: () -> Unit onSend: () -> Unit
) { ) {
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val colorScheme = MaterialTheme.colorScheme
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(backgroundColor) .background(backgroundColor)
.padding(horizontal = 16.dp, vertical = 14.dp), .padding(horizontal = 12.dp, vertical = 8.dp), // Match main chat padding
horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically,
verticalAlignment = Alignment.Top horizontalArrangement = Arrangement.spacedBy(8.dp) // Match main chat spacing
) { ) {
// Text field // Text input with placeholder overlay (matches main chat exactly)
TextField( Box(
value = draft, modifier = Modifier.weight(1f)
onValueChange = onDraftChange, ) {
modifier = Modifier.weight(1f), androidx.compose.foundation.text.BasicTextField(
placeholder = { 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(
text = "add a note for this place", text = "add a note for this place",
fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 14.sp, fontFamily = FontFamily.Monospace
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) ),
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 - circular with icon (matches main chat exactly)
IconButton(
// Send button - iOS arrow.up.circle.fill icon onClick = { if (sendButtonEnabled) onSend() },
Box( enabled = sendButtonEnabled,
modifier = Modifier modifier = Modifier.size(32.dp)
.padding(top = 2.dp)
.size(20.dp)
.clickable(enabled = sendButtonEnabled, onClick = onSend),
contentAlignment = Alignment.Center
) { ) {
Icon( Box(
imageVector = Icons.Default.ArrowUpward, modifier = Modifier
contentDescription = "Send", .size(30.dp)
tint = if (sendButtonEnabled) accentGreen else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), .background(
modifier = Modifier.size(20.dp) 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
}
)
}
} }
} }
} }