mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
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:
+45
-10
@@ -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(
|
||||
|
||||
+26
-4
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user