From 0ad3180fc5bf0673b9e3bce7ae38e19470ae2ac5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 14:25:25 +0000 Subject: [PATCH 1/2] feat: add anonymous reply with throwaway keypair Allow users to reply to notes using a fresh, randomly generated keypair that is not linked to their account. When the "Anonymous" toggle is activated in the compose screen, the reply is signed with a new throwaway identity and broadcast to the user's outbox relays without being cached as the user's own event. - Add AnonymousPostButton composable (PersonOff icon toggle) - Add wantsAnonymousPost state to ShortNotePostViewModel - Add signAnonymouslyAndBroadcast to Account (creates temp KeyPair) - Show warning banner and hide user avatar when anonymous mode is on - Add string resources for anonymous post UI https://claude.ai/code/session_013MaeT2T9Bg4HaVe2ACNADy --- .../vitorpamplona/amethyst/model/Account.kt | 16 +++++ .../creators/anonymous/AnonymousPostButton.kt | 58 +++++++++++++++++++ .../loggedIn/home/ShortNotePostScreen.kt | 36 ++++++++++-- .../loggedIn/home/ShortNotePostViewModel.kt | 9 ++- amethyst/src/main/res/values/strings.xml | 4 ++ 5 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/anonymous/AnonymousPostButton.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 70735fdd83..0cd0018a2f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -139,6 +139,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip01Core.tags.references.references @@ -1292,6 +1293,21 @@ class Account( return event } + suspend fun signAnonymouslyAndBroadcast( + template: EventTemplate, + broadcast: List = emptyList(), + ): T { + val anonymousSigner = NostrSignerInternal(KeyPair()) + val event = anonymousSigner.sign(template) + + val relayList = nip65RelayList.outboxFlow.value.toSet() + + client.send(event, relayList) + broadcast.forEach { client.send(it, relayList) } + + return event + } + /** * Creates a post event without sending it. * Returns the event, target relays, and extra events to broadcast. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/anonymous/AnonymousPostButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/anonymous/AnonymousPostButton.kt new file mode 100644 index 0000000000..29a0f43ea7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/anonymous/AnonymousPostButton.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.creators.anonymous + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PersonOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size19Modifier + +@Composable +fun AnonymousPostButton( + isActive: Boolean, + onClick: () -> Unit, +) { + IconButton( + onClick = { onClick() }, + ) { + if (!isActive) { + Icon( + imageVector = Icons.Default.PersonOff, + contentDescription = stringRes(R.string.post_anonymously), + modifier = Size19Modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) + } else { + Icon( + imageVector = Icons.Default.PersonOff, + contentDescription = stringRes(R.string.post_anonymously), + modifier = Size19Modifier, + tint = Color.Red, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 277c5b8439..31fe265df3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -25,6 +25,7 @@ import android.content.Intent import android.net.Uri import android.os.Parcelable import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -46,6 +47,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -77,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.note.creators.anonymous.AnonymousPostButton import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList @@ -278,16 +281,35 @@ private fun NewPostScreenBody( } } + if (postViewModel.wantsAnonymousPost) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.errorContainer) + .padding(horizontal = Size10dp, vertical = 8.dp), + verticalAlignment = CenterVertically, + ) { + Text( + text = stringRes(R.string.anonymous_reply_warning), + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall, + ) + } + } + // Only show text input if no voice message is being posted if (postViewModel.voiceMetadata == null && postViewModel.voiceRecording == null) { Row( modifier = Modifier.padding(vertical = Size10dp), ) { - BaseUserPicture( - accountViewModel.userProfile(), - Size35dp, - accountViewModel = accountViewModel, - ) + if (!postViewModel.wantsAnonymousPost) { + BaseUserPicture( + accountViewModel.userProfile(), + Size35dp, + accountViewModel = accountViewModel, + ) + } MessageField( R.string.what_s_on_your_mind, postViewModel, @@ -602,6 +624,10 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { postViewModel.wantsInvoice = !postViewModel.wantsInvoice } } + + AnonymousPostButton(postViewModel.wantsAnonymousPost) { + postViewModel.wantsAnonymousPost = !postViewModel.wantsAnonymousPost + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 2bbded69bd..67f686e78a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -283,6 +283,9 @@ open class ShortNotePostViewModel : var wantsZapRaiser by mutableStateOf(false) override val zapRaiserAmount = mutableStateOf(null) + // Anonymous Reply + var wantsAnonymousPost by mutableStateOf(false) + fun lnAddress(): String? = account.userProfile().lnAddress() fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null @@ -712,9 +715,12 @@ open class ShortNotePostViewModel : } val version = draftTag.current + val anonymous = wantsAnonymousPost cancel() - if (accountViewModel.settings.isCompleteUIMode()) { + if (anonymous) { + accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast) + } else if (accountViewModel.settings.isCompleteUIMode()) { // Tracked broadcasting with progress feedback (non-blocking) val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast) @@ -1073,6 +1079,7 @@ open class ShortNotePostViewModel : wantsToAddGeoHash = false wantsExclusiveGeoPost = false wantsSecretEmoji = false + wantsAnonymousPost = false forwardZapTo.value = SplitBuilder() forwardZapToEditting.value = TextFieldValue("") diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b31cf49746..76130d1b35 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -539,6 +539,10 @@ No trace in Nostr, only in Lightning + Anonymous + Post as a new throwaway identity. Your account will not be linked to this reply. + This reply will be posted from a new anonymous identity + File Server Choose a server to upload this file to From d90c868f323c0f9313e005d5311552afad7239a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 21:49:15 +0000 Subject: [PATCH 2/2] feat: activate anonymous reply by tapping user picture instead of bottom button Replace the bottom bar AnonymousPostButton with a tap-on-avatar activation scheme. Tapping the user picture enables anonymous mode and shows the incognito icon in its place. Tapping the incognito icon toggles back to normal mode. https://claude.ai/code/session_013MaeT2T9Bg4HaVe2ACNADy --- .../loggedIn/home/ShortNotePostScreen.kt | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 31fe265df3..b66568b310 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -26,7 +26,9 @@ import android.net.Uri import android.os.Parcelable import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -79,7 +81,6 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.NoteCompose -import com.vitorpamplona.amethyst.ui.note.creators.anonymous.AnonymousPostButton import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList @@ -110,6 +111,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size19Modifier +import com.vitorpamplona.amethyst.ui.theme.Size35Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage @@ -303,12 +305,30 @@ private fun NewPostScreenBody( Row( modifier = Modifier.padding(vertical = Size10dp), ) { - if (!postViewModel.wantsAnonymousPost) { - BaseUserPicture( - accountViewModel.userProfile(), - Size35dp, - accountViewModel = accountViewModel, - ) + if (postViewModel.wantsAnonymousPost) { + IconButton( + onClick = { postViewModel.wantsAnonymousPost = false }, + ) { + Icon( + painter = painterRes(resourceId = R.drawable.incognito, 1), + contentDescription = stringRes(R.string.post_anonymously), + modifier = Size35Modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) + } + } else { + Box( + modifier = + Modifier.clickable { + postViewModel.wantsAnonymousPost = true + }, + ) { + BaseUserPicture( + accountViewModel.userProfile(), + Size35dp, + accountViewModel = accountViewModel, + ) + } } MessageField( R.string.what_s_on_your_mind, @@ -624,10 +644,6 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { postViewModel.wantsInvoice = !postViewModel.wantsInvoice } } - - AnonymousPostButton(postViewModel.wantsAnonymousPost) { - postViewModel.wantsAnonymousPost = !postViewModel.wantsAnonymousPost - } } }