feat: editable Notify block — pick receivers for private posts

Phase 3 of the private-notes plan: the composer's Notify row gains an
'+ Add' chip backed by the existing user-suggestion search, so users can
p-tag people who aren't cited in the text — for any post, public or
private. While the private toggle is ON the row is always visible,
relabeled 'Visible to' (the p-tags ARE the audience of the wrap), and an
empty list shows a 'only you will see this' hint for self-only notes.

https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
This commit is contained in:
Claude
2026-06-11 20:53:52 +00:00
parent 0fc81cf79d
commit 880995e17c
4 changed files with 87 additions and 4 deletions
@@ -52,20 +52,23 @@ import kotlinx.collections.immutable.ImmutableList
fun Notifying(
baseMentions: ImmutableList<User>?,
accountViewModel: AccountViewModel,
label: String? = null,
showWhenEmpty: Boolean = false,
onAddUser: (() -> Unit)? = null,
onClick: (User) -> Unit,
) {
val mentions = baseMentions?.toSet()
FlowRow(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
if (!mentions.isNullOrEmpty()) {
if (!mentions.isNullOrEmpty() || showWhenEmpty) {
Text(
stringRes(R.string.reply_notify),
label ?: stringRes(R.string.reply_notify),
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.align(CenterVertically),
)
mentions.forEachIndexed { _, user ->
mentions?.forEachIndexed { _, user ->
Button(
shape = ButtonBorder,
colors =
@@ -77,6 +80,23 @@ fun Notifying(
DisplayUserNameWithDeleteMark(user, accountViewModel)
}
}
if (onAddUser != null) {
Button(
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.mediumImportanceLink,
),
onClick = onAddUser,
) {
Text(
text = stringRes(R.string.notify_add_user),
color = Color.White,
textAlign = TextAlign.Center,
)
}
}
}
}
}
@@ -84,6 +84,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview
import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -128,6 +129,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.persistentListOf
@@ -306,11 +308,41 @@ private fun NewPostScreenBody(
}
Row {
Notifying(postViewModel.pTags?.toImmutableList(), accountViewModel) {
Notifying(
baseMentions = postViewModel.pTags?.toImmutableList(),
accountViewModel = accountViewModel,
label = if (postViewModel.wantsPrivateNote) stringRes(R.string.private_note_visible_to) else null,
showWhenEmpty = postViewModel.wantsPrivateNote,
onAddUser = { postViewModel.wantsToAddNotifyUser = !postViewModel.wantsToAddNotifyUser },
) {
postViewModel.removeFromReplyList(it)
}
}
if (postViewModel.wantsToAddNotifyUser) {
OutlinedThinPaddingTextField(
state = postViewModel.notifyUserSearchText,
onTextChanged = postViewModel::onNotifyUserSearchTextChanged,
label = { Text(text = stringRes(R.string.notify_search_and_add_user)) },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringRes(R.string.zap_split_search_and_add_user_placeholder),
color = MaterialTheme.colorScheme.placeholderText,
)
},
singleLine = true,
)
}
if (postViewModel.wantsPrivateNote && postViewModel.pTags.isNullOrEmpty()) {
Text(
text = stringRes(R.string.private_note_no_receivers),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
)
}
// Only show text input if no voice message is being posted
if (postViewModel.voiceMetadata == null && postViewModel.voiceRecording == null) {
Row(
@@ -167,6 +167,7 @@ enum class UserSuggestionAnchor {
MAIN_MESSAGE,
FORWARD_ZAPS,
TO_USERS,
NOTIFY,
}
@Stable
@@ -322,6 +323,26 @@ open class ShortNotePostViewModel :
wantsPrivateNote = !wantsPrivateNote
}
// Notify / Visible-to editor: lets the user p-tag people who aren't
// cited in the message. For private notes the Notify list IS the
// audience, so this is how receivers are picked.
var wantsToAddNotifyUser by mutableStateOf(false)
val notifyUserSearchText = TextFieldState()
fun onNotifyUserSearchTextChanged() {
if (notifyUserSearchText.selection.collapsed) {
val lastWord = notifyUserSearchText.text.toString()
userSuggestionsMainMessage = UserSuggestionAnchor.NOTIFY
userSuggestions?.processCurrentWord(lastWord)
}
}
fun addToReplyList(user: User) {
if (pTags?.contains(user) != true) {
pTags = (pTags ?: emptyList()).plus(user)
}
}
// A single ephemeral signer reused for the whole compose session so that media
// uploads (Blossom/NIP-96 auth events) and the final anonymous post are all signed
// by the same throwaway key, instead of leaking the real account's pubkey into the
@@ -1286,6 +1307,8 @@ open class ShortNotePostViewModel :
scheduledForSec = null
wantsPrivateNote = false
privateNoteLocked = false
wantsToAddNotifyUser = false
notifyUserSearchText.clearText()
forwardZapTo.value = SplitBuilder()
forwardZapToEditting.clearText()
@@ -1350,6 +1373,10 @@ open class ShortNotePostViewModel :
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.value.addItem(item)
forwardZapToEditting.clearText()
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.NOTIFY) {
addToReplyList(item)
notifyUserSearchText.clearText()
wantsToAddNotifyUser = false
}
userSuggestionsMainMessage = null
+4
View File
@@ -873,6 +873,10 @@
<string name="private_note">Make private: gift-wrap the note to the notified users only</string>
<string name="disable_private_note">Make public</string>
<string name="private_note_locked">Replies to a private note always stay private</string>
<string name="private_note_visible_to">Visible to</string>
<string name="private_note_no_receivers">No receivers yet: only you will be able to see this note. Add people to share it with.</string>
<string name="notify_add_user">+ Add</string>
<string name="notify_search_and_add_user">Search and add a user to notify</string>
<string name="bookmark_absence_indicator">is not a bookmark here</string>
<string name="bookmark_remove_action_desc">Remove bookmark from list</string>
<string name="bookmark_add_action_desc">Add bookmark to list</string>