Merge remote-tracking branch 'upstream/main' into claude/concord-quartz-amethyst-plan-0oy779

This commit is contained in:
Vitor Pamplona
2026-07-13 12:43:12 -04:00
8 changed files with 114 additions and 42 deletions
@@ -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<HexKey> = 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),
)
},
@@ -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<List<User>?>(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<Set<HexKey>>(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()
@@ -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)
}
}
@@ -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,
@@ -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<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
}
// 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<Note>?,
): List<User> {
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()
+2
View File
@@ -1298,6 +1298,8 @@
<string name="private_note_no_receivers">No receivers yet: only you will be able to see this note. Add people to share it with.</string>
<string name="notify_add_user">Add</string>
<string name="notify_remove_user">Remove user from notifications</string>
<string name="notify_mute_user">Notifying. Tap to mute the notification for this user</string>
<string name="notify_unmute_user">Muted. Tap to notify this user again</string>
<string name="notify_search_and_add_user">Search and add a user to notify</string>
<string name="bookmark_absence_indicator">is not a bookmark here</string>
<string name="bookmark_remove_action_desc">Remove bookmark from list</string>
@@ -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")