diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 9d505f3a61..7b4a8ca73e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -171,6 +171,7 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris import com.vitorpamplona.quartz.nip10Notes.content.findURLs +import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent @@ -2836,6 +2837,20 @@ class Account( sendMyPublicAndPrivateOutbox(muteList.showWords(words)) } + suspend fun muteThread(rootHex: HexKey) { + if (isThreadMuted(rootHex)) return + sendMyPublicAndPrivateOutbox(muteList.hideThread(rootHex)) + } + + suspend fun unmuteThread(rootHex: HexKey) { + if (!isThreadMuted(rootHex)) return + muteList.showThread(rootHex)?.let { sendMyPublicAndPrivateOutbox(it) } + } + + fun resolveThreadRoot(note: Note): HexKey = note.event?.threadRootIdOrSelf() ?: note.idHex + + fun isThreadMuted(rootHex: HexKey): Boolean = hiddenUsers.flow.value.isThreadMuted(rootHex) + suspend fun requestDVMContentDiscovery( dvmPublicKey: User, onReady: (event: NIP90ContentDiscoveryRequestEvent, relays: Set) -> Unit, @@ -2975,6 +2990,8 @@ class Account( } override fun isAcceptable(note: Note): Boolean { + val mutedThreads = hiddenUsers.flow.value.mutedThreads + if (mutedThreads.isNotEmpty() && mutedThreads.contains(resolveThreadRoot(note))) return false return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author isAcceptableDirect(note) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt index ef18b8b4c9..d23ccb0d78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag @@ -56,6 +57,7 @@ class HiddenUsersState( ): LiveHiddenUsers { val hiddenUsers = blockList.mapNotNullTo(mutableSetOf()) { if (it is UserTag) it.pubKey else null } + muteList.mapNotNull { if (it is UserTag) it.pubKey else null } val hiddenWords = blockList.mapNotNullTo(mutableSetOf()) { if (it is WordTag) it.word else null } + muteList.mapNotNull { if (it is WordTag) it.word else null } + val mutedThreads = muteList.mapNotNullTo(mutableSetOf()) { if (it is EventTag) it.eventId else null } return LiveHiddenUsers( showSensitiveContent = showSensitiveContent, @@ -66,6 +68,7 @@ class HiddenUsersState( spammers = transientHiddenUsers, hiddenWords = hiddenWords, maxHashtagLimit = maxHashtagLimit, + mutedThreads = mutedThreads, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListDecryptionCache.kt index 274aebf359..947c93ec8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListDecryptionCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListDecryptionCache.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip51Lists.muteList import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.mutedThreadIdSet import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUserIdSet import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUsers import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUsersAndWords @@ -44,6 +45,8 @@ class MuteListDecryptionCache( fun cachedWordSet(event: MuteListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedWordSet() + fun cachedThreadIdSet(event: MuteListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedThreadIdSet() + suspend fun mutedUsersAndWords(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedUsersAndWords() suspend fun mutedUsers(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedUsers() @@ -53,4 +56,6 @@ class MuteListDecryptionCache( suspend fun mutedWords(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedWords() suspend fun mutedWordSet(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedWordSet() + + suspend fun mutedThreadIdSet(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedThreadIdSet() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt index 2bcf1ce923..670645211a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt @@ -24,8 +24,10 @@ import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag @@ -139,6 +141,37 @@ class MuteListState( } } + suspend fun hideThread(rootHex: HexKey): MuteListEvent { + val muteList = getMuteList() + return if (muteList != null) { + MuteListEvent.add( + earlierVersion = muteList, + mute = EventTag(rootHex), + isPrivate = true, + signer = signer, + ) + } else { + MuteListEvent.create( + mute = EventTag(rootHex), + isPrivate = true, + signer = signer, + ) + } + } + + suspend fun showThread(rootHex: HexKey): MuteListEvent? { + val muteList = getMuteList() + return if (muteList != null) { + MuteListEvent.remove( + earlierVersion = muteList, + mute = EventTag(rootHex), + signer = signer, + ) + } else { + null + } + } + suspend fun showUsers(pubkeys: List): MuteListEvent? { if (pubkeys.isEmpty()) return null val muteList = getMuteList() ?: return null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 2d78756a15..4f235a6431 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -604,6 +604,9 @@ class EventNotificationConsumer( Log.d(TAG) { "Notify ZapRequest $noteZapRequest zapped $noteZapped" } + // Drop zaps on muted threads, hidden authors, etc. + if (!account.isAcceptable(noteZapped)) return + if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return Log.d(TAG, "Notify Amount Bigger than 10") @@ -720,6 +723,9 @@ class EventNotificationConsumer( val reactedPostId = event.originalPost().firstOrNull() ?: return val reactedNote = LocalCache.checkGetOrCreateNote(reactedPostId) + // Drop reactions on muted threads, hidden authors, etc. + if (reactedNote != null && !account.isAcceptable(reactedNote)) return + val author = LocalCache.getOrCreateUser(event.pubKey) val user = author.toBestDisplayName() val userPicture = author.profilePicture() @@ -832,6 +838,9 @@ class EventNotificationConsumer( ) { val replyNote = LocalCache.getNoteIfExists(event.id) ?: return + // Drop events from muted threads, hidden authors, etc. + if (!account.isAcceptable(replyNote)) return + val author = LocalCache.getOrCreateUser(event.pubKey) val user = author.toBestDisplayName() val userPicture = author.profilePicture() @@ -890,6 +899,9 @@ class EventNotificationConsumer( // Age + self-author gates run centrally in dispatchForAccount. val note = LocalCache.getNoteIfExists(event.id) ?: return + // Drop events from muted threads, hidden authors, etc. + if (!account.isAcceptable(note)) return + val author = LocalCache.getOrCreateUser(event.pubKey) val user = author.toBestDisplayName() val userPicture = author.profilePicture() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt index 2b459aa793..c1ef9f4c42 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags +import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -43,6 +44,11 @@ class FilterByListParams( ) { fun isNotHidden(userHex: String) = !(hiddenLists.hiddenUsers.contains(userHex) || hiddenLists.spammers.contains(userHex)) + fun isNotInMutedThread(noteEvent: Event): Boolean { + if (hiddenLists.mutedThreads.isEmpty()) return true + return !hiddenLists.mutedThreads.contains(noteEvent.threadRootIdOrSelf()) + } + fun isNotInTheFuture(noteEvent: Event) = noteEvent.createdAt <= now fun hasExcessiveHashtags(noteEvent: Event) = hiddenLists.maxHashtagLimit > 0 && noteEvent.countHashtags() > hiddenLists.maxHashtagLimit @@ -84,6 +90,7 @@ class FilterByListParams( comingFrom: List, ) = (applyTopFilter(comingFrom, noteEvent)) && (isHiddenList || isNotHidden(noteEvent.pubKey)) && + isNotInMutedThread(noteEvent) && isNotInTheFuture(noteEvent) && !hasExcessiveHashtags(noteEvent) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index a20547295b..0f1b1bf97d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -323,6 +323,27 @@ fun CardBody( showBlockAlertDialog.value = true } } + + VerticalDivider(color = primaryLight) + + val isMuted = accountViewModel.isThreadMutedFor(note) + NoteQuickActionItem( + MaterialSymbols.AutoMirrored.VolumeOff, + stringRes( + if (isMuted) { + R.string.quick_action_unmute_thread + } else { + R.string.quick_action_mute_thread + }, + ), + ) { + if (isMuted) { + accountViewModel.unmuteThread(note) + } else { + accountViewModel.muteThread(note) + } + onDismiss() + } } } HorizontalDivider( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index 180aff43f2..c5b03619e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -335,6 +335,18 @@ fun NoteDropDownMenu( // Moderation section M3ActionSection { + val isThreadMuted = accountViewModel.isThreadMutedFor(note) + M3ActionRow( + icon = MaterialSymbols.AutoMirrored.VolumeOff, + text = stringRes(if (isThreadMuted) R.string.quick_action_unmute_thread else R.string.quick_action_mute_thread), + ) { + if (isThreadMuted) { + accountViewModel.unmuteThread(note) + } else { + accountViewModel.muteThread(note) + } + onDismiss() + } if (state.isLoggedUser) { M3ActionRow(icon = MaterialSymbols.Delete, text = stringRes(R.string.request_deletion), isDestructive = true) { accountViewModel.delete(note) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 857b6c6fb2..c180e87feb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -138,6 +138,16 @@ class AccountFeedContentStates( account.hiddenUsers.flow.collect { dmKnown.invalidateData() dmNew.invalidateData() + // Re-mute removes cards, not just adds them. CardFeedContentState's + // refreshSuspended() takes an additive-only path when lastNotes is + // populated, which keeps stale cards for notes that no longer pass + // the filter. Clear first so the refresh hits the full-rebuild branch. + notifications.clear() + notifications.invalidateData() + notificationsFollowing.clear() + notificationsFollowing.invalidateData() + notificationsEveryone.clear() + notificationsEveryone.invalidateData() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index b94318d380..5ad6de3d07 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1260,6 +1260,26 @@ class AccountViewModel( fun showWords(words: List) = launchSigner { account.showWords(words) } + fun muteThread(note: Note) { + launchSigner { + account.muteThread(account.resolveThreadRoot(note)) + } + } + + fun unmuteThread(note: Note) { + launchSigner { + account.unmuteThread(account.resolveThreadRoot(note)) + } + } + + fun unmuteThread(rootHex: HexKey) { + launchSigner { + account.unmuteThread(rootHex) + } + } + + fun isThreadMutedFor(note: Note): Boolean = account.isThreadMuted(account.resolveThreadRoot(note)) + fun createStatus(newStatus: String) = launchSigner { account.createStatus(newStatus) } fun updateStatus( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 3e96624a0c..c24217b769 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -225,6 +225,17 @@ class NotificationFeedFilter( } } + // Reactions/zaps/reposts target a note via `replyTo`, not via thread-root tags, + // so isNotInMutedThread on the wrapper event misses them. + if (noteEvent is ReactionEvent || noteEvent is LnZapEvent || + noteEvent is RepostEvent || noteEvent is GenericRepostEvent + ) { + val target = it.replyTo?.lastOrNull() + if (target != null && account.isThreadMuted(account.resolveThreadRoot(target))) { + return false + } + } + // Chess events bypass the follow filter — opponents may not be followed val isChessEvent = noteEvent is LiveChessGameAcceptEvent || noteEvent is LiveChessMoveEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt index 9556dc6834..39d7729553 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt @@ -78,6 +78,8 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.WarningType import com.vitorpamplona.amethyst.model.parseWarningType import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenWord @@ -104,6 +106,7 @@ import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.HiddenAccountsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.HiddenWordsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.MutedThreadsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.SpammerAccountsFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder @@ -141,16 +144,23 @@ fun SecurityFiltersScreen( factory = SpammerAccountsFeedViewModel.Factory(accountViewModel.account), ) + val mutedThreadsFeedViewModel: MutedThreadsFeedViewModel = + viewModel( + factory = MutedThreadsFeedViewModel.Factory(accountViewModel.account), + ) + WatchAccountAndBlockList(accountViewModel = accountViewModel) { hiddenFeedViewModel.invalidateData() spammerFeedViewModel.invalidateData() hiddenWordsFeedViewModel.invalidateData() + mutedThreadsFeedViewModel.invalidateData() } SecurityFiltersScreen( hiddenFeedViewModel, hiddenWordsFeedViewModel, spammerFeedViewModel, + mutedThreadsFeedViewModel, accountViewModel, nav, ) @@ -162,6 +172,7 @@ fun SecurityFiltersScreen( hiddenFeedViewModel: HiddenAccountsFeedViewModel, hiddenWordsViewModel: HiddenWordsFeedViewModel, spammerFeedViewModel: SpammerAccountsFeedViewModel, + mutedThreadsFeedViewModel: MutedThreadsFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { @@ -175,6 +186,7 @@ fun SecurityFiltersScreen( hiddenWordsViewModel.invalidateData() hiddenFeedViewModel.invalidateData() spammerFeedViewModel.invalidateData() + mutedThreadsFeedViewModel.invalidateData() } } @@ -182,7 +194,7 @@ fun SecurityFiltersScreen( onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } } - val pagerState = rememberPagerState { 3 } + val pagerState = rememberPagerState { 4 } val coroutineScope = rememberCoroutineScope() var selectedUsers by remember { mutableStateOf(setOf()) } @@ -258,6 +270,11 @@ fun SecurityFiltersScreen( onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } }, text = { Text(text = stringRes(R.string.hidden_words)) }, ) + Tab( + selected = pagerState.currentPage == 3, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(3) } }, + text = { Text(text = stringRes(R.string.settings_muted_threads_title)) }, + ) } HorizontalPager(state = pagerState) { page -> when (page) { @@ -289,6 +306,13 @@ fun SecurityFiltersScreen( }, ) } + + 3 -> { + MutedThreadsFeed( + viewModel = mutedThreadsFeedViewModel, + accountViewModel = accountViewModel, + ) + } } } } @@ -801,3 +825,116 @@ private fun SelectableHiddenUsersList( } } } + +@Composable +private fun MutedThreadsFeed( + viewModel: MutedThreadsFeedViewModel, + accountViewModel: AccountViewModel, +) { + RefresheableBox(viewModel, false) { + val feedState by viewModel.feedState.feedContent.collectAsStateWithLifecycle() + + CrossfadeIfEnabled( + targetState = feedState, + animationSpec = tween(durationMillis = 100), + accountViewModel = accountViewModel, + ) { state -> + when (state) { + is FeedState.Empty -> { + Column( + Modifier + .fillMaxSize() + .padding(10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(text = stringRes(R.string.settings_muted_threads_empty)) + } + } + + is FeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + + is FeedState.Loading -> { + LoadingFeed() + } + + is FeedState.Loaded -> { + val items by state.feed.collectAsStateWithLifecycle() + val listState = rememberLazyListState() + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = rememberFeedContentPadding(FeedPadding), + state = listState, + ) { + itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, note -> + MutedThreadRow(note = note, accountViewModel = accountViewModel) + HorizontalDivider(thickness = DividerThickness) + } + } + } + } + } + } +} + +@Composable +private fun MutedThreadRow( + note: Note, + accountViewModel: AccountViewModel, +) { + val event = note.event + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = Size15dp, vertical = Size10dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + if (event == null) { + Text( + text = stringRes(R.string.settings_muted_threads_unknown, note.idHex.take(12) + "…"), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + val authorName = note.author?.metadataOrNull()?.bestName() + if (authorName != null) { + Text( + text = authorName, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = + event.content + .lines() + .firstOrNull() + ?.trim() ?: "", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Button( + modifier = Modifier.padding(start = 3.dp), + onClick = { accountViewModel.unmuteThread(note) }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringRes(R.string.action_unmute), color = Color.White) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/MutedThreadsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/MutedThreadsFeedFilter.kt new file mode 100644 index 0000000000..616b91b5fe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/MutedThreadsFeedFilter.kt @@ -0,0 +1,39 @@ +/* + * 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.screen.loggedIn.settings.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.FeedFilter + +class MutedThreadsFeedFilter( + val account: Account, +) : FeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + + override fun showHiddenKey(): Boolean = true + + override fun feed(): List = + account.hiddenUsers.flow.value.mutedThreads + .map { LocalCache.getOrCreateNote(it) } + .sortedByDescending { it.createdAt() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/MutedThreadsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/MutedThreadsFeedViewModel.kt new file mode 100644 index 0000000000..fc9a365a88 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/MutedThreadsFeedViewModel.kt @@ -0,0 +1,39 @@ +/* + * 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.screen.loggedIn.settings.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel + +@Stable +class MutedThreadsFeedViewModel( + val account: Account, +) : AndroidFeedViewModel(MutedThreadsFeedFilter(account)) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = MutedThreadsFeedViewModel(account) as T + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b5bb37be9f..4ec6447448 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -401,6 +401,8 @@ Block Delete Block + Mute thread + Unmute thread Report Delete Don\'t show again @@ -1449,6 +1451,7 @@ Muted. Click to unmute Sound on. Click to mute + Unmute Skip back %d seconds Skip forward %d seconds Picture-in-Picture @@ -1606,6 +1609,9 @@ Hidden Words Hide new word or sentence + Muted threads + No muted threads + Unknown thread · %1$s Profile Picture Show Profile pictures diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt index 2fe0507b53..714d26fec9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt @@ -60,9 +60,12 @@ data class LiveHiddenUsers( val hiddenUsers: Set = emptySet(), val spammers: Set = emptySet(), val hiddenWords: Set = emptySet(), + val mutedThreads: Set = emptySet(), val maxHashtagLimit: Int = 5, ) { fun isUserHidden(userHex: String) = hiddenUsers.contains(userHex) || spammers.contains(userHex) + + fun isThreadMuted(rootHex: String) = mutedThreads.contains(rootHex) } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 4edd15882f..1a2790d087 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.anyHashTag import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress @@ -860,6 +861,12 @@ open class Note( return true } + if (accountChoices.mutedThreads.isNotEmpty() && + accountChoices.mutedThreads.contains(thisEvent.threadRootIdOrSelf()) + ) { + return true + } + // if the post is sensitive and the user doesn't want to see sensitive content if (accountChoices.showSensitiveContent == false && thisEvent.isSensitiveOrNSFW()) { return true diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteIsHiddenForTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteIsHiddenForTest.kt new file mode 100644 index 0000000000..b8c1378006 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteIsHiddenForTest.kt @@ -0,0 +1,93 @@ +/* + * 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.commons.model + +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class NoteIsHiddenForTest { + private val rootId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + private val replyId = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + private val authorPubKey = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + + private val noHidden = + LiveHiddenUsers( + showSensitiveContent = null, + hiddenWordsCase = emptyList(), + hiddenUsersHashCodes = emptySet(), + spammersHashCodes = emptySet(), + mutedThreads = emptySet(), + ) + + private fun textNoteEvent( + id: String, + pubKey: String = authorPubKey, + eTags: Array> = emptyArray(), + ) = TextNoteEvent( + id = id, + pubKey = pubKey, + createdAt = 1_700_000_000L, + tags = eTags, + content = "hello", + sig = "sig", + ) + + private fun rootETag(eventId: String) = arrayOf("e", eventId, "", "root") + + @Test + fun reply_inMutedThread_isHidden() { + val event = textNoteEvent(id = replyId, eTags = arrayOf(rootETag(rootId))) + val note = Note(replyId).also { it.event = event } + val choices = noHidden.copy(mutedThreads = setOf(rootId)) + + assertTrue(note.isHiddenFor(choices), "Reply inside a muted thread must be hidden") + } + + @Test + fun topLevelNote_ownIdMuted_isHidden() { + val event = textNoteEvent(id = rootId, eTags = emptyArray()) + val note = Note(rootId).also { it.event = event } + val choices = noHidden.copy(mutedThreads = setOf(rootId)) + + assertTrue(note.isHiddenFor(choices), "Top-level note whose id is muted must be hidden") + } + + @Test + fun note_inUnmutedThread_isNotHidden() { + val otherRoot = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + val event = textNoteEvent(id = replyId, eTags = arrayOf(rootETag(otherRoot))) + val note = Note(replyId).also { it.event = event } + val choices = noHidden.copy(mutedThreads = setOf(rootId)) + + assertFalse(note.isHiddenFor(choices), "Reply in an un-muted thread must not be hidden") + } + + @Test + fun authorHidden_isHidden_regression() { + val event = textNoteEvent(id = replyId) + val note = Note(replyId).also { it.event = event } + val choices = noHidden.copy(hiddenUsersHashCodes = setOf(authorPubKey.hashCode())) + + assertTrue(note.isHiddenFor(choices), "Note whose author is hidden must still be hidden") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt index 56e92115cb..b8379fb628 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip10Notes import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedATags import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers @@ -30,6 +31,15 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull +fun Event.threadRootIdOrSelf(): HexKey { + val threaded = this as? BaseThreadedEvent ?: return id + threaded.root()?.eventId?.let { return it } + // NIP-10 legacy single-level form: when only a "reply"-marked e-tag is + // present (no "root" marker), the reply target IS the conversation root. + threaded.markedReply()?.eventId?.let { return it } + return id +} + @Immutable open class BaseThreadedEvent( id: HexKey, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt index 46ebe49dee..b94b093067 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip51Lists.muteList import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag @@ -36,3 +37,9 @@ fun TagArray.mutedUserIdSet() = mapNotNullTo(mutableSetOf(), UserTag::parseKey) fun TagArray.mutedWords() = mapNotNull(WordTag::parse) fun TagArray.mutedWordSet() = mapNotNullTo(mutableSetOf(), WordTag::parse) + +fun TagArray.mutedThreads() = mapNotNull(EventTag::parse) + +fun TagArray.mutedThreadIds() = mapNotNull(EventTag::parseId) + +fun TagArray.mutedThreadIdSet() = mapNotNullTo(mutableSetOf(), EventTag::parseId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/EventTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/EventTag.kt new file mode 100644 index 0000000000..765e0f0a5d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/EventTag.kt @@ -0,0 +1,69 @@ +/* + * 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.quartz.nip51Lists.muteList.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +@Immutable +class EventTag( + val eventId: HexKey, + val relayHint: NormalizedRelayUrl? = null, + val pubKeyHint: HexKey? = null, +) : MuteTag { + override fun toTagArray() = assemble(eventId, relayHint, pubKeyHint) + + override fun toTagIdOnly() = assemble(eventId, null, null) + + companion object { + const val TAG_NAME = "e" + + fun isTagged(tag: Array): Boolean = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 + + fun parse(tag: Tag): EventTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + val hint = tag.getOrNull(2)?.takeIf { it.isNotEmpty() }?.let { RelayUrlNormalizer.normalizeOrNull(it) } + val pubKey = tag.getOrNull(3)?.takeIf { it.length == 64 } + return EventTag(tag[1], hint, pubKey) + } + + fun parseId(tag: Array): HexKey? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + fun assemble( + eventId: HexKey, + relayHint: NormalizedRelayUrl?, + pubKeyHint: HexKey?, + ) = arrayOfNotNull(TAG_NAME, eventId, relayHint?.url, pubKeyHint) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt index 90b6779792..7d95b71067 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt @@ -28,8 +28,8 @@ sealed interface MuteTag { fun toTagIdOnly(): Tag companion object { - fun isTagged(tag: Array) = WordTag.isTagged(tag) || UserTag.isTagged(tag) + fun isTagged(tag: Array) = WordTag.isTagged(tag) || UserTag.isTagged(tag) || EventTag.isTagged(tag) - fun parse(tag: Array): MuteTag? = WordTag.parse(tag) ?: UserTag.parse(tag) + fun parse(tag: Array): MuteTag? = WordTag.parse(tag) ?: UserTag.parse(tag) ?: EventTag.parse(tag) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/ThreadRootIdOrSelfTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/ThreadRootIdOrSelfTest.kt new file mode 100644 index 0000000000..2ae2407d19 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/ThreadRootIdOrSelfTest.kt @@ -0,0 +1,75 @@ +/* + * 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.quartz.nip10Notes + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ThreadRootIdOrSelfTest { + private val selfId = "3b1ef90115dc6f383e9588d29c86fe3ecda6ecc98415af556cd1399731a15e09" + private val rootId = "41ed6fdfbe827e7e87f3bfd852270b036c199b721777593d257029d742adcb46" + private val parentId = "54851588857363fa077fc756c1a64528f511d39476ca37e6a781bcb7aabb9e7d" + private val pubKey = "b62b357c1b939ae051a361d5befd409ddda06264a7588f1a986c24b1d13cc53f" + private val relay = "wss://relay.damus.io" + private val sig = "0".repeat(128) + + private fun textNote(tags: Array>): TextNoteEvent = TextNoteEvent(selfId, pubKey, 1778593701L, tags, "", sig) + + @Test fun topLevelNote_noETags_resolvesToOwnId() { + // A note with no e-tags is the root of its own thread. + val note = textNote(emptyArray()) + assertEquals(selfId, note.threadRootIdOrSelf()) + } + + @Test fun replyWithRootMarker_resolvesToRoot() { + // Modern NIP-10: explicit "root" marker. + val note = textNote(arrayOf(arrayOf("e", rootId, relay, "root"))) + assertEquals(rootId, note.threadRootIdOrSelf()) + } + + @Test fun replyWithBothRootAndReplyMarkers_resolvesToRoot() { + // Multi-level reply: "root" marker points to thread root, + // "reply" marker points to immediate parent. + val note = + textNote( + arrayOf( + arrayOf("e", rootId, relay, "root"), + arrayOf("e", parentId, relay, "reply"), + ), + ) + assertEquals(rootId, note.threadRootIdOrSelf()) + } + + @Test fun replyWithOnlyReplyMarker_resolvesToReplyTarget() { + // NIP-10 legacy single-level form: a one-level reply marked only + // "reply" with no "root" marker. The "reply" target IS the + // conversation root. Regression case from issue #161 device QA. + val note = textNote(arrayOf(arrayOf("e", rootId, relay, "reply"))) + assertEquals(rootId, note.threadRootIdOrSelf()) + } + + @Test fun replyWithUnmarkedETag_resolvesToTaggedEvent() { + // Positional NIP-10: e-tag with no marker. Treated as root by + // BaseThreadedEvent.unmarkedRoot(). + val note = textNote(arrayOf(arrayOf("e", rootId, relay))) + assertEquals(rootId, note.threadRootIdOrSelf()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListEventTest.kt new file mode 100644 index 0000000000..8dfd37e726 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListEventTest.kt @@ -0,0 +1,226 @@ +/* + * 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.quartz.nip51Lists.muteList + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class MuteListEventTest { + private val signer = NostrSignerInternal("nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair()) + + // 64-char hex IDs + private val rootA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1" + private val rootB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2b" + private val pubA = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc3c" + + @Test + fun create_withEventTag_privateContent() = + runTest { + val event = + MuteListEvent.create( + mute = EventTag(rootA), + isPrivate = true, + signer = signer, + createdAt = 1_700_000_000, + ) + + // No public "e" tag should be present + val publicETags = event.tags.filter { it.size >= 2 && it[0] == "e" && it[1] == rootA } + assertTrue(publicETags.isEmpty(), "Private mute must not appear as a public e-tag") + + // Content must be non-empty (it is encrypted) + assertTrue(event.content.isNotEmpty(), "Content must be non-empty when mute is private") + + // Decrypting must yield exactly one EventTag with eventId == rootA + val privateMutes = assertNotNull(event.privateMutes(signer), "privateMutes must not return null") + assertEquals(1, privateMutes.size, "Expected exactly one private mute") + val tag = assertNotNull(privateMutes.firstOrNull() as? EventTag, "Mute must be an EventTag") + assertEquals(rootA, tag.eventId) + } + + @Test + fun add_eventTagToExistingMute_combinesPrivateSet() = + runTest { + val firstEvent = + MuteListEvent.create( + mute = EventTag(rootA), + isPrivate = true, + signer = signer, + createdAt = 1_700_000_001, + ) + + val secondEvent = + MuteListEvent.add( + earlierVersion = firstEvent, + mute = EventTag(rootB), + isPrivate = true, + signer = signer, + createdAt = 1_700_000_002, + ) + + val privateMutes = assertNotNull(secondEvent.privateMutes(signer), "privateMutes must not return null") + val ids = privateMutes.filterIsInstance().map { it.eventId }.toSet() + assertTrue(ids.contains(rootA), "rootA must be present after add") + assertTrue(ids.contains(rootB), "rootB must be present after add") + } + + @Test + fun add_eventTagPreservesPriorUserAndWordTags() = + runTest { + // Build a kind-10000 with one UserTag and one WordTag (both private) + val base = + MuteListEvent.create( + publicMutes = emptyList(), + privateMutes = listOf(UserTag(pubA), WordTag("spam")), + signer = signer, + createdAt = 1_700_000_003, + ) + + val updated = + MuteListEvent.add( + earlierVersion = base, + mute = EventTag(rootA), + isPrivate = true, + signer = signer, + createdAt = 1_700_000_004, + ) + + val privateMutes = assertNotNull(updated.privateMutes(signer), "privateMutes must not return null") + assertEquals(3, privateMutes.size, "Expected three private mutes (UserTag + WordTag + EventTag)") + + val userTags = privateMutes.filterIsInstance() + val wordTags = privateMutes.filterIsInstance() + val eventTags = privateMutes.filterIsInstance() + + assertEquals(1, userTags.size, "Must have exactly one UserTag") + assertEquals(pubA, userTags.first().pubKey) + + assertEquals(1, wordTags.size, "Must have exactly one WordTag") + assertEquals("spam", wordTags.first().word) + + assertEquals(1, eventTags.size, "Must have exactly one EventTag") + assertEquals(rootA, eventTags.first().eventId) + } + + @Test + fun remove_eventTagLeavesOthers() = + runTest { + // Build event with both rootA and rootB muted privately + val base = + MuteListEvent.create( + publicMutes = emptyList(), + privateMutes = listOf(EventTag(rootA), EventTag(rootB)), + signer = signer, + createdAt = 1_700_000_005, + ) + + val updated = + MuteListEvent.remove( + earlierVersion = base, + mute = EventTag(rootA), + signer = signer, + createdAt = 1_700_000_006, + ) + + val privateMutes = assertNotNull(updated.privateMutes(signer), "privateMutes must not return null") + val ids = privateMutes.filterIsInstance().map { it.eventId }.toSet() + assertTrue(!ids.contains(rootA), "rootA must have been removed") + assertTrue(ids.contains(rootB), "rootB must still be present") + } + + @Test + fun removeAll_mixedTagsRemovesUserAndEvent_keepsWord() = + runTest { + val base = + MuteListEvent.create( + publicMutes = emptyList(), + privateMutes = listOf(UserTag(pubA), WordTag("spam"), EventTag(rootA)), + signer = signer, + createdAt = 1_700_000_007, + ) + + val updated = + MuteListEvent.removeAll( + earlierVersion = base, + mutes = listOf(UserTag(pubA), EventTag(rootA)), + signer = signer, + createdAt = 1_700_000_008, + ) + + val privateMutes = assertNotNull(updated.privateMutes(signer), "privateMutes must not return null") + + val userTags = privateMutes.filterIsInstance() + val wordTags = privateMutes.filterIsInstance() + val eventTags = privateMutes.filterIsInstance() + + assertTrue(userTags.none { it.pubKey == pubA }, "UserTag(pubA) must have been removed") + assertTrue(eventTags.none { it.eventId == rootA }, "EventTag(rootA) must have been removed") + assertEquals(1, wordTags.size, "WordTag must still be present") + assertEquals("spam", wordTags.first().word) + } + + @Test + fun legacyMuteListWithoutEventTags_decodesToEmptyThreadSet() = + runTest { + // Build a kind-10000 with only p + word tags (public), no e tags + val legacyEvent = + MuteListEvent.create( + publicMutes = listOf(UserTag(pubA), WordTag("spam")), + privateMutes = emptyList(), + signer = signer, + createdAt = 1_700_000_009, + ) + + // Calling mutedThreadIdSet() on a tag array with no e-tags must not crash and return empty + val publicThreadIds = legacyEvent.tags.mutedThreadIdSet() + assertTrue(publicThreadIds.isEmpty(), "Legacy event with only p+word tags must have empty thread id set") + + // privateMutes returns empty list (content is blank/empty for no private mutes) + val privateMutes = legacyEvent.privateMutes(signer) + val privateEventTags = (privateMutes ?: emptyList()).filterIsInstance() + assertTrue(privateEventTags.isEmpty(), "No private EventTags in a legacy event") + } + + @Test + fun roundTrip_eventTagsViaEncryption_preservesIds() = + runTest { + val event = + MuteListEvent.create( + publicMutes = emptyList(), + privateMutes = listOf(EventTag(rootA), EventTag(rootB)), + signer = signer, + createdAt = 1_700_000_010, + ) + + val decrypted = assertNotNull(event.privateMutes(signer), "privateMutes must not return null") + val ids = decrypted.filterIsInstance().map { it.eventId }.toSet() + + assertEquals(setOf(rootA, rootB), ids, "Round-trip must preserve all muted thread IDs") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExtMutedThreadsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExtMutedThreadsTest.kt new file mode 100644 index 0000000000..b73ea24aef --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExtMutedThreadsTest.kt @@ -0,0 +1,60 @@ +/* + * 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.quartz.nip51Lists.muteList + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TagArrayExtMutedThreadsTest { + private val id1 = "3ae34f70016c33d36f9e6ad395591ea36fee7ac488d1dad383ae64ae3d988f50" + private val id2 = "00000000016c33d36f9e6ad395591ea36fee7ac488d1dad383ae64ae3d988f50" + + @Test fun mutedThreads_returnsEventTagsOnly() { + val tags = + arrayOf( + arrayOf("e", id1), + arrayOf("p", id2), + arrayOf("word", "spam"), + arrayOf("e", id2, "wss://relay.damus.io"), + ) + val parsed = tags.mutedThreads() + assertEquals(2, parsed.size) + assertEquals(id1, parsed[0].eventId) + assertEquals(id2, parsed[1].eventId) + } + + @Test fun mutedThreadIdSet_extractsIdsAcrossMixedTags() { + val tags = + arrayOf( + arrayOf("e", id1), + arrayOf("p", id2), + arrayOf("e", id2), + ) + val ids = tags.mutedThreadIdSet() + assertEquals(setOf(id1, id2), ids) + } + + @Test fun mutedThreadIdSet_emptyOnNoEventTags() { + val tags = arrayOf(arrayOf("p", id1), arrayOf("word", "spam")) + assertTrue(tags.mutedThreadIdSet().isEmpty()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/EventTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/EventTagTest.kt new file mode 100644 index 0000000000..00d2051981 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/EventTagTest.kt @@ -0,0 +1,101 @@ +/* + * 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.quartz.nip51Lists.muteList.tags + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class EventTagTest { + private val rootHex = "3ae34f70016c33d36f9e6ad395591ea36fee7ac488d1dad383ae64ae3d988f50" + private val authorHex = "d0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c" + private val relayHint = "wss://relay.damus.io" + + @Test fun isTagged_eventTagWithIdOnly() { + assertTrue(EventTag.isTagged(arrayOf("e", rootHex))) + } + + @Test fun isTagged_eventTagWithRelayHint() { + assertTrue(EventTag.isTagged(arrayOf("e", rootHex, relayHint))) + } + + @Test fun isTagged_rejectsShortId() { + assertFalse(EventTag.isTagged(arrayOf("e", "tooShort"))) + } + + @Test fun isTagged_rejectsWrongPrefix() { + assertFalse(EventTag.isTagged(arrayOf("p", rootHex))) + } + + @Test fun parse_idOnly() { + val tag = assertNotNull(EventTag.parse(arrayOf("e", rootHex))) + assertEquals(rootHex, tag.eventId) + assertNull(tag.relayHint) + assertNull(tag.pubKeyHint) + } + + @Test fun parse_withRelayHint() { + val tag = assertNotNull(EventTag.parse(arrayOf("e", rootHex, relayHint))) + assertEquals(rootHex, tag.eventId) + assertEquals("wss://relay.damus.io/", tag.relayHint?.url) + } + + @Test fun parse_withRelayAndPubkeyHint() { + val tag = assertNotNull(EventTag.parse(arrayOf("e", rootHex, relayHint, authorHex))) + assertEquals(authorHex, tag.pubKeyHint) + } + + @Test fun parse_rejectsShortId() { + assertNull(EventTag.parse(arrayOf("e", "tooShort"))) + } + + @Test fun parseId_extractsIdOnly() { + assertEquals(rootHex, EventTag.parseId(arrayOf("e", rootHex, relayHint))) + } + + @Test fun toTagArray_roundTripsWithHints() { + val original = EventTag(rootHex, RelayUrlNormalizer.normalizeOrNull(relayHint), authorHex) + val parsed = assertNotNull(EventTag.parse(original.toTagArray())) + assertEquals(rootHex, parsed.eventId) + assertEquals(authorHex, parsed.pubKeyHint) + } + + @Test fun toTagIdOnly_stripsHints() { + val tag = EventTag(rootHex, RelayUrlNormalizer.normalizeOrNull(relayHint), authorHex) + val stripped = tag.toTagIdOnly() + assertEquals(2, stripped.size) + assertEquals("e", stripped[0]) + assertEquals(rootHex, stripped[1]) + } + + @Test fun muteTagCompanion_parsesEventTag() { + val parsed = assertNotNull(MuteTag.parse(arrayOf("e", rootHex))) + assertTrue(parsed is EventTag) + } + + @Test fun muteTagCompanion_isTaggedRecognizesEventTag() { + assertTrue(MuteTag.isTagged(arrayOf("e", rootHex))) + } +}