feat: persist the geohash nickname so it survives app restarts

The location-chat nickname was in-memory only (ChannelNewMessageViewModel
.geohashNickname), so it reset to empty on process death. It can't be
recovered from relays either: the anonymous per-cell key publishes no kind-0
profile, and kind-20000 messages are ephemeral, so the nickname (a per-message
`n` tag) has no durable home on the network.

Persist it on-device instead, as a single global handle:
- GeohashChatIdentityState gains nickname()/setNickname(), stored in the
  account's encrypted storage next to the per-cell device seed (survives
  restarts, switches with the account).
- The chat screen restores it into the composer on room open, and the
  nickname dialog writes it back on Save.

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-17 01:45:01 +00:00
parent d07bac7489
commit fcf772d505
2 changed files with 44 additions and 2 deletions
@@ -58,6 +58,34 @@ class GeohashChatIdentityState(
@Volatile private var cachedDeviceSeed: ByteArray? = null
@Volatile private var cachedNickname: String? = null
/**
* The user's display handle for location chats: a single global nickname, persisted per account.
* Bitchat carries this as the per-message `["n", …]` tag rather than a kind-0 profile, and kind-20000
* messages are ephemeral (relays needn't store them), so the only durable home for it is the device.
* Kept in this account's encrypted storage, so it survives restarts and switches with the account.
* Empty string means "no nickname set". Reads touch disk on first call — invoke off the main thread.
*/
fun nickname(): String {
cachedNickname?.let { return it }
synchronized(lock) {
cachedNickname?.let { return it }
val value = Amethyst.instance.encryptedStorage(signer.pubKey).getString(PREF_NICKNAME, "") ?: ""
cachedNickname = value
return value
}
}
/** Persists the global location-chat nickname (trimmed) for this account. */
fun setNickname(value: String) {
val trimmed = value.trim()
synchronized(lock) {
cachedNickname = trimmed
Amethyst.instance.encryptedStorage(signer.pubKey).edit { putString(PREF_NICKNAME, trimmed) }
}
}
/** The Nostr key pair to use inside [geohash]. Derivation is cheap but cached; call off the main thread. */
fun keyPair(geohash: String): KeyPair =
synchronized(lock) {
@@ -90,5 +118,6 @@ class GeohashChatIdentityState(
companion object {
private const val PREF_KEY = "geohash_chat_device_seed"
private const val PREF_NICKNAME = "geohash_chat_nickname"
}
}
@@ -87,6 +87,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.E
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/**
@@ -143,6 +145,13 @@ private fun GeohashChatRoom(
newMessageModel.init(accountViewModel)
newMessageModel.load(channel)
// The nickname lives on-device (the throwaway key has no kind-0 profile, and kind-20000 messages
// are ephemeral), so restore the account's saved global handle into the composer on open.
LaunchedEffect(newMessageModel) {
val saved = withContext(Dispatchers.IO) { accountViewModel.account.geohashIdentity.nickname() }
if (saved.isNotBlank()) newMessageModel.geohashNickname = saved
}
// Teleport is a fact, not a per-message choice: the app decides whether the sender is physically
// in this cell. When the device's location is known we compare it to the channel cell (objective);
// otherwise we fall back to how the user arrived — the map picker passes teleported=true, "near me"
@@ -277,7 +286,7 @@ private fun GeohashIdentityAvatar(
) {
var showNickname by remember { mutableStateOf(false) }
if (showNickname) {
GeohashNicknameDialog(model) { showNickname = false }
GeohashNicknameDialog(model, accountViewModel) { showNickname = false }
}
val postAsSelf = model.geohashPostAsSelf
@@ -342,6 +351,7 @@ private fun GeohashIdentityAvatar(
@Composable
private fun GeohashNicknameDialog(
model: ChannelNewMessageViewModel,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
) {
var text by remember { mutableStateOf(model.geohashNickname) }
@@ -349,7 +359,10 @@ private fun GeohashNicknameDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
model.geohashNickname = text.trim()
val trimmed = text.trim()
model.geohashNickname = trimmed
// Persist the global handle so it survives app restarts (see GeohashChatIdentityState).
accountViewModel.account.geohashIdentity.setNickname(trimmed)
onDismiss()
}) { Text("Save") }
},