mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
+9
-6
@@ -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(
|
||||
|
||||
+145
@@ -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()
|
||||
}
|
||||
}
|
||||
-152
@@ -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),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
+29
-10
@@ -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
|
||||
|
||||
+57
-9
@@ -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),
|
||||
|
||||
+7
-7
@@ -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) },
|
||||
)
|
||||
|
||||
|
||||
+25
-97
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user