feat(geohash-chat): opt-in "post as my real account"

Adds a per-session composer toggle (off by default) to post to a location channel
under the user's real Nostr identity instead of the anonymous per-geohash key —
trading location privacy for profile, reputation, and zaps. Enabling it requires
confirming a dialog that spells out the location-exposure trade-off. When on, the
message is signed with the account signer (and PoW mined for that pubkey); when
off, the anonymous identity is used as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
This commit is contained in:
Claude
2026-07-16 01:24:59 +00:00
parent a5c6eeaf03
commit a18b37e8ca
2 changed files with 71 additions and 14 deletions
@@ -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(
@@ -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<Boolean> = _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<Boolean> = _postAsSelf.asStateFlow()
private val seen = HashSet<String>()
private val present = HashSet<String>()
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) }
}
}