feat: bring the audience flap to the comment composer

The redesign shipped only in ShortNotePostScreen. The NIP-22 comment
composer still rendered the old flat Notifying row — bold grey label,
wall of chips, alpha(0.4) for muted — and it backs four more screens: the
url, geohash, hashtag and generic-comment posters. Both composers now use
AudienceFlap and the manage sheet.

Rather than duplicate ~60 lines of state glue, the shared behaviour moves
into IAudience, following the composer-interface convention already in the
package (IExpiration, IMessageField, IZapField, IZapRaiser,
ILocationGrabber). Each ViewModel keeps its own backing field — pTags on
the short note, notifying on the comment — and maps onto it, so
createTemplate and the draft loaders keep reading the name they always
did. The rules themselves still live in the unit-tested AudienceSelection;
the interface is thin plumbing over them.

The comment composer gains what it never had: a way to add people to the
notify list at all. It previously passed no onAddUser, so the row was
mute-only. It now reaches the same search-and-lists sheet, including bulk
adds from people lists and follow packs with the same caps and the same
group-chip undo. Its flap stays in notify form — a comment is never
gift-wrapped, so isPrivate is fixed false and no tint or lock appears.

Notifying.kt is deleted: with both call sites migrated it had no consumers
left anywhere in the repo. Its notify_add_user string stays in
strings.xml, like private_note_no_receivers before it, since pruning a
default-locale string alone would trip lint's ExtraTranslation across the
Crowdin-managed locale files.

Full amethyst suite green at 1197.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eVeWjMgG8WSU6jKk8d3o
This commit is contained in:
Claude
2026-08-12 21:55:16 +00:00
parent fbc8e5d1f7
commit 9cca9efe8a
7 changed files with 272 additions and 281 deletions
@@ -90,8 +90,8 @@ import kotlinx.collections.immutable.persistentSetOf
* The composer's audience control: who is p-tagged, and — when the note is
* private — who can decrypt it at all.
*
* Replaces the old flat [Notifying] row for the short-note composer. Two things
* differ:
* Replaces the old flat `Notifying` row in every composer that had one. Two
* things differ:
*
* 1. It is a **container**, tinted when the note is sealed, so the audience
* reads as the flap of the envelope the message sits in rather than as loose
@@ -101,7 +101,9 @@ import kotlinx.collections.immutable.persistentSetOf
* the row is expanded, so a bulk add from a people list can no longer push
* the message field off screen.
*
* [Notifying] stays as it was for the comment composer, which has not opted in.
* Used by the short-note composer (new post, reply, quote, fork, draft, group
* thread, poll) and by the NIP-22 comment composer behind the url, geohash,
* hashtag and generic-comment screens.
*/
@Composable
fun AudienceFlap(
@@ -397,8 +399,9 @@ private fun AudienceDetail(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
// See Notifying: the chips' 48dp minimum touch target would otherwise
// dominate the gap between wrapped rows.
// The chips render through a selectable Surface that enforces a 48dp
// minimum touch target, which would otherwise dominate the gap between
// wrapped rows and swamp verticalArrangement.
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
groupChips.forEach { group ->
key(group.listId) {
@@ -408,7 +411,7 @@ private fun AudienceDetail(
// Deduped before keying: Compose throws on a duplicate key, and pTags
// can carry the same pubkey twice (a draft round-trips whatever p tags
// the event had). The Notifying row this replaces deduped via toSet().
// the event had). The flat row this replaces deduped via toSet().
audience.distinctBy { it.pubkeyHex }.forEach { user ->
key(user.pubkeyHex) {
AudienceMemberChip(
@@ -0,0 +1,145 @@
/*
* 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.notify
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* The audience a composer will `p` tag — and, when the note is sealed, the set
* of people who can decrypt it at all.
*
* Composers name their own backing field (`pTags` on the short-note composer,
* `notifying` on the comment composer), so this interface maps onto whichever
* one they already have and supplies the shared behaviour on top. The rules it
* delegates to live in [AudienceSelection], which is Compose-free and unit
* tested; everything here is thin state plumbing.
*/
@Stable
interface IAudience {
/** The people who will be p-tagged, muted ones included. */
var audienceMembers: List<User>?
/**
* Members whose bell is off. They keep their chip — so they are one tap from
* coming back — but are dropped from the outgoing event's `p` tags.
*/
var mutedNotifies: Set<HexKey>
/**
* Which list each pubkey arrived from. Display and undo only; never read
* when building the event, where [activeAudience] is the single source.
*/
var notifyProvenance: Map<HexKey, Set<String>>
/** Whether the manage sheet is open. */
var wantsToManageAudience: Boolean
/** Backs the manage sheet's search box. */
val audienceSearchText: TextFieldState
/** Called after every mutation so the composer can re-save its draft. */
fun onAudienceChanged()
/** Who will actually be p-tagged: the chip list minus the muted ones. */
fun activeAudience(): List<User>? = audienceMembers?.filter { it.pubkeyHex !in mutedNotifies }
fun toggleNotify(user: User) {
mutedNotifies =
if (user.pubkeyHex in mutedNotifies) {
mutedNotifies - user.pubkeyHex
} else {
mutedNotifies + user.pubkeyHex
}
onAudienceChanged()
}
/**
* Adds people in one shot, deliberately with a single write per field: N
* individual adds would recompose the audience row N times and bump the
* draft version N times for one user gesture.
*
* [fromListTag] records provenance so a whole bulk add can be undone as a
* unit; pass null for people picked one at a time.
*/
fun addAllToAudience(
users: Collection<User>,
fromListTag: String? = null,
) {
if (users.isEmpty()) return
val current = audienceMembers ?: emptyList()
val addition =
AudienceSelection.addToAudience(
current = current,
incoming = users,
provenance = notifyProvenance,
fromListTag = fromListTag,
currentlyMuted = mutedNotifies,
)
if (addition.newcomers.isNotEmpty()) {
audienceMembers = current + addition.newcomers
}
// Anyone this add brings into the audience gets their bell back —
// otherwise the sheet would promise to add somebody and nothing would
// visibly happen.
if (mutedNotifies.any { it in addition.unmutes }) {
mutedNotifies = mutedNotifies - addition.unmutes
}
notifyProvenance = addition.provenance
onAudienceChanged()
}
/**
* Undoes a whole bulk add. People who also arrived from another list, or who
* were in the audience for an unrelated reason, stay — only the ones this
* list alone brought in are dropped.
*/
fun removeListFromAudience(listId: String) {
val removal = AudienceSelection.removeListFromProvenance(notifyProvenance, listId)
notifyProvenance = removal.provenance
if (removal.orphaned.isNotEmpty()) {
audienceMembers = audienceMembers?.filterNot { it.pubkeyHex in removal.orphaned }?.ifEmpty { null }
mutedNotifies = mutedNotifies - removal.orphaned
}
onAudienceChanged()
}
/**
* Clears the editing surface. The members themselves are owned by the
* composer, which resets them on its own schedule (a draft load rebuilds
* them; a cancel drops them).
*/
fun resetAudienceEditor() {
wantsToManageAudience = false
notifyProvenance = emptyMap()
audienceSearchText.clearText()
}
}
@@ -1,152 +0,0 @@
/*
* 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.notify
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.InputChip
import androidx.compose.material3.InputChipDefaults
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size24dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun Notifying(
baseMentions: ImmutableList<User>?,
accountViewModel: AccountViewModel,
label: String? = null,
showWhenEmpty: Boolean = false,
mutedNotifies: ImmutableSet<HexKey> = persistentSetOf(),
onAddUser: (() -> Unit)? = null,
onToggleNotify: (User) -> Unit,
) {
val mentions = baseMentions?.toSet()
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
if (!mentions.isNullOrEmpty() || showWhenEmpty) {
Text(
label ?: stringRes(R.string.reply_notify),
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.align(CenterVertically),
)
// The chips render through a selectable Surface that enforces a 48dp minimum
// touch target, inflating each chip's measured height well above its visible
// 32dp pill. That invisible padding would dominate the gap between wrapped
// rows and make verticalArrangement barely noticeable. Disabling the minimum
// interactive size lets the chips measure at their visible height so the row
// spacing matches the horizontal spacing between chips.
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
mentions?.forEach { user ->
NotifyUserChip(user, user.pubkeyHex in mutedNotifies, accountViewModel) { onToggleNotify(user) }
}
if (onAddUser != null) {
AddUserChip(onAddUser)
}
}
}
}
}
@Composable
private fun NotifyUserChip(
user: User,
isMuted: Boolean,
accountViewModel: AccountViewModel,
onToggleNotify: () -> Unit,
) {
InputChip(
selected = false,
onClick = onToggleNotify,
// The bell-off icon alone is easy to miss at chip size, so a muted member
// also fades as a second cue while staying in the list for easy re-adding.
modifier = if (isMuted) Modifier.alpha(0.4f) else Modifier,
label = {
UsernameDisplay(
user,
weight = Modifier.widthIn(max = 180.dp),
fontWeight = FontWeight.SemiBold,
accountViewModel = accountViewModel,
)
},
// Use the leadingIcon slot instead of avatar: InputChip clips the avatar slot
// to a circle, which would cut off the following badge that BaseUserPicture
// intentionally draws slightly outside the picture's circle.
leadingIcon = {
BaseUserPicture(user, Size24dp, accountViewModel)
},
trailingIcon = {
Icon(
symbol = if (isMuted) MaterialSymbols.NotificationsOff else MaterialSymbols.Notifications,
contentDescription = stringRes(if (isMuted) R.string.notify_unmute_user else R.string.notify_mute_user),
modifier = Modifier.size(InputChipDefaults.IconSize),
)
},
)
}
@Composable
private fun AddUserChip(onAddUser: () -> Unit) {
AssistChip(
onClick = onAddUser,
label = { Text(text = stringRes(R.string.notify_add_user)) },
leadingIcon = {
Icon(
symbol = MaterialSymbols.PersonAdd,
contentDescription = null,
modifier = Modifier.size(AssistChipDefaults.IconSize),
)
},
)
}
@@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField
import com.vitorpamplona.amethyst.ui.note.creators.notify.IAudience
import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewState
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.IZapRaiser
@@ -141,7 +142,8 @@ open class CommentPostViewModel :
IMessageField,
IZapField,
IZapRaiser,
IExpiration {
IExpiration,
IAudience {
val draftTag = DraftTagState()
// Strong reference to the live cache note for the current draft tag (derived from the
@@ -190,16 +192,27 @@ open class CommentPostViewModel :
// Members of the notifying list whose bell is off: they keep their chip
// (so they are one tap away from being added back) but are dropped from
// the extra notification p tags of the outgoing comment.
var mutedNotifies by mutableStateOf<Set<HexKey>>(emptySet())
override var mutedNotifies by mutableStateOf<Set<HexKey>>(emptySet())
fun toggleNotify(user: User) {
mutedNotifies =
if (user.pubkeyHex in mutedNotifies) {
mutedNotifies - user.pubkeyHex
} else {
mutedNotifies + user.pubkeyHex
}
draftTag.newVersion()
// IAudience maps onto `notifying`, which the draft loaders and the comment
// builder already read under that name.
override var audienceMembers: List<User>?
get() = notifying
set(value) {
notifying = value
}
override val audienceSearchText = TextFieldState()
override var wantsToManageAudience by mutableStateOf(false)
override var notifyProvenance by mutableStateOf<Map<HexKey, Set<String>>>(emptyMap())
override fun onAudienceChanged() = draftTag.newVersion()
fun onAudienceSearchTextChanged() {
if (audienceSearchText.selection.collapsed) {
userSuggestionsMainMessage = UserSuggestionAnchor.NOTIFY
userSuggestions?.processCurrentWord(audienceSearchText.text.toString())
}
}
// NIP-9B: latest community rules document for the community we're posting into.
@@ -330,6 +343,7 @@ open class CommentPostViewModel :
this.replyingTo = post
this.externalIdentity = (post.event as? CommentEvent)?.scope()
mutedNotifies = emptySet()
notifyProvenance = emptyMap()
(post.event as? LnZapEvent)?.let { zap ->
notifying = listOfNotNull(zapSenderToNotify(zap))
}
@@ -527,6 +541,7 @@ open class CommentPostViewModel :
notifying = draftEvent.rootAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) } +
draftEvent.replyAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) }
mutedNotifies = emptySet()
notifyProvenance = emptyMap()
// Replies to zaps notify the zap sender through a plain p tag (the receipt's
// author keys above are the lightning provider). The sender chip always comes
@@ -871,6 +886,7 @@ open class CommentPostViewModel :
notifying = null
mutedNotifies = emptySet()
resetAudienceEditor()
wantsInvoice = false
wantsZapraiser = false
@@ -940,6 +956,9 @@ open class CommentPostViewModel :
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.value.addItem(item)
forwardZapToEditting.clearText()
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.NOTIFY) {
addAllToAudience(listOf(item))
audienceSearchText.clearText()
}
userSuggestionsMainMessage = null
@@ -35,6 +35,7 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -83,7 +84,10 @@ import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection
import com.vitorpamplona.amethyst.ui.note.creators.location.GeohashLocationPickerDialog
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField
import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying
import com.vitorpamplona.amethyst.ui.note.creators.notify.AudienceFlap
import com.vitorpamplona.amethyst.ui.note.creators.notify.AudienceSelection
import com.vitorpamplona.amethyst.ui.note.creators.notify.AudienceSheet
import com.vitorpamplona.amethyst.ui.note.creators.notify.rememberAudienceLists
import com.vitorpamplona.amethyst.ui.note.creators.pow.PowOverrideButton
import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews
import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton
@@ -232,8 +236,34 @@ private fun GenericCommentPostBody(
nav: Nav,
) {
val scrollState = rememberScrollState()
val audienceLists = rememberAudienceLists(accountViewModel)
Column(Modifier.fillMaxSize()) {
// Hosted outside the scrolling content: the sheet is its own window, so
// it survives wherever the composer scrolls to.
if (postViewModel.wantsToManageAudience) {
AudienceSheet(
audience = postViewModel.notifying?.toImmutableList() ?: persistentListOf(),
mutedNotifies = postViewModel.mutedNotifies.toImmutableSet(),
isPrivate = false,
searchState = postViewModel.audienceSearchText,
onSearchChanged = postViewModel::onAudienceSearchTextChanged,
userSuggestions = postViewModel.userSuggestions,
accountViewModel = accountViewModel,
onAddUser = {
postViewModel.addAllToAudience(listOf(it))
postViewModel.audienceSearchText.clearText()
postViewModel.userSuggestions?.reset()
},
onAddList = { list, users -> postViewModel.addAllToAudience(users, list.id) },
onDismiss = {
postViewModel.wantsToManageAudience = false
postViewModel.audienceSearchText.clearText()
postViewModel.userSuggestions?.reset()
},
)
}
Row(
modifier =
Modifier
@@ -274,15 +304,33 @@ private fun GenericCommentPostBody(
}
}
Row {
Notifying(
baseMentions = postViewModel.notifying?.toImmutableList(),
accountViewModel = accountViewModel,
mutedNotifies = postViewModel.mutedNotifies.toImmutableSet(),
) {
postViewModel.toggleNotify(it)
val audience = remember(postViewModel.notifying) { postViewModel.notifying?.toImmutableList() ?: persistentListOf() }
val mutedNotifies = remember(postViewModel.mutedNotifies) { postViewModel.mutedNotifies.toImmutableSet() }
val groupChips =
remember(postViewModel.notifyProvenance, audience, mutedNotifies, audienceLists) {
AudienceSelection
.activeGroupChips(
provenance = postViewModel.notifyProvenance,
audience =
audience.mapNotNullTo(mutableSetOf()) {
it.pubkeyHex.takeIf { hex -> hex !in mutedNotifies }
},
lists = audienceLists,
).toImmutableList()
}
}
AudienceFlap(
audience = audience,
// A comment is never gift-wrapped, so the flap stays in its
// quiet notify form here.
isPrivate = false,
accountViewModel = accountViewModel,
mutedNotifies = mutedNotifies,
groupChips = groupChips,
onManage = { postViewModel.wantsToManageAudience = true },
onRemoveGroup = { postViewModel.removeListFromAudience(it) },
onToggleNotify = { postViewModel.toggleNotify(it) },
)
Row(
modifier = Modifier.padding(vertical = Size10dp),
@@ -327,19 +327,19 @@ private fun NewPostScreenBody(
audience = audience,
mutedNotifies = mutedNotifies,
isPrivate = postViewModel.wantsPrivateNote,
searchState = postViewModel.notifyUserSearchText,
onSearchChanged = postViewModel::onNotifyUserSearchTextChanged,
searchState = postViewModel.audienceSearchText,
onSearchChanged = postViewModel::onAudienceSearchTextChanged,
userSuggestions = postViewModel.userSuggestions,
accountViewModel = accountViewModel,
onAddUser = {
postViewModel.addAllToReplyList(listOf(it))
postViewModel.notifyUserSearchText.clearText()
postViewModel.addAllToAudience(listOf(it))
postViewModel.audienceSearchText.clearText()
postViewModel.userSuggestions?.reset()
},
onAddList = { list, users -> postViewModel.addAllToReplyList(users, list.id) },
onAddList = { list, users -> postViewModel.addAllToAudience(users, list.id) },
onDismiss = {
postViewModel.wantsToManageAudience = false
postViewModel.notifyUserSearchText.clearText()
postViewModel.audienceSearchText.clearText()
postViewModel.userSuggestions?.reset()
},
)
@@ -383,7 +383,7 @@ private fun NewPostScreenBody(
mutedNotifies = mutedNotifies,
groupChips = groupChips,
onManage = { postViewModel.wantsToManageAudience = true },
onRemoveGroup = { postViewModel.removeListFromReplyList(it) },
onRemoveGroup = { postViewModel.removeListFromAudience(it) },
onToggleNotify = { postViewModel.toggleNotify(it) },
)
@@ -76,7 +76,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField
import com.vitorpamplona.amethyst.ui.note.creators.notify.AudienceSelection
import com.vitorpamplona.amethyst.ui.note.creators.notify.IAudience
import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewState
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.IZapRaiser
@@ -191,7 +191,8 @@ open class ShortNotePostViewModel :
IMessageField,
IZapField,
IZapRaiser,
IExpiration {
IExpiration,
IAudience {
val draftTag = DraftTagState()
// Strong reference to the live cache note for the current draft tag (derived from the
@@ -226,6 +227,16 @@ open class ShortNotePostViewModel :
var pTags by mutableStateOf<List<User>?>(null)
var eTags by mutableStateOf<List<Note>?>(null)
// IAudience maps onto pTags rather than replacing it: the field is read all
// over createTemplate and the draft loaders under that name.
override var audienceMembers: List<User>?
get() = pTags
set(value) {
pTags = value
}
override fun onAudienceChanged() = draftTag.newVersion()
val iMetaAttachments = IMetaAttachments()
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
@@ -357,20 +368,13 @@ open class ShortNotePostViewModel :
// 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.
val notifyUserSearchText = TextFieldState()
override val audienceSearchText = TextFieldState()
override var wantsToManageAudience by mutableStateOf(false)
override var notifyProvenance by mutableStateOf<Map<HexKey, Set<String>>>(emptyMap())
// The audience sheet: search, people lists and follow packs all live
// behind this one flag.
var wantsToManageAudience by mutableStateOf(false)
// Display-only record of which list each pubkey arrived from, so a bulk
// add can be undone as a unit (the group chip's ✕). Never read when
// building the event — the p tags always come from [activeNotifies].
var notifyProvenance by mutableStateOf<Map<HexKey, Set<String>>>(emptyMap())
fun onNotifyUserSearchTextChanged() {
if (notifyUserSearchText.selection.collapsed) {
val lastWord = notifyUserSearchText.text.toString()
fun onAudienceSearchTextChanged() {
if (audienceSearchText.selection.collapsed) {
val lastWord = audienceSearchText.text.toString()
userSuggestionsMainMessage = UserSuggestionAnchor.NOTIFY
userSuggestions?.processCurrentWord(lastWord)
}
@@ -379,82 +383,7 @@ open class ShortNotePostViewModel :
// Members of pTags whose bell is off: they keep their chip in the Notify
// list (so they are one tap away from being added back) but are dropped
// from the outgoing event's p tags.
var mutedNotifies by mutableStateOf<Set<HexKey>>(emptySet())
fun toggleNotify(user: User) {
mutedNotifies =
if (user.pubkeyHex in mutedNotifies) {
mutedNotifies - user.pubkeyHex
} else {
mutedNotifies + user.pubkeyHex
}
draftTag.newVersion()
}
// The users that will actually be p-tagged: the chip list minus the muted ones.
fun activeNotifies(): List<User>? = pTags?.filter { it.pubkeyHex !in mutedNotifies }
fun addToReplyList(user: User) {
if (pTags?.contains(user) != true) {
pTags = (pTags ?: emptyList()).plus(user)
}
mutedNotifies = mutedNotifies - user.pubkeyHex
}
/**
* Bulk sibling of [addToReplyList], used when a whole people list or follow
* pack is added at once. Deliberately one state write per field: calling
* [addToReplyList] N times would recompose the audience row N times and
* bump the draft version N times, saving N drafts for one user gesture.
*
* [fromListTag] records provenance so the group chip can undo exactly this
* batch later; pass null for people picked one at a time.
*/
fun addAllToReplyList(
users: Collection<User>,
fromListTag: String? = null,
) {
if (users.isEmpty()) return
val current = pTags ?: emptyList()
val addition =
AudienceSelection.addToAudience(
current = current,
incoming = users,
provenance = notifyProvenance,
fromListTag = fromListTag,
currentlyMuted = mutedNotifies,
)
if (addition.newcomers.isNotEmpty()) {
pTags = current + addition.newcomers
}
// Anyone re-added by a list gets their bell back: the list says they
// are part of the audience, and a muted chip would silently drop them.
if (mutedNotifies.any { it in addition.unmutes }) {
mutedNotifies = mutedNotifies - addition.unmutes
}
notifyProvenance = addition.provenance
draftTag.newVersion()
}
/**
* Removes a whole bulk add. People who also arrived from another list, or
* who were added by hand, stay — only the ones this list alone brought in
* are dropped.
*/
fun removeListFromReplyList(listId: String) {
val removal = AudienceSelection.removeListFromProvenance(notifyProvenance, listId)
notifyProvenance = removal.provenance
if (removal.orphaned.isNotEmpty()) {
pTags = pTags?.filterNot { it.pubkeyHex in removal.orphaned }?.ifEmpty { null }
mutedNotifies = mutedNotifies - removal.orphaned
}
draftTag.newVersion()
}
override var mutedNotifies by mutableStateOf<Set<HexKey>>(emptySet())
// 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
@@ -1292,7 +1221,7 @@ open class ShortNotePostViewModel :
notify(replyingTo.toPTag())
}
}
activeNotifies()?.let { userList ->
activeAudience()?.let { userList ->
val tags =
userList.map {
val tag = it.toPTag()
@@ -1312,7 +1241,7 @@ open class ShortNotePostViewModel :
val tagger =
NewMessageTagger(
message.text.toString().trim(),
activeNotifies(),
activeAudience(),
eTags,
accountViewModel,
)
@@ -1666,8 +1595,7 @@ open class ShortNotePostViewModel :
powOverride = null
wantsPrivateNote = false
privateNoteLocked = false
wantsToManageAudience = false
notifyUserSearchText.clearText()
resetAudienceEditor()
forwardZapTo.value = SplitBuilder()
forwardZapToEditting.clearText()
@@ -1729,8 +1657,8 @@ open class ShortNotePostViewModel :
forwardZapTo.value.addItem(item)
forwardZapToEditting.clearText()
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.NOTIFY) {
addToReplyList(item)
notifyUserSearchText.clearText()
addAllToAudience(listOf(item))
audienceSearchText.clearText()
}
userSuggestionsMainMessage = null