diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceFlap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceFlap.kt index c8f4ba6079..5206c737ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceFlap.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceFlap.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/IAudience.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/IAudience.kt new file mode 100644 index 0000000000..c0a9612e78 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/IAudience.kt @@ -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? + + /** + * 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 + + /** + * 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> + + /** 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? = 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, + 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() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt deleted file mode 100644 index b7c1ca7a31..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt +++ /dev/null @@ -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?, - accountViewModel: AccountViewModel, - label: String? = null, - showWhenEmpty: Boolean = false, - mutedNotifies: ImmutableSet = 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), - ) - }, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index f6cf99ee14..822ea06026 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -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>(emptySet()) + override var mutedNotifies by mutableStateOf>(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? + get() = notifying + set(value) { + notifying = value + } + + override val audienceSearchText = TextFieldState() + override var wantsToManageAudience by mutableStateOf(false) + override var notifyProvenance by mutableStateOf>>(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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index 6baef9f1a2..82124be2b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -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), 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 557e074199..c3650daacb 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 @@ -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) }, ) 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 bf33492da1..6f31c72231 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 @@ -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?>(null) var eTags by mutableStateOf?>(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? + get() = pTags + set(value) { + pTags = value + } + + override fun onAudienceChanged() = draftTag.newVersion() + val iMetaAttachments = IMetaAttachments() var nip95attachments by mutableStateOf>>(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>>(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>>(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>(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? = 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, - 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>(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