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:
callebtc
2025-10-18 14:19:53 +02:00
committed by GitHub
parent e8862d5128
commit f99cb46b26
38 changed files with 1769 additions and 24 deletions
@@ -19,6 +19,9 @@ class BitchatApplication : Application() {
// Initialize relay directory (loads assets/nostr_relays.csv)
RelayDirectory.initialize(this)
// Initialize LocationNotesManager dependencies early so sheet subscriptions can start immediately
try { com.bitchat.android.nostr.LocationNotesInitializer.initialize(this) } catch (_: Exception) { }
// Initialize favorites persistence early so MessageRouter/NostrTransport can use it on startup
try {
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(this)
@@ -598,6 +598,9 @@ class MainActivity : ComponentActivity() {
PoWPreferenceManager.init(this@MainActivity)
Log.d("MainActivity", "PoW preferences initialized")
// Initialize Location Notes Manager (extracted to separate file)
com.bitchat.android.nostr.LocationNotesInitializer.initialize(this@MainActivity)
// Ensure all permissions are still granted (user might have revoked in settings)
if (!permissionManager.areAllPermissionsGranted()) {
val missing = permissionManager.getMissingPermissions()
@@ -115,4 +115,36 @@ object Geohash {
lonMax = maxOf(lonInterval.first, lonInterval.second)
)
}
/**
* Returns the 8 neighboring geohash cells at the same precision as the input.
* Neighbors include N, NE, E, SE, S, SW, W, NW, even when crossing parent cell boundaries.
*/
fun neighborsSamePrecision(geohash: String): Set<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
*/
enum class GeohashChannelLevel(val precision: Int, val displayName: String) {
BUILDING(8, "Building"), // iOS: precision 8 for building-level (used for Location Notes)
BLOCK(7, "Block"),
NEIGHBORHOOD(6, "Neighborhood"),
CITY(5, "City"),
@@ -88,10 +88,17 @@ class LocationChannelManager private constructor(private val context: Context) {
/**
* Enable location channels (request permission if needed)
* UNIFIED: Only requests location if location services are enabled by user
*/
fun enableLocationChannels() {
Log.d(TAG, "enableLocationChannels() called")
// UNIFIED FIX: Check if location services are enabled by user
if (!isLocationServicesEnabled()) {
Log.w(TAG, "Location services disabled by user - not requesting location")
return
}
when (getCurrentPermissionStatus()) {
PermissionState.NOT_DETERMINED -> {
Log.d(TAG, "Permission not determined - user needs to grant in app settings")
@@ -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
*/
@@ -193,4 +205,12 @@ data class NostrFilter(
return "NostrFilter(${parts.joinToString(", ")})"
}
/**
* Get geohash value from g tag filter (if present)
* Returns the first geohash in the filter or null if none
*/
fun getGeohash(): String? {
return tagFilters?.get("g")?.firstOrNull()
}
}
@@ -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)
* 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.sp
import com.bitchat.android.core.ui.utils.singleOrTripleClickable
import com.bitchat.android.geohash.LocationChannelManager.PermissionState
import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.Offset
/**
* Header components for ChatScreen
@@ -53,23 +56,27 @@ fun isFavoriteReactive(
}
@Composable
fun TorStatusIcon(
fun TorStatusDot(
modifier: Modifier = Modifier
) {
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState()
if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) {
val cableColor = when {
torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500)
torStatus.running && torStatus.bootstrapPercent >= 100 -> Color(0xFF00C851)
else -> Color.Red
val dotColor = when {
torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500) // Orange - bootstrapping
torStatus.running && torStatus.bootstrapPercent >= 100 -> Color(0xFF00C851) // Green - connected
else -> Color.Red // Red - error/disconnected
}
Canvas(
modifier = modifier
) {
val radius = size.minDimension / 2
drawCircle(
color = dotColor,
radius = radius,
center = Offset(size.width / 2, size.height / 2)
)
}
Icon(
imageVector = Icons.Outlined.Cable,
contentDescription = stringResource(R.string.cd_tor_status),
modifier = modifier,
tint = cableColor
)
}
}
@@ -233,7 +240,8 @@ fun ChatHeaderContent(
onSidebarClick: () -> Unit,
onTripleClick: () -> Unit,
onShowAppInfo: () -> Unit,
onLocationChannelsClick: () -> Unit
onLocationChannelsClick: () -> Unit,
onLocationNotesClick: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
@@ -289,6 +297,7 @@ fun ChatHeaderContent(
onTripleTitleClick = onTripleClick,
onSidebarClick = onSidebarClick,
onLocationChannelsClick = onLocationChannelsClick,
onLocationNotesClick = onLocationNotesClick,
viewModel = viewModel
)
}
@@ -510,6 +519,7 @@ private fun MainHeader(
onTripleTitleClick: () -> Unit,
onSidebarClick: () -> Unit,
onLocationChannelsClick: () -> Unit,
onLocationNotesClick: () -> Unit,
viewModel: ChatViewModel
) {
val colorScheme = MaterialTheme.colorScheme
@@ -573,7 +583,7 @@ private fun MainHeader(
}
// Location channels button (matching iOS implementation) and bookmark grouped tightly
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 4.dp)) {
LocationChannelsButton(
viewModel = viewModel,
onClick = onLocationChannelsClick
@@ -588,7 +598,7 @@ private fun MainHeader(
val isBookmarked = bookmarks.contains(currentGeohash)
Box(
modifier = Modifier
.padding(start = 1.dp) // minimal gap between geohash and bookmark
.padding(start = 2.dp) // minimal gap between geohash and bookmark
.size(20.dp)
.clickable { bookmarksStore.toggle(currentGeohash) },
contentAlignment = Alignment.Center
@@ -603,15 +613,25 @@ private fun MainHeader(
}
}
// Tor status cable icon when Tor is enabled
TorStatusIcon(modifier = Modifier.size(14.dp))
// Location Notes button (extracted to separate component)
LocationNotesButton(
viewModel = viewModel,
onClick = onLocationNotesClick
)
// Tor status dot when Tor is enabled
TorStatusDot(
modifier = Modifier
.size(8.dp)
.padding(start = 0.dp, end = 2.dp)
)
// PoW status indicator
PoWStatusIndicator(
modifier = Modifier,
style = PoWIndicatorStyle.COMPACT
)
Spacer(modifier = Modifier.width(2.dp))
PeerCounter(
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
joinedChannels = joinedChannels,
@@ -63,6 +63,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
var showPasswordDialog by remember { mutableStateOf(false) }
var passwordInput by remember { mutableStateOf("") }
var showLocationChannelsSheet by remember { mutableStateOf(false) }
var showLocationNotesSheet by remember { mutableStateOf(false) }
var showUserSheet by remember { mutableStateOf(false) }
var selectedUserForSheet by remember { mutableStateOf("") }
var selectedMessageForSheet by remember { mutableStateOf<BitchatMessage?>(null) }
@@ -241,7 +242,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
onSidebarToggle = { viewModel.showSidebar() },
onShowAppInfo = { viewModel.showAppInfo() },
onPanicClear = { viewModel.panicClearAllData() },
onLocationChannelsClick = { showLocationChannelsSheet = true }
onLocationChannelsClick = { showLocationChannelsSheet = true },
onLocationNotesClick = { showLocationNotesSheet = true }
)
// Divider under header - positioned after status bar + header height
@@ -354,6 +356,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
onAppInfoDismiss = { viewModel.hideAppInfo() },
showLocationChannelsSheet = showLocationChannelsSheet,
onLocationChannelsSheetDismiss = { showLocationChannelsSheet = false },
showLocationNotesSheet = showLocationNotesSheet,
onLocationNotesSheetDismiss = { showLocationNotesSheet = false },
showUserSheet = showUserSheet,
onUserSheetDismiss = {
showUserSheet = false
@@ -435,8 +439,12 @@ private fun ChatFloatingHeader(
onSidebarToggle: () -> Unit,
onShowAppInfo: () -> Unit,
onPanicClear: () -> Unit,
onLocationChannelsClick: () -> Unit
onLocationChannelsClick: () -> Unit,
onLocationNotesClick: () -> Unit
) {
val context = androidx.compose.ui.platform.LocalContext.current
val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) }
Surface(
modifier = Modifier
.fillMaxWidth()
@@ -460,7 +468,12 @@ private fun ChatFloatingHeader(
onSidebarClick = onSidebarToggle,
onTripleClick = onPanicClear,
onShowAppInfo = onShowAppInfo,
onLocationChannelsClick = onLocationChannelsClick
onLocationChannelsClick = onLocationChannelsClick,
onLocationNotesClick = {
// Ensure location is loaded before showing sheet
locationManager.refreshChannels()
onLocationNotesClick()
}
)
},
colors = TopAppBarDefaults.topAppBarColors(
@@ -471,6 +484,7 @@ private fun ChatFloatingHeader(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChatDialogs(
showPasswordDialog: Boolean,
@@ -483,6 +497,8 @@ private fun ChatDialogs(
onAppInfoDismiss: () -> Unit,
showLocationChannelsSheet: Boolean,
onLocationChannelsSheetDismiss: () -> Unit,
showLocationNotesSheet: Boolean,
onLocationNotesSheetDismiss: () -> Unit,
showUserSheet: Boolean,
onUserSheetDismiss: () -> Unit,
selectedUserForSheet: String,
@@ -523,6 +539,14 @@ private fun ChatDialogs(
)
}
// Location notes sheet (extracted to separate presenter)
if (showLocationNotesSheet) {
LocationNotesSheetPresenter(
viewModel = viewModel,
onDismiss = onLocationNotesSheetDismiss
)
}
// User action sheet
if (showUserSheet) {
ChatUserSheet(
@@ -147,6 +147,8 @@ class ChatViewModel(
mediaSendingManager.handleTransferProgressEvent(evt)
}
}
// Removed background location notes subscription. Notes now load only when sheet opens.
}
fun cancelMediaSend(messageId: String) {
@@ -591,6 +593,8 @@ class ChatViewModel(
}
}
// Location notes subscription management moved to LocationNotesViewModelExtensions.kt
/**
* Update reactive states for all connected peers (session states, fingerprints, nicknames, RSSI)
*/
@@ -247,8 +247,11 @@ fun LocationChannelsSheet(
}
// Nearby options (only show if location services are enabled)
// CRITICAL: Filter out .building level (precision 8) - iOS pattern
// iOS: let nearby = manager.availableChannels.filter { $0.level != .building }
if (availableChannels.isNotEmpty() && locationServicesEnabled) {
items(availableChannels) { channel ->
val nearbyChannels = availableChannels.filter { it.level != GeohashChannelLevel.BUILDING }
items(nearbyChannels) { channel ->
val coverage = coverageString(channel.geohash.length)
val nameBase = locationNames[channel.level]
val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it }
@@ -570,8 +573,6 @@ fun LocationChannelsSheet(
if (isPresented) {
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
locationManager.refreshChannels()
}
if (locationServicesEnabled) {
locationManager.beginLiveRefresh()
}
val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList()
@@ -713,6 +714,7 @@ private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int
val ctx = androidx.compose.ui.platform.LocalContext.current
val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
val levelName = when (channel.level) {
com.bitchat.android.geohash.GeohashChannelLevel.BUILDING -> "Building" // iOS: precision 8 for location notes
com.bitchat.android.geohash.GeohashChannelLevel.BLOCK -> stringResource(com.bitchat.android.R.string.location_level_block)
com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD -> stringResource(com.bitchat.android.R.string.location_level_neighborhood)
com.bitchat.android.geohash.GeohashChannelLevel.CITY -> stringResource(com.bitchat.android.R.string.location_level_city)
@@ -749,7 +751,8 @@ private fun levelForLength(length: Int): GeohashChannelLevel {
5 -> GeohashChannelLevel.CITY
6 -> GeohashChannelLevel.NEIGHBORHOOD
7 -> GeohashChannelLevel.BLOCK
else -> GeohashChannelLevel.BLOCK
8 -> GeohashChannelLevel.BUILDING // iOS: precision 8 for building-level
else -> if (length > 8) GeohashChannelLevel.BUILDING else GeohashChannelLevel.BLOCK
}
}
@@ -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")
}
}
}
}
+13
View File
@@ -349,4 +349,17 @@
<item quantity="other">%d أشخاص</item>
</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>
+14
View File
@@ -349,4 +349,18 @@
<item quantity="other">%d Personen</item>
</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>
+13
View File
@@ -349,4 +349,17 @@
<item quantity="other">%d personas</item>
</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>
+13
View File
@@ -363,4 +363,17 @@
<item quantity="other">%d personnes</item>
</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>
+13
View File
@@ -1,5 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 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>
+13
View File
@@ -349,4 +349,17 @@
<item quantity="other">%d लोग</item>
</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>
+13
View File
@@ -349,4 +349,17 @@
<item quantity="other">%d orang</item>
</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>
+13
View File
@@ -383,4 +383,17 @@
<item quantity="other">%d persone</item>
</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>
+13
View File
@@ -349,4 +349,17 @@
<item quantity="other">%d 人</item>
</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>
+13
View File
@@ -349,4 +349,17 @@
<item quantity="other">%d명</item>
</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>
+13
View File
@@ -381,4 +381,17 @@
<item quantity="other">%d personen</item>
</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>
+13
View File
@@ -1,5 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 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>
@@ -349,4 +349,17 @@
<item quantity="other">%d pessoas</item>
</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>
+13
View File
@@ -349,4 +349,17 @@
<item quantity="other">%d pessoas</item>
</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>
+13
View File
@@ -339,4 +339,17 @@
<item quantity="other">%d человек</item>
</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>
+13
View File
@@ -337,4 +337,17 @@
<item quantity="one">1 person</item>
<item quantity="other">%d personer</item>
</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>
+13
View File
@@ -337,4 +337,17 @@
<item quantity="one">1 kişi</item>
<item quantity="other">%d kişi</item>
</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>
+13
View File
@@ -349,4 +349,17 @@
<item quantity="other">%d لوگ</item>
</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>
@@ -1,5 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 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>
@@ -1,5 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 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>
+13
View File
@@ -362,4 +362,17 @@
<item quantity="other">%d 人</item>
</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>
+16
View File
@@ -73,6 +73,7 @@
<string name="cd_nostr_reachable">Nostr reachable</string>
<string name="cd_unread_private_messages">Unread private messages</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_tor_status">Tor status</string>
<string name="cd_connected_peers">Connected peers</string>
@@ -216,6 +217,21 @@
<string name="location_level_province">province</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 -->
<string name="debug_tools">debug tools</string>
<string name="debug_tools_desc">developer utilities for diagnostics and control</string>