mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 16:25:22 +00:00
try 1
This commit is contained in:
@@ -598,6 +598,64 @@ 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 Counter (iOS parity)
|
||||||
|
com.bitchat.android.nostr.LocationNotesCounter.initialize(
|
||||||
|
relayManager = { com.bitchat.android.nostr.NostrRelayManager.getInstance(this@MainActivity) },
|
||||||
|
subscribe = { filter, id, handler ->
|
||||||
|
// Use subscribeForGeohash for location notes (finds geo-local relays)
|
||||||
|
val geohashFromFilter = filter.getDebugDescription().substringAfter("#g=").substringBefore(")").substringBefore(",")
|
||||||
|
com.bitchat.android.nostr.NostrRelayManager.getInstance(this@MainActivity).subscribeForGeohash(
|
||||||
|
geohash = geohashFromFilter,
|
||||||
|
filter = filter,
|
||||||
|
id = id,
|
||||||
|
handler = handler,
|
||||||
|
includeDefaults = true,
|
||||||
|
nRelays = 5
|
||||||
|
)
|
||||||
|
},
|
||||||
|
unsubscribe = { id ->
|
||||||
|
com.bitchat.android.nostr.NostrRelayManager.getInstance(this@MainActivity).unsubscribe(id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Log.d("MainActivity", "Location Notes Counter initialized")
|
||||||
|
|
||||||
|
// CRITICAL FIX: Initialize Location Notes Manager (iOS parity)
|
||||||
|
com.bitchat.android.nostr.LocationNotesManager.getInstance().initialize(
|
||||||
|
relayManager = { com.bitchat.android.nostr.NostrRelayManager.getInstance(this@MainActivity) },
|
||||||
|
subscribe = { filter, id, handler ->
|
||||||
|
// Extract geohash from filter for geo-routing
|
||||||
|
val geohashFromFilter = filter.getDebugDescription()
|
||||||
|
.substringAfter("#g=")
|
||||||
|
.substringBefore(")")
|
||||||
|
.substringBefore(",")
|
||||||
|
|
||||||
|
Log.d("MainActivity", "Location Notes subscribing to geohash: $geohashFromFilter")
|
||||||
|
|
||||||
|
com.bitchat.android.nostr.NostrRelayManager.getInstance(this@MainActivity).subscribeForGeohash(
|
||||||
|
geohash = geohashFromFilter,
|
||||||
|
filter = filter,
|
||||||
|
id = id,
|
||||||
|
handler = handler,
|
||||||
|
includeDefaults = true,
|
||||||
|
nRelays = 5
|
||||||
|
)
|
||||||
|
},
|
||||||
|
unsubscribe = { id ->
|
||||||
|
com.bitchat.android.nostr.NostrRelayManager.getInstance(this@MainActivity).unsubscribe(id)
|
||||||
|
},
|
||||||
|
sendEvent = { event, relayUrls ->
|
||||||
|
if (relayUrls != null) {
|
||||||
|
com.bitchat.android.nostr.NostrRelayManager.getInstance(this@MainActivity).sendEvent(event, relayUrls)
|
||||||
|
} else {
|
||||||
|
com.bitchat.android.nostr.NostrRelayManager.getInstance(this@MainActivity).sendEvent(event)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deriveIdentity = { geohash ->
|
||||||
|
com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity(geohash, this@MainActivity)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Log.d("MainActivity", "✅ Location Notes Manager initialized")
|
||||||
|
|
||||||
// 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()
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package com.bitchat.android.nostr
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight counter for location notes
|
||||||
|
* Singleton manager for counting unique notes per geohash
|
||||||
|
* iOS-compatible implementation
|
||||||
|
*/
|
||||||
|
object LocationNotesCounter {
|
||||||
|
|
||||||
|
private const val TAG = "LocationNotesCounter"
|
||||||
|
|
||||||
|
// Published state
|
||||||
|
private val _geohash = MutableLiveData<String?>(null)
|
||||||
|
val geohash: LiveData<String?> = _geohash
|
||||||
|
|
||||||
|
private val _count = MutableLiveData(0)
|
||||||
|
val count: LiveData<Int> = _count
|
||||||
|
|
||||||
|
private val _initialLoadComplete = MutableLiveData(false)
|
||||||
|
val initialLoadComplete: LiveData<Boolean> = _initialLoadComplete
|
||||||
|
|
||||||
|
private val _relayAvailable = MutableLiveData(false)
|
||||||
|
val relayAvailable: LiveData<Boolean> = _relayAvailable
|
||||||
|
|
||||||
|
// Private state
|
||||||
|
private var subscriptionID: String? = null
|
||||||
|
private val eventIDs = mutableSetOf<String>() // For deduplication
|
||||||
|
|
||||||
|
// Dependencies (injected)
|
||||||
|
private var relayLookup: (() -> NostrRelayManager)? = null
|
||||||
|
private var subscribeFunc: ((NostrFilter, String, (NostrEvent) -> Unit) -> String)? = null
|
||||||
|
private var unsubscribeFunc: ((String) -> Unit)? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize dependencies
|
||||||
|
*/
|
||||||
|
fun initialize(
|
||||||
|
relayManager: () -> NostrRelayManager,
|
||||||
|
subscribe: (NostrFilter, String, (NostrEvent) -> Unit) -> String,
|
||||||
|
unsubscribe: (String) -> Unit
|
||||||
|
) {
|
||||||
|
this.relayLookup = relayManager
|
||||||
|
this.subscribeFunc = subscribe
|
||||||
|
this.unsubscribeFunc = unsubscribe
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to count notes for a specific geohash
|
||||||
|
*/
|
||||||
|
fun subscribe(geohash: String) {
|
||||||
|
if (_geohash.value == geohash && subscriptionID != null) {
|
||||||
|
Log.d(TAG, "Already subscribed to geohash: $geohash")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Subscribing to count notes for geohash: $geohash")
|
||||||
|
|
||||||
|
// Cancel existing subscription
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
_geohash.value = geohash
|
||||||
|
_count.value = 0
|
||||||
|
eventIDs.clear()
|
||||||
|
_initialLoadComplete.value = false
|
||||||
|
|
||||||
|
// Check relay availability
|
||||||
|
val relayManager = relayLookup?.invoke()
|
||||||
|
val hasRelays = relayManager?.getRelayStatuses()?.any { it.isConnected } == true
|
||||||
|
_relayAvailable.value = hasRelays
|
||||||
|
|
||||||
|
if (!hasRelays) {
|
||||||
|
Log.w(TAG, "No relays available for counter subscription")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val subscribe = subscribeFunc
|
||||||
|
if (subscribe == null) {
|
||||||
|
Log.e(TAG, "Cannot subscribe - subscribe function not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val filter = NostrFilter.geohashNotes(
|
||||||
|
geohash = geohash,
|
||||||
|
since = null,
|
||||||
|
limit = 200 // Count up to 200 recent notes
|
||||||
|
)
|
||||||
|
|
||||||
|
val subId = "location-notes-count-$geohash"
|
||||||
|
|
||||||
|
subscriptionID = subscribe(filter, subId) { event ->
|
||||||
|
handleEvent(event, geohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark initial load complete after brief delay
|
||||||
|
kotlinx.coroutines.MainScope().launch {
|
||||||
|
kotlinx.coroutines.delay(1500)
|
||||||
|
if (!_initialLoadComplete.value!!) {
|
||||||
|
_initialLoadComplete.value = true
|
||||||
|
Log.d(TAG, "Initial count load complete: ${_count.value} notes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming event
|
||||||
|
*/
|
||||||
|
private fun handleEvent(event: NostrEvent, expectedGeohash: String) {
|
||||||
|
// Validate event
|
||||||
|
if (event.kind != NostrKind.TEXT_NOTE) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for geohash tag
|
||||||
|
val geohashTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }
|
||||||
|
if (geohashTag == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if matches expected geohash
|
||||||
|
val eventGeohash = geohashTag[1]
|
||||||
|
if (eventGeohash != expectedGeohash) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate and count
|
||||||
|
if (!eventIDs.contains(event.id)) {
|
||||||
|
eventIDs.add(event.id)
|
||||||
|
_count.value = eventIDs.size
|
||||||
|
|
||||||
|
if (!_initialLoadComplete.value!!) {
|
||||||
|
_initialLoadComplete.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel subscription
|
||||||
|
*/
|
||||||
|
fun cancel() {
|
||||||
|
subscriptionID?.let { subId ->
|
||||||
|
Log.d(TAG, "🚫 Canceling counter subscription: $subId")
|
||||||
|
unsubscribeFunc?.invoke(subId)
|
||||||
|
subscriptionID = null
|
||||||
|
}
|
||||||
|
|
||||||
|
_geohash.value = null
|
||||||
|
_count.value = 0
|
||||||
|
eventIDs.clear()
|
||||||
|
_initialLoadComplete.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
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 (nickname or truncated pubkey)
|
||||||
|
*/
|
||||||
|
val displayName: String
|
||||||
|
get() = nickname ?: "@${pubkey.take(8)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 subscriptionID: String? = null
|
||||||
|
private val noteIDs = mutableSetOf<String>() // For deduplication
|
||||||
|
|
||||||
|
// 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
|
||||||
|
*/
|
||||||
|
fun setGeohash(newGeohash: String) {
|
||||||
|
if (_geohash.value == newGeohash) {
|
||||||
|
Log.d(TAG, "Geohash unchanged, skipping: $newGeohash")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Setting geohash: $newGeohash")
|
||||||
|
|
||||||
|
// Cancel existing subscription
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
// Clear state
|
||||||
|
_notes.value = emptyList()
|
||||||
|
noteIDs.clear()
|
||||||
|
_initialLoadComplete.value = false
|
||||||
|
_errorMessage.value = null
|
||||||
|
_geohash.value = newGeohash
|
||||||
|
|
||||||
|
// Start new subscription
|
||||||
|
subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 subscription
|
||||||
|
cancel()
|
||||||
|
_notes.value = emptyList()
|
||||||
|
noteIDs.clear()
|
||||||
|
_initialLoadComplete.value = false
|
||||||
|
subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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")
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val identity = withContext(Dispatchers.IO) {
|
||||||
|
deriveIdentity(currentGeohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
val event = withContext(Dispatchers.IO) {
|
||||||
|
NostrProtocol.createGeohashTextNote(
|
||||||
|
content = content,
|
||||||
|
geohash = currentGeohash,
|
||||||
|
senderIdentity = identity,
|
||||||
|
nickname = nickname
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic local echo - add note immediately to UI
|
||||||
|
val localNote = Note(
|
||||||
|
id = event.id,
|
||||||
|
pubkey = event.pubkey,
|
||||||
|
content = content,
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to relays
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
sendEventFunc?.invoke(event, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "✅ Note sent successfully: ${event.id.take(16)}...")
|
||||||
|
|
||||||
|
} 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 subscribe() {
|
||||||
|
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")
|
||||||
|
_state.value = State.NO_RELAYS
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL FIX: Get geo-specific relays for this geohash (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 any relays (iOS pattern: guard !relays.isEmpty())
|
||||||
|
if (relays.isEmpty()) {
|
||||||
|
Log.w(TAG, "No geo relays available for geohash: $currentGeohash")
|
||||||
|
_state.value = State.NO_RELAYS
|
||||||
|
_initialLoadComplete.value = true
|
||||||
|
_errorMessage.value = "No relays available"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "📡 Found ${relays.size} geo relays for geohash $currentGeohash: ${relays.joinToString()}")
|
||||||
|
|
||||||
|
_state.value = State.LOADING
|
||||||
|
|
||||||
|
val filter = NostrFilter.geohashNotes(
|
||||||
|
geohash = currentGeohash,
|
||||||
|
since = null, // Get all notes (relays will limit)
|
||||||
|
limit = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
val subId = "location-notes-$currentGeohash"
|
||||||
|
|
||||||
|
Log.d(TAG, "📡 Subscribing to location notes: $subId")
|
||||||
|
|
||||||
|
subscriptionID = subscribe(filter, subId) { event ->
|
||||||
|
handleEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (eventGeohash != _geohash.value) {
|
||||||
|
Log.v(TAG, "Ignoring event for different 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})")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel subscription and clear state
|
||||||
|
*/
|
||||||
|
fun cancel() {
|
||||||
|
subscriptionID?.let { subId ->
|
||||||
|
Log.d(TAG, "🚫 Canceling subscription: $subId")
|
||||||
|
unsubscribeFunc?.invoke(subId)
|
||||||
|
subscriptionID = null
|
||||||
|
}
|
||||||
|
|
||||||
|
_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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,7 @@ 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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Header components for ChatScreen
|
* Header components for ChatScreen
|
||||||
@@ -233,7 +234,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 +291,7 @@ fun ChatHeaderContent(
|
|||||||
onTripleTitleClick = onTripleClick,
|
onTripleTitleClick = onTripleClick,
|
||||||
onSidebarClick = onSidebarClick,
|
onSidebarClick = onSidebarClick,
|
||||||
onLocationChannelsClick = onLocationChannelsClick,
|
onLocationChannelsClick = onLocationChannelsClick,
|
||||||
|
onLocationNotesClick = onLocationNotesClick,
|
||||||
viewModel = viewModel
|
viewModel = viewModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -510,6 +513,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
|
||||||
@@ -526,6 +530,15 @@ private fun MainHeader(
|
|||||||
val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) }
|
val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) }
|
||||||
val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList())
|
val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList())
|
||||||
|
|
||||||
|
// Location notes counter (iOS parity)
|
||||||
|
val notesCounter = remember { com.bitchat.android.nostr.LocationNotesCounter }
|
||||||
|
val notesCount by notesCounter.count.observeAsState(0)
|
||||||
|
|
||||||
|
// Location channel manager for permission state
|
||||||
|
val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) }
|
||||||
|
val permissionState by locationManager.permissionState.observeAsState()
|
||||||
|
val locationPermissionGranted = permissionState == PermissionState.AUTHORIZED
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
@@ -572,6 +585,23 @@ private fun MainHeader(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Location Notes button (mesh only, when location is authorized)
|
||||||
|
// iOS: Shows to the left of #mesh badge when in mesh mode and location permitted
|
||||||
|
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Mesh && locationPermissionGranted) {
|
||||||
|
val hasNotes = (notesCount ?: 0) > 0
|
||||||
|
IconButton(
|
||||||
|
onClick = onLocationNotesClick,
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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 = 14.dp)) {
|
||||||
LocationChannelsButton(
|
LocationChannelsButton(
|
||||||
|
|||||||
@@ -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,7 +439,8 @@ private fun ChatFloatingHeader(
|
|||||||
onSidebarToggle: () -> Unit,
|
onSidebarToggle: () -> Unit,
|
||||||
onShowAppInfo: () -> Unit,
|
onShowAppInfo: () -> Unit,
|
||||||
onPanicClear: () -> Unit,
|
onPanicClear: () -> Unit,
|
||||||
onLocationChannelsClick: () -> Unit
|
onLocationChannelsClick: () -> Unit,
|
||||||
|
onLocationNotesClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -460,7 +465,8 @@ private fun ChatFloatingHeader(
|
|||||||
onSidebarClick = onSidebarToggle,
|
onSidebarClick = onSidebarToggle,
|
||||||
onTripleClick = onPanicClear,
|
onTripleClick = onPanicClear,
|
||||||
onShowAppInfo = onShowAppInfo,
|
onShowAppInfo = onShowAppInfo,
|
||||||
onLocationChannelsClick = onLocationChannelsClick
|
onLocationChannelsClick = onLocationChannelsClick,
|
||||||
|
onLocationNotesClick = onLocationNotesClick
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
@@ -471,6 +477,7 @@ private fun ChatFloatingHeader(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChatDialogs(
|
private fun ChatDialogs(
|
||||||
showPasswordDialog: Boolean,
|
showPasswordDialog: Boolean,
|
||||||
@@ -483,6 +490,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 +532,68 @@ private fun ChatDialogs(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Location notes sheet
|
||||||
|
if (showLocationNotesSheet) {
|
||||||
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) }
|
||||||
|
val availableChannels by locationManager.availableChannels.observeAsState(emptyList())
|
||||||
|
val nickname by viewModel.nickname.observeAsState("")
|
||||||
|
|
||||||
|
// Get building-level geohash (precision 8 for iOS compatibility)
|
||||||
|
val buildingGeohash = availableChannels.firstOrNull { it.level == com.bitchat.android.geohash.GeohashChannelLevel.BUILDING }?.geohash
|
||||||
|
|
||||||
|
if (buildingGeohash != null) {
|
||||||
|
// Get location name from locationManager
|
||||||
|
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
|
||||||
|
val locationName = locationNames[com.bitchat.android.geohash.GeohashChannelLevel.BUILDING]
|
||||||
|
?: locationNames[com.bitchat.android.geohash.GeohashChannelLevel.BLOCK]
|
||||||
|
|
||||||
|
LocationNotesSheet(
|
||||||
|
geohash = buildingGeohash,
|
||||||
|
locationName = locationName,
|
||||||
|
nickname = nickname,
|
||||||
|
onDismiss = onLocationNotesSheetDismiss
|
||||||
|
)
|
||||||
|
|
||||||
|
// Subscribe to notes counter when sheet is shown
|
||||||
|
LaunchedEffect(buildingGeohash) {
|
||||||
|
com.bitchat.android.nostr.LocationNotesCounter.subscribe(buildingGeohash)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No block geohash available - show error state
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onLocationNotesSheetDismiss,
|
||||||
|
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 = {
|
||||||
|
locationManager.enableLocationChannels()
|
||||||
|
locationManager.refreshChannels()
|
||||||
|
}) {
|
||||||
|
Text("Enable Location")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// User action sheet
|
// User action sheet
|
||||||
if (showUserSheet) {
|
if (showUserSheet) {
|
||||||
ChatUserSheet(
|
ChatUserSheet(
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ class ChatViewModel(
|
|||||||
mediaSendingManager.handleTransferProgressEvent(evt)
|
mediaSendingManager.handleTransferProgressEvent(evt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize location notes counter subscription management (iOS parity)
|
||||||
|
initializeLocationNotesCounterSubscription()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelMediaSend(messageId: String) {
|
fun cancelMediaSend(messageId: String) {
|
||||||
@@ -591,6 +594,70 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize location notes counter subscription management (iOS parity)
|
||||||
|
* Subscribes/unsubscribes based on channel selection and location permission
|
||||||
|
*/
|
||||||
|
private fun initializeLocationNotesCounterSubscription() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Observe channel changes
|
||||||
|
selectedLocationChannel.observeForever { channel ->
|
||||||
|
updateNotesCounterSubscription(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update notes counter subscription based on current channel and location permission
|
||||||
|
* iOS pattern: Subscribe when in mesh mode and location is authorized
|
||||||
|
*/
|
||||||
|
private fun updateNotesCounterSubscription(channel: com.bitchat.android.geohash.ChannelID?) {
|
||||||
|
try {
|
||||||
|
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
|
||||||
|
val permissionState = locationManager.permissionState.value
|
||||||
|
val locationPermissionGranted = permissionState == com.bitchat.android.geohash.LocationChannelManager.PermissionState.AUTHORIZED
|
||||||
|
|
||||||
|
when (channel) {
|
||||||
|
is com.bitchat.android.geohash.ChannelID.Mesh -> {
|
||||||
|
// Mesh mode: subscribe to block-level geohash notes if location authorized
|
||||||
|
if (locationPermissionGranted) {
|
||||||
|
// Refresh location to get current block geohash
|
||||||
|
locationManager.refreshChannels()
|
||||||
|
|
||||||
|
// Get building-level geohash (precision 8, same as iOS)
|
||||||
|
val buildingGeohash = locationManager.availableChannels.value
|
||||||
|
?.firstOrNull { it.level == com.bitchat.android.geohash.GeohashChannelLevel.BUILDING }
|
||||||
|
?.geohash
|
||||||
|
|
||||||
|
if (buildingGeohash != null) {
|
||||||
|
Log.d(TAG, "📍 Subscribing to location notes for building geohash: $buildingGeohash")
|
||||||
|
com.bitchat.android.nostr.LocationNotesCounter.subscribe(buildingGeohash)
|
||||||
|
} else {
|
||||||
|
// Keep existing subscription if we had one (avoid flicker)
|
||||||
|
if (com.bitchat.android.nostr.LocationNotesCounter.geohash.value == null) {
|
||||||
|
com.bitchat.android.nostr.LocationNotesCounter.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
com.bitchat.android.nostr.LocationNotesCounter.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||||
|
// Location channel mode: cancel counter (only show in mesh mode)
|
||||||
|
com.bitchat.android.nostr.LocationNotesCounter.cancel()
|
||||||
|
}
|
||||||
|
null -> {
|
||||||
|
// Default to mesh behavior
|
||||||
|
if (locationPermissionGranted) {
|
||||||
|
locationManager.refreshChannels()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "updateNotesCounterSubscription failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update reactive states for all connected peers (session states, fingerprints, nicknames, RSSI)
|
* Update reactive states for all connected peers (session states, fingerprints, nicknames, RSSI)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -713,6 +713,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 +750,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,433 @@
|
|||||||
|
package com.bitchat.android.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Send
|
||||||
|
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.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.bitchat.android.geohash.LocationChannelManager
|
||||||
|
import com.bitchat.android.nostr.LocationNotesCounter
|
||||||
|
import com.bitchat.android.nostr.LocationNotesManager
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Location Notes Sheet - Material Design bottom sheet for location-based notes
|
||||||
|
* iOS-compatible implementation with Android Material Design
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun LocationNotesSheet(
|
||||||
|
geohash: String,
|
||||||
|
locationName: String?,
|
||||||
|
nickname: String?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// Managers
|
||||||
|
val notesManager = remember { LocationNotesManager.getInstance() }
|
||||||
|
val counter = remember { LocationNotesCounter }
|
||||||
|
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 count by counter.count.observeAsState(0)
|
||||||
|
val initialLoadComplete by notesManager.initialLoadComplete.observeAsState(false)
|
||||||
|
|
||||||
|
// Input field state
|
||||||
|
var messageText by remember { mutableStateOf("") }
|
||||||
|
val canSend = messageText.isNotBlank()
|
||||||
|
|
||||||
|
// Scroll state
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
// Effect to set geohash when sheet opens
|
||||||
|
LaunchedEffect(geohash) {
|
||||||
|
notesManager.setGeohash(geohash)
|
||||||
|
counter.subscribe(geohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup when sheet closes
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
// Don't cancel subscriptions here - they'll be managed by the manager lifecycle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
modifier = modifier,
|
||||||
|
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight(0.9f)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
LocationNotesHeader(
|
||||||
|
geohash = geohash,
|
||||||
|
locationName = locationName,
|
||||||
|
count = count,
|
||||||
|
onClose = onDismiss
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Content area
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
state == LocationNotesManager.State.NO_RELAYS -> {
|
||||||
|
EmptyStateMessage(
|
||||||
|
message = "No relays available",
|
||||||
|
icon = "🌐"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
state == LocationNotesManager.State.LOADING && !initialLoadComplete -> {
|
||||||
|
LoadingState()
|
||||||
|
}
|
||||||
|
notes.isEmpty() && initialLoadComplete -> {
|
||||||
|
EmptyStateMessage(
|
||||||
|
message = "No notes yet.\nBe the first to share!",
|
||||||
|
icon = "📝"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
LocationNotesList(
|
||||||
|
notes = notes,
|
||||||
|
listState = listState,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
errorMessage?.let { error ->
|
||||||
|
Text(
|
||||||
|
text = error,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Input field
|
||||||
|
LocationNotesInputField(
|
||||||
|
text = messageText,
|
||||||
|
onTextChange = { messageText = it },
|
||||||
|
onSend = {
|
||||||
|
if (canSend) {
|
||||||
|
notesManager.send(messageText, nickname)
|
||||||
|
messageText = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = state != LocationNotesManager.State.NO_RELAYS,
|
||||||
|
canSend = canSend
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header with geohash, count, location name, and close button
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LocationNotesHeader(
|
||||||
|
geohash: String,
|
||||||
|
locationName: String?,
|
||||||
|
count: Int,
|
||||||
|
onClose: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = geohash,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "($count)",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
locationName?.let { name ->
|
||||||
|
Text(
|
||||||
|
text = name,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Close",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of location notes
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LocationNotesList(
|
||||||
|
notes: List<LocationNotesManager.Note>,
|
||||||
|
listState: androidx.compose.foundation.lazy.LazyListState,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = modifier,
|
||||||
|
reverseLayout = false, // Normal order (newest at top since sorted desc)
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
items(notes, key = { it.id }) { note ->
|
||||||
|
LocationNoteItem(note = note)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual note item
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LocationNoteItem(note: LocationNotesManager.Note) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
|
||||||
|
.padding(12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = note.displayName,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatTimestamp(note.createdAt),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = note.content,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
lineHeight = 20.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input field with send button
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LocationNotesInputField(
|
||||||
|
text: String,
|
||||||
|
onTextChange: (String) -> Unit,
|
||||||
|
onSend: () -> Unit,
|
||||||
|
enabled: Boolean,
|
||||||
|
canSend: Boolean
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(24.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = onTextChange,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = if (enabled) "Share a note..." else "No relays available",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = enabled,
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
disabledContainerColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
disabledIndicatorColor = Color.Transparent
|
||||||
|
),
|
||||||
|
maxLines = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
if (canSend && enabled) {
|
||||||
|
Color(0xFFFF8C00) // Orange color when enabled
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.clickable(enabled = canSend && enabled) { onSend() },
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Send,
|
||||||
|
contentDescription = "Send",
|
||||||
|
tint = if (canSend && enabled) Color.White else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading state indicator
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LoadingState() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = "Loading notes...",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty state message
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun EmptyStateMessage(message: String, icon: String) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = icon,
|
||||||
|
fontSize = 48.sp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format timestamp - iOS-compatible formatting
|
||||||
|
* Relative for < 7 days, absolute otherwise
|
||||||
|
*/
|
||||||
|
private fun formatTimestamp(createdAt: Int): String {
|
||||||
|
val now = System.currentTimeMillis() / 1000
|
||||||
|
val diff = now - createdAt
|
||||||
|
|
||||||
|
return when {
|
||||||
|
diff < 60 -> "just now"
|
||||||
|
diff < 3600 -> {
|
||||||
|
val minutes = diff / 60
|
||||||
|
"${minutes}m ago"
|
||||||
|
}
|
||||||
|
diff < 86400 -> {
|
||||||
|
val hours = diff / 3600
|
||||||
|
"${hours}h ago"
|
||||||
|
}
|
||||||
|
diff < 604800 -> { // 7 days
|
||||||
|
val days = diff / 86400
|
||||||
|
"${days}d ago"
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Absolute date for older than 7 days
|
||||||
|
val date = Date(createdAt * 1000L)
|
||||||
|
val formatter = SimpleDateFormat("MMM d, yyyy", Locale.getDefault())
|
||||||
|
formatter.format(date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user