mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
Merge 5cc4ba344a89fa395896426352742d8415bac712 into e07a38f6344cbb4af15c0bec1c13c6a5da82bd81
This commit is contained in:
commit
1fef051f01
@ -19,7 +19,7 @@ class LocationNotesManager private constructor() {
|
||||
companion object {
|
||||
private const val TAG = "LocationNotesManager"
|
||||
private const val MAX_NOTES_IN_MEMORY = 500
|
||||
|
||||
private const val DELETIONS_THROTTLE_MS = 500L
|
||||
@Volatile
|
||||
private var INSTANCE: LocationNotesManager? = null
|
||||
|
||||
@ -69,6 +69,9 @@ class LocationNotesManager private constructor() {
|
||||
// Published state (StateFlow for Android)
|
||||
private val _notes = MutableStateFlow<List<Note>>(emptyList())
|
||||
val notes: StateFlow<List<Note>> = _notes.asStateFlow()
|
||||
|
||||
private val _localPubkey = MutableStateFlow<String?>(null)
|
||||
val localPubkey: StateFlow<String?> = _localPubkey.asStateFlow()
|
||||
|
||||
private val _geohash = MutableStateFlow<String?>(null)
|
||||
val geohash: StateFlow<String?> = _geohash.asStateFlow()
|
||||
@ -96,6 +99,7 @@ class LocationNotesManager private constructor() {
|
||||
|
||||
// Coroutine scope for background operations
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private var lastDeletionsSubscribeTime = 0L
|
||||
private var liveLocationToken: Long? = null
|
||||
private var subscribeRetryJob: Job? = null
|
||||
private var initialLoadJob: Job? = null
|
||||
@ -103,7 +107,7 @@ class LocationNotesManager private constructor() {
|
||||
init {
|
||||
LiveLocationPrivacyGate.addRevocationListener(::stop)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Initialize dependencies
|
||||
*/
|
||||
@ -159,6 +163,14 @@ class LocationNotesManager private constructor() {
|
||||
noteIDs.clear()
|
||||
_geohash.value = normalized
|
||||
|
||||
// Derive and cache local pubkey for ownership checks
|
||||
scope.launch {
|
||||
runCatching {
|
||||
val identity = withContext(Dispatchers.IO) { deriveIdentityFunc?.invoke(normalized) }
|
||||
if (identity != null) _localPubkey.value = identity.publicKeyHex
|
||||
}
|
||||
}
|
||||
|
||||
// Compute target geohashes: center + neighbors (±1)
|
||||
val neighbors = try {
|
||||
com.bitchat.android.geohash.Geohash.neighborsSamePrecision(normalized)
|
||||
@ -320,6 +332,75 @@ class LocationNotesManager private constructor() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a location note by sending a NIP-09 kind:5 deletion event to relays.
|
||||
* Removes the note locally immediately for optimistic UI, then broadcasts the
|
||||
* deletion event. Only notes authored by the local user can be deleted.
|
||||
*/
|
||||
fun deleteNote(noteId: String) {
|
||||
val token = LiveLocationPrivacyGate.captureToken() ?: run {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
val currentGeohash = _geohash.value ?: run {
|
||||
Log.w(TAG, "Cannot delete note - no geohash set")
|
||||
return
|
||||
}
|
||||
val targetNote = _notes.value.firstOrNull { it.id == noteId } ?: run {
|
||||
Log.w(TAG, "Cannot delete note - note not found: ${noteId.take(16)}")
|
||||
return
|
||||
}
|
||||
val deriveIdentity = deriveIdentityFunc ?: run {
|
||||
Log.e(TAG, "Cannot delete note - deriveIdentity not initialized")
|
||||
return
|
||||
}
|
||||
var relays: List<String> = emptyList()
|
||||
try {
|
||||
LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
relays = RelayDirectory.closestRelaysForGeohash(currentGeohash, 5)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to lookup relays for location-note deletion")
|
||||
}
|
||||
if (!LiveLocationPrivacyGate.accepts(token)) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val identity = withContext(Dispatchers.IO) { deriveIdentity(currentGeohash) }
|
||||
if (targetNote.pubkey != identity.publicKeyHex) {
|
||||
Log.w(TAG, "Blocked delete for non-owned note: ${noteId.take(16)}")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val deletionEvent = withContext(Dispatchers.IO) {
|
||||
NostrProtocol.createDeletionEvent(
|
||||
targetEventId = noteId,
|
||||
senderIdentity = identity
|
||||
)
|
||||
}
|
||||
if (!LiveLocationPrivacyGate.accepts(token)) return@launch
|
||||
|
||||
// Optimistic local removal
|
||||
_notes.value = _notes.value.filter { it.id != noteId }
|
||||
noteIDs.remove(noteId)
|
||||
|
||||
// Broadcast to geo relays
|
||||
withContext(Dispatchers.IO) {
|
||||
LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
sendEventFunc?.invoke(deletionEvent, relays, token)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "✅ Note deleted: ${noteId.take(16)}...")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to delete note: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to location notes for current geohash
|
||||
*/
|
||||
@ -371,7 +452,10 @@ class LocationNotesManager private constructor() {
|
||||
|
||||
_state.value = State.LOADING
|
||||
|
||||
// Subscribe for each geohash in the ±1 set
|
||||
// Subscribe for each geohash in the ±1 set — kind:1 only.
|
||||
// kind:5 deletion events carry an #e tag (referencing the deleted event ID) but
|
||||
// NOT a #g tag, so they would always fail this filter's matches() check.
|
||||
// A separate deletion subscription is opened after initial notes load (below).
|
||||
subscribedGeohashes.forEach { gh ->
|
||||
if (!LiveLocationPrivacyGate.accepts(token)) return
|
||||
val filter = NostrFilter.geohashNotes(
|
||||
@ -390,8 +474,9 @@ class LocationNotesManager private constructor() {
|
||||
Log.e(TAG, "Failed to subscribe to location notes")
|
||||
}
|
||||
}
|
||||
|
||||
// Mark initial load complete after brief delay to allow relay responses
|
||||
|
||||
// Mark initial load complete after brief delay to allow relay responses,
|
||||
// then open a kind:5 subscription filtered by the IDs of the notes we loaded.
|
||||
initialLoadJob = scope.launch {
|
||||
delay(2000) // Wait 2 seconds for initial batch
|
||||
if (_geohash.value == currentGeohash &&
|
||||
@ -401,9 +486,43 @@ class LocationNotesManager private constructor() {
|
||||
_initialLoadComplete.value = true
|
||||
_state.value = State.READY
|
||||
}
|
||||
subscribeDeletions()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open (or refresh) a kind:5 subscription covering the notes currently in memory.
|
||||
*
|
||||
* NIP-09 deletion events do not carry a #g (geohash) tag — they only reference the
|
||||
* target event via an #e tag — so we cannot reuse the geohash-scoped subscription.
|
||||
* Instead we build a filter keyed on the #e values of every note we already hold.
|
||||
*
|
||||
* This is called once after initial note load and re-called whenever new notes arrive
|
||||
* (see [handleEvent]), so deletions published after the initial batch are also caught.
|
||||
*/
|
||||
private fun subscribeDeletions() {
|
||||
val subscribe = subscribeFunc ?: return
|
||||
val ids = noteIDs.toList()
|
||||
if (ids.isEmpty()) return
|
||||
|
||||
// Cancel any previous deletion subscription before re-subscribing with updated IDs.
|
||||
subscriptionIDs["__deletions__"]?.let {
|
||||
try { unsubscribeFunc?.invoke(it) } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
val filter = NostrFilter(
|
||||
kinds = listOf(NostrKind.DELETION),
|
||||
tagFilters = mapOf("e" to ids)
|
||||
)
|
||||
try {
|
||||
val id = subscribe(filter, "location-deletions") { event -> handleEvent(event) }
|
||||
subscriptionIDs["__deletions__"] = id
|
||||
Log.d(TAG, "📡 Subscribed to kind:5 deletions for ${ids.size} note(s)")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to subscribe for deletions: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming event from subscription
|
||||
*/
|
||||
@ -411,7 +530,31 @@ class LocationNotesManager private constructor() {
|
||||
val token = liveLocationToken
|
||||
if (token == null || !LiveLocationPrivacyGate.accepts(token)) return
|
||||
|
||||
// Validate event
|
||||
// Handle NIP-09 deletion events: remove notes authored by the sender.
|
||||
if (event.kind == NostrKind.DELETION) {
|
||||
// Verify the Schnorr signature before trusting event.pubkey.
|
||||
// Without this check any relay or client could forge a kind:5 event with
|
||||
// someone else's pubkey and silently hide that user's notes.
|
||||
if (!event.isValidSignature()) {
|
||||
Log.w(TAG, "Ignoring kind:5 with invalid signature from ${event.pubkey.take(8)}")
|
||||
return
|
||||
}
|
||||
val targetIds = event.tags
|
||||
.filter { it.size >= 2 && it[0] == "e" }
|
||||
.map { it[1] }.toSet()
|
||||
if (targetIds.isNotEmpty()) {
|
||||
val before = _notes.value
|
||||
val removed = before.filter { it.id in targetIds && it.pubkey == event.pubkey }
|
||||
if (removed.isNotEmpty()) {
|
||||
_notes.value = before.filter { it.id !in targetIds || it.pubkey != event.pubkey }
|
||||
removed.forEach { noteIDs.remove(it.id) }
|
||||
Log.d(TAG, "🗑️ Removed ${removed.size} note(s) via kind:5 from ${event.pubkey.take(8)}")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Validate event — only TEXT_NOTE beyond this point
|
||||
if (event.kind != NostrKind.TEXT_NOTE) {
|
||||
Log.v(TAG, "Ignoring non-text-note event: kind=${event.kind}")
|
||||
return
|
||||
@ -452,19 +595,34 @@ class LocationNotesManager private constructor() {
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
// Refresh the kind:5 deletion subscription to include this newly arrived note,
|
||||
// so deletions published after initial load are also streamed in real-time.
|
||||
maybeResubscribeDeletions()
|
||||
|
||||
// Update state
|
||||
if (!_initialLoadComplete.value!!) {
|
||||
_initialLoadComplete.value = true
|
||||
}
|
||||
_state.value = State.READY
|
||||
}
|
||||
|
||||
|
||||
|
||||
private fun maybeResubscribeDeletions() {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastDeletionsSubscribeTime > DELETIONS_THROTTLE_MS) {
|
||||
lastDeletionsSubscribeTime = now
|
||||
subscribeDeletions()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim oldest notes to stay within memory limit
|
||||
*/
|
||||
|
||||
@ -210,6 +210,7 @@ data class NostrEvent(
|
||||
object NostrKind {
|
||||
const val METADATA = 0
|
||||
const val TEXT_NOTE = 1
|
||||
const val DELETION = 5 // NIP-09 event deletion request
|
||||
const val DIRECT_MESSAGE = 14 // NIP-17 direct message (unsigned)
|
||||
const val FILE_MESSAGE = 15 // NIP-17 file message (unsigned)
|
||||
const val SEAL = 13 // NIP-17 sealed event
|
||||
|
||||
@ -128,6 +128,24 @@ object NostrProtocol {
|
||||
return@withContext senderIdentity.signEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a NIP-09 deletion event (kind 5) for a given note
|
||||
* Signals to relays and other clients that the note should be removed
|
||||
*/
|
||||
suspend fun createDeletionEvent(
|
||||
targetEventId: String,
|
||||
senderIdentity: NostrIdentity
|
||||
): NostrEvent = withContext(Dispatchers.Default) {
|
||||
val event = NostrEvent(
|
||||
pubkey = senderIdentity.publicKeyHex,
|
||||
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
||||
kind = NostrKind.DELETION,
|
||||
tags = listOf(listOf("e", targetEventId)),
|
||||
content = ""
|
||||
)
|
||||
return@withContext senderIdentity.signEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a geohash-scoped presence event (kind 20001)
|
||||
* Has no content and no nickname, used for participant counting
|
||||
|
||||
@ -5,6 +5,7 @@ import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@ -16,22 +17,26 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
|
||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.nostr.LocationNotesManager
|
||||
import com.bitchat.android.nostr.NearbyNotesController
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.Calendar
|
||||
@ -47,17 +52,20 @@ fun LocationNotesSheet(
|
||||
locationName: String?,
|
||||
nickname: String?,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
onNoteLongClick: (LocationNotesManager.Note) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val accentGreen = colorScheme.primary
|
||||
|
||||
|
||||
|
||||
// Managers
|
||||
val notesManager = remember { LocationNotesManager.getInstance() }
|
||||
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
||||
val nearbyNotesController = remember { NearbyNotesController.shared }
|
||||
|
||||
|
||||
|
||||
// State
|
||||
val notes by notesManager.notes.collectAsStateWithLifecycle()
|
||||
val state by notesManager.state.collectAsStateWithLifecycle(LocationNotesManager.State.IDLE)
|
||||
@ -65,19 +73,22 @@ fun LocationNotesSheet(
|
||||
val initialLoadComplete by notesManager.initialLoadComplete.collectAsStateWithLifecycle(false)
|
||||
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
|
||||
val locationEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(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.collectAsStateWithLifecycle()
|
||||
val displayLocationName = locationNames[GeohashChannelLevel.BUILDING]?.takeIf { it.isNotEmpty() }
|
||||
?: locationNames[GeohashChannelLevel.BLOCK]?.takeIf { it.isNotEmpty() }
|
||||
|
||||
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
|
||||
|
||||
val sendButtonEnabled =
|
||||
draft.trim().isNotEmpty() && state != LocationNotesManager.State.NO_RELAYS
|
||||
|
||||
// Scroll state
|
||||
val listState = rememberLazyListState()
|
||||
val isScrolled by remember {
|
||||
@ -87,7 +98,7 @@ fun LocationNotesSheet(
|
||||
}
|
||||
val topBarAlpha by animateFloatAsState(
|
||||
targetValue = if (isScrolled) 0.95f else 0f,
|
||||
label = "topBarAlpha"
|
||||
label = "topBarAlpha",
|
||||
)
|
||||
|
||||
// Refresh location when sheet opens
|
||||
@ -123,8 +134,11 @@ fun LocationNotesSheet(
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp),
|
||||
) {
|
||||
item(key = "notes_header") {
|
||||
LocationNotesHeader(
|
||||
@ -139,23 +153,29 @@ fun LocationNotesSheet(
|
||||
state == LocationNotesManager.State.NO_RELAYS -> {
|
||||
item {
|
||||
NoRelaysRow(
|
||||
onRetry = { notesManager.refresh() }
|
||||
onRetry = { notesManager.refresh() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
state == LocationNotesManager.State.LOADING && !initialLoadComplete -> {
|
||||
item {
|
||||
LoadingRow()
|
||||
}
|
||||
}
|
||||
|
||||
notes.isEmpty() -> {
|
||||
item {
|
||||
EmptyRow()
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
items(notes, key = { it.id }) { note ->
|
||||
NoteRow(note = note)
|
||||
NoteRow(
|
||||
note = note,
|
||||
onLongClick = onNoteLongClick,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
item {
|
||||
@ -170,7 +190,7 @@ fun LocationNotesSheet(
|
||||
item {
|
||||
ErrorRow(
|
||||
message = error,
|
||||
onDismiss = { notesManager.clearError() }
|
||||
onDismiss = { notesManager.clearError() },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -183,27 +203,29 @@ fun LocationNotesSheet(
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
title = {
|
||||
BitchatSheetTitle(
|
||||
text = pluralStringResource(
|
||||
id = R.plurals.location_notes_title,
|
||||
count = count,
|
||||
geohash,
|
||||
count
|
||||
)
|
||||
text =
|
||||
pluralStringResource(
|
||||
id = R.plurals.location_notes_title,
|
||||
count = count,
|
||||
geohash,
|
||||
count,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
){
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Column {
|
||||
// Divider before input (matches iOS overlay)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f),
|
||||
thickness = 1.dp
|
||||
thickness = 1.dp,
|
||||
)
|
||||
|
||||
// Input section (matches iOS inputSection)
|
||||
@ -219,7 +241,7 @@ fun LocationNotesSheet(
|
||||
notesManager.send(content, nickname)
|
||||
draft = ""
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -238,10 +260,11 @@ private fun LocationNotesHeader(
|
||||
accentGreen: Color,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 12.dp)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 12.dp),
|
||||
) {
|
||||
// Location name in green (building or block)
|
||||
locationName?.let { name ->
|
||||
@ -250,20 +273,20 @@ private fun LocationNotesHeader(
|
||||
text = name,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = accentGreen
|
||||
color = accentGreen,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Description
|
||||
Text(
|
||||
text = stringResource(R.string.location_notes_description),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
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))
|
||||
@ -271,7 +294,7 @@ private fun LocationNotesHeader(
|
||||
text = stringResource(R.string.location_notes_relays_unavailable),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -282,28 +305,40 @@ private fun LocationNotesHeader(
|
||||
* Shows @basename then timestamp, then content below
|
||||
*/
|
||||
@Composable
|
||||
private fun NoteRow(note: LocationNotesManager.Note) {
|
||||
private fun NoteRow(
|
||||
note: LocationNotesManager.Note,
|
||||
onLongClick: (LocationNotesManager.Note) -> Unit,
|
||||
) {
|
||||
// Extract baseName (before #suffix like iOS)
|
||||
val baseName = note.displayName.split("#", limit = 2).firstOrNull() ?: note.displayName
|
||||
val ts = timestampText(note.createdAt)
|
||||
|
||||
val haptic = LocalHapticFeedback.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.pointerInput(note.id) {
|
||||
detectTapGestures(
|
||||
onLongPress = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onLongClick(note)
|
||||
},
|
||||
)
|
||||
}.padding(vertical = 4.dp),
|
||||
) {
|
||||
// First row: @nickname and timestamp
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "@$baseName",
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
if (ts.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
@ -311,19 +346,19 @@ private fun NoteRow(note: LocationNotesManager.Note) {
|
||||
text = ts,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
|
||||
// Second row: content
|
||||
Text(
|
||||
text = note.content,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -334,23 +369,24 @@ private fun NoteRow(note: LocationNotesManager.Note) {
|
||||
@Composable
|
||||
private fun NoRelaysRow(onRetry: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.location_notes_no_relays_title),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.location_notes_no_relays_desc),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
@ -358,7 +394,7 @@ private fun NoRelaysRow(onRetry: () -> Unit) {
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable(onClick = onRetry)
|
||||
modifier = Modifier.clickable(onClick = onRetry),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -369,22 +405,23 @@ private fun NoRelaysRow(onRetry: () -> Unit) {
|
||||
@Composable
|
||||
private fun LoadingRow() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.loading_location_notes),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -395,23 +432,24 @@ private fun LoadingRow() {
|
||||
@Composable
|
||||
private fun EmptyRow() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.location_notes_empty_title),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.location_notes_empty_desc),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -420,26 +458,30 @@ private fun EmptyRow() {
|
||||
* Error row - matches iOS errorRow
|
||||
*/
|
||||
@Composable
|
||||
private fun ErrorRow(message: String, onDismiss: () -> Unit) {
|
||||
private fun ErrorRow(
|
||||
message: String,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "⚠",
|
||||
fontSize = 12.sp
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = message,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
@ -448,7 +490,7 @@ private fun ErrorRow(message: String, onDismiss: () -> Unit) {
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable(onClick = onDismiss)
|
||||
modifier = Modifier.clickable(onClick = onDismiss),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -495,7 +537,7 @@ private fun LocationNotesInputSection(
|
||||
) {
|
||||
// Text input with placeholder overlay (matches main chat exactly)
|
||||
Box(
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
androidx.compose.foundation.text.BasicTextField(
|
||||
value = draft,
|
||||
@ -513,7 +555,7 @@ private fun LocationNotesInputSection(
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
|
||||
// Placeholder when empty (matches main chat)
|
||||
if (draft.isEmpty()) {
|
||||
Text(
|
||||
@ -522,29 +564,31 @@ private fun LocationNotesInputSection(
|
||||
fontFamily = BitchatFontFamily
|
||||
),
|
||||
color = colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Send button - circular with icon (matches main chat exactly)
|
||||
IconButton(
|
||||
onClick = { if (sendButtonEnabled) onSend() },
|
||||
enabled = sendButtonEnabled,
|
||||
modifier = Modifier.size(32.dp)
|
||||
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
|
||||
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,
|
||||
@ -562,6 +606,118 @@ private fun LocationNotesInputSection(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Note Actions Sheet - shown on long-press of a note
|
||||
* Reuses BitchatBottomSheet pattern from ChatUserSheet
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun NoteActionsSheet(
|
||||
note: LocationNotesManager.Note,
|
||||
onDelete: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark =
|
||||
colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val standardRed = Color(0xFFFF3B30) // iOS red
|
||||
|
||||
BitchatBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// Header: note author
|
||||
val baseName = note.displayName.split("#", limit = 2).firstOrNull() ?: note.displayName
|
||||
Text(
|
||||
text = "@$baseName",
|
||||
fontSize = 18.sp,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
// Action list
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
item {
|
||||
NoteActionRow(
|
||||
title = stringResource(R.string.action_delete_note_title),
|
||||
subtitle = stringResource(R.string.action_delete_note_subtitle),
|
||||
titleColor = standardRed,
|
||||
onClick = onDelete,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel button
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.cancel_lower),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontFamily = BitchatFontFamily,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single action row inside NoteActionsSheet
|
||||
* Matches UserActionRow pattern from ChatUserSheet
|
||||
*/
|
||||
@Composable
|
||||
private fun NoteActionRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
titleColor: Color,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
// iOS-style list row (plain button, no card background)
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
color = Color.Transparent,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = titleColor,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamp text - matches iOS timestampText exactly
|
||||
* Shows relative time for < 7 days, absolute date otherwise
|
||||
@ -569,40 +725,47 @@ private fun LocationNotesInputSection(
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
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 < 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"
|
||||
@ -611,11 +774,12 @@ private fun timestampText(createdAt: Int): String {
|
||||
} else {
|
||||
// Absolute date formatting
|
||||
val sameYear = dateYear == nowYear
|
||||
val formatter = if (sameYear) {
|
||||
SimpleDateFormat("MMM d", Locale.getDefault())
|
||||
} else {
|
||||
SimpleDateFormat("MMM d, y", Locale.getDefault())
|
||||
}
|
||||
val formatter =
|
||||
if (sameYear) {
|
||||
SimpleDateFormat("MMM d", Locale.getDefault())
|
||||
} else {
|
||||
SimpleDateFormat("MMM d, y", Locale.getDefault())
|
||||
}
|
||||
formatter.format(date)
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,19 +4,22 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
|
||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.nostr.LocationNotesManager
|
||||
|
||||
/**
|
||||
* Presenter component for LocationNotesSheet
|
||||
@ -27,29 +30,38 @@ import com.bitchat.android.R
|
||||
@Composable
|
||||
fun LocationNotesSheetPresenter(
|
||||
viewModel: ChatViewModel,
|
||||
onDismiss: () -> Unit
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
||||
val notesManager = remember { LocationNotesManager.getInstance() }
|
||||
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
|
||||
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
|
||||
val isLoadingLocation by locationManager.isLoadingLocation.collectAsStateWithLifecycle()
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
|
||||
|
||||
// Note long-press state — owned here so NoteActionsSheet is a sibling of
|
||||
// LocationNotesSheet (not nested inside it), which avoids Dialog window conflicts
|
||||
var selectedNote by remember { mutableStateOf<LocationNotesManager.Note?>(null) }
|
||||
val localPubkey by notesManager.localPubkey.collectAsStateWithLifecycle()
|
||||
|
||||
// iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash
|
||||
val buildingGeohash = availableChannels.firstOrNull { it.level == GeohashChannelLevel.BUILDING }?.geohash
|
||||
|
||||
val buildingGeohash =
|
||||
availableChannels.firstOrNull { it.level == GeohashChannelLevel.BUILDING }?.geohash
|
||||
|
||||
if (buildingGeohash != null) {
|
||||
// Get location name from locationManager
|
||||
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
|
||||
val locationName = locationNames[GeohashChannelLevel.BUILDING]
|
||||
?: locationNames[GeohashChannelLevel.BLOCK]
|
||||
|
||||
val locationName =
|
||||
locationNames[GeohashChannelLevel.BUILDING]
|
||||
?: locationNames[GeohashChannelLevel.BLOCK]
|
||||
|
||||
LocationNotesSheet(
|
||||
geohash = buildingGeohash,
|
||||
locationName = locationName,
|
||||
nickname = nickname,
|
||||
onDismiss = onDismiss
|
||||
onDismiss = onDismiss,
|
||||
onNoteLongClick = { note -> selectedNote = note },
|
||||
)
|
||||
} else if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && isLoadingLocation) {
|
||||
LocationNotesAcquiringSheet(onDismiss = onDismiss)
|
||||
@ -57,7 +69,19 @@ fun LocationNotesSheetPresenter(
|
||||
// No building geohash available - show error state (matches iOS)
|
||||
LocationNotesErrorSheet(
|
||||
onDismiss = onDismiss,
|
||||
locationManager = locationManager
|
||||
locationManager = locationManager,
|
||||
)
|
||||
}
|
||||
|
||||
// Note actions sheet
|
||||
selectedNote?.takeIf { it.pubkey == localPubkey }?.let { note ->
|
||||
NoteActionsSheet(
|
||||
note = note,
|
||||
onDelete = {
|
||||
notesManager.deleteNote(note.id)
|
||||
selectedNote = null
|
||||
},
|
||||
onDismiss = { selectedNote = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -67,33 +91,32 @@ fun LocationNotesSheetPresenter(
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun LocationNotesAcquiringSheet(
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
private fun LocationNotesAcquiringSheet(onDismiss: () -> Unit) {
|
||||
BitchatBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = "Acquiring Location",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(48.dp),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "Please wait while your location is being determined",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -106,30 +129,31 @@ private fun LocationNotesAcquiringSheet(
|
||||
@Composable
|
||||
private fun LocationNotesErrorSheet(
|
||||
onDismiss: () -> Unit,
|
||||
locationManager: LocationChannelManager
|
||||
locationManager: LocationChannelManager,
|
||||
) {
|
||||
BitchatBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 80.dp, bottom = 24.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 80.dp, bottom = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "Location Unavailable",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
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
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Button(onClick = {
|
||||
@ -148,9 +172,9 @@ private fun LocationNotesErrorSheet(
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
title = {
|
||||
BitchatSheetTitle(
|
||||
text = stringResource(R.string.cd_location_notes).uppercase()
|
||||
text = stringResource(R.string.cd_location_notes).uppercase(),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -184,6 +184,8 @@
|
||||
<string name="action_hug_subtitle">إرسال عناق ودي</string>
|
||||
<string name="action_block_title">حظر %1$s</string>
|
||||
<string name="action_block_subtitle">حظر جميع رسائل هذا المستخدم</string>
|
||||
<string name="action_delete_note_title">حذف الملاحظة</string>
|
||||
<string name="action_delete_note_subtitle">يرسل طلب حذف nostr إلى المرحلات</string>
|
||||
|
||||
<!-- ورقة قنوات geohash -->
|
||||
<string name="location_channels_title">#قنوات الموقع</string>
|
||||
|
||||
@ -184,6 +184,8 @@
|
||||
<string name="action_hug_subtitle">একটি বন্ধুত্বপূর্ণ আলিঙ্গন পাঠান</string>
|
||||
<string name="action_block_title">%1$s কে ব্লক করুন</string>
|
||||
<string name="action_block_subtitle">এই ব্যবহারকারীর সমস্ত বার্তা ব্লক করুন</string>
|
||||
<string name="action_delete_note_title">নোট মুছুন</string>
|
||||
<string name="action_delete_note_subtitle">রিলেতে nostr মুছে ফেলার অনুরোধ পাঠায়</string>
|
||||
|
||||
<!-- জিওহ্যাশ চ্যানেল শিট -->
|
||||
<string name="location_channels_title">#অবস্থান চ্যানেল</string>
|
||||
|
||||
@ -184,6 +184,8 @@
|
||||
<string name="action_hug_subtitle">Eine freundliche Umarmung senden</string>
|
||||
<string name="action_block_title">%1$s blockieren</string>
|
||||
<string name="action_block_subtitle">Alle Nachrichten dieses Benutzers blockieren</string>
|
||||
<string name="action_delete_note_title">Notiz löschen</string>
|
||||
<string name="action_delete_note_subtitle">sendet eine nostr-Löschanfrage an Relays</string>
|
||||
|
||||
<!-- Geohash‑Kanäle Sheet -->
|
||||
<string name="location_channels_title">#Standort‑Kanäle</string>
|
||||
|
||||
@ -184,6 +184,8 @@
|
||||
<string name="action_hug_subtitle">Enviar un abrazo amistoso</string>
|
||||
<string name="action_block_title">Bloquear a %1$s</string>
|
||||
<string name="action_block_subtitle">Bloquear todos los mensajes de este usuario</string>
|
||||
<string name="action_delete_note_title">eliminar nota</string>
|
||||
<string name="action_delete_note_subtitle">envía una solicitud de eliminación de nostr a los relays</string>
|
||||
|
||||
<!-- Hoja de canales geohash -->
|
||||
<string name="location_channels_title">#Canales de ubicación</string>
|
||||
|
||||
@ -184,6 +184,8 @@
|
||||
<string name="action_hug_subtitle">یک بغل دوستانه ارسال کن</string>
|
||||
<string name="action_block_title">%1$s را مسدود کن</string>
|
||||
<string name="action_block_subtitle">همهٔ پیامهای این کاربر را مسدود کن</string>
|
||||
<string name="action_delete_note_title">حذف یادداشت</string>
|
||||
<string name="action_delete_note_subtitle">درخواست حذف nostr را به رلهها ارسال میکند</string>
|
||||
|
||||
<!-- برگهٔ کانالهای geohash -->
|
||||
<string name="location_channels_title">#کانالهای مکانی</string>
|
||||
|
||||
@ -177,6 +177,8 @@
|
||||
<string name="action_hug_subtitle">magpadala ng magiliw na yakap</string>
|
||||
<string name="action_block_title">harangan si %1$s</string>
|
||||
<string name="action_block_subtitle">harangan ang lahat ng mensahe mula sa user na ito</string>
|
||||
<string name="action_delete_note_title">tanggalin ang tala</string>
|
||||
<string name="action_delete_note_subtitle">nagpapadala ng kahilingan sa pagtanggal ng nostr sa mga relay</string>
|
||||
|
||||
<!-- Location channels sheet -->
|
||||
<string name="location_channels_title">#mga channel sa lokasyon</string>
|
||||
|
||||
@ -177,6 +177,8 @@
|
||||
<string name="action_hug_subtitle">envoyer une accolade amicale</string>
|
||||
<string name="action_block_title">bloquer %1$s</string>
|
||||
<string name="action_block_subtitle">bloquer tous les messages de cet utilisateur</string>
|
||||
<string name="action_delete_note_title">supprimer la note</string>
|
||||
<string name="action_delete_note_subtitle">envoie une demande de suppression nostr aux relais</string>
|
||||
|
||||
<!-- Feuille des canaux de lieu -->
|
||||
<string name="location_channels_title">#canaux de lieu</string>
|
||||
|
||||
@ -398,4 +398,11 @@
|
||||
<string name="about_app_language">שפת האפליקציה</string>
|
||||
<string name="about_system_default">ברירת המחדל של המערכת</string>
|
||||
<string name="about_select_language">בחירת שפה</string>
|
||||
|
||||
<string name="action_delete_note_subtitle">שולח בקשת מחיקה של nostr לממסרים</string>
|
||||
<string name="action_delete_note_title">מחק הערה</string>
|
||||
<string name="cd_open_about">פתיחת אודות</string>
|
||||
<string name="nearby_notes_many">%d פתקים הושארו כאן — הקש לקריאה</string>
|
||||
<string name="nearby_notes_one">פתק אחד הושאר כאן — הקש לקריאה</string>
|
||||
<string name="nearby_notes_reveal">בדיקה אם הושארו כאן פתקים</string>
|
||||
</resources>
|
||||
|
||||
@ -184,6 +184,8 @@
|
||||
<string name="action_hug_subtitle">दोस्ताना आलिंगन भेजें</string>
|
||||
<string name="action_block_title">%1$s को ब्लॉक करें</string>
|
||||
<string name="action_block_subtitle">इस उपयोगकर्ता के सभी संदेश ब्लॉक करें</string>
|
||||
<string name="action_delete_note_title">नोट हटाएं</string>
|
||||
<string name="action_delete_note_subtitle">रिले को nostr हटाने का अनुरोध भेजता है</string>
|
||||
|
||||
<!-- geohash चैनल शीट -->
|
||||
<string name="location_channels_title">#स्थान चैनल</string>
|
||||
|
||||
@ -184,6 +184,8 @@
|
||||
<string name="action_hug_subtitle">Kirim pelukan yang ramah</string>
|
||||
<string name="action_block_title">Blokir %1$s</string>
|
||||
<string name="action_block_subtitle">Blokir semua pesan dari pengguna ini</string>
|
||||
<string name="action_delete_note_title">hapus catatan</string>
|
||||
<string name="action_delete_note_subtitle">mengirim permintaan penghapusan nostr ke relay</string>
|
||||
|
||||
<!-- Sheet channel geohash -->
|
||||
<string name="location_channels_title">#Channel lokasi</string>
|
||||
|
||||
@ -184,6 +184,8 @@
|
||||
<string name="action_hug_subtitle">invia un abbraccio amichevole</string>
|
||||
<string name="action_block_title">blocca %1$s</string>
|
||||
<string name="action_block_subtitle">blocca tutti i messaggi da questo utente</string>
|
||||
<string name="action_delete_note_title">elimina nota</string>
|
||||
<string name="action_delete_note_subtitle">invia una richiesta di eliminazione nostr ai relay</string>
|
||||
|
||||
<!-- Schermata canali di posizione -->
|
||||
<string name="location_channels_title">#canali di posizione</string>
|
||||
|
||||
@ -184,6 +184,8 @@
|
||||
<string name="action_hug_subtitle">フレンドリーなハグを送る</string>
|
||||
<string name="action_block_title">%1$s をブロック</string>
|
||||
<string name="action_block_subtitle">このユーザーからのすべてのメッセージをブロック</string>
|
||||
<string name="action_delete_note_title">ノートを削除</string>
|
||||
<string name="action_delete_note_subtitle">nostr の削除リクエストをリレーに送信します</string>
|
||||
|
||||
<!-- Geohash チャンネル シート -->
|
||||
<string name="location_channels_title">#ロケーション・チャンネル</string>
|
||||
|
||||
@ -184,6 +184,8 @@
|
||||
<string name="action_hug_subtitle">გაგზავნეთ მეგობრული ჩახუტება</string>
|
||||
<string name="action_block_title">%1$s-ის დაბლოკვა</string>
|
||||
<string name="action_block_subtitle">ამ მომხმარებლის ყველა შეტყობინების დაბლოკვა</string>
|
||||
<string name="action_delete_note_title">ჩანაწერის წაშლა</string>
|
||||
<string name="action_delete_note_subtitle">nostr-ის წაშლის მოთხოვნას გზავნის relay-ებზე</string>
|
||||
|
||||
<!-- geohash არხების შიტი -->
|
||||
<string name="location_channels_title">#მდებარეობის არხები</string>
|
||||
|
||||
@ -184,6 +184,8 @@
|
||||
<string name="action_hug_subtitle">친근한 포옹 보내기</string>
|
||||
<string name="action_block_title">%1$s 차단</string>
|
||||
<string name="action_block_subtitle">이 사용자의 모든 메시지 차단</string>
|
||||
<string name="action_delete_note_title">노트 삭제</string>
|
||||
<string name="action_delete_note_subtitle">nostr 삭제 요청을 릴레이에 전송합니다</string>
|
||||
|
||||
<!-- geohash 채널 시트 -->
|
||||
<string name="location_channels_title">#위치 채널</string>
|
||||
|
||||
@ -184,6 +184,8 @@
|
||||
<string name="action_hug_subtitle">handefasa hafatra honofinofy sariaka</string>
|
||||
<string name="action_block_title">hanorina %1$s</string>
|
||||
<string name="action_block_subtitle">hanorina ny hafatra rehetra avy amin\'ity mpampiasa ity</string>
|
||||
<string name="action_delete_note_title">fafao ny fanamarihana</string>
|
||||
<string name="action_delete_note_subtitle">mandefa fangatahana fafana nostr any amin\'ny relay</string>
|
||||
|
||||
<!-- Takelaka fantsona toerana -->
|
||||
<string name="location_channels_title">#fantsona toerana</string>
|
||||
|
||||
@ -439,4 +439,11 @@
|
||||
<string name="about_app_language">Bahasa aplikasi</string>
|
||||
<string name="about_system_default">Lalai sistem</string>
|
||||
<string name="about_select_language">Pilih bahasa</string>
|
||||
|
||||
<string name="action_delete_note_subtitle">menghantar permintaan pemadaman nostr ke relay</string>
|
||||
<string name="action_delete_note_title">padam nota</string>
|
||||
<string name="cd_open_about">Buka Perihal</string>
|
||||
<string name="nearby_notes_many">%d nota ditinggalkan di sini — ketik untuk baca</string>
|
||||
<string name="nearby_notes_one">1 nota ditinggalkan di sini — ketik untuk baca</string>
|
||||
<string name="nearby_notes_reveal">semak nota yang ditinggalkan di sini</string>
|
||||
</resources>
|
||||
|
||||
@ -177,6 +177,8 @@
|
||||
<string name="action_hug_subtitle">मैत्रीपूर्ण अँगालो पठाउनुहोस्</string>
|
||||
<string name="action_block_title">%1$s लाई ब्लक गर्नुहोस्</string>
|
||||
<string name="action_block_subtitle">यस प्रयोगकर्ताबाट सबै सन्देश ब्लक गर्नुहोस्</string>
|
||||
<string name="action_delete_note_title">नोट मेट्नुहोस्</string>
|
||||
<string name="action_delete_note_subtitle">रिलेहरूमा nostr मेटाउने अनुरोध पठाउँछ</string>
|
||||
|
||||
<!-- स्थान च्यानल शीट -->
|
||||
<string name="location_channels_title">#स्थान च्यानल</string>
|
||||
|
||||
@ -184,6 +184,8 @@
|
||||
<string name="action_hug_subtitle">stuur een vriendelijke knuffel</string>
|
||||
<string name="action_block_title">%1$s blokkeren</string>
|
||||
<string name="action_block_subtitle">blokkeer alle berichten van deze gebruiker</string>
|
||||
<string name="action_delete_note_title">notitie verwijderen</string>
|
||||
<string name="action_delete_note_subtitle">stuurt een nostr-verwijderverzoek naar relays</string>
|
||||
|
||||
<!-- Locatiekanalen sheet -->
|
||||
<string name="location_channels_title">#locatiekanalen</string>
|
||||
|
||||
@ -184,6 +184,8 @@
|
||||
<string name="action_hug_subtitle">دوستانہ گلے ملنا پھجو</string>
|
||||
<string name="action_block_title">%1$s نوں بلاک کرو</string>
|
||||
<string name="action_block_subtitle">اس یوزر دے سارے پیغام بلاک کرو</string>
|
||||
<string name="action_delete_note_title">ਨੋਟ ਮਿਟਾਓ</string>
|
||||
<string name="action_delete_note_subtitle">ਰਿਲੇਅਾਂ ਨੂੰ nostr ਮਿਟਾਉਣ ਦੀ ਬੇਨਤੀ ਭੇਜਦਾ ਹੈ</string>
|
||||
|
||||
<!-- geohash چینل شیٹ -->
|
||||
<string name="location_channels_title">#لوکیشن چینل</string>
|
||||
|
||||
@ -404,4 +404,11 @@
|
||||
<string name="about_app_language">Język aplikacji</string>
|
||||
<string name="about_system_default">Domyślny systemu</string>
|
||||
<string name="about_select_language">Wybierz język</string>
|
||||
|
||||
<string name="action_delete_note_subtitle">wysyła żądanie usunięcia nostr do przekaźników</string>
|
||||
<string name="action_delete_note_title">usuń notatkę</string>
|
||||
<string name="cd_open_about">Otwórz informacje</string>
|
||||
<string name="nearby_notes_many">%d notatek zostawionych tutaj — stuknij, aby przeczytać</string>
|
||||
<string name="nearby_notes_one">1 notatka zostawiona tutaj — stuknij, aby przeczytać</string>
|
||||
<string name="nearby_notes_reveal">sprawdź, czy zostawiono tutaj notatki</string>
|
||||
</resources>
|
||||
|
||||
@ -184,6 +184,8 @@
|
||||
<string name="action_hug_subtitle">Enviar um abraço amigável</string>
|
||||
<string name="action_block_title">Bloquear %1$s</string>
|
||||
<string name="action_block_subtitle">Bloquear todas as mensagens deste usuário</string>
|
||||
<string name="action_delete_note_title">excluir nota</string>
|
||||
<string name="action_delete_note_subtitle">envia uma solicitação de exclusão nostr para os relays</string>
|
||||
|
||||
<!-- Canais geohash -->
|
||||
<string name="location_channels_title">#Canais de localização</string>
|
||||
|
||||
@ -184,6 +184,8 @@
|
||||
<string name="action_hug_subtitle">Enviar um abraço amigável</string>
|
||||
<string name="action_block_title">Bloquear %1$s</string>
|
||||
<string name="action_block_subtitle">Bloquear todas as mensagens deste utilizador</string>
|
||||
<string name="action_delete_note_title">eliminar nota</string>
|
||||
<string name="action_delete_note_subtitle">envia um pedido de eliminação nostr para os relays</string>
|
||||
|
||||
<!-- Folha de canais geohash -->
|
||||
<string name="location_channels_title">#Canais de localização</string>
|
||||
|
||||
@ -164,6 +164,8 @@
|
||||
<string name="action_hug_subtitle">отправить дружеские объятия</string>
|
||||
<string name="action_block_title">заблокировать %1$s</string>
|
||||
<string name="action_block_subtitle">заблокировать все сообщения от этого пользователя</string>
|
||||
<string name="action_delete_note_title">удалить заметку</string>
|
||||
<string name="action_delete_note_subtitle">отправляет запрос на удаление nostr в ретрансляторы</string>
|
||||
|
||||
<string name="location_channels_title">#каналы локации</string>
|
||||
<string name="location_channels_desc">общайся с людьми рядом через каналы geohash. делится только грубый geohash, никогда точный gps. не делай скриншоты и не делись экраном для защиты приватности.</string>
|
||||
|
||||
@ -164,6 +164,8 @@
|
||||
<string name="action_hug_subtitle">skicka en vänlig kram</string>
|
||||
<string name="action_block_title">blockera %1$s</string>
|
||||
<string name="action_block_subtitle">blockera alla meddelanden från den här användaren</string>
|
||||
<string name="action_delete_note_title">ta bort anteckning</string>
|
||||
<string name="action_delete_note_subtitle">skickar en nostr-borttagningsbegäran till reläer</string>
|
||||
|
||||
<string name="location_channels_title">#platskanaler</string>
|
||||
<string name="location_channels_desc">chatta med folk nära dig via geohash‑kanaler. endast en grov geohash delas, aldrig exakt gps. ta inte skärmdumpar eller dela skärmen för att skydda din integritet.</string>
|
||||
|
||||
@ -396,4 +396,11 @@
|
||||
<string name="about_app_language">பயன்பாட்டு மொழி</string>
|
||||
<string name="about_system_default">கணினி இயல்புநிலை</string>
|
||||
<string name="about_select_language">மொழியைத் தேர்ந்தெடுக்கவும்</string>
|
||||
|
||||
<string name="action_delete_note_subtitle">relay-களுக்கு nostr நீக்கும் கோரிக்கையை அனுப்புகிறது</string>
|
||||
<string name="action_delete_note_title">குறிப்பை நீக்கு</string>
|
||||
<string name="cd_open_about">அறிமுகத்தைத் திற</string>
|
||||
<string name="nearby_notes_many">இங்கே %d குறிப்புகள் விடப்பட்டுள்ளன — படிக்க தட்டவும்</string>
|
||||
<string name="nearby_notes_one">இங்கே 1 குறிப்பு விடப்பட்டுள்ளது — படிக்க தட்டவும்</string>
|
||||
<string name="nearby_notes_reveal">இங்கே விடப்பட்ட குறிப்புகள் உள்ளதா எனப் பார்க்கவும்</string>
|
||||
</resources>
|
||||
|
||||
@ -184,6 +184,8 @@
|
||||
<string name="action_hug_subtitle">ส่งกอดแบบเป็นมิตร</string>
|
||||
<string name="action_block_title">บล็อก %1$s</string>
|
||||
<string name="action_block_subtitle">บล็อกข้อความทั้งหมดจากผู้ใช้นี้</string>
|
||||
<string name="action_delete_note_title">ลบบันทึก</string>
|
||||
<string name="action_delete_note_subtitle">ส่งคำขอลบ nostr ไปยัง relay</string>
|
||||
|
||||
<!-- แผ่นช่อง geohash -->
|
||||
<string name="location_channels_title">#ช่องตามตำแหน่ง</string>
|
||||
|
||||
@ -164,6 +164,8 @@
|
||||
<string name="action_hug_subtitle">samimi bir sarılma gönder</string>
|
||||
<string name="action_block_title">%1$s kişisini engelle</string>
|
||||
<string name="action_block_subtitle">bu kullanıcıdan gelen tüm mesajları engelle</string>
|
||||
<string name="action_delete_note_title">notu sil</string>
|
||||
<string name="action_delete_note_subtitle">rölelere bir nostr silme isteği gönderir</string>
|
||||
|
||||
<string name="location_channels_title">#konum kanalları</string>
|
||||
<string name="location_channels_desc">yakındaki insanlarla geohash kanallarıyla sohbet et. yalnızca kabaca geohash paylaşılır, kesin gps asla değil. gizliliğin için ekran görüntüsü alma ve paylaşma.</string>
|
||||
|
||||
@ -406,4 +406,11 @@
|
||||
<string name="about_app_language">Мова застосунку</string>
|
||||
<string name="about_system_default">Системна мова</string>
|
||||
<string name="about_select_language">Вибрати мову</string>
|
||||
|
||||
<string name="action_delete_note_subtitle">надсилає запит на видалення nostr до ретрансляторів</string>
|
||||
<string name="action_delete_note_title">видалити нотатку</string>
|
||||
<string name="cd_open_about">Відкрити розділ «Про застосунок»</string>
|
||||
<string name="nearby_notes_many">тут залишено %d нотаток — торкніться, щоб прочитати</string>
|
||||
<string name="nearby_notes_one">тут залишено 1 нотатку — торкніться, щоб прочитати</string>
|
||||
<string name="nearby_notes_reveal">перевірити, чи залишено тут нотатки</string>
|
||||
</resources>
|
||||
|
||||
@ -184,6 +184,8 @@
|
||||
<string name="action_hug_subtitle">دوستانہ آلنگن بھیجیں</string>
|
||||
<string name="action_block_title">%1$s کو بلاک کریں</string>
|
||||
<string name="action_block_subtitle">اس صارف کے تمام پیغامات بلاک کریں</string>
|
||||
<string name="action_delete_note_title">نوٹ حذف کریں</string>
|
||||
<string name="action_delete_note_subtitle">ریلے پر nostr حذف کی درخواست بھیجتا ہے</string>
|
||||
|
||||
<!-- geohash چینلز شیٹ -->
|
||||
<string name="location_channels_title">#مقام چینلز</string>
|
||||
|
||||
@ -184,6 +184,8 @@
|
||||
<string name="action_hug_subtitle">Gửi một cái ôm thân thiện</string>
|
||||
<string name="action_block_title">Chặn %1$s</string>
|
||||
<string name="action_block_subtitle">Chặn tất cả tin nhắn từ người dùng này</string>
|
||||
<string name="action_delete_note_title">xóa ghi chú</string>
|
||||
<string name="action_delete_note_subtitle">gửi yêu cầu xóa nostr tới các relay</string>
|
||||
|
||||
<!-- Sheet kênh geohash -->
|
||||
<string name="location_channels_title">#Kênh vị trí</string>
|
||||
|
||||
@ -394,4 +394,10 @@
|
||||
<string name="about_app_language">应用语言</string>
|
||||
<string name="about_system_default">跟随系统</string>
|
||||
<string name="about_select_language">选择语言</string>
|
||||
|
||||
<string name="action_delete_note_subtitle">向中继发送 nostr 删除请求</string>
|
||||
<string name="action_delete_note_title">删除笔记</string>
|
||||
<string name="nearby_notes_many">这里留有 %d 条留言 — 点按阅读</string>
|
||||
<string name="nearby_notes_one">这里留有 1 条留言 — 点按阅读</string>
|
||||
<string name="nearby_notes_reveal">查看这里留下的留言</string>
|
||||
</resources>
|
||||
|
||||
@ -395,4 +395,10 @@
|
||||
<string name="about_app_language">應用程式語言</string>
|
||||
<string name="about_system_default">跟隨系統</string>
|
||||
<string name="about_select_language">選擇語言</string>
|
||||
|
||||
<string name="action_delete_note_subtitle">向中繼發送 nostr 刪除請求</string>
|
||||
<string name="action_delete_note_title">刪除筆記</string>
|
||||
<string name="nearby_notes_many">這裡留有 %d 則留言 — 點按閱讀</string>
|
||||
<string name="nearby_notes_one">這裡留有 1 則留言 — 點按閱讀</string>
|
||||
<string name="nearby_notes_reveal">查看這裡留下的留言</string>
|
||||
</resources>
|
||||
|
||||
@ -177,6 +177,8 @@
|
||||
<string name="action_hug_subtitle">发送一个友好的拥抱</string>
|
||||
<string name="action_block_title">屏蔽 %1$s</string>
|
||||
<string name="action_block_subtitle">屏蔽该用户的所有消息</string>
|
||||
<string name="action_delete_note_title">删除笔记</string>
|
||||
<string name="action_delete_note_subtitle">向中继发送 nostr 删除请求</string>
|
||||
|
||||
<!-- 地点频道面板 -->
|
||||
<string name="location_channels_title">#地点频道</string>
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
<string name="no_one_connected">No one connected</string>
|
||||
<string name="emergency_clear_hint">Triple tap to clear all data</string>
|
||||
<string name="your_network">Network</string>
|
||||
|
||||
|
||||
<!-- Battery Optimization Strings -->
|
||||
<string name="battery_optimization_detected">Battery Optimization Detected</string>
|
||||
<string name="battery_optimization_disabled">Battery Optimization Disabled</string>
|
||||
@ -42,7 +42,7 @@
|
||||
<string name="battery_optimization_continue">Continue</string>
|
||||
<string name="retry">Retry</string>
|
||||
<string name="skip">Skip</string>
|
||||
|
||||
|
||||
<!-- Notifications -->
|
||||
<string name="notification_summary_more">and %1$d more</string>
|
||||
<string name="notification_messages_from_people">%1$d messages from %2$d people</string>
|
||||
@ -352,6 +352,8 @@
|
||||
<string name="action_block_subtitle">Block all messages from this user</string>
|
||||
<string name="action_private_message_title">Message %1$s</string>
|
||||
<string name="action_private_message_subtitle">Send a private message</string>
|
||||
<string name="action_delete_note_title">Delete note</string>
|
||||
<string name="action_delete_note_subtitle">Sends a nostr deletion request to relays</string>
|
||||
|
||||
|
||||
<!-- Location channels sheet -->
|
||||
|
||||
@ -0,0 +1,398 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import com.bitchat.android.geohash.LiveLocationPrivacyGate
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Unit tests for [LocationNotesManager] covering:
|
||||
* - Incoming kind:1 (text note) events — adding, deduplication, geohash filtering
|
||||
* - Incoming kind:5 (NIP-09 deletion) events — signature-verified, pubkey-gated removal
|
||||
* - [LocationNotesManager.deleteNote] — optimistic local removal + relay broadcast
|
||||
*
|
||||
* The manager's private [handleEvent] method is exercised through the event-handler
|
||||
* callbacks captured from the [subscribe] lambda injected in [initialize].
|
||||
*
|
||||
* Two subscriptions are created:
|
||||
* - kind:1 handler stored under "location-notes-<geohash>"
|
||||
* - kind:5 deletion handler stored under "location-deletions", registered
|
||||
* synchronously the first time a kind:1 note arrives (via maybeResubscribeDeletions)
|
||||
*
|
||||
* No Robolectric is required because [NostrCrypto] / [NostrIdentity] depend only on
|
||||
* BouncyCastle (pure JVM), and [android.util.Log] is stubbed by the project-level
|
||||
* test mock at src/test/kotlin/android/util/Log.kt.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class LocationNotesManagerTest {
|
||||
|
||||
private lateinit var manager: LocationNotesManager
|
||||
|
||||
/**
|
||||
* All event handlers registered via subscribe(), keyed by subscription ID.
|
||||
* The kind:1 text-note handler is stored under "location-notes-<geohash>".
|
||||
* The kind:5 deletion handler is stored under "location-deletions".
|
||||
*/
|
||||
private val capturedHandlers = mutableMapOf<String, (NostrEvent) -> Unit>()
|
||||
|
||||
/** Convenience accessor for the kind:1 note subscription handler.
|
||||
* Subscription IDs are now "location-notes-<UUID>"; every geohash cell's
|
||||
* subscription shares the same handleEvent callback, so any entry works. */
|
||||
private val capturedEventHandler: ((NostrEvent) -> Unit)?
|
||||
get() = capturedHandlers.entries.firstOrNull { it.key.startsWith("location-notes-") }?.value
|
||||
|
||||
/** Convenience accessor for the kind:5 deletion subscription handler. */
|
||||
private val capturedDeletionHandler: ((NostrEvent) -> Unit)?
|
||||
get() = capturedHandlers["location-deletions"]
|
||||
|
||||
/** Mutable hook so individual tests can install their own send-event spy. */
|
||||
@Volatile
|
||||
private var sendEventCallback: (NostrEvent, List<String>?) -> Unit = { _, _ -> }
|
||||
|
||||
/** Two stable secp256k1 identities derived from fixed seeds (no Android context needed). */
|
||||
private val authorIdentity = NostrIdentity.fromSeed("location-notes-test-author-seed")
|
||||
private val attackerIdentity = NostrIdentity.fromSeed("location-notes-test-attacker-seed")
|
||||
|
||||
/** A valid 8-character base32 geohash (building-level precision). */
|
||||
private val testGeohash = "u4pruydq"
|
||||
|
||||
// ─── Test lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
// Dispatchers.Unconfined has isDispatchNeeded()=false — continuations that resume
|
||||
// after withContext(IO) run inline on the IO thread without being posted back to a
|
||||
// TestCoroutineScheduler, so deleteNote's latch-based sync works without pumping.
|
||||
// No virtual-clock control is needed: maybeResubscribeDeletions() is synchronous.
|
||||
Dispatchers.setMain(Dispatchers.Unconfined)
|
||||
|
||||
// Arm the process-wide live-location consent gate (fail-closed by default);
|
||||
// without it setGeohash/handleEvent/deleteNote are all no-ops.
|
||||
LiveLocationPrivacyGate.update(true)
|
||||
|
||||
// Create a brand-new manager instance via reflection, bypassing the singleton.
|
||||
// This guarantees a fresh, non-cancelled CoroutineScope for every test,
|
||||
// regardless of what any previous test (or its @After) did to the singleton.
|
||||
manager = createFreshInstance()
|
||||
|
||||
manager.initialize(
|
||||
relayManager = { error("relayManager should not be called in these tests") },
|
||||
subscribe = { _, subId, handler ->
|
||||
capturedHandlers[subId] = handler
|
||||
subId
|
||||
},
|
||||
unsubscribe = { subId -> capturedHandlers.remove(subId) },
|
||||
sendEvent = { event, relays, _ -> sendEventCallback(event, relays) },
|
||||
deriveIdentity = { _ -> authorIdentity },
|
||||
)
|
||||
|
||||
// Triggers subscribeAll() synchronously, which populates capturedHandlers.
|
||||
manager.setGeohash(testGeohash)
|
||||
}
|
||||
|
||||
@After
|
||||
fun teardown() {
|
||||
manager.cleanup()
|
||||
capturedHandlers.clear()
|
||||
// Restore the gate to its fail-closed default so other test classes
|
||||
// sharing this JVM see the untouched state.
|
||||
LiveLocationPrivacyGate.update(false)
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
// ─── handleEvent: kind:1 (text notes) ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `kind 1 event adds note to the list`() {
|
||||
capturedEventHandler!!.invoke(
|
||||
makeTextNoteEvent(id = "note-001", pubkey = authorIdentity.publicKeyHex, content = "Hello!")
|
||||
)
|
||||
|
||||
assertEquals(1, manager.notes.value.size)
|
||||
assertEquals("note-001", manager.notes.value[0].id)
|
||||
assertEquals("Hello!", manager.notes.value[0].content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kind 1 event with same id is deduplicated`() {
|
||||
val event = makeTextNoteEvent(id = "dup-note", pubkey = authorIdentity.publicKeyHex)
|
||||
capturedEventHandler!!.invoke(event)
|
||||
capturedEventHandler!!.invoke(event) // second delivery of the same event
|
||||
|
||||
assertEquals("Duplicate event must not be stored twice", 1, manager.notes.value.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kind 1 event without geohash tag is ignored`() {
|
||||
val eventWithoutGtag = NostrEvent(
|
||||
id = "no-gtag",
|
||||
pubkey = authorIdentity.publicKeyHex,
|
||||
createdAt = 1_700_000_000,
|
||||
kind = NostrKind.TEXT_NOTE,
|
||||
tags = emptyList(), // intentionally no "g" tag
|
||||
content = "no location",
|
||||
)
|
||||
|
||||
capturedEventHandler!!.invoke(eventWithoutGtag)
|
||||
|
||||
assertEquals("Event without geohash tag must be ignored", 0, manager.notes.value.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kind 1 event with different geohash is ignored`() {
|
||||
val eventForOtherCell = makeTextNoteEvent(
|
||||
id = "other-cell",
|
||||
pubkey = authorIdentity.publicKeyHex,
|
||||
geohash = "s000000a", // different geohash, not subscribed
|
||||
)
|
||||
|
||||
capturedEventHandler!!.invoke(eventForOtherCell)
|
||||
|
||||
assertEquals("Event for a non-subscribed geohash must be ignored", 0, manager.notes.value.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kind 1 event stores nickname from n-tag`() {
|
||||
val eventWithNick = NostrEvent(
|
||||
id = "nick-note",
|
||||
pubkey = authorIdentity.publicKeyHex,
|
||||
createdAt = 1_700_000_000,
|
||||
kind = NostrKind.TEXT_NOTE,
|
||||
tags = listOf(listOf("g", testGeohash), listOf("n", "Alice")),
|
||||
content = "Hi from Alice",
|
||||
)
|
||||
|
||||
capturedEventHandler!!.invoke(eventWithNick)
|
||||
|
||||
assertEquals("Alice", manager.notes.value[0].nickname)
|
||||
}
|
||||
|
||||
// ─── handleEvent: kind:5 (NIP-09 deletion) ───────────────────────────────
|
||||
//
|
||||
// maybeResubscribeDeletions() is synchronous — capturedDeletionHandler is
|
||||
// populated immediately when the first kind:1 note arrives, no time-advancement
|
||||
// needed. Deletion events must be properly signed; handleEvent() calls
|
||||
// isValidSignature() before honouring any kind:5 request.
|
||||
|
||||
@Test
|
||||
fun `kind 5 removes the matching note authored by the same pubkey`() {
|
||||
capturedEventHandler!!.invoke(
|
||||
makeTextNoteEvent(id = "del-target", pubkey = authorIdentity.publicKeyHex, content = "To be deleted")
|
||||
)
|
||||
assertEquals(1, manager.notes.value.size)
|
||||
|
||||
capturedDeletionHandler!!.invoke(
|
||||
makeDeletionEvent(targetId = "del-target", identity = authorIdentity)
|
||||
)
|
||||
|
||||
assertEquals("Note must be removed by kind:5 from the same author", 0, manager.notes.value.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kind 5 does NOT remove a note when pubkey does not match`() {
|
||||
capturedEventHandler!!.invoke(
|
||||
makeTextNoteEvent(id = "protected", pubkey = authorIdentity.publicKeyHex, content = "Protected")
|
||||
)
|
||||
assertEquals(1, manager.notes.value.size)
|
||||
|
||||
// Deletion request from a *different* identity — must be rejected
|
||||
capturedDeletionHandler!!.invoke(
|
||||
makeDeletionEvent(targetId = "protected", identity = attackerIdentity)
|
||||
)
|
||||
|
||||
assertEquals("Note must survive a deletion attempt from a different pubkey", 1, manager.notes.value.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kind 5 removes only the referenced note and leaves others intact`() {
|
||||
capturedEventHandler!!.invoke(
|
||||
makeTextNoteEvent(id = "note-A", pubkey = authorIdentity.publicKeyHex, content = "Note A")
|
||||
)
|
||||
// note-A arrival registers capturedDeletionHandler synchronously (throttle window opens).
|
||||
// note-B arrival is throttled (< 1000 ms), but handleEvent() still processes all e-tags
|
||||
// regardless of which IDs were in the subscription filter at registration time.
|
||||
capturedEventHandler!!.invoke(
|
||||
makeTextNoteEvent(id = "note-B", pubkey = authorIdentity.publicKeyHex, content = "Note B")
|
||||
)
|
||||
assertEquals(2, manager.notes.value.size)
|
||||
|
||||
capturedDeletionHandler!!.invoke(
|
||||
makeDeletionEvent(targetId = "note-A", identity = authorIdentity)
|
||||
)
|
||||
|
||||
assertEquals("Only the referenced note should be removed", 1, manager.notes.value.size)
|
||||
assertEquals("note-B", manager.notes.value[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kind 5 with no e-tags does nothing`() {
|
||||
capturedEventHandler!!.invoke(
|
||||
makeTextNoteEvent(id = "safe-note", pubkey = authorIdentity.publicKeyHex, content = "Safe")
|
||||
)
|
||||
assertEquals(1, manager.notes.value.size)
|
||||
|
||||
// A properly signed kind:5 with no e-tags — passes the signature guard but
|
||||
// targetIds will be empty so no note should be removed.
|
||||
val malformed = NostrEvent(
|
||||
pubkey = authorIdentity.publicKeyHex,
|
||||
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
||||
kind = NostrKind.DELETION,
|
||||
tags = emptyList(),
|
||||
content = "",
|
||||
).sign(authorIdentity.privateKeyHex)
|
||||
capturedDeletionHandler!!.invoke(malformed)
|
||||
|
||||
assertEquals("Malformed kind:5 with no e-tags must not remove any note", 1, manager.notes.value.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kind 5 can delete multiple notes in one event via multiple e-tags`() {
|
||||
capturedEventHandler!!.invoke(makeTextNoteEvent(id = "batch-A", pubkey = authorIdentity.publicKeyHex, content = "A"))
|
||||
capturedEventHandler!!.invoke(makeTextNoteEvent(id = "batch-B", pubkey = authorIdentity.publicKeyHex, content = "B"))
|
||||
capturedEventHandler!!.invoke(makeTextNoteEvent(id = "keep-C", pubkey = authorIdentity.publicKeyHex, content = "C"))
|
||||
assertEquals(3, manager.notes.value.size)
|
||||
|
||||
// A single kind:5 event referencing two note IDs simultaneously.
|
||||
val batchDeletion = NostrEvent(
|
||||
pubkey = authorIdentity.publicKeyHex,
|
||||
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
||||
kind = NostrKind.DELETION,
|
||||
tags = listOf(listOf("e", "batch-A"), listOf("e", "batch-B")),
|
||||
content = "",
|
||||
).sign(authorIdentity.privateKeyHex)
|
||||
capturedDeletionHandler!!.invoke(batchDeletion)
|
||||
|
||||
assertEquals("Both referenced notes must be removed", 1, manager.notes.value.size)
|
||||
assertEquals("keep-C", manager.notes.value[0].id)
|
||||
}
|
||||
|
||||
// ─── deleteNote (optimistic local removal + relay broadcast) ─────────────
|
||||
|
||||
@Test
|
||||
fun `deleteNote removes note optimistically before relay broadcast`() {
|
||||
capturedEventHandler!!.invoke(
|
||||
makeTextNoteEvent(id = "rm-note", pubkey = authorIdentity.publicKeyHex, content = "Will be deleted")
|
||||
)
|
||||
assertEquals(1, manager.notes.value.size)
|
||||
|
||||
// sendEvent is called AFTER the optimistic removal — use it as a sync point.
|
||||
val latch = CountDownLatch(1)
|
||||
sendEventCallback = { _, _ -> latch.countDown() }
|
||||
|
||||
manager.deleteNote("rm-note")
|
||||
|
||||
assertTrue("deleteNote coroutine must complete within 3 s", latch.await(3, TimeUnit.SECONDS))
|
||||
assertEquals("Note must be removed from the list", 0, manager.notes.value.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleteNote broadcasts a kind 5 event with the correct e-tag`() {
|
||||
capturedEventHandler!!.invoke(
|
||||
makeTextNoteEvent(id = "broadcast-me", pubkey = authorIdentity.publicKeyHex, content = "Broadcast target")
|
||||
)
|
||||
|
||||
val captured = mutableListOf<NostrEvent>()
|
||||
val latch = CountDownLatch(1)
|
||||
sendEventCallback = { event, _ -> captured.add(event); latch.countDown() }
|
||||
|
||||
manager.deleteNote("broadcast-me")
|
||||
|
||||
assertTrue("Deletion event must be sent within 3 s", latch.await(3, TimeUnit.SECONDS))
|
||||
assertEquals("Exactly one event must be broadcast", 1, captured.size)
|
||||
assertEquals("Broadcast event must be kind 5", NostrKind.DELETION, captured[0].kind)
|
||||
|
||||
val eTag = captured[0].tags.firstOrNull { it.size >= 2 && it[0] == "e" }
|
||||
assertNotNull("Broadcast kind:5 must have an e-tag", eTag)
|
||||
assertEquals("e-tag must reference the deleted note id", "broadcast-me", eTag!![1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleteNote does nothing when geohash is not set`() {
|
||||
// Create a separate bare instance with no geohash configured.
|
||||
val bare = createFreshInstance()
|
||||
bare.initialize(
|
||||
relayManager = { error("not needed") },
|
||||
subscribe = { _, subId, _ -> subId },
|
||||
unsubscribe = { },
|
||||
sendEvent = { _, _, _ -> error("sendEvent must not be called") },
|
||||
deriveIdentity = { _ -> authorIdentity },
|
||||
)
|
||||
// setGeohash intentionally NOT called — deleteNote should silently no-op.
|
||||
bare.deleteNote("irrelevant-id")
|
||||
bare.cleanup()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleteNote does not delete or broadcast when note is not owned by local identity`() {
|
||||
capturedEventHandler!!.invoke(
|
||||
makeTextNoteEvent(id = "foreign-note", pubkey = attackerIdentity.publicKeyHex, content = "Not mine")
|
||||
)
|
||||
assertEquals(1, manager.notes.value.size)
|
||||
|
||||
var sendCalled = false
|
||||
sendEventCallback = { _, _ -> sendCalled = true }
|
||||
|
||||
manager.deleteNote("foreign-note")
|
||||
|
||||
assertEquals("Foreign note must remain", 1, manager.notes.value.size)
|
||||
assertEquals("foreign-note", manager.notes.value[0].id)
|
||||
assertTrue("Deletion event must not be broadcast for non-owned note", !sendCalled)
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a minimal kind:1 text-note event for a given geohash cell.
|
||||
* Left unsigned — [LocationNotesManager.handleEvent] does not verify signatures on kind:1.
|
||||
*/
|
||||
private fun makeTextNoteEvent(
|
||||
id: String,
|
||||
pubkey: String,
|
||||
geohash: String = testGeohash,
|
||||
content: String = "test note content",
|
||||
) = NostrEvent(
|
||||
id = id,
|
||||
pubkey = pubkey,
|
||||
createdAt = 1_700_000_000,
|
||||
kind = NostrKind.TEXT_NOTE,
|
||||
tags = listOf(listOf("g", geohash)),
|
||||
content = content,
|
||||
)
|
||||
|
||||
/**
|
||||
* Build a properly signed kind:5 deletion event targeting [targetId], authored by [identity].
|
||||
*
|
||||
* The event is signed via BIP-340 Schnorr because [LocationNotesManager.handleEvent]
|
||||
* calls [NostrEvent.isValidSignature] before honouring any kind:5 request.
|
||||
*/
|
||||
private fun makeDeletionEvent(
|
||||
targetId: String,
|
||||
identity: NostrIdentity,
|
||||
) = NostrEvent(
|
||||
pubkey = identity.publicKeyHex,
|
||||
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
||||
kind = NostrKind.DELETION,
|
||||
tags = listOf(listOf("e", targetId)),
|
||||
content = "",
|
||||
).sign(identity.privateKeyHex)
|
||||
|
||||
/**
|
||||
* Create a fresh [LocationNotesManager] instance by invoking its private constructor
|
||||
* via reflection. This bypasses the singleton so every test gets an independent
|
||||
* object with a non-cancelled [CoroutineScope].
|
||||
*/
|
||||
private fun createFreshInstance(): LocationNotesManager {
|
||||
val ctor = LocationNotesManager::class.java.getDeclaredConstructor()
|
||||
ctor.isAccessible = true
|
||||
return ctor.newInstance() as LocationNotesManager
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,26 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for [NostrProtocol].
|
||||
*
|
||||
* Covers gift-wrap decryption seal authentication, and the structure and
|
||||
* validity of the NIP-09 (kind:5) deletion event produced when a user
|
||||
* requests removal of one of their own location notes.
|
||||
*/
|
||||
class NostrProtocolTest {
|
||||
private val gson = Gson()
|
||||
|
||||
// Deterministic secp256k1 identity derived from a fixed seed — no Android context required.
|
||||
private val senderIdentity = NostrIdentity.fromSeed("test-sender-seed-nostr-protocol")
|
||||
|
||||
@Test
|
||||
fun decryptPrivateMessage_acceptsAuthenticatedSeal() {
|
||||
val sender = NostrIdentity.generate()
|
||||
@ -82,4 +95,92 @@ class NostrProtocolTest {
|
||||
content = giftWrapContent
|
||||
).sign(wrapPrivateKey)
|
||||
}
|
||||
|
||||
// ─── NIP-09 deletion event (kind:5) ──────────────────────────────────────
|
||||
|
||||
// ─── kind ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `createDeletionEvent has kind 5 (NIP-09)`() = runBlocking {
|
||||
val event = NostrProtocol.createDeletionEvent("target123", senderIdentity)
|
||||
assertEquals("Event kind must be 5 per NIP-09", NostrKind.DELETION, event.kind)
|
||||
}
|
||||
|
||||
// ─── e-tag ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `createDeletionEvent contains e-tag referencing the target event`() = runBlocking {
|
||||
val targetId = "abc123def456deadbeef"
|
||||
val event = NostrProtocol.createDeletionEvent(targetId, senderIdentity)
|
||||
|
||||
val eTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "e" }
|
||||
assertNotNull("Must contain an e-tag", eTag)
|
||||
assertEquals("e-tag value must equal targetEventId", targetId, eTag!![1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createDeletionEvent contains exactly one e-tag`() = runBlocking {
|
||||
val event = NostrProtocol.createDeletionEvent("singleTarget", senderIdentity)
|
||||
|
||||
val eTags = event.tags.filter { it.size >= 2 && it[0] == "e" }
|
||||
assertEquals("Exactly one e-tag expected for a single-note deletion", 1, eTags.size)
|
||||
}
|
||||
|
||||
// ─── content ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `createDeletionEvent has empty content per NIP-09`() = runBlocking {
|
||||
val event = NostrProtocol.createDeletionEvent("anyid", senderIdentity)
|
||||
assertEquals("Content must be empty string", "", event.content)
|
||||
}
|
||||
|
||||
// ─── pubkey ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `createDeletionEvent pubkey matches sender identity`() = runBlocking {
|
||||
val event = NostrProtocol.createDeletionEvent("anyid", senderIdentity)
|
||||
assertEquals("Pubkey must equal sender's public key", senderIdentity.publicKeyHex, event.pubkey)
|
||||
}
|
||||
|
||||
// ─── signature ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `createDeletionEvent has a non-null signature`() = runBlocking {
|
||||
val event = NostrProtocol.createDeletionEvent("anyid", senderIdentity)
|
||||
assertNotNull("Event must be signed", event.sig)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createDeletionEvent has a valid BIP-340 Schnorr signature`() = runBlocking {
|
||||
val event = NostrProtocol.createDeletionEvent("anyid", senderIdentity)
|
||||
assertTrue("Schnorr signature must verify correctly", event.isValidSignature())
|
||||
}
|
||||
|
||||
// ─── timestamp ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `createDeletionEvent timestamp is within the current second`() = runBlocking {
|
||||
val before = (System.currentTimeMillis() / 1000).toInt()
|
||||
val event = NostrProtocol.createDeletionEvent("ts-check", senderIdentity)
|
||||
val after = (System.currentTimeMillis() / 1000).toInt()
|
||||
|
||||
assertTrue("createdAt must be >= start of test", event.createdAt >= before)
|
||||
assertTrue("createdAt must be <= end of test", event.createdAt <= after)
|
||||
}
|
||||
|
||||
// ─── event ID ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `createDeletionEvent has a non-empty event id`() = runBlocking {
|
||||
val event = NostrProtocol.createDeletionEvent("anyid", senderIdentity)
|
||||
assertTrue("Event id must not be empty", event.id.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createDeletionEvent id is consistent with content (NIP-01 hash)`() = runBlocking {
|
||||
val event = NostrProtocol.createDeletionEvent("anyid", senderIdentity)
|
||||
// isValidSignature() internally recalculates the id and checks it matches the stored id,
|
||||
// so a passing signature check implies the id is correct too.
|
||||
assertTrue("Event id and signature must be mutually consistent", event.isValidSignature())
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user