diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt index 82fcda1664..5a443b7ec6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -29,6 +31,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilterChip @@ -37,6 +41,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -88,11 +93,35 @@ fun GeohashChatScreen( val relays by viewModel.relays.collectAsStateWithLifecycle() val myPubKey by viewModel.myPubKey.collectAsStateWithLifecycle() val teleporting by viewModel.teleported.collectAsStateWithLifecycle() + val postingAsSelf by viewModel.postAsSelf.collectAsStateWithLifecycle() var nickname by remember { mutableStateOf("") } var draft by remember { mutableStateOf("") } + var showPostAsSelfWarning by remember { mutableStateOf(false) } val listState = rememberLazyListState() + if (showPostAsSelfWarning) { + AlertDialog( + onDismissRequest = { showPostAsSelfWarning = false }, + title = { Text("Post as your real account?") }, + text = { + Text( + "Location chat is anonymous by default. If you post as yourself, messages here are " + + "signed with your Nostr identity and publicly reveal that you were at this location.", + ) + }, + confirmButton = { + TextButton(onClick = { + viewModel.setPostAsSelf(true) + showPostAsSelfWarning = false + }) { Text("Post as me") } + }, + dismissButton = { + TextButton(onClick = { showPostAsSelfWarning = false }) { Text("Cancel") } + }, + ) + } + Column(Modifier.fillMaxSize().imePadding()) { Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)) { Text("#$geohash", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) @@ -120,22 +149,28 @@ fun GeohashChatScreen( } HorizontalDivider() + OutlinedTextField( + value = nickname, + onValueChange = { nickname = it }, + singleLine = true, + label = { Text("Nickname (optional)") }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + ) Row( - Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, + Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - OutlinedTextField( - value = nickname, - onValueChange = { nickname = it }, - singleLine = true, - label = { Text("Nickname (optional)") }, - modifier = Modifier.weight(1f), - ) FilterChip( selected = teleporting, onClick = { viewModel.setTeleported(!teleporting) }, label = { Text("✈ Teleport") }, - modifier = Modifier.padding(start = 8.dp), + ) + FilterChip( + selected = postingAsSelf, + onClick = { + if (postingAsSelf) viewModel.setPostAsSelf(false) else showPostAsSelfWarning = true + }, + label = { Text("Post as me") }, ) } Row( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt index 51de14f317..f6798671e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner import com.vitorpamplona.quartz.utils.TimeUtils @@ -82,6 +83,13 @@ class GeohashChatViewModel : ViewModel() { private val _teleported = MutableStateFlow(false) val teleported: StateFlow = _teleported.asStateFlow() + /** + * When true, messages are signed with the user's REAL account key instead of the anonymous + * per-geohash identity — trading location privacy for profile/reputation/zaps. Off by default. + */ + private val _postAsSelf = MutableStateFlow(false) + val postAsSelf: StateFlow = _postAsSelf.asStateFlow() + private val seen = HashSet() private val present = HashSet() private val subId = newSubId() @@ -105,6 +113,10 @@ class GeohashChatViewModel : ViewModel() { _teleported.value = value } + fun setPostAsSelf(value: Boolean) { + _postAsSelf.value = value + } + private suspend fun start() { _myPubKey.value = withContext(Dispatchers.IO) { GeohashChatIdentity.keyPair(accountViewModel.account, geohash).pubKey.toHexKey() } @@ -169,19 +181,29 @@ class GeohashChatViewModel : ViewModel() { if (relays.isEmpty()) return viewModelScope.launch { - val keyPair = withContext(Dispatchers.IO) { GeohashChatIdentity.keyPair(accountViewModel.account, geohash) } - val signer = NostrSignerInternal(keyPair) + val account = accountViewModel.account + // Anonymous per-geohash identity by default; the real account only when the user opts in. + val signer: NostrSigner + val pubKeyHex: String + if (_postAsSelf.value) { + signer = account.signer + pubKeyHex = account.signer.pubKey + } else { + val keyPair = withContext(Dispatchers.IO) { GeohashChatIdentity.keyPair(account, geohash) } + signer = NostrSignerInternal(keyPair) + pubKeyHex = keyPair.pubKey.toHexKey() + } var template = GeohashChatEvent.build(trimmed, geohash, nickname = nickname?.ifBlank { null }, teleported = _teleported.value) template = withContext(Dispatchers.Default) { val deadline = System.nanoTime() + POW_TIMEOUT_NANOS runCatching { - PoWMiner.mine(template, keyPair.pubKey.toHexKey(), POW_BITS, powThreads()) { System.nanoTime() < deadline } + PoWMiner.mine(template, pubKeyHex, POW_BITS, powThreads()) { System.nanoTime() < deadline } }.getOrDefault(template) } - runCatching { accountViewModel.account.signWithAndSendPrivately(template, signer, relays) } + runCatching { account.signWithAndSendPrivately(template, signer, relays) } } }