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 30ca6da76..d3b1cfa56 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -167,6 +167,7 @@ import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver import com.vitorpamplona.quartz.nip04Dm.PrivateDMCache import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris @@ -2836,6 +2837,21 @@ class Account( sendMyPublicAndPrivateOutbox(muteList.showWords(words)) } + suspend fun muteThread(rootHex: HexKey) { + sendMyPublicAndPrivateOutbox(muteList.hideThread(rootHex)) + } + + suspend fun unmuteThread(rootHex: HexKey) { + muteList.showThread(rootHex)?.let { sendMyPublicAndPrivateOutbox(it) } + } + + fun resolveThreadRoot(note: Note): HexKey { + val ev = note.event + return (ev as? BaseThreadedEvent)?.root()?.eventId ?: note.idHex + } + + fun isThreadMuted(rootHex: HexKey): Boolean = hiddenUsers.flow.value.isThreadMuted(rootHex) + suspend fun requestDVMContentDiscovery( dvmPublicKey: User, onReady: (event: NIP90ContentDiscoveryRequestEvent, relays: Set) -> Unit, @@ -2970,6 +2986,7 @@ class Account( } override fun isAcceptable(note: Note): Boolean { + if (isThreadMuted(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 ef18b8b4c..d23ccb0d7 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 274aebf35..947c93ec8 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 2bcf1ce92..670645211 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 2d78756a1..4f235a643 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 2b459aa79..bb37bd3a2 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.BaseThreadedEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -43,6 +44,12 @@ 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 + val rootId = (noteEvent as? BaseThreadedEvent)?.root()?.eventId ?: noteEvent.id + return !hiddenLists.mutedThreads.contains(rootId) + } + fun isNotInTheFuture(noteEvent: Event) = noteEvent.createdAt <= now fun hasExcessiveHashtags(noteEvent: Event) = hiddenLists.maxHashtagLimit > 0 && noteEvent.countHashtags() > hiddenLists.maxHashtagLimit @@ -84,6 +91,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 4dfbb1555..40819190b 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 @@ -326,6 +326,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 180aff43f..c5b03619e 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 857b6c6fb..e63e09fd9 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,7 @@ class AccountFeedContentStates( account.hiddenUsers.flow.collect { dmKnown.invalidateData() dmNew.invalidateData() + notifications.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 774e62577..9fd849025 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 @@ -1238,6 +1238,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 3e96624a0..324088747 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,20 @@ class NotificationFeedFilter( } } + // Drop reactions/zaps/reposts whose target post is in a muted thread. + // The inner-note renderer would otherwise show a misleading + // "This post was hidden because it mentions your hidden users or words" + // placeholder for muted-thread targets (regression from Task 6's + // Note.isHiddenFor extension). + 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 9556dc683..39d772955 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 000000000..594ce96c6 --- /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 + .mapNotNull { LocalCache.getNoteIfExists(it) ?: 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 000000000..fc9a365a8 --- /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 b5bb37be9..4ec644744 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