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 index 64f1d09337..b7c1ca7a31 100644 --- 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 @@ -36,6 +36,7 @@ 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 @@ -49,7 +50,10 @@ 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 @@ -58,8 +62,9 @@ fun Notifying( accountViewModel: AccountViewModel, label: String? = null, showWhenEmpty: Boolean = false, + mutedNotifies: ImmutableSet = persistentSetOf(), onAddUser: (() -> Unit)? = null, - onClick: (User) -> Unit, + onToggleNotify: (User) -> Unit, ) { val mentions = baseMentions?.toSet() @@ -83,7 +88,7 @@ fun Notifying( // spacing matches the horizontal spacing between chips. CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { mentions?.forEach { user -> - NotifyUserChip(user, accountViewModel) { onClick(user) } + NotifyUserChip(user, user.pubkeyHex in mutedNotifies, accountViewModel) { onToggleNotify(user) } } if (onAddUser != null) { @@ -97,12 +102,16 @@ fun Notifying( @Composable private fun NotifyUserChip( user: User, + isMuted: Boolean, accountViewModel: AccountViewModel, - onRemove: () -> Unit, + onToggleNotify: () -> Unit, ) { InputChip( selected = false, - onClick = onRemove, + 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, @@ -119,8 +128,8 @@ private fun NotifyUserChip( }, trailingIcon = { Icon( - symbol = MaterialSymbols.Close, - contentDescription = stringRes(R.string.notify_remove_user), + 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), ) }, 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 3c6b6d9b79..0183fee6c3 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 @@ -74,6 +74,7 @@ import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate @@ -177,6 +178,21 @@ open class CommentPostViewModel : var notifying by mutableStateOf?>(null) + // 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()) + + fun toggleNotify(user: User) { + mutedNotifies = + if (user.pubkeyHex in mutedNotifies) { + mutedNotifies - user.pubkeyHex + } else { + mutedNotifies + user.pubkeyHex + } + draftTag.newVersion() + } + // NIP-9B: latest community rules document for the community we're posting into. // Null when the reply target is not a community, or no rules have been observed yet. var communityRules: CommunityRulesEvent? by mutableStateOf(null) @@ -303,6 +319,7 @@ open class CommentPostViewModel : open fun reply(post: Note) { this.replyingTo = post this.externalIdentity = (post.event as? CommentEvent)?.scope() + mutedNotifies = emptySet() (post.event as? LnZapEvent)?.let { zap -> notifying = listOfNotNull(zapSenderToNotify(zap)) } @@ -498,14 +515,16 @@ open class CommentPostViewModel : notifying = draftEvent.rootAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) } + draftEvent.replyAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) } + mutedNotifies = emptySet() // Replies to zaps notify the zap sender through a plain p tag (the receipt's - // author keys above are the lightning provider). Restore the sender chip only - // if the draft still tags them — its absence means the user removed it. + // author keys above are the lightning provider). The sender chip always comes + // back; a missing p tag in the draft means the user muted their bell. (replyingTo?.event as? LnZapEvent)?.let { zap -> zapSenderToNotify(zap)?.let { sender -> - if (draftEvent.tags.mapNotNull(PTag::parseKey).contains(sender.pubkeyHex)) { - notifying = (notifying ?: emptyList()) + sender + notifying = ((notifying ?: emptyList()) + sender).distinct() + if (!draftEvent.tags.mapNotNull(PTag::parseKey).contains(sender.pubkeyHex)) { + mutedNotifies = mutedNotifies + sender.pubkeyHex } } } @@ -665,9 +684,9 @@ open class CommentPostViewModel : } } else if (replyingToEvent is LnZapEvent) { val sender = zapSenderToNotify(replyingToEvent) - // notifying starts with the sender; a missing entry means the - // user removed the chip, so respect that and don't tag them. - if (sender != null && notifying?.contains(sender) != false) { + // The sender's chip stays in the list; a muted bell means + // the user doesn't want to ping them, so don't tag them. + if (sender != null && sender.pubkeyHex !in mutedNotifies) { listOf(sender.toPTag()) } else { emptyList() @@ -855,6 +874,7 @@ open class CommentPostViewModel : mediaUploadTracker.finishUpload() notifying = null + mutedNotifies = emptySet() wantsInvoice = false wantsZapraiser = false @@ -886,10 +906,6 @@ open class CommentPostViewModel : this.multiOrchestrator?.remove(selected) } - open fun removeFromReplyList(userToRemove: User) { - notifying = notifying?.filter { it != userToRemove } - } - override fun onMessageChanged() { urlPreviews.update(message.text.toString()) revalidateDraft() 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 8d4497e18d..9d06d1f355 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 @@ -99,6 +99,7 @@ import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -257,8 +258,12 @@ private fun GenericCommentPostBody( } Row { - Notifying(postViewModel.notifying?.toImmutableList(), accountViewModel) { - postViewModel.removeFromReplyList(it) + Notifying( + baseMentions = postViewModel.notifying?.toImmutableList(), + accountViewModel = accountViewModel, + mutedNotifies = postViewModel.mutedNotifies.toImmutableSet(), + ) { + postViewModel.toggleNotify(it) } } 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 534927c0f1..a9cb01b725 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 @@ -142,6 +142,7 @@ import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.launch @@ -327,9 +328,10 @@ private fun NewPostScreenBody( accountViewModel = accountViewModel, label = if (postViewModel.wantsPrivateNote) stringRes(R.string.private_note_visible_to) else null, showWhenEmpty = postViewModel.wantsPrivateNote, + mutedNotifies = postViewModel.mutedNotifies.toImmutableSet(), onAddUser = { postViewModel.wantsToAddNotifyUser = !postViewModel.wantsToAddNotifyUser }, ) { - postViewModel.removeFromReplyList(it) + postViewModel.toggleNotify(it) } } @@ -350,7 +352,7 @@ private fun NewPostScreenBody( ) } - if (postViewModel.wantsPrivateNote && postViewModel.pTags.isNullOrEmpty()) { + if (postViewModel.wantsPrivateNote && postViewModel.activeNotifies().isNullOrEmpty()) { Text( text = stringRes(R.string.private_note_no_receivers), style = MaterialTheme.typography.bodySmall, 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 3cea485aef..edc2f40266 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 @@ -367,10 +367,29 @@ 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 } // A single ephemeral signer reused for the whole compose session so that media @@ -569,6 +588,7 @@ open class ShortNotePostViewModel : originalNote = replyingTo privateNoteLocked = replyingTo?.isPrivateRumor() == true wantsPrivateNote = privateNoteLocked + mutedNotifies = emptySet() replyingTo?.let { replyNote -> if (replyNote.event is BaseThreadedEvent) { this.eTags = (replyNote.replyTo ?: emptyList()).plus(replyNote) @@ -577,20 +597,7 @@ open class ShortNotePostViewModel : } if (replyNote.event !is CommunityDefinitionEvent) { - replyNote.author?.let { replyUser -> - val currentMentions = - (replyNote.event as? TextNoteEvent) - ?.mentions() - ?.toSet() - ?.map { LocalCache.getOrCreateUser(it.pubKey) } - ?: emptyList() - - if (currentMentions.contains(replyUser)) { - this.pTags = currentMentions - } else { - this.pTags = currentMentions.plus(replyUser) - } - } + this.pTags = threadMembers(replyNote, this.eTags) } } ?: run { @@ -708,6 +715,22 @@ open class ShortNotePostViewModel : } } + // Everyone taking part in the thread gets a Notify chip: whoever the parent + // already p-tags, plus the author of every note in the reply chain. Members + // the user doesn't want to ping are muted via their chip's bell, not removed. + private fun threadMembers( + replyNote: Note, + threadNotes: List?, + ): List { + val mentions = + (replyNote.event as? TextNoteEvent) + ?.mentions() + ?.map { LocalCache.getOrCreateUser(it.pubKey) } + ?: emptyList() + val authors = threadNotes?.mapNotNull { it.author } ?: emptyList() + return (mentions + authors + listOfNotNull(replyNote.author)).distinct() + } + private fun loadFromDraft(draftEvent: TextNoteEvent) { canAddInvoice = accountViewModel.userProfile().lnAddress() != null canAddZapRaiser = accountViewModel.userProfile().lnAddress() != null @@ -786,6 +809,19 @@ open class ShortNotePostViewModel : privateNoteLocked = originalNote?.isPrivateRumor() == true wantsPrivateNote = privateNoteLocked + // A muted thread member is simply absent from the draft's p tags, so + // rebuild the full chip list and mark whoever the draft dropped as muted. + val draftNotifies = pTags.orEmpty().map { it.pubkeyHex }.toSet() + val members = + originalNote + ?.takeIf { it.event !is CommunityDefinitionEvent } + ?.let { threadMembers(it, eTags) } + .orEmpty() + mutedNotifies = members.mapNotNullTo(mutableSetOf()) { member -> member.pubkeyHex.takeIf { it !in draftNotifies } } + if (members.isNotEmpty()) { + pTags = (pTags.orEmpty() + members).distinct() + } + if (forwardZapTo.value.items.isNotEmpty()) { wantsForwardZapTo = true } @@ -845,6 +881,7 @@ open class ShortNotePostViewModel : draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull { LocalCache.checkGetOrCreateUser(it[1]) } + mutedNotifies = emptySet() canUsePoll = originalNote == null canUseZapPoll = originalNote == null @@ -917,6 +954,7 @@ open class ShortNotePostViewModel : draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull { LocalCache.checkGetOrCreateUser(it[1]) } + mutedNotifies = emptySet() canUsePoll = originalNote == null canUseZapPoll = originalNote == null @@ -1168,9 +1206,11 @@ open class ShortNotePostViewModel : val tags = prepareETagsAsReplyTo(replyingTo, null) accountViewModel.fixReplyTagHints(tags) markedETags(tags) - notify(replyingTo.toPTag()) + if (replyingTo.event.pubKey !in mutedNotifies) { + notify(replyingTo.toPTag()) + } } - pTags?.let { userList -> + activeNotifies()?.let { userList -> val tags = userList.map { val tag = it.toPTag() @@ -1190,7 +1230,7 @@ open class ShortNotePostViewModel : val tagger = NewMessageTagger( message.text.toString().trim(), - pTags, + activeNotifies(), eTags, accountViewModel, ) @@ -1512,6 +1552,7 @@ open class ShortNotePostViewModel : voiceSelectedServer = null voiceOrchestrator = null pTags = null + mutedNotifies = emptySet() wantsPoll = false pollOptions = newStateMapPollOptions() @@ -1562,10 +1603,6 @@ open class ShortNotePostViewModel : this.multiOrchestrator?.remove(selected) } - open fun removeFromReplyList(userToRemove: User) { - pTags = pTags?.filter { it != userToRemove } - } - open fun addToMessage(it: String) { message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it) onMessageChanged() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index cd6431329c..c49ecf87ed 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1298,6 +1298,8 @@ No receivers yet: only you will be able to see this note. Add people to share it with. Add Remove user from notifications + Notifying. Tap to mute the notification for this user + Muted. Tap to notify this user again Search and add a user to notify is not a bookmark here Remove bookmark from list diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf index 01bd58472e..07e1c3e472 100644 Binary files a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf and b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf differ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt index a038c5603d..adee791893 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt @@ -164,6 +164,7 @@ object MaterialSymbols { val NoAccounts = MaterialSymbol("\uF03E") val NoEncryption = MaterialSymbol("\uF03F") val Notifications = MaterialSymbol("\uE7F5") + val NotificationsOff = MaterialSymbol("\uE7F6") val Numbers = MaterialSymbol("\uEAC7") val OpenInBrowser = MaterialSymbol("\uE89D") val OpenInFull = MaterialSymbol("\uF1CE")