mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 03:25:20 +00:00
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
This commit is contained in:
@@ -19,6 +19,9 @@ class BitchatApplication : Application() {
|
|||||||
// Initialize relay directory (loads assets/nostr_relays.csv)
|
// Initialize relay directory (loads assets/nostr_relays.csv)
|
||||||
RelayDirectory.initialize(this)
|
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
|
// Initialize favorites persistence early so MessageRouter/NostrTransport can use it on startup
|
||||||
try {
|
try {
|
||||||
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(this)
|
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(this)
|
||||||
|
|||||||
@@ -598,6 +598,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
PoWPreferenceManager.init(this@MainActivity)
|
PoWPreferenceManager.init(this@MainActivity)
|
||||||
Log.d("MainActivity", "PoW preferences initialized")
|
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)
|
// Ensure all permissions are still granted (user might have revoked in settings)
|
||||||
if (!permissionManager.areAllPermissionsGranted()) {
|
if (!permissionManager.areAllPermissionsGranted()) {
|
||||||
val missing = permissionManager.getMissingPermissions()
|
val missing = permissionManager.getMissingPermissions()
|
||||||
|
|||||||
@@ -115,4 +115,36 @@ object Geohash {
|
|||||||
lonMax = maxOf(lonInterval.first, lonInterval.second)
|
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<String> {
|
||||||
|
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<String>()
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package com.bitchat.android.geohash
|
|||||||
* Direct port from iOS implementation for 100% compatibility
|
* Direct port from iOS implementation for 100% compatibility
|
||||||
*/
|
*/
|
||||||
enum class GeohashChannelLevel(val precision: Int, val displayName: String) {
|
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"),
|
BLOCK(7, "Block"),
|
||||||
NEIGHBORHOOD(6, "Neighborhood"),
|
NEIGHBORHOOD(6, "Neighborhood"),
|
||||||
CITY(5, "City"),
|
CITY(5, "City"),
|
||||||
|
|||||||
@@ -88,10 +88,17 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable location channels (request permission if needed)
|
* Enable location channels (request permission if needed)
|
||||||
|
* UNIFIED: Only requests location if location services are enabled by user
|
||||||
*/
|
*/
|
||||||
fun enableLocationChannels() {
|
fun enableLocationChannels() {
|
||||||
Log.d(TAG, "enableLocationChannels() called")
|
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()) {
|
when (getCurrentPermissionStatus()) {
|
||||||
PermissionState.NOT_DETERMINED -> {
|
PermissionState.NOT_DETERMINED -> {
|
||||||
Log.d(TAG, "Permission not determined - user needs to grant in app settings")
|
Log.d(TAG, "Permission not determined - user needs to grant in app settings")
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<Note>>(emptyList())
|
||||||
|
val notes: LiveData<List<Note>> = _notes
|
||||||
|
|
||||||
|
private val _geohash = MutableLiveData<String?>(null)
|
||||||
|
val geohash: LiveData<String?> = _geohash
|
||||||
|
|
||||||
|
private val _initialLoadComplete = MutableLiveData(false)
|
||||||
|
val initialLoadComplete: LiveData<Boolean> = _initialLoadComplete
|
||||||
|
|
||||||
|
private val _state = MutableLiveData(State.IDLE)
|
||||||
|
val state: LiveData<State> = _state
|
||||||
|
|
||||||
|
private val _errorMessage = MutableLiveData<String?>(null)
|
||||||
|
val errorMessage: LiveData<String?> = _errorMessage
|
||||||
|
|
||||||
|
// Private state
|
||||||
|
private var subscriptionIDs: MutableMap<String, String> = mutableMapOf()
|
||||||
|
private val noteIDs = mutableSetOf<String>() // For deduplication
|
||||||
|
private var subscribedGeohashes: Set<String> = 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<String>?) -> 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<String>?) -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
* Create filter for specific event IDs
|
||||||
*/
|
*/
|
||||||
@@ -193,4 +205,12 @@ data class NostrFilter(
|
|||||||
|
|
||||||
return "NostrFilter(${parts.joinToString(", ")})"
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<List<String>>()
|
||||||
|
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)
|
* Create a geohash-scoped ephemeral public message (kind 20000)
|
||||||
* Includes Proof of Work mining if enabled in settings
|
* Includes Proof of Work mining if enabled in settings
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ import androidx.compose.ui.text.input.ImeAction
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.bitchat.android.core.ui.utils.singleOrTripleClickable
|
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
|
* Header components for ChatScreen
|
||||||
@@ -53,25 +56,29 @@ fun isFavoriteReactive(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TorStatusIcon(
|
fun TorStatusDot(
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState()
|
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState()
|
||||||
|
|
||||||
if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) {
|
if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) {
|
||||||
val cableColor = when {
|
val dotColor = when {
|
||||||
torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500)
|
torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500) // Orange - bootstrapping
|
||||||
torStatus.running && torStatus.bootstrapPercent >= 100 -> Color(0xFF00C851)
|
torStatus.running && torStatus.bootstrapPercent >= 100 -> Color(0xFF00C851) // Green - connected
|
||||||
else -> Color.Red
|
else -> Color.Red // Red - error/disconnected
|
||||||
}
|
}
|
||||||
Icon(
|
Canvas(
|
||||||
imageVector = Icons.Outlined.Cable,
|
modifier = modifier
|
||||||
contentDescription = stringResource(R.string.cd_tor_status),
|
) {
|
||||||
modifier = modifier,
|
val radius = size.minDimension / 2
|
||||||
tint = cableColor
|
drawCircle(
|
||||||
|
color = dotColor,
|
||||||
|
radius = radius,
|
||||||
|
center = Offset(size.width / 2, size.height / 2)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NoiseSessionIcon(
|
fun NoiseSessionIcon(
|
||||||
@@ -233,7 +240,8 @@ fun ChatHeaderContent(
|
|||||||
onSidebarClick: () -> Unit,
|
onSidebarClick: () -> Unit,
|
||||||
onTripleClick: () -> Unit,
|
onTripleClick: () -> Unit,
|
||||||
onShowAppInfo: () -> Unit,
|
onShowAppInfo: () -> Unit,
|
||||||
onLocationChannelsClick: () -> Unit
|
onLocationChannelsClick: () -> Unit,
|
||||||
|
onLocationNotesClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
@@ -289,6 +297,7 @@ fun ChatHeaderContent(
|
|||||||
onTripleTitleClick = onTripleClick,
|
onTripleTitleClick = onTripleClick,
|
||||||
onSidebarClick = onSidebarClick,
|
onSidebarClick = onSidebarClick,
|
||||||
onLocationChannelsClick = onLocationChannelsClick,
|
onLocationChannelsClick = onLocationChannelsClick,
|
||||||
|
onLocationNotesClick = onLocationNotesClick,
|
||||||
viewModel = viewModel
|
viewModel = viewModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -510,6 +519,7 @@ private fun MainHeader(
|
|||||||
onTripleTitleClick: () -> Unit,
|
onTripleTitleClick: () -> Unit,
|
||||||
onSidebarClick: () -> Unit,
|
onSidebarClick: () -> Unit,
|
||||||
onLocationChannelsClick: () -> Unit,
|
onLocationChannelsClick: () -> Unit,
|
||||||
|
onLocationNotesClick: () -> Unit,
|
||||||
viewModel: ChatViewModel
|
viewModel: ChatViewModel
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
@@ -573,7 +583,7 @@ private fun MainHeader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Location channels button (matching iOS implementation) and bookmark grouped tightly
|
// 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(
|
LocationChannelsButton(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onClick = onLocationChannelsClick
|
onClick = onLocationChannelsClick
|
||||||
@@ -588,7 +598,7 @@ private fun MainHeader(
|
|||||||
val isBookmarked = bookmarks.contains(currentGeohash)
|
val isBookmarked = bookmarks.contains(currentGeohash)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
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)
|
.size(20.dp)
|
||||||
.clickable { bookmarksStore.toggle(currentGeohash) },
|
.clickable { bookmarksStore.toggle(currentGeohash) },
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
@@ -603,15 +613,25 @@ private fun MainHeader(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tor status cable icon when Tor is enabled
|
// Location Notes button (extracted to separate component)
|
||||||
TorStatusIcon(modifier = Modifier.size(14.dp))
|
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
|
// PoW status indicator
|
||||||
PoWStatusIndicator(
|
PoWStatusIndicator(
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
style = PoWIndicatorStyle.COMPACT
|
style = PoWIndicatorStyle.COMPACT
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
PeerCounter(
|
PeerCounter(
|
||||||
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
|
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
|
||||||
joinedChannels = joinedChannels,
|
joinedChannels = joinedChannels,
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
var showPasswordDialog by remember { mutableStateOf(false) }
|
var showPasswordDialog by remember { mutableStateOf(false) }
|
||||||
var passwordInput by remember { mutableStateOf("") }
|
var passwordInput by remember { mutableStateOf("") }
|
||||||
var showLocationChannelsSheet by remember { mutableStateOf(false) }
|
var showLocationChannelsSheet by remember { mutableStateOf(false) }
|
||||||
|
var showLocationNotesSheet by remember { mutableStateOf(false) }
|
||||||
var showUserSheet by remember { mutableStateOf(false) }
|
var showUserSheet by remember { mutableStateOf(false) }
|
||||||
var selectedUserForSheet by remember { mutableStateOf("") }
|
var selectedUserForSheet by remember { mutableStateOf("") }
|
||||||
var selectedMessageForSheet by remember { mutableStateOf<BitchatMessage?>(null) }
|
var selectedMessageForSheet by remember { mutableStateOf<BitchatMessage?>(null) }
|
||||||
@@ -241,7 +242,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
onSidebarToggle = { viewModel.showSidebar() },
|
onSidebarToggle = { viewModel.showSidebar() },
|
||||||
onShowAppInfo = { viewModel.showAppInfo() },
|
onShowAppInfo = { viewModel.showAppInfo() },
|
||||||
onPanicClear = { viewModel.panicClearAllData() },
|
onPanicClear = { viewModel.panicClearAllData() },
|
||||||
onLocationChannelsClick = { showLocationChannelsSheet = true }
|
onLocationChannelsClick = { showLocationChannelsSheet = true },
|
||||||
|
onLocationNotesClick = { showLocationNotesSheet = true }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Divider under header - positioned after status bar + header height
|
// Divider under header - positioned after status bar + header height
|
||||||
@@ -354,6 +356,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
onAppInfoDismiss = { viewModel.hideAppInfo() },
|
onAppInfoDismiss = { viewModel.hideAppInfo() },
|
||||||
showLocationChannelsSheet = showLocationChannelsSheet,
|
showLocationChannelsSheet = showLocationChannelsSheet,
|
||||||
onLocationChannelsSheetDismiss = { showLocationChannelsSheet = false },
|
onLocationChannelsSheetDismiss = { showLocationChannelsSheet = false },
|
||||||
|
showLocationNotesSheet = showLocationNotesSheet,
|
||||||
|
onLocationNotesSheetDismiss = { showLocationNotesSheet = false },
|
||||||
showUserSheet = showUserSheet,
|
showUserSheet = showUserSheet,
|
||||||
onUserSheetDismiss = {
|
onUserSheetDismiss = {
|
||||||
showUserSheet = false
|
showUserSheet = false
|
||||||
@@ -435,8 +439,12 @@ private fun ChatFloatingHeader(
|
|||||||
onSidebarToggle: () -> Unit,
|
onSidebarToggle: () -> Unit,
|
||||||
onShowAppInfo: () -> Unit,
|
onShowAppInfo: () -> Unit,
|
||||||
onPanicClear: () -> 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(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -460,7 +468,12 @@ private fun ChatFloatingHeader(
|
|||||||
onSidebarClick = onSidebarToggle,
|
onSidebarClick = onSidebarToggle,
|
||||||
onTripleClick = onPanicClear,
|
onTripleClick = onPanicClear,
|
||||||
onShowAppInfo = onShowAppInfo,
|
onShowAppInfo = onShowAppInfo,
|
||||||
onLocationChannelsClick = onLocationChannelsClick
|
onLocationChannelsClick = onLocationChannelsClick,
|
||||||
|
onLocationNotesClick = {
|
||||||
|
// Ensure location is loaded before showing sheet
|
||||||
|
locationManager.refreshChannels()
|
||||||
|
onLocationNotesClick()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
@@ -471,6 +484,7 @@ private fun ChatFloatingHeader(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChatDialogs(
|
private fun ChatDialogs(
|
||||||
showPasswordDialog: Boolean,
|
showPasswordDialog: Boolean,
|
||||||
@@ -483,6 +497,8 @@ private fun ChatDialogs(
|
|||||||
onAppInfoDismiss: () -> Unit,
|
onAppInfoDismiss: () -> Unit,
|
||||||
showLocationChannelsSheet: Boolean,
|
showLocationChannelsSheet: Boolean,
|
||||||
onLocationChannelsSheetDismiss: () -> Unit,
|
onLocationChannelsSheetDismiss: () -> Unit,
|
||||||
|
showLocationNotesSheet: Boolean,
|
||||||
|
onLocationNotesSheetDismiss: () -> Unit,
|
||||||
showUserSheet: Boolean,
|
showUserSheet: Boolean,
|
||||||
onUserSheetDismiss: () -> Unit,
|
onUserSheetDismiss: () -> Unit,
|
||||||
selectedUserForSheet: String,
|
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
|
// User action sheet
|
||||||
if (showUserSheet) {
|
if (showUserSheet) {
|
||||||
ChatUserSheet(
|
ChatUserSheet(
|
||||||
|
|||||||
@@ -147,6 +147,8 @@ class ChatViewModel(
|
|||||||
mediaSendingManager.handleTransferProgressEvent(evt)
|
mediaSendingManager.handleTransferProgressEvent(evt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Removed background location notes subscription. Notes now load only when sheet opens.
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelMediaSend(messageId: String) {
|
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)
|
* Update reactive states for all connected peers (session states, fingerprints, nicknames, RSSI)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -247,8 +247,11 @@ fun LocationChannelsSheet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Nearby options (only show if location services are enabled)
|
// 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) {
|
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 coverage = coverageString(channel.geohash.length)
|
||||||
val nameBase = locationNames[channel.level]
|
val nameBase = locationNames[channel.level]
|
||||||
val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it }
|
val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it }
|
||||||
@@ -570,8 +573,6 @@ fun LocationChannelsSheet(
|
|||||||
if (isPresented) {
|
if (isPresented) {
|
||||||
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
|
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
|
||||||
locationManager.refreshChannels()
|
locationManager.refreshChannels()
|
||||||
}
|
|
||||||
if (locationServicesEnabled) {
|
|
||||||
locationManager.beginLiveRefresh()
|
locationManager.beginLiveRefresh()
|
||||||
}
|
}
|
||||||
val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList()
|
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 ctx = androidx.compose.ui.platform.LocalContext.current
|
||||||
val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
|
val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
|
||||||
val levelName = when (channel.level) {
|
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.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.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)
|
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
|
5 -> GeohashChannelLevel.CITY
|
||||||
6 -> GeohashChannelLevel.NEIGHBORHOOD
|
6 -> GeohashChannelLevel.NEIGHBORHOOD
|
||||||
7 -> GeohashChannelLevel.BLOCK
|
7 -> GeohashChannelLevel.BLOCK
|
||||||
else -> GeohashChannelLevel.BLOCK
|
8 -> GeohashChannelLevel.BUILDING // iOS: precision 8 for building-level
|
||||||
|
else -> if (length > 8) GeohashChannelLevel.BUILDING else GeohashChannelLevel.BLOCK
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -349,4 +349,17 @@
|
|||||||
<item quantity="other">%d أشخاص</item>
|
<item quantity="other">%d أشخاص</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<plurals name="location_notes_title">
|
||||||
|
<item quantity="one">#%1$s ± 1 • %2$d ملاحظة</item>
|
||||||
|
<item quantity="other">#%1$s ± 1 • %2$d ملاحظات</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="location_notes_description">أضف ملاحظات قصيرة ودائمة إلى هذا المكان ليتمكن الزوار الآخرون من العثور عليها.</string>
|
||||||
|
<string name="location_notes_relays_unavailable">قنوات الترحيل الجغرافية غير متاحة؛ تم إيقاف الملاحظات</string>
|
||||||
|
<string name="location_notes_no_relays_title">لا توجد قنوات ترحيل جغرافية قريبة</string>
|
||||||
|
<string name="location_notes_no_relays_desc">تعتمد الملاحظات على قنوات الترحيل الجغرافية. تحقق من الاتصال ثم أعد المحاولة.</string>
|
||||||
|
<string name="loading_location_notes">جارٍ تحميل الملاحظات…</string>
|
||||||
|
<string name="location_notes_empty_title">لا توجد ملاحظات بعد</string>
|
||||||
|
<string name="location_notes_empty_desc">كن أول من يضيف ملاحظة لهذا المكان.</string>
|
||||||
|
<string name="dismiss">إغلاق</string>
|
||||||
|
<string name="location_notes_input_placeholder">أضف ملاحظة لهذا المكان</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -349,4 +349,18 @@
|
|||||||
<item quantity="other">%d Personen</item>
|
<item quantity="other">%d Personen</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
|
||||||
|
<plurals name="location_notes_title">
|
||||||
|
<item quantity="one">#%1$s ± 1 • %2$d Notiz</item>
|
||||||
|
<item quantity="other">#%1$s ± 1 • %2$d Notizen</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="location_notes_description">füge kurze dauerhafte Notizen zu diesem Ort hinzu, damit andere Besucher sie finden.</string>
|
||||||
|
<string name="location_notes_relays_unavailable">Geo-Relays nicht verfügbar; Notizen pausiert</string>
|
||||||
|
<string name="location_notes_no_relays_title">keine Geo-Relays in der Nähe</string>
|
||||||
|
<string name="location_notes_no_relays_desc">Notizen basieren auf Geo-Relays. Verbindung prüfen und erneut versuchen.</string>
|
||||||
|
<string name="loading_location_notes">Notizen werden geladen…</string>
|
||||||
|
<string name="location_notes_empty_title">noch keine Notizen</string>
|
||||||
|
<string name="location_notes_empty_desc">sei der Erste, der hier eine Notiz hinterlässt.</string>
|
||||||
|
<string name="dismiss">schließen</string>
|
||||||
|
<string name="location_notes_input_placeholder">füge eine Notiz zu diesem Ort hinzu</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -349,4 +349,17 @@
|
|||||||
<item quantity="other">%d personas</item>
|
<item quantity="other">%d personas</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<plurals name="location_notes_title">
|
||||||
|
<item quantity="one">#%1$s ± 1 • %2$d nota</item>
|
||||||
|
<item quantity="other">#%1$s ± 1 • %2$d notas</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="location_notes_description">agrega notas cortas y permanentes a este lugar para que otros visitantes las encuentren.</string>
|
||||||
|
<string name="location_notes_relays_unavailable">relés geográficos no disponibles; notas en pausa</string>
|
||||||
|
<string name="location_notes_no_relays_title">no hay relés geográficos cercanos</string>
|
||||||
|
<string name="location_notes_no_relays_desc">las notas dependen de relés geográficos. verifica la conexión e inténtalo de nuevo.</string>
|
||||||
|
<string name="loading_location_notes">cargando notas…</string>
|
||||||
|
<string name="location_notes_empty_title">aún no hay notas</string>
|
||||||
|
<string name="location_notes_empty_desc">sé el primero en añadir una para este sitio.</string>
|
||||||
|
<string name="dismiss">cerrar</string>
|
||||||
|
<string name="location_notes_input_placeholder">agrega una nota para este lugar</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -363,4 +363,17 @@
|
|||||||
<item quantity="other">%d personnes</item>
|
<item quantity="other">%d personnes</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<plurals name="location_notes_title">
|
||||||
|
<item quantity="one">#%1$s ± 1 • %2$d note</item>
|
||||||
|
<item quantity="other">#%1$s ± 1 • %2$d notes</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="location_notes_description">ajoutez de courtes notes permanentes à cet endroit pour que d\'autres visiteurs les trouvent.</string>
|
||||||
|
<string name="location_notes_relays_unavailable">relais géo indisponibles ; notes en pause</string>
|
||||||
|
<string name="location_notes_no_relays_title">aucun relais géo à proximité</string>
|
||||||
|
<string name="location_notes_no_relays_desc">les notes dépendent des relais géo. vérifiez la connexion et réessayez.</string>
|
||||||
|
<string name="loading_location_notes">chargement des notes…</string>
|
||||||
|
<string name="location_notes_empty_title">aucune note pour le moment</string>
|
||||||
|
<string name="location_notes_empty_desc">soyez le premier à en ajouter pour cet endroit.</string>
|
||||||
|
<string name="dismiss">fermer</string>
|
||||||
|
<string name="location_notes_input_placeholder">ajoutez une note pour cet endroit</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- TODO: Hebrew translations -->
|
<!-- TODO: Hebrew translations -->
|
||||||
|
<plurals name="location_notes_title">
|
||||||
|
<item quantity="one">#%1$s ± 1 • %2$d הערה</item>
|
||||||
|
<item quantity="other">#%1$s ± 1 • %2$d הערות</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="location_notes_description">הוסף הערות קצרות וקבועות למיקום זה כדי שמבקרים אחרים ימצאו אותן.</string>
|
||||||
|
<string name="location_notes_relays_unavailable">ממסרי מיקום אינם זמינים; ההערות הושהו</string>
|
||||||
|
<string name="location_notes_no_relays_title">אין ממסרי מיקום בקרבת מקום</string>
|
||||||
|
<string name="location_notes_no_relays_desc">ההערות תלויות בממסרי מיקום. בדוק חיבור ונסה שוב.</string>
|
||||||
|
<string name="loading_location_notes">טוען הערות…</string>
|
||||||
|
<string name="location_notes_empty_title">אין עדיין הערות</string>
|
||||||
|
<string name="location_notes_empty_desc">היה הראשון להוסיף הערה למקום זה.</string>
|
||||||
|
<string name="dismiss">סגור</string>
|
||||||
|
<string name="location_notes_input_placeholder">הוסף הערה למקום זה</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
|
|||||||
@@ -349,4 +349,17 @@
|
|||||||
<item quantity="other">%d लोग</item>
|
<item quantity="other">%d लोग</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<plurals name="location_notes_title">
|
||||||
|
<item quantity="one">#%1$s ± 1 • %2$d नोट</item>
|
||||||
|
<item quantity="other">#%1$s ± 1 • %2$d नोट्स</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="location_notes_description">अन्य आगंतुकों के लिए इस स्थान पर छोटी स्थायी नोट्स जोड़ें।</string>
|
||||||
|
<string name="location_notes_relays_unavailable">जियो रिले उपलब्ध नहीं; नोट्स विरामित</string>
|
||||||
|
<string name="location_notes_no_relays_title">आसपास कोई जियो रिले नहीं</string>
|
||||||
|
<string name="location_notes_no_relays_desc">नोट्स जियो रिले पर निर्भर हैं। कनेक्शन जांचें और फिर से प्रयास करें।</string>
|
||||||
|
<string name="loading_location_notes">नोट्स लोड हो रहे हैं…</string>
|
||||||
|
<string name="location_notes_empty_title">अभी कोई नोट नहीं</string>
|
||||||
|
<string name="location_notes_empty_desc">इस स्थान के लिए पहला नोट जोड़ें।</string>
|
||||||
|
<string name="dismiss">बंद करें</string>
|
||||||
|
<string name="location_notes_input_placeholder">इस स्थान के लिए एक नोट जोड़ें</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -349,4 +349,17 @@
|
|||||||
<item quantity="other">%d orang</item>
|
<item quantity="other">%d orang</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<plurals name="location_notes_title">
|
||||||
|
<item quantity="one">#%1$s ± 1 • %2$d catatan</item>
|
||||||
|
<item quantity="other">#%1$s ± 1 • %2$d catatan</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="location_notes_description">tambahkan catatan singkat permanen ke lokasi ini agar pengunjung lain dapat menemukannya.</string>
|
||||||
|
<string name="location_notes_relays_unavailable">relay geo tidak tersedia; catatan dijeda</string>
|
||||||
|
<string name="location_notes_no_relays_title">tidak ada relay geo di sekitar</string>
|
||||||
|
<string name="location_notes_no_relays_desc">catatan bergantung pada relay geo. periksa koneksi lalu coba lagi.</string>
|
||||||
|
<string name="loading_location_notes">memuat catatan…</string>
|
||||||
|
<string name="location_notes_empty_title">belum ada catatan</string>
|
||||||
|
<string name="location_notes_empty_desc">jadilah yang pertama menambahkan catatan untuk tempat ini.</string>
|
||||||
|
<string name="dismiss">tutup</string>
|
||||||
|
<string name="location_notes_input_placeholder">tambahkan catatan untuk tempat ini</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -383,4 +383,17 @@
|
|||||||
<item quantity="other">%d persone</item>
|
<item quantity="other">%d persone</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<plurals name="location_notes_title">
|
||||||
|
<item quantity="one">#%1$s ± 1 • %2$d nota</item>
|
||||||
|
<item quantity="other">#%1$s ± 1 • %2$d note</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="location_notes_description">aggiungi brevi note permanenti a questo luogo per altri visitatori.</string>
|
||||||
|
<string name="location_notes_relays_unavailable">relay geolocalizzati non disponibili; note in pausa</string>
|
||||||
|
<string name="location_notes_no_relays_title">nessun relay geolocalizzato nelle vicinanze</string>
|
||||||
|
<string name="location_notes_no_relays_desc">le note dipendono dai relay geo. controlla la connessione e riprova.</string>
|
||||||
|
<string name="loading_location_notes">caricamento note…</string>
|
||||||
|
<string name="location_notes_empty_title">nessuna nota ancora</string>
|
||||||
|
<string name="location_notes_empty_desc">sii il primo ad aggiungerne una per questo posto.</string>
|
||||||
|
<string name="dismiss">chiudi</string>
|
||||||
|
<string name="location_notes_input_placeholder">aggiungi una nota per questo luogo</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -349,4 +349,17 @@
|
|||||||
<item quantity="other">%d 人</item>
|
<item quantity="other">%d 人</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<plurals name="location_notes_title">
|
||||||
|
<item quantity="one">#%1$s ± 1 • %2$d 件のメモ</item>
|
||||||
|
<item quantity="other">#%1$s ± 1 • %2$d 件のメモ</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="location_notes_description">他の訪問者が見つけられるよう、この場所に短いメモを追加しましょう。</string>
|
||||||
|
<string name="location_notes_relays_unavailable">ジオリレーが利用できません。メモを一時停止中</string>
|
||||||
|
<string name="location_notes_no_relays_title">近くにジオリレーがありません</string>
|
||||||
|
<string name="location_notes_no_relays_desc">メモはジオリレーに依存します。接続を確認して再試行してください。</string>
|
||||||
|
<string name="loading_location_notes">メモを読み込み中…</string>
|
||||||
|
<string name="location_notes_empty_title">まだメモはありません</string>
|
||||||
|
<string name="location_notes_empty_desc">この場所の最初のメモを追加しましょう。</string>
|
||||||
|
<string name="dismiss">閉じる</string>
|
||||||
|
<string name="location_notes_input_placeholder">この場所へのメモを追加</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -349,4 +349,17 @@
|
|||||||
<item quantity="other">%d명</item>
|
<item quantity="other">%d명</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<plurals name="location_notes_title">
|
||||||
|
<item quantity="one">#%1$s ± 1 • %2$d개의 노트</item>
|
||||||
|
<item quantity="other">#%1$s ± 1 • %2$d개의 노트</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="location_notes_description">다른 방문자들이 볼 수 있도록 이 위치에 짧은 영구 노트를 추가하세요.</string>
|
||||||
|
<string name="location_notes_relays_unavailable">지오 릴레이를 사용할 수 없음; 노트 일시 중지됨</string>
|
||||||
|
<string name="location_notes_no_relays_title">주변에 지오 릴레이가 없음</string>
|
||||||
|
<string name="location_notes_no_relays_desc">노트는 지오 릴레이에 의존합니다. 연결을 확인하고 다시 시도하세요.</string>
|
||||||
|
<string name="loading_location_notes">노트 불러오는 중…</string>
|
||||||
|
<string name="location_notes_empty_title">아직 노트가 없습니다</string>
|
||||||
|
<string name="location_notes_empty_desc">이 장소에 첫 번째 노트를 추가해 보세요.</string>
|
||||||
|
<string name="dismiss">닫기</string>
|
||||||
|
<string name="location_notes_input_placeholder">이 장소에 노트 추가</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -381,4 +381,17 @@
|
|||||||
<item quantity="other">%d personen</item>
|
<item quantity="other">%d personen</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<plurals name="location_notes_title">
|
||||||
|
<item quantity="one">#%1$s ± 1 • %2$d notitie</item>
|
||||||
|
<item quantity="other">#%1$s ± 1 • %2$d notities</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="location_notes_description">voeg korte permanente notities toe aan deze locatie zodat andere bezoekers ze kunnen vinden.</string>
|
||||||
|
<string name="location_notes_relays_unavailable">geo-relays niet beschikbaar; notities gepauzeerd</string>
|
||||||
|
<string name="location_notes_no_relays_title">geen geo-relays in de buurt</string>
|
||||||
|
<string name="location_notes_no_relays_desc">notities zijn afhankelijk van geo-relays. controleer de verbinding en probeer opnieuw.</string>
|
||||||
|
<string name="loading_location_notes">notities laden…</string>
|
||||||
|
<string name="location_notes_empty_title">nog geen notities</string>
|
||||||
|
<string name="location_notes_empty_desc">wees de eerste die een notitie voor deze plek toevoegt.</string>
|
||||||
|
<string name="dismiss">sluiten</string>
|
||||||
|
<string name="location_notes_input_placeholder">voeg een notitie toe voor deze plek</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- TODO: Polish translations -->
|
<!-- TODO: Polish translations -->
|
||||||
|
<plurals name="location_notes_title">
|
||||||
|
<item quantity="one">#%1$s ± 1 • %2$d notatka</item>
|
||||||
|
<item quantity="other">#%1$s ± 1 • %2$d notatek</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="location_notes_description">dodaj krótkie stałe notatki do tej lokalizacji, aby inni odwiedzający mogli je znaleźć.</string>
|
||||||
|
<string name="location_notes_relays_unavailable">przekaźniki geo niedostępne; notatki wstrzymane</string>
|
||||||
|
<string name="location_notes_no_relays_title">brak przekaźników geo w pobliżu</string>
|
||||||
|
<string name="location_notes_no_relays_desc">notatki zależą od przekaźników geo. sprawdź połączenie i spróbuj ponownie.</string>
|
||||||
|
<string name="loading_location_notes">ładowanie notatek…</string>
|
||||||
|
<string name="location_notes_empty_title">brak notatek</string>
|
||||||
|
<string name="location_notes_empty_desc">bądź pierwszy, który doda notatkę dla tego miejsca.</string>
|
||||||
|
<string name="dismiss">zamknij</string>
|
||||||
|
<string name="location_notes_input_placeholder">dodaj notatkę dla tego miejsca</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
|
|||||||
@@ -349,4 +349,17 @@
|
|||||||
<item quantity="other">%d pessoas</item>
|
<item quantity="other">%d pessoas</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<plurals name="location_notes_title">
|
||||||
|
<item quantity="one">#%1$s ± 1 • %2$d nota</item>
|
||||||
|
<item quantity="other">#%1$s ± 1 • %2$d notas</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="location_notes_description">adicione notas curtas e permanentes a este local para que outros visitantes as encontrem.</string>
|
||||||
|
<string name="location_notes_relays_unavailable">relés geográficos indisponíveis; notas em pausa</string>
|
||||||
|
<string name="location_notes_no_relays_title">nenhum relé geográfico nas proximidades</string>
|
||||||
|
<string name="location_notes_no_relays_desc">as notas dependem de relés geográficos. verifique a conexão e tente novamente.</string>
|
||||||
|
<string name="loading_location_notes">carregando notas…</string>
|
||||||
|
<string name="location_notes_empty_title">ainda não há notas</string>
|
||||||
|
<string name="location_notes_empty_desc">seja o primeiro a adicionar uma para este local.</string>
|
||||||
|
<string name="dismiss">fechar</string>
|
||||||
|
<string name="location_notes_input_placeholder">adicione uma nota para este local</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -349,4 +349,17 @@
|
|||||||
<item quantity="other">%d pessoas</item>
|
<item quantity="other">%d pessoas</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<plurals name="location_notes_title">
|
||||||
|
<item quantity="one">#%1$s ± 1 • %2$d nota</item>
|
||||||
|
<item quantity="other">#%1$s ± 1 • %2$d notas</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="location_notes_description">adicione notas curtas e permanentes a este local para que outros visitantes as encontrem.</string>
|
||||||
|
<string name="location_notes_relays_unavailable">relés geográficos indisponíveis; notas em pausa</string>
|
||||||
|
<string name="location_notes_no_relays_title">nenhum relé geográfico nas proximidades</string>
|
||||||
|
<string name="location_notes_no_relays_desc">as notas dependem de relés geográficos. verifique a conexão e tente novamente.</string>
|
||||||
|
<string name="loading_location_notes">carregando notas…</string>
|
||||||
|
<string name="location_notes_empty_title">ainda não há notas</string>
|
||||||
|
<string name="location_notes_empty_desc">seja o primeiro a adicionar uma para este local.</string>
|
||||||
|
<string name="dismiss">fechar</string>
|
||||||
|
<string name="location_notes_input_placeholder">adicione uma nota para este local</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -339,4 +339,17 @@
|
|||||||
<item quantity="other">%d человек</item>
|
<item quantity="other">%d человек</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<plurals name="location_notes_title">
|
||||||
|
<item quantity="one">#%1$s ± 1 • %2$d заметка</item>
|
||||||
|
<item quantity="other">#%1$s ± 1 • %2$d заметок</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="location_notes_description">добавьте короткие постоянные заметки к этому месту, чтобы другие посетители могли их найти.</string>
|
||||||
|
<string name="location_notes_relays_unavailable">гео-релей недоступны; заметки приостановлены</string>
|
||||||
|
<string name="location_notes_no_relays_title">рядом нет гео-релеев</string>
|
||||||
|
<string name="location_notes_no_relays_desc">заметки зависят от гео-релеев. проверьте подключение и повторите попытку.</string>
|
||||||
|
<string name="loading_location_notes">загрузка заметок…</string>
|
||||||
|
<string name="location_notes_empty_title">заметок пока нет</string>
|
||||||
|
<string name="location_notes_empty_desc">станьте первым, кто добавит заметку для этого места.</string>
|
||||||
|
<string name="dismiss">закрыть</string>
|
||||||
|
<string name="location_notes_input_placeholder">добавьте заметку для этого места</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -337,4 +337,17 @@
|
|||||||
<item quantity="one">1 person</item>
|
<item quantity="one">1 person</item>
|
||||||
<item quantity="other">%d personer</item>
|
<item quantity="other">%d personer</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<plurals name="location_notes_title">
|
||||||
|
<item quantity="one">#%1$s ± 1 • %2$d anteckning</item>
|
||||||
|
<item quantity="other">#%1$s ± 1 • %2$d anteckningar</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="location_notes_description">lägg till korta permanenta anteckningar för den här platsen så att andra besökare kan hitta dem.</string>
|
||||||
|
<string name="location_notes_relays_unavailable">geo-reläer är inte tillgängliga; anteckningar pausade</string>
|
||||||
|
<string name="location_notes_no_relays_title">inga geo-reläer i närheten</string>
|
||||||
|
<string name="location_notes_no_relays_desc">anteckningar är beroende av geo-reläer. kontrollera anslutningen och försök igen.</string>
|
||||||
|
<string name="loading_location_notes">laddar anteckningar…</string>
|
||||||
|
<string name="location_notes_empty_title">inga anteckningar ännu</string>
|
||||||
|
<string name="location_notes_empty_desc">var först med att lägga till en anteckning för den här platsen.</string>
|
||||||
|
<string name="dismiss">stäng</string>
|
||||||
|
<string name="location_notes_input_placeholder">lägg till en anteckning för den här platsen</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -337,4 +337,17 @@
|
|||||||
<item quantity="one">1 kişi</item>
|
<item quantity="one">1 kişi</item>
|
||||||
<item quantity="other">%d kişi</item>
|
<item quantity="other">%d kişi</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<plurals name="location_notes_title">
|
||||||
|
<item quantity="one">#%1$s ± 1 • %2$d not</item>
|
||||||
|
<item quantity="other">#%1$s ± 1 • %2$d not</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="location_notes_description">Diğer ziyaretçilerin bulabilmesi için bu konuma kısa kalıcı notlar ekleyin.</string>
|
||||||
|
<string name="location_notes_relays_unavailable">coğrafi röleler kullanılamıyor; notlar duraklatıldı</string>
|
||||||
|
<string name="location_notes_no_relays_title">yakınlarda coğrafi röle yok</string>
|
||||||
|
<string name="location_notes_no_relays_desc">notlar coğrafi rölelere bağlıdır. bağlantıyı kontrol edip tekrar deneyin.</string>
|
||||||
|
<string name="loading_location_notes">notlar yükleniyor…</string>
|
||||||
|
<string name="location_notes_empty_title">henüz not yok</string>
|
||||||
|
<string name="location_notes_empty_desc">bu yer için ilk notu ekleyen siz olun.</string>
|
||||||
|
<string name="dismiss">kapat</string>
|
||||||
|
<string name="location_notes_input_placeholder">bu yer için bir not ekleyin</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -349,4 +349,17 @@
|
|||||||
<item quantity="other">%d لوگ</item>
|
<item quantity="other">%d لوگ</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<plurals name="location_notes_title">
|
||||||
|
<item quantity="one">#%1$s ± 1 • %2$d نوٹ</item>
|
||||||
|
<item quantity="other">#%1$s ± 1 • %2$d نوٹس</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="location_notes_description">دیگر زائرین کے لیے اس جگہ پر مختصر مستقل نوٹس شامل کریں۔</string>
|
||||||
|
<string name="location_notes_relays_unavailable">جغرافیائی ریلے دستیاب نہیں؛ نوٹس معطل</string>
|
||||||
|
<string name="location_notes_no_relays_title">قریب کوئی جغرافیائی ریلے نہیں</string>
|
||||||
|
<string name="location_notes_no_relays_desc">نوٹس جغرافیائی ریلے پر منحصر ہیں۔ کنکشن چیک کریں اور دوبارہ کوشش کریں۔</string>
|
||||||
|
<string name="loading_location_notes">نوٹس لوڈ ہو رہے ہیں…</string>
|
||||||
|
<string name="location_notes_empty_title">ابھی تک کوئی نوٹس نہیں</string>
|
||||||
|
<string name="location_notes_empty_desc">اس جگہ کے لیے پہلا نوٹ شامل کریں۔</string>
|
||||||
|
<string name="dismiss">بند کریں</string>
|
||||||
|
<string name="location_notes_input_placeholder">اس جگہ کے لیے ایک نوٹ شامل کریں</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- TODO: Chinese (Simplified) translations -->
|
<!-- TODO: Chinese (Simplified) translations -->
|
||||||
|
<plurals name="location_notes_title">
|
||||||
|
<item quantity="one">#%1$s ± 1 • %2$d 条笔记</item>
|
||||||
|
<item quantity="other">#%1$s ± 1 • %2$d 条笔记</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="location_notes_description">为此位置添加简短的永久备注,方便其他访客查看。</string>
|
||||||
|
<string name="location_notes_relays_unavailable">地理中继不可用;笔记已暂停</string>
|
||||||
|
<string name="location_notes_no_relays_title">附近没有地理中继</string>
|
||||||
|
<string name="location_notes_no_relays_desc">笔记依赖地理中继。请检查连接后重试。</string>
|
||||||
|
<string name="loading_location_notes">正在加载笔记…</string>
|
||||||
|
<string name="location_notes_empty_title">还没有笔记</string>
|
||||||
|
<string name="location_notes_empty_desc">成为第一个为此地点添加笔记的人。</string>
|
||||||
|
<string name="dismiss">关闭</string>
|
||||||
|
<string name="location_notes_input_placeholder">为此地点添加一条笔记</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- TODO: Chinese (Traditional) translations -->
|
<!-- TODO: Chinese (Traditional) translations -->
|
||||||
|
<plurals name="location_notes_title">
|
||||||
|
<item quantity="one">#%1$s ± 1 • %2$d 則筆記</item>
|
||||||
|
<item quantity="other">#%1$s ± 1 • %2$d 則筆記</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="location_notes_description">為此位置新增簡短永久備註,供其他訪客查看。</string>
|
||||||
|
<string name="location_notes_relays_unavailable">地理中繼無法使用;筆記已暫停</string>
|
||||||
|
<string name="location_notes_no_relays_title">附近沒有地理中繼</string>
|
||||||
|
<string name="location_notes_no_relays_desc">筆記依賴地理中繼。請檢查連線後重試。</string>
|
||||||
|
<string name="loading_location_notes">正在載入筆記…</string>
|
||||||
|
<string name="location_notes_empty_title">尚無筆記</string>
|
||||||
|
<string name="location_notes_empty_desc">成為第一個為此地點新增筆記的人。</string>
|
||||||
|
<string name="dismiss">關閉</string>
|
||||||
|
<string name="location_notes_input_placeholder">為此地點新增一則筆記</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
|
|||||||
@@ -362,4 +362,17 @@
|
|||||||
<item quantity="other">%d 人</item>
|
<item quantity="other">%d 人</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<plurals name="location_notes_title">
|
||||||
|
<item quantity="one">#%1$s ± 1 • %2$d 条笔记</item>
|
||||||
|
<item quantity="other">#%1$s ± 1 • %2$d 条笔记</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="location_notes_description">为此位置添加简短的永久备注,方便其他访客查看。</string>
|
||||||
|
<string name="location_notes_relays_unavailable">地理中继不可用;笔记已暂停</string>
|
||||||
|
<string name="location_notes_no_relays_title">附近没有地理中继</string>
|
||||||
|
<string name="location_notes_no_relays_desc">笔记依赖地理中继。请检查连接后重试。</string>
|
||||||
|
<string name="loading_location_notes">正在加载笔记…</string>
|
||||||
|
<string name="location_notes_empty_title">还没有笔记</string>
|
||||||
|
<string name="location_notes_empty_desc">成为第一个为此地点添加笔记的人。</string>
|
||||||
|
<string name="dismiss">关闭</string>
|
||||||
|
<string name="location_notes_input_placeholder">为此地点添加一条笔记</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
<string name="cd_nostr_reachable">Nostr reachable</string>
|
<string name="cd_nostr_reachable">Nostr reachable</string>
|
||||||
<string name="cd_unread_private_messages">Unread private messages</string>
|
<string name="cd_unread_private_messages">Unread private messages</string>
|
||||||
<string name="cd_toggle_bookmark">Toggle bookmark</string>
|
<string name="cd_toggle_bookmark">Toggle bookmark</string>
|
||||||
|
<string name="cd_location_notes">Location notes</string>
|
||||||
<string name="cd_teleported">Teleported</string>
|
<string name="cd_teleported">Teleported</string>
|
||||||
<string name="cd_tor_status">Tor status</string>
|
<string name="cd_tor_status">Tor status</string>
|
||||||
<string name="cd_connected_peers">Connected peers</string>
|
<string name="cd_connected_peers">Connected peers</string>
|
||||||
@@ -216,6 +217,21 @@
|
|||||||
<string name="location_level_province">province</string>
|
<string name="location_level_province">province</string>
|
||||||
<string name="location_level_region">region</string>
|
<string name="location_level_region">region</string>
|
||||||
|
|
||||||
|
<!-- Location notes sheet -->
|
||||||
|
<plurals name="location_notes_title">
|
||||||
|
<item quantity="one">#%1$s ± 1 • %2$d note</item>
|
||||||
|
<item quantity="other">#%1$s ± 1 • %2$d notes</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="location_notes_description">add short permanent notes to this location for other visitors to find.</string>
|
||||||
|
<string name="location_notes_relays_unavailable">geo relays unavailable; notes paused</string>
|
||||||
|
<string name="location_notes_no_relays_title">no geo relays nearby</string>
|
||||||
|
<string name="location_notes_no_relays_desc">notes rely on geo relays. check connection and try again.</string>
|
||||||
|
<string name="loading_location_notes">loading notes…</string>
|
||||||
|
<string name="location_notes_empty_title">no notes yet</string>
|
||||||
|
<string name="location_notes_empty_desc">be the first to add one for this spot.</string>
|
||||||
|
<string name="dismiss">dismiss</string>
|
||||||
|
<string name="location_notes_input_placeholder">add a note for this place</string>
|
||||||
|
|
||||||
<!-- Debug / Diagnostics -->
|
<!-- Debug / Diagnostics -->
|
||||||
<string name="debug_tools">debug tools</string>
|
<string name="debug_tools">debug tools</string>
|
||||||
<string name="debug_tools_desc">developer utilities for diagnostics and control</string>
|
<string name="debug_tools_desc">developer utilities for diagnostics and control</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user