From f99cb46b2661e6ebdc2e2ab599ae2eaec9f0b101 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 18 Oct 2025 14:19:53 +0200 Subject: [PATCH] Location notes (#482) * try 1 * works * equalize UI with ios * ui cleanup * geohash chats: no building * load notes in background * insta * simplify and tor icon change * icons nice * refactor * unify location enabled / disabled * cooler * simplify, doesnt subscribe right away * load when clicked * plus minus location notes * load when tor is available * translations * fix transalations * implement review comments --- .../com/bitchat/android/BitchatApplication.kt | 3 + .../java/com/bitchat/android/MainActivity.kt | 3 + .../com/bitchat/android/geohash/Geohash.kt | 32 + .../android/geohash/LocationChannel.kt | 1 + .../android/geohash/LocationChannelManager.kt | 7 + .../android/nostr/LocationNotesInitializer.kt | 63 ++ .../android/nostr/LocationNotesManager.kt | 468 ++++++++++++++ .../com/bitchat/android/nostr/NostrFilter.kt | 20 + .../bitchat/android/nostr/NostrProtocol.kt | 28 + .../java/com/bitchat/android/ui/ChatHeader.kt | 54 +- .../java/com/bitchat/android/ui/ChatScreen.kt | 30 +- .../com/bitchat/android/ui/ChatViewModel.kt | 4 + .../android/ui/LocationChannelsSheet.kt | 11 +- .../bitchat/android/ui/LocationNotesButton.kt | 67 ++ .../bitchat/android/ui/LocationNotesSheet.kt | 612 ++++++++++++++++++ .../android/ui/LocationNotesSheetPresenter.kt | 100 +++ app/src/main/res/values-ar/strings.xml | 13 + app/src/main/res/values-de/strings.xml | 14 + app/src/main/res/values-es/strings.xml | 13 + app/src/main/res/values-fr/strings.xml | 13 + app/src/main/res/values-he/strings.xml | 13 + app/src/main/res/values-hi/strings.xml | 13 + app/src/main/res/values-id/strings.xml | 13 + app/src/main/res/values-it/strings.xml | 13 + app/src/main/res/values-ja/strings.xml | 13 + app/src/main/res/values-ko/strings.xml | 13 + app/src/main/res/values-nl/strings.xml | 13 + app/src/main/res/values-pl/strings.xml | 13 + app/src/main/res/values-pt-rBR/strings.xml | 13 + app/src/main/res/values-pt/strings.xml | 13 + app/src/main/res/values-ru/strings.xml | 13 + app/src/main/res/values-sv/strings.xml | 13 + app/src/main/res/values-tr/strings.xml | 13 + app/src/main/res/values-ur/strings.xml | 13 + app/src/main/res/values-zh-rCN/strings.xml | 13 + app/src/main/res/values-zh-rTW/strings.xml | 13 + app/src/main/res/values-zh/strings.xml | 13 + app/src/main/res/values/strings.xml | 16 + 38 files changed, 1769 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.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/LocationNotesButton.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index 7a0ede22..df9cd6e5 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -19,6 +19,9 @@ class BitchatApplication : Application() { // Initialize relay directory (loads assets/nostr_relays.csv) RelayDirectory.initialize(this) + // Initialize LocationNotesManager dependencies early so sheet subscriptions can start immediately + try { com.bitchat.android.nostr.LocationNotesInitializer.initialize(this) } catch (_: Exception) { } + // Initialize favorites persistence early so MessageRouter/NostrTransport can use it on startup try { com.bitchat.android.favorites.FavoritesPersistenceService.initialize(this) diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index f35a403c..4900d2fb 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -598,6 +598,9 @@ class MainActivity : ComponentActivity() { PoWPreferenceManager.init(this@MainActivity) Log.d("MainActivity", "PoW preferences initialized") + // Initialize Location Notes Manager (extracted to separate file) + com.bitchat.android.nostr.LocationNotesInitializer.initialize(this@MainActivity) + // 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/Geohash.kt b/app/src/main/java/com/bitchat/android/geohash/Geohash.kt index 6907a42d..00b2cdf3 100644 --- a/app/src/main/java/com/bitchat/android/geohash/Geohash.kt +++ b/app/src/main/java/com/bitchat/android/geohash/Geohash.kt @@ -115,4 +115,36 @@ object Geohash { lonMax = maxOf(lonInterval.first, lonInterval.second) ) } + + /** + * Returns the 8 neighboring geohash cells at the same precision as the input. + * Neighbors include N, NE, E, SE, S, SW, W, NW, even when crossing parent cell boundaries. + */ + fun neighborsSamePrecision(geohash: String): Set { + if (geohash.isEmpty()) return emptySet() + val p = geohash.length + val b = decodeToBounds(geohash) + val dLat = b.latMax - b.latMin + val dLon = b.lonMax - b.lonMin + + fun wrapLon(lon: Double): Double { + var x = lon + while (x > 180.0) x -= 360.0 + while (x < -180.0) x += 360.0 + return x + } + + val neighbors = mutableSetOf() + for (dy in -1..1) { + for (dx in -1..1) { + if (dx == 0 && dy == 0) continue // skip center + val centerLat = (b.latMin + b.latMax) / 2 + dy * dLat + val rawLonCenter = (b.lonMin + b.lonMax) / 2 + dx * dLon + val centerLon = wrapLon(rawLonCenter) + val enc = encode(centerLat.coerceIn(-90.0, 90.0), centerLon, p) + if (enc.isNotEmpty() && enc != geohash) neighbors.add(enc) + } + } + return neighbors + } } 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/geohash/LocationChannelManager.kt b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt index 5f0ed439..b8da1fee 100644 --- a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt @@ -88,10 +88,17 @@ class LocationChannelManager private constructor(private val context: Context) { /** * Enable location channels (request permission if needed) + * UNIFIED: Only requests location if location services are enabled by user */ fun enableLocationChannels() { Log.d(TAG, "enableLocationChannels() called") + // UNIFIED FIX: Check if location services are enabled by user + if (!isLocationServicesEnabled()) { + Log.w(TAG, "Location services disabled by user - not requesting location") + return + } + when (getCurrentPermissionStatus()) { PermissionState.NOT_DETERMINED -> { Log.d(TAG, "Permission not determined - user needs to grant in app settings") diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.kt new file mode 100644 index 00000000..bed29902 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.kt @@ -0,0 +1,63 @@ +package com.bitchat.android.nostr + +import android.content.Context +import android.util.Log + +/** + * Initializer for LocationNotesManager with all dependencies + * Extracts initialization logic from MainActivity for better separation of concerns + */ +object LocationNotesInitializer { + + private const val TAG = "LocationNotesInitializer" + + /** + * Initialize LocationNotesManager with all required dependencies + * + * @param context Application context + * @return true if initialization succeeded, false otherwise + */ + fun initialize(context: Context): Boolean { + return try { + LocationNotesManager.getInstance().initialize( + relayManager = { NostrRelayManager.getInstance(context) }, + subscribe = { filter, id, handler -> + // CRITICAL FIX: Extract geohash properly from filter using getGeohash() method + val geohashFromFilter = filter.getGeohash() ?: run { + Log.e(TAG, "❌ Cannot extract geohash from filter for location notes") + return@initialize id // Return subscription ID even on error + } + + Log.d(TAG, "📍 Location Notes subscribing to geohash: $geohashFromFilter") + + NostrRelayManager.getInstance(context).subscribeForGeohash( + geohash = geohashFromFilter, + filter = filter, + id = id, + handler = handler, + includeDefaults = true, + nRelays = 5 + ) + }, + unsubscribe = { id -> + NostrRelayManager.getInstance(context).unsubscribe(id) + }, + sendEvent = { event, relayUrls -> + if (relayUrls != null) { + NostrRelayManager.getInstance(context).sendEvent(event, relayUrls) + } else { + NostrRelayManager.getInstance(context).sendEvent(event) + } + }, + deriveIdentity = { geohash -> + NostrIdentityBridge.deriveIdentity(geohash, context) + } + ) + Log.d(TAG, "✅ Location Notes Manager initialized") + true + } catch (e: Exception) { + Log.e(TAG, "❌ Failed to initialize Location Notes Manager: ${e.message}", e) + 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..655a9783 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt @@ -0,0 +1,468 @@ +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 - matches iOS exactly + * Format: "nickname#abcd" or "anon#abcd" where abcd is last 4 chars of pubkey + */ + val displayName: String + get() { + val suffix = pubkey.takeLast(4) + val nick = nickname?.trim() + return if (!nick.isNullOrEmpty()) { + "$nick#$suffix" + } else { + "anon#$suffix" + } + } + } + + /** + * 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 subscriptionIDs: MutableMap = mutableMapOf() + private val noteIDs = mutableSetOf() // For deduplication + private var subscribedGeohashes: Set = emptySet() + + // 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 + * iOS: Validates building-level precision (8 characters) + */ + fun setGeohash(newGeohash: String) { + val normalized = newGeohash.lowercase() + + if (_geohash.value == normalized) { + Log.d(TAG, "Geohash unchanged, skipping: $normalized") + return + } + + // 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() + + // Set loading state before clearing to prevent empty state flicker (iOS pattern) + _state.value = State.LOADING + _initialLoadComplete.value = false + _errorMessage.value = null + + // Clear notes + _notes.value = emptyList() + noteIDs.clear() + _geohash.value = normalized + + // Compute target geohashes: center + neighbors (±1) + val neighbors = try { + com.bitchat.android.geohash.Geohash.neighborsSamePrecision(normalized) + } catch (_: Exception) { emptySet() } + subscribedGeohashes = (neighbors + normalized).toSet() + + // Start new subscriptions for all cells + subscribeAll() + } + + /** + * 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 + */ + 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 subscriptions for current ±1 set + cancel() + _notes.value = emptyList() + noteIDs.clear() + _initialLoadComplete.value = false + // Rebuild subscribedGeohashes and resubscribe + val neighbors = try { + com.bitchat.android.geohash.Geohash.neighborsSamePrecision(currentGeohash) + } catch (_: Exception) { emptySet() } + subscribedGeohashes = (neighbors + currentGeohash).toSet() + subscribeAll() + } + + /** + * 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 trimmed = content.trim() + if (trimmed.isEmpty()) { + return + } + + // CRITICAL FIX: Get geo-specific relays for sending (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 relays (iOS pattern: guard !relays.isEmpty()) + if (relays.isEmpty()) { + Log.w(TAG, "Send blocked - no geo relays for geohash: $currentGeohash") + _state.value = State.NO_RELAYS + _errorMessage.value = "No relays available" + 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 via ${relays.size} geo relays") + + scope.launch { + try { + val identity = withContext(Dispatchers.IO) { + deriveIdentity(currentGeohash) + } + + val event = withContext(Dispatchers.IO) { + NostrProtocol.createGeohashTextNote( + content = trimmed, + geohash = currentGeohash, + senderIdentity = identity, + nickname = nickname + ) + } + + // Optimistic local echo - add note immediately to UI + val localNote = Note( + id = event.id, + pubkey = event.pubkey, + content = trimmed, + 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() + } + } + + // CRITICAL FIX: Send to geo-specific relays (matching iOS pattern) + // iOS: dependencies.sendEvent(event, relays) + withContext(Dispatchers.IO) { + sendEventFunc?.invoke(event, relays) + } + + Log.d(TAG, "✅ Note sent successfully to ${relays.size} geo relays: ${event.id.take(16)}...") + + // Clear any error messages on successful send + _errorMessage.value = null + _state.value = State.READY + + } 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 subscribeAll() { + 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; will retry shortly") + _state.value = State.LOADING + // Retry a few times in case initialization is racing the sheet open + scope.launch { + var attempts = 0 + while (attempts < 10 && subscribeFunc == null) { + delay(300) + attempts++ + } + val subNow = subscribeFunc + if (subNow != null) { + // Try again now that dependencies are ready + subscribeAll() + } else { + // Give UI a chance to show empty state rather than spinner forever + if (!_initialLoadComplete.value!!) { + _initialLoadComplete.value = true + _state.value = State.READY + } + } + } + return + } + + _state.value = State.LOADING + + // Subscribe for each geohash in the ±1 set + subscribedGeohashes.forEach { gh -> + val filter = NostrFilter.geohashNotes( + geohash = gh, + since = null, + limit = 200 + ) + val subId = "location-notes-$gh" + Log.d(TAG, "📡 Subscribing to location notes: $subId") + try { + val id = subscribe(filter, subId) { event -> handleEvent(event) } + subscriptionIDs[gh] = id + } catch (e: Exception) { + Log.e(TAG, "Failed to subscribe for $gh: ${e.message}") + } + } + + // 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 (!subscribedGeohashes.contains(eventGeohash)) { + Log.v(TAG, "Ignoring event for non-subscribed 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})") + } + + /** + * Clear error message - matches iOS clearError() + */ + fun clearError() { + _errorMessage.value = null + } + + /** + * Cancel subscription and clear state + */ + fun cancel() { + if (subscriptionIDs.isNotEmpty()) { + subscriptionIDs.values.forEach { subId -> + try { + Log.d(TAG, "🚫 Canceling subscription: $subId") + unsubscribeFunc?.invoke(subId) + } catch (_: Exception) { } + } + subscriptionIDs.clear() + } + subscribedGeohashes = emptySet() + _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..b6313ea7 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 */ @@ -193,4 +205,12 @@ data class NostrFilter( return "NostrFilter(${parts.joinToString(", ")})" } + + /** + * Get geohash value from g tag filter (if present) + * Returns the first geohash in the filter or null if none + */ + fun getGeohash(): String? { + return tagFilters?.get("g")?.firstOrNull() + } } 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..4b1b22ca 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,9 @@ 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 +import androidx.compose.foundation.Canvas +import androidx.compose.ui.geometry.Offset /** * Header components for ChatScreen @@ -53,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 - ) } } @@ -233,7 +240,8 @@ fun ChatHeaderContent( onSidebarClick: () -> Unit, onTripleClick: () -> Unit, onShowAppInfo: () -> Unit, - onLocationChannelsClick: () -> Unit + onLocationChannelsClick: () -> Unit, + onLocationNotesClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme @@ -289,6 +297,7 @@ fun ChatHeaderContent( onTripleTitleClick = onTripleClick, onSidebarClick = onSidebarClick, onLocationChannelsClick = onLocationChannelsClick, + onLocationNotesClick = onLocationNotesClick, viewModel = viewModel ) } @@ -510,6 +519,7 @@ private fun MainHeader( onTripleTitleClick: () -> Unit, onSidebarClick: () -> Unit, onLocationChannelsClick: () -> Unit, + onLocationNotesClick: () -> Unit, viewModel: ChatViewModel ) { val colorScheme = MaterialTheme.colorScheme @@ -573,7 +583,7 @@ private fun MainHeader( } // Location channels button (matching iOS implementation) and bookmark grouped tightly - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 4.dp)) { LocationChannelsButton( viewModel = viewModel, onClick = onLocationChannelsClick @@ -588,7 +598,7 @@ private fun MainHeader( val isBookmarked = bookmarks.contains(currentGeohash) Box( modifier = Modifier - .padding(start = 1.dp) // minimal gap between geohash and bookmark + .padding(start = 2.dp) // minimal gap between geohash and bookmark .size(20.dp) .clickable { bookmarksStore.toggle(currentGeohash) }, contentAlignment = Alignment.Center @@ -603,15 +613,25 @@ private fun MainHeader( } } - // Tor status cable icon when Tor is enabled - TorStatusIcon(modifier = Modifier.size(14.dp)) + // Location Notes button (extracted to separate component) + LocationNotesButton( + viewModel = viewModel, + onClick = onLocationNotesClick + ) + + // Tor status dot when Tor is enabled + TorStatusDot( + modifier = Modifier + .size(8.dp) + .padding(start = 0.dp, end = 2.dp) + ) // PoW status indicator PoWStatusIndicator( modifier = Modifier, style = PoWIndicatorStyle.COMPACT ) - + Spacer(modifier = Modifier.width(2.dp)) PeerCounter( connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID }, joinedChannels = joinedChannels, 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..0ecc25a0 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,8 +439,12 @@ private fun ChatFloatingHeader( onSidebarToggle: () -> Unit, onShowAppInfo: () -> Unit, onPanicClear: () -> Unit, - onLocationChannelsClick: () -> Unit + onLocationChannelsClick: () -> Unit, + onLocationNotesClick: () -> Unit ) { + val context = androidx.compose.ui.platform.LocalContext.current + val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) } + Surface( modifier = Modifier .fillMaxWidth() @@ -460,7 +468,12 @@ private fun ChatFloatingHeader( onSidebarClick = onSidebarToggle, onTripleClick = onPanicClear, onShowAppInfo = onShowAppInfo, - onLocationChannelsClick = onLocationChannelsClick + onLocationChannelsClick = onLocationChannelsClick, + onLocationNotesClick = { + // Ensure location is loaded before showing sheet + locationManager.refreshChannels() + onLocationNotesClick() + } ) }, colors = TopAppBarDefaults.topAppBarColors( @@ -471,6 +484,7 @@ private fun ChatFloatingHeader( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ChatDialogs( showPasswordDialog: Boolean, @@ -483,6 +497,8 @@ private fun ChatDialogs( onAppInfoDismiss: () -> Unit, showLocationChannelsSheet: Boolean, onLocationChannelsSheetDismiss: () -> Unit, + showLocationNotesSheet: Boolean, + onLocationNotesSheetDismiss: () -> Unit, showUserSheet: Boolean, onUserSheetDismiss: () -> Unit, selectedUserForSheet: String, @@ -523,6 +539,14 @@ private fun ChatDialogs( ) } + // Location notes sheet (extracted to separate presenter) + if (showLocationNotesSheet) { + LocationNotesSheetPresenter( + viewModel = viewModel, + onDismiss = onLocationNotesSheetDismiss + ) + } + // 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..b0661c56 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,8 @@ class ChatViewModel( mediaSendingManager.handleTransferProgressEvent(evt) } } + + // Removed background location notes subscription. Notes now load only when sheet opens. } fun cancelMediaSend(messageId: String) { @@ -591,6 +593,8 @@ class ChatViewModel( } } + // Location notes subscription management moved to LocationNotesViewModelExtensions.kt + /** * 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..251c8af9 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -247,8 +247,11 @@ fun LocationChannelsSheet( } // Nearby options (only show if location services are enabled) + // CRITICAL: Filter out .building level (precision 8) - iOS pattern + // iOS: let nearby = manager.availableChannels.filter { $0.level != .building } if (availableChannels.isNotEmpty() && locationServicesEnabled) { - items(availableChannels) { channel -> + val nearbyChannels = availableChannels.filter { it.level != GeohashChannelLevel.BUILDING } + items(nearbyChannels) { channel -> val coverage = coverageString(channel.geohash.length) val nameBase = locationNames[channel.level] val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it } @@ -570,8 +573,6 @@ fun LocationChannelsSheet( if (isPresented) { if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) { locationManager.refreshChannels() - } - if (locationServicesEnabled) { locationManager.beginLiveRefresh() } val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList() @@ -713,6 +714,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 +751,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/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt new file mode 100644 index 00000000..09084a92 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -0,0 +1,67 @@ +package com.bitchat.android.ui + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.bitchat.android.R +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.LocationChannelManager +import com.bitchat.android.nostr.LocationNotesManager + +/** + * Location Notes button component for MainHeader + * Shows in mesh mode when location permission granted AND services enabled + * Icon turns primary color when notes exist, gray otherwise + */ +@Composable +fun LocationNotesButton( + viewModel: ChatViewModel, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + val context = LocalContext.current + + // Get channel and permission state + val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() + val locationManager = remember { LocationChannelManager.getInstance(context) } + val permissionState by locationManager.permissionState.observeAsState() + val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false) + + // Check both permission AND location services enabled + val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED + val locationEnabled = locationPermissionGranted && locationServicesEnabled + + // Get notes count from LocationNotesManager + val notesManager = remember { LocationNotesManager.getInstance() } + val notes by notesManager.notes.observeAsState(emptyList()) + val notesCount = notes.size + + // Only show in mesh mode when location is authorized (iOS pattern) + if (selectedLocationChannel is ChannelID.Mesh && locationEnabled) { + val hasNotes = notesCount > 0 + IconButton( + onClick = onClick, + 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 + ) + } + } +} 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..23b9d952 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -0,0 +1,612 @@ +package com.bitchat.android.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +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.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowUpward +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.FontFamily +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.pluralStringResource +import com.bitchat.android.R +import androidx.compose.ui.text.font.FontWeight +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.LocationNotesManager +import java.text.SimpleDateFormat +import java.util.* +import java.util.Calendar + +/** + * Location Notes Sheet - EXACT iOS UI match for bitchat + * Matches iOS LocationNotesView.swift exactly in style, colors, fonts, and text + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LocationNotesSheet( + geohash: String, + locationName: String?, + nickname: String?, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val isDark = isSystemInDarkTheme() + + // iOS color scheme + val backgroundColor = if (isDark) Color.Black else Color.White + val accentGreen = if (isDark) Color.Green else Color(0xFF008000) // dark: green, light: dark green (0, 0.5, 0) + + // Managers + val notesManager = remember { LocationNotesManager.getInstance() } + 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 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() } + ?: locationNames[GeohashChannelLevel.BLOCK]?.takeIf { it.isNotEmpty() } + + // Input field state + var draft by remember { mutableStateOf("") } + val sendButtonEnabled = draft.trim().isNotEmpty() && state != LocationNotesManager.State.NO_RELAYS + + // Scroll state + val listState = rememberLazyListState() + + // Effect to set geohash when sheet opens + LaunchedEffect(geohash) { + notesManager.setGeohash(geohash) + } + + // Cleanup when sheet closes + DisposableEffect(Unit) { + onDispose { + notesManager.cancel() + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + modifier = modifier, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = backgroundColor, + contentColor = if (isDark) Color.White else Color.Black + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.9f) + ) { + // Header section (matches iOS headerSection) + LocationNotesHeader( + geohash = geohash, + count = count, + locationName = displayLocationName, + state = state, + accentGreen = accentGreen, + backgroundColor = backgroundColor, + onClose = onDismiss + ) + + // ScrollView with notes content + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .background(backgroundColor) + ) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) + ) { + // Notes content (matches iOS notesContent) + when { + state == LocationNotesManager.State.NO_RELAYS -> { + item { + NoRelaysRow( + onRetry = { notesManager.refresh() } + ) + } + } + state == LocationNotesManager.State.LOADING && !initialLoadComplete -> { + item { + LoadingRow() + } + } + notes.isEmpty() -> { + item { + EmptyRow() + } + } + else -> { + items(notes, key = { it.id }) { note -> + NoteRow(note = note) + Spacer(modifier = Modifier.height(12.dp)) + } + } + } + + // Error row (matches iOS errorRow) + errorMessage?.let { error -> + if (state != LocationNotesManager.State.NO_RELAYS) { + item { + ErrorRow( + message = error, + onDismiss = { notesManager.clearError() } + ) + } + } + } + } + } + + // Divider before input (matches iOS overlay) + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f), + thickness = 1.dp + ) + + // Input section (matches iOS inputSection) + LocationNotesInputSection( + draft = draft, + onDraftChange = { draft = it }, + sendButtonEnabled = sendButtonEnabled, + accentGreen = accentGreen, + backgroundColor = backgroundColor, + onSend = { + val content = draft.trim() + if (content.isNotEmpty()) { + notesManager.send(content, nickname) + draft = "" + } + } + ) + } + } +} + +/** + * Header section - matches iOS headerSection exactly + * Shows: "#geohash • X notes", location name, description, and close button + */ +@Composable +private fun LocationNotesHeader( + geohash: String, + count: Int, + locationName: String?, + state: LocationNotesManager.State, + accentGreen: Color, + backgroundColor: Color, + onClose: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor) + .padding(horizontal = 16.dp) + .padding(top = 16.dp, bottom = 12.dp) + ) { + // Title row with close button + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Localized title with ±1 and note count + Text( + text = pluralStringResource( + id = R.plurals.location_notes_title, + count = count, + geohash, + count + ), + fontFamily = FontFamily.Monospace, + fontSize = 18.sp, + color = MaterialTheme.colorScheme.onSurface + ) + + // Close button - iOS style with xmark icon + Box( + modifier = Modifier + .size(32.dp) + .clickable(onClick = onClose), + contentAlignment = Alignment.Center + ) { + Text( + text = "✕", + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Location name in green (building or block) + locationName?.let { name -> + if (name.isNotEmpty()) { + Text( + text = name, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = accentGreen + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + + // Description + Text( + text = stringResource(R.string.location_notes_description), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + + // Relays paused message if no relays + if (state == LocationNotesManager.State.NO_RELAYS) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.location_notes_relays_unavailable), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } +} + +/** + * Note row - matches iOS noteRow exactly + * Shows @basename then timestamp, then content below + */ +@Composable +private fun NoteRow(note: LocationNotesManager.Note) { + // Extract baseName (before #suffix like iOS) + val baseName = note.displayName.split("#", limit = 2).firstOrNull() ?: note.displayName + val ts = timestampText(note.createdAt) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + // First row: @nickname and timestamp + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "@$baseName", + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + if (ts.isNotEmpty()) { + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = ts, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } + + Spacer(modifier = Modifier.height(2.dp)) + + // Second row: content + Text( + text = note.content, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface + ) + } +} + +/** + * No relays row - matches iOS noRelaysRow + */ +@Composable +private fun NoRelaysRow(onRetry: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + ) { + Text( + text = stringResource(R.string.location_notes_no_relays_title), + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.location_notes_no_relays_desc), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.retry), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable(onClick = onRetry) + ) + } +} + +/** + * Loading row - matches iOS loadingRow + */ +@Composable +private fun LoadingRow() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = stringResource(R.string.loading_location_notes), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } +} + +/** + * Empty row - matches iOS emptyRow + */ +@Composable +private fun EmptyRow() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + ) { + Text( + text = stringResource(R.string.location_notes_empty_title), + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.location_notes_empty_desc), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } +} + +/** + * Error row - matches iOS errorRow + */ +@Composable +private fun ErrorRow(message: String, onDismiss: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + ) { + Row( + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "⚠", + fontSize = 12.sp + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = message, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.dismiss), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable(onClick = onDismiss) + ) + } +} + +/** + * Input section - matches main chat input exactly + */ +@Composable +private fun LocationNotesInputSection( + draft: String, + onDraftChange: (String) -> Unit, + sendButtonEnabled: Boolean, + accentGreen: Color, + backgroundColor: Color, + onSend: () -> Unit +) { + val isDark = isSystemInDarkTheme() + val colorScheme = MaterialTheme.colorScheme + + Row( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor) + .padding(horizontal = 12.dp, vertical = 8.dp), // Match main chat padding + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) // Match main chat spacing + ) { + // 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 = stringResource(R.string.location_notes_input_placeholder), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace + ), + color = colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth() + ) + } + } + + // Send button - circular with icon (matches main chat exactly) + IconButton( + onClick = { if (sendButtonEnabled) onSend() }, + enabled = sendButtonEnabled, + modifier = Modifier.size(32.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 = stringResource(R.string.send_message), + 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 + } + ) + } + } + } +} + +/** + * Timestamp text - matches iOS timestampText exactly + * Shows relative time for < 7 days, absolute date otherwise + */ +private fun timestampText(createdAt: Int): String { + val date = Date(createdAt * 1000L) + val now = Date() + + // Calculate days difference + val calendar = Calendar.getInstance() + calendar.time = date + val dateDay = calendar.get(Calendar.DAY_OF_YEAR) + val dateYear = calendar.get(Calendar.YEAR) + + calendar.time = now + val nowDay = calendar.get(Calendar.DAY_OF_YEAR) + val nowYear = calendar.get(Calendar.YEAR) + + val daysDiff = if (dateYear == nowYear) { + nowDay - dateDay + } else { + // Simplified: just check if less than 7 days by timestamp + val diff = (now.time - date.time) / (1000 * 60 * 60 * 24) + diff.toInt() + } + + return if (daysDiff < 7) { + // Relative formatting (abbreviated) + val diffMillis = now.time - date.time + val diffSeconds = diffMillis / 1000 + + when { + diffSeconds < 60 -> "" // Don't show "just now" in iOS + diffSeconds < 3600 -> { + val minutes = (diffSeconds / 60).toInt() + "${minutes}m ago" + } + diffSeconds < 86400 -> { + val hours = (diffSeconds / 3600).toInt() + "${hours}h ago" + } + else -> { + val days = (diffSeconds / 86400).toInt() + "${days}d ago" + } + } + } else { + // Absolute date formatting + val sameYear = dateYear == nowYear + val formatter = if (sameYear) { + SimpleDateFormat("MMM d", Locale.getDefault()) + } else { + SimpleDateFormat("MMM d, y", Locale.getDefault()) + } + formatter.format(date) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt new file mode 100644 index 00000000..9d4da4c9 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt @@ -0,0 +1,100 @@ +package com.bitchat.android.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.bitchat.android.geohash.GeohashChannelLevel +import com.bitchat.android.geohash.LocationChannelManager + +/** + * Presenter component for LocationNotesSheet + * Handles sheet presentation logic with proper error states + * Extracts this logic from ChatScreen for better separation of concerns + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LocationNotesSheetPresenter( + viewModel: ChatViewModel, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val locationManager = remember { LocationChannelManager.getInstance(context) } + val availableChannels by locationManager.availableChannels.observeAsState(emptyList()) + val nickname by viewModel.nickname.observeAsState("") + + // iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash + val buildingGeohash = availableChannels.firstOrNull { it.level == GeohashChannelLevel.BUILDING }?.geohash + + if (buildingGeohash != null) { + // Get location name from locationManager + val locationNames by locationManager.locationNames.observeAsState(emptyMap()) + val locationName = locationNames[GeohashChannelLevel.BUILDING] + ?: locationNames[GeohashChannelLevel.BLOCK] + + LocationNotesSheet( + geohash = buildingGeohash, + locationName = locationName, + nickname = nickname, + onDismiss = onDismiss + ) + } else { + // No building geohash available - show error state (matches iOS) + LocationNotesErrorSheet( + onDismiss = onDismiss, + locationManager = locationManager + ) + } +} + +/** + * Error sheet when location is unavailable + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LocationNotesErrorSheet( + onDismiss: () -> Unit, + locationManager: LocationChannelManager +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + 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 = { + // UNIFIED FIX: Enable location services first (user toggle) + locationManager.enableLocationServices() + // Then request location channels (which will also request permission if needed) + locationManager.enableLocationChannels() + locationManager.refreshChannels() + }) { + Text("Enable Location") + } + } + } +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 9e6f27e2..94414ce5 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -349,4 +349,17 @@ %d أشخاص + + #%1$s ± 1 • %2$d ملاحظة + #%1$s ± 1 • %2$d ملاحظات + + أضف ملاحظات قصيرة ودائمة إلى هذا المكان ليتمكن الزوار الآخرون من العثور عليها. + قنوات الترحيل الجغرافية غير متاحة؛ تم إيقاف الملاحظات + لا توجد قنوات ترحيل جغرافية قريبة + تعتمد الملاحظات على قنوات الترحيل الجغرافية. تحقق من الاتصال ثم أعد المحاولة. + جارٍ تحميل الملاحظات… + لا توجد ملاحظات بعد + كن أول من يضيف ملاحظة لهذا المكان. + إغلاق + أضف ملاحظة لهذا المكان diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 1eb689ed..786c9dcb 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -349,4 +349,18 @@ %d Personen + + + #%1$s ± 1 • %2$d Notiz + #%1$s ± 1 • %2$d Notizen + + füge kurze dauerhafte Notizen zu diesem Ort hinzu, damit andere Besucher sie finden. + Geo-Relays nicht verfügbar; Notizen pausiert + keine Geo-Relays in der Nähe + Notizen basieren auf Geo-Relays. Verbindung prüfen und erneut versuchen. + Notizen werden geladen… + noch keine Notizen + sei der Erste, der hier eine Notiz hinterlässt. + schließen + füge eine Notiz zu diesem Ort hinzu diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 67e71521..acf3cb92 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -349,4 +349,17 @@ %d personas + + #%1$s ± 1 • %2$d nota + #%1$s ± 1 • %2$d notas + + agrega notas cortas y permanentes a este lugar para que otros visitantes las encuentren. + relés geográficos no disponibles; notas en pausa + no hay relés geográficos cercanos + las notas dependen de relés geográficos. verifica la conexión e inténtalo de nuevo. + cargando notas… + aún no hay notas + sé el primero en añadir una para este sitio. + cerrar + agrega una nota para este lugar diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 64d27cbf..b1936bce 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -363,4 +363,17 @@ %d personnes + + #%1$s ± 1 • %2$d note + #%1$s ± 1 • %2$d notes + + ajoutez de courtes notes permanentes à cet endroit pour que d\'autres visiteurs les trouvent. + relais géo indisponibles ; notes en pause + aucun relais géo à proximité + les notes dépendent des relais géo. vérifiez la connexion et réessayez. + chargement des notes… + aucune note pour le moment + soyez le premier à en ajouter pour cet endroit. + fermer + ajoutez une note pour cet endroit diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index fbca26d0..a15f87af 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -1,5 +1,18 @@ + + #%1$s ± 1 • %2$d הערה + #%1$s ± 1 • %2$d הערות + + הוסף הערות קצרות וקבועות למיקום זה כדי שמבקרים אחרים ימצאו אותן. + ממסרי מיקום אינם זמינים; ההערות הושהו + אין ממסרי מיקום בקרבת מקום + ההערות תלויות בממסרי מיקום. בדוק חיבור ונסה שוב. + טוען הערות… + אין עדיין הערות + היה הראשון להוסיף הערה למקום זה. + סגור + הוסף הערה למקום זה diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 59f0723c..20981383 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -349,4 +349,17 @@ %d लोग + + #%1$s ± 1 • %2$d नोट + #%1$s ± 1 • %2$d नोट्स + + अन्य आगंतुकों के लिए इस स्थान पर छोटी स्थायी नोट्स जोड़ें। + जियो रिले उपलब्ध नहीं; नोट्स विरामित + आसपास कोई जियो रिले नहीं + नोट्स जियो रिले पर निर्भर हैं। कनेक्शन जांचें और फिर से प्रयास करें। + नोट्स लोड हो रहे हैं… + अभी कोई नोट नहीं + इस स्थान के लिए पहला नोट जोड़ें। + बंद करें + इस स्थान के लिए एक नोट जोड़ें diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 65e7b40b..00f8e7cf 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -349,4 +349,17 @@ %d orang + + #%1$s ± 1 • %2$d catatan + #%1$s ± 1 • %2$d catatan + + tambahkan catatan singkat permanen ke lokasi ini agar pengunjung lain dapat menemukannya. + relay geo tidak tersedia; catatan dijeda + tidak ada relay geo di sekitar + catatan bergantung pada relay geo. periksa koneksi lalu coba lagi. + memuat catatan… + belum ada catatan + jadilah yang pertama menambahkan catatan untuk tempat ini. + tutup + tambahkan catatan untuk tempat ini diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index f1f1066e..d086ba3e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -383,4 +383,17 @@ %d persone + + #%1$s ± 1 • %2$d nota + #%1$s ± 1 • %2$d note + + aggiungi brevi note permanenti a questo luogo per altri visitatori. + relay geolocalizzati non disponibili; note in pausa + nessun relay geolocalizzato nelle vicinanze + le note dipendono dai relay geo. controlla la connessione e riprova. + caricamento note… + nessuna nota ancora + sii il primo ad aggiungerne una per questo posto. + chiudi + aggiungi una nota per questo luogo diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index b1118a17..be41d3d4 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -349,4 +349,17 @@ %d 人 + + #%1$s ± 1 • %2$d 件のメモ + #%1$s ± 1 • %2$d 件のメモ + + 他の訪問者が見つけられるよう、この場所に短いメモを追加しましょう。 + ジオリレーが利用できません。メモを一時停止中 + 近くにジオリレーがありません + メモはジオリレーに依存します。接続を確認して再試行してください。 + メモを読み込み中… + まだメモはありません + この場所の最初のメモを追加しましょう。 + 閉じる + この場所へのメモを追加 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 52dfd76e..3163fa0c 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -349,4 +349,17 @@ %d명 + + #%1$s ± 1 • %2$d개의 노트 + #%1$s ± 1 • %2$d개의 노트 + + 다른 방문자들이 볼 수 있도록 이 위치에 짧은 영구 노트를 추가하세요. + 지오 릴레이를 사용할 수 없음; 노트 일시 중지됨 + 주변에 지오 릴레이가 없음 + 노트는 지오 릴레이에 의존합니다. 연결을 확인하고 다시 시도하세요. + 노트 불러오는 중… + 아직 노트가 없습니다 + 이 장소에 첫 번째 노트를 추가해 보세요. + 닫기 + 이 장소에 노트 추가 diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 38e3c212..8d4d3096 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -381,4 +381,17 @@ %d personen + + #%1$s ± 1 • %2$d notitie + #%1$s ± 1 • %2$d notities + + voeg korte permanente notities toe aan deze locatie zodat andere bezoekers ze kunnen vinden. + geo-relays niet beschikbaar; notities gepauzeerd + geen geo-relays in de buurt + notities zijn afhankelijk van geo-relays. controleer de verbinding en probeer opnieuw. + notities laden… + nog geen notities + wees de eerste die een notitie voor deze plek toevoegt. + sluiten + voeg een notitie toe voor deze plek diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index df589a4b..492b0af0 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1,5 +1,18 @@ + + #%1$s ± 1 • %2$d notatka + #%1$s ± 1 • %2$d notatek + + dodaj krótkie stałe notatki do tej lokalizacji, aby inni odwiedzający mogli je znaleźć. + przekaźniki geo niedostępne; notatki wstrzymane + brak przekaźników geo w pobliżu + notatki zależą od przekaźników geo. sprawdź połączenie i spróbuj ponownie. + ładowanie notatek… + brak notatek + bądź pierwszy, który doda notatkę dla tego miejsca. + zamknij + dodaj notatkę dla tego miejsca diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 3a6085f0..7d1998be 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -349,4 +349,17 @@ %d pessoas + + #%1$s ± 1 • %2$d nota + #%1$s ± 1 • %2$d notas + + adicione notas curtas e permanentes a este local para que outros visitantes as encontrem. + relés geográficos indisponíveis; notas em pausa + nenhum relé geográfico nas proximidades + as notas dependem de relés geográficos. verifique a conexão e tente novamente. + carregando notas… + ainda não há notas + seja o primeiro a adicionar uma para este local. + fechar + adicione uma nota para este local diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 17f39885..207810b4 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -349,4 +349,17 @@ %d pessoas + + #%1$s ± 1 • %2$d nota + #%1$s ± 1 • %2$d notas + + adicione notas curtas e permanentes a este local para que outros visitantes as encontrem. + relés geográficos indisponíveis; notas em pausa + nenhum relé geográfico nas proximidades + as notas dependem de relés geográficos. verifique a conexão e tente novamente. + carregando notas… + ainda não há notas + seja o primeiro a adicionar uma para este local. + fechar + adicione uma nota para este local diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index a6f9526e..85fd0ba4 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -339,4 +339,17 @@ %d человек + + #%1$s ± 1 • %2$d заметка + #%1$s ± 1 • %2$d заметок + + добавьте короткие постоянные заметки к этому месту, чтобы другие посетители могли их найти. + гео-релей недоступны; заметки приостановлены + рядом нет гео-релеев + заметки зависят от гео-релеев. проверьте подключение и повторите попытку. + загрузка заметок… + заметок пока нет + станьте первым, кто добавит заметку для этого места. + закрыть + добавьте заметку для этого места diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 5ae23301..62d8ef18 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -337,4 +337,17 @@ 1 person %d personer + + #%1$s ± 1 • %2$d anteckning + #%1$s ± 1 • %2$d anteckningar + + lägg till korta permanenta anteckningar för den här platsen så att andra besökare kan hitta dem. + geo-reläer är inte tillgängliga; anteckningar pausade + inga geo-reläer i närheten + anteckningar är beroende av geo-reläer. kontrollera anslutningen och försök igen. + laddar anteckningar… + inga anteckningar ännu + var först med att lägga till en anteckning för den här platsen. + stäng + lägg till en anteckning för den här platsen diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index b2830852..cb728f5c 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -337,4 +337,17 @@ 1 kişi %d kişi + + #%1$s ± 1 • %2$d not + #%1$s ± 1 • %2$d not + + Diğer ziyaretçilerin bulabilmesi için bu konuma kısa kalıcı notlar ekleyin. + coğrafi röleler kullanılamıyor; notlar duraklatıldı + yakınlarda coğrafi röle yok + notlar coğrafi rölelere bağlıdır. bağlantıyı kontrol edip tekrar deneyin. + notlar yükleniyor… + henüz not yok + bu yer için ilk notu ekleyen siz olun. + kapat + bu yer için bir not ekleyin diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 9475fc97..f6a5832c 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -349,4 +349,17 @@ %d لوگ + + #%1$s ± 1 • %2$d نوٹ + #%1$s ± 1 • %2$d نوٹس + + دیگر زائرین کے لیے اس جگہ پر مختصر مستقل نوٹس شامل کریں۔ + جغرافیائی ریلے دستیاب نہیں؛ نوٹس معطل + قریب کوئی جغرافیائی ریلے نہیں + نوٹس جغرافیائی ریلے پر منحصر ہیں۔ کنکشن چیک کریں اور دوبارہ کوشش کریں۔ + نوٹس لوڈ ہو رہے ہیں… + ابھی تک کوئی نوٹس نہیں + اس جگہ کے لیے پہلا نوٹ شامل کریں۔ + بند کریں + اس جگہ کے لیے ایک نوٹ شامل کریں diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2d670d71..6ab11776 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1,5 +1,18 @@ + + #%1$s ± 1 • %2$d 条笔记 + #%1$s ± 1 • %2$d 条笔记 + + 为此位置添加简短的永久备注,方便其他访客查看。 + 地理中继不可用;笔记已暂停 + 附近没有地理中继 + 笔记依赖地理中继。请检查连接后重试。 + 正在加载笔记… + 还没有笔记 + 成为第一个为此地点添加笔记的人。 + 关闭 + 为此地点添加一条笔记 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 2a3aa81b..b119edfb 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1,5 +1,18 @@ + + #%1$s ± 1 • %2$d 則筆記 + #%1$s ± 1 • %2$d 則筆記 + + 為此位置新增簡短永久備註,供其他訪客查看。 + 地理中繼無法使用;筆記已暫停 + 附近沒有地理中繼 + 筆記依賴地理中繼。請檢查連線後重試。 + 正在載入筆記… + 尚無筆記 + 成為第一個為此地點新增筆記的人。 + 關閉 + 為此地點新增一則筆記 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index b4a90b17..1ae11941 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -362,4 +362,17 @@ %d 人 + + #%1$s ± 1 • %2$d 条笔记 + #%1$s ± 1 • %2$d 条笔记 + + 为此位置添加简短的永久备注,方便其他访客查看。 + 地理中继不可用;笔记已暂停 + 附近没有地理中继 + 笔记依赖地理中继。请检查连接后重试。 + 正在加载笔记… + 还没有笔记 + 成为第一个为此地点添加笔记的人。 + 关闭 + 为此地点添加一条笔记 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4bd9ae15..cc4903f6 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 @@ -216,6 +217,21 @@ province region + + + #%1$s ± 1 • %2$d note + #%1$s ± 1 • %2$d notes + + add short permanent notes to this location for other visitors to find. + geo relays unavailable; notes paused + no geo relays nearby + notes rely on geo relays. check connection and try again. + loading notes… + no notes yet + be the first to add one for this spot. + dismiss + add a note for this place + debug tools developer utilities for diagnostics and control