mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
Merge remote-tracking branch 'upstream/main' into claude/concord-quartz-amethyst-plan-0oy779
This commit is contained in:
+15
-6
@@ -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),
|
||||
)
|
||||
},
|
||||
|
||||
+27
-11
@@ -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()
|
||||
|
||||
+7
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -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,
|
||||
|
||||
+58
-21
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
Binary file not shown.
+1
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user