From 6dc723ae2c274269a94a7dd43e3fcccc1582209f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 21:07:16 +0000 Subject: [PATCH 1/2] feat: auto-unpin deleted posts from NIP-51 pin list When a kind-5 deletion event arrives for a note that is currently pinned, rewrite the user's PinListEvent to drop the deleted entries and broadcast the new list. Mirrors the existing deletedNotes() pattern used by peopleLists, followLists, and labeledBookmarkLists in Account. Previously the Pinned Notes screen silently hid deleted posts via the FeedContentState deletion filter, leaving orphan entries in the pin list that the user had no way to clean up. --- .../vitorpamplona/amethyst/model/Account.kt | 8 ++++++++ .../amethyst/model/nip51Lists/PinListState.kt | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) 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 025bf0ffce..441a1be260 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2293,6 +2293,13 @@ class Account( } } + suspend fun removeDeletedPins(deletedNotes: Set) { + if (!isWriteable()) return + + val event = pinState.removeDeletedPins(deletedNotes) ?: return + sendMyPublicAndPrivateOutbox(event) + } + suspend fun createAddPinEvent(note: Note): Pair>? { if (!isWriteable() || note.isDraft()) return null @@ -2674,6 +2681,7 @@ class Account( peopleLists.deletedNotes(deletedNotes) followLists.deletedNotes(deletedNotes) labeledBookmarkLists.deletedNotes(deletedNotes) + removeDeletedPins(deletedNotes) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt index fbe7114088..f19d846300 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt @@ -127,4 +127,22 @@ class PinListState( signer = signer, ) } + + suspend fun removeDeletedPins(deletedNotes: Set): PinListEvent? { + val currentList = getPinList() ?: return null + val deletedIds = deletedNotes.mapTo(HashSet()) { it.idHex } + val pinsToRemove = currentList.pinnedEvents().filter { it.eventId in deletedIds } + if (pinsToRemove.isEmpty()) return null + + var working: PinListEvent = currentList + for (pin in pinsToRemove) { + working = + PinListEvent.remove( + earlierVersion = working, + pin = pin, + signer = signer, + ) + } + return working + } } From 85fcf50df923488cfb1fb907c2077e915e02e0ab Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 22:58:54 +0000 Subject: [PATCH 2/2] feat: warn and offer cleanup for deleted items in pin and bookmark lists Adds a dismissible top-of-screen banner to the Pinned Notes, Bookmarks, Old Bookmarks, and Bookmark Set (detail) screens that appears when one or more listed items have been deleted by their author (kind-5 deletion, pubkey-matched via DeletionIndex). Tapping "Remove from list" rewrites the underlying list event (NIP-51 kind 10001/10003/30001/30003) with the deleted entries stripped out and broadcasts it once. The scan is scoped to each screen's loaded list (typically <20 items) and runs only while composed, so it avoids the performance hit of hooking Account.deletedEventBundles, which fires for every kind-5 deletion in the firehose. Public and private (NIP-44 encrypted) bookmarks are both cleaned in a single resign() call per list. --- .../vitorpamplona/amethyst/model/Account.kt | 20 ++++- .../amethyst/model/nip51Lists/PinListState.kt | 25 +++--- .../LabeledBookmarkListsState.kt | 54 +++++++++++++ .../ui/components/DeletedItemsBanner.kt | 76 +++++++++++++++++++ .../ui/screen/loggedIn/AccountViewModel.kt | 18 +++++ .../default/BookmarkListScreen.kt | 43 ++++++++++- .../display/BookmarkGroupScreen.kt | 71 +++++++++++++++++ .../display/BookmarkGroupViewModel.kt | 13 ++++ .../old/OldBookmarkListScreen.kt | 43 ++++++++++- .../loggedIn/pinnednotes/PinnedNotesScreen.kt | 29 ++++++- amethyst/src/main/res/values/strings.xml | 4 + .../model/nip51Lists/BookmarkListState.kt | 44 +++++++++++ .../model/nip51Lists/OldBookmarkListState.kt | 29 +++++++ 13 files changed, 452 insertions(+), 17 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/DeletedItemsBanner.kt 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 441a1be260..21b19ec836 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -134,6 +134,7 @@ import com.vitorpamplona.quartz.experimental.profileGallery.mimeType import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -2195,6 +2196,24 @@ class Account( } } + suspend fun removeDeletedBookmarks( + deletedEventIds: Set, + deletedAddresses: Set
, + ) { + if (!isWriteable()) return + val event = bookmarkState.removeDeletedBookmarks(deletedEventIds, deletedAddresses) ?: return + sendMyPublicAndPrivateOutbox(event) + } + + suspend fun removeDeletedOldBookmarks( + deletedEventIds: Set, + deletedAddresses: Set
, + ) { + if (!isWriteable()) return + val event = oldBookmarkState.removeDeletedBookmarks(deletedEventIds, deletedAddresses) ?: return + sendMyPublicAndPrivateOutbox(event) + } + /** * Creates a bookmark event without sending it. * Returns the event and target relays for tracked broadcasting. @@ -2681,7 +2700,6 @@ class Account( peopleLists.deletedNotes(deletedNotes) followLists.deletedNotes(deletedNotes) labeledBookmarkLists.deletedNotes(deletedNotes) - removeDeletedPins(deletedNotes) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt index f19d846300..4521b3deb0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt @@ -130,19 +130,18 @@ class PinListState( suspend fun removeDeletedPins(deletedNotes: Set): PinListEvent? { val currentList = getPinList() ?: return null - val deletedIds = deletedNotes.mapTo(HashSet()) { it.idHex } - val pinsToRemove = currentList.pinnedEvents().filter { it.eventId in deletedIds } - if (pinsToRemove.isEmpty()) return null + if (deletedNotes.isEmpty()) return null - var working: PinListEvent = currentList - for (pin in pinsToRemove) { - working = - PinListEvent.remove( - earlierVersion = working, - pin = pin, - signer = signer, - ) - } - return working + val deletedIds = deletedNotes.mapTo(HashSet()) { it.idHex } + val newTags = + currentList.tags + .filter { tag -> + val bookmark = EventBookmark.parse(tag) + bookmark == null || bookmark.eventId !in deletedIds + }.toTypedArray() + + if (newTags.size == currentList.tags.size) return null + + return PinListEvent.resign(tags = newTags, signer = signer) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt index 54c03aec62..6c1a5aa944 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt @@ -29,10 +29,13 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.filter +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.update import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.description import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.image @@ -298,4 +301,55 @@ class LabeledBookmarkListsState( ) account.sendMyPublicAndPrivateOutbox(updatedList) } + + suspend fun removeDeletedBookmarksFromList( + bookmarkListIdentifier: String, + deletedEventIds: Set, + deletedAddresses: Set
, + account: Account, + ) { + if (deletedEventIds.isEmpty() && deletedAddresses.isEmpty()) return + + val currentList = getLabeledBookmarkListNote(bookmarkListIdentifier)?.event as? LabeledBookmarkListEvent ?: return + + val newPublicTags = + currentList.tags + .filter { tag -> + when (val bookmark = BookmarkIdTag.parse(tag)) { + is EventBookmark -> bookmark.eventId !in deletedEventIds + is AddressBookmark -> bookmark.address !in deletedAddresses + null -> true + } + }.toTypedArray() + + val oldPrivateTags = currentList.privateTags(account.signer) + + val updatedList = + if (oldPrivateTags == null) { + if (newPublicTags.size == currentList.tags.size) return + LabeledBookmarkListEvent.resign( + content = currentList.content, + tags = newPublicTags, + signer = account.signer, + ) + } else { + val newPrivateTags = + oldPrivateTags + .filter { tag -> + when (val bookmark = BookmarkIdTag.parse(tag)) { + is EventBookmark -> bookmark.eventId !in deletedEventIds + is AddressBookmark -> bookmark.address !in deletedAddresses + null -> true + } + }.toTypedArray() + if (newPublicTags.size == currentList.tags.size && newPrivateTags.size == oldPrivateTags.size) return + LabeledBookmarkListEvent.resign( + tags = newPublicTags, + privateTags = newPrivateTags, + signer = account.signer, + ) + } + + account.sendMyPublicAndPrivateOutbox(updatedList) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/DeletedItemsBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/DeletedItemsBanner.kt new file mode 100644 index 0000000000..13c3e23dc1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/DeletedItemsBanner.kt @@ -0,0 +1,76 @@ +/* + * 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.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BigPadding +import com.vitorpamplona.amethyst.ui.theme.StdPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.imageModifier + +@Composable +fun DeletedItemsBanner( + count: Int, + onRemove: () -> Unit, + onDismiss: () -> Unit, +) { + if (count <= 0) return + + Column(modifier = StdPadding) { + Card( + modifier = MaterialTheme.colorScheme.imageModifier, + ) { + Column(modifier = BigPadding) { + Text( + text = stringRes(R.string.deleted_items_banner_title, count), + style = MaterialTheme.typography.bodyMedium, + ) + + Spacer(modifier = StdVertSpacer) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { + Text(text = stringRes(R.string.deleted_items_banner_dismiss)) + } + Button(onClick = onRemove) { + Text(text = stringRes(R.string.deleted_items_banner_remove)) + } + } + } + } + } +} 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 56d1640939..d068d43b88 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 @@ -919,6 +919,10 @@ class AccountViewModel( } } + fun removeDeletedPins(deletedNotes: Set) { + launchSigner { account.removeDeletedPins(deletedNotes) } + } + fun addPrivateBookmark(note: Note) { if (settings.isCompleteUIMode()) { launchSigner { @@ -988,6 +992,20 @@ class AccountViewModel( } } + fun removeDeletedBookmarks( + deletedEventIds: Set, + deletedAddresses: Set
, + ) { + launchSigner { account.removeDeletedBookmarks(deletedEventIds, deletedAddresses) } + } + + fun removeDeletedOldBookmarks( + deletedEventIds: Set, + deletedAddresses: Set
, + ) { + launchSigner { account.removeDeletedOldBookmarks(deletedEventIds, deletedAddresses) } + } + fun broadcast(note: Note) = launchSigner { account.broadcast(note) } fun timestamp(note: Note) = launchSigner { account.otsState.timestamp(note) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt index 7ee7752c32..f32fd36849 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt @@ -34,14 +34,18 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.amethyst.ui.components.DeletedItemsBanner import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton @@ -81,7 +85,7 @@ fun BookmarkListScreen( // Preload all bookmarked events so they don't load one-by-one when scrolling PreloadBookmarkEvents(bookmarkState, accountViewModel) - RenderBookmarkScreen(publicFeedViewModel, privateFeedViewModel, accountViewModel, nav) + RenderBookmarkScreen(publicFeedViewModel, privateFeedViewModel, bookmarkState, accountViewModel, nav) } @Composable @@ -89,12 +93,36 @@ fun BookmarkListScreen( private fun RenderBookmarkScreen( publicFeedViewModel: BookmarkPublicFeedViewModel, privateFeedViewModel: BookmarkPrivateFeedViewModel, + bookmarkState: com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState.BookmarkList?, accountViewModel: AccountViewModel, nav: INav, ) { val pagerState = rememberPagerState { 2 } val coroutineScope = rememberCoroutineScope() + val cache = accountViewModel.account.cache + val deletedEventIds = remember(bookmarkState) { mutableSetOf() } + val deletedAddresses = remember(bookmarkState) { mutableSetOf() } + val deletedCount = + remember(bookmarkState) { + deletedEventIds.clear() + deletedAddresses.clear() + val all = bookmarkState?.public.orEmpty() + bookmarkState?.private.orEmpty() + all.forEach { note -> + val event = note.event + if (event != null && cache.hasBeenDeleted(event)) { + deletedEventIds.add(note.idHex) + if (note is AddressableNote) deletedAddresses.add(note.address) + } + } + deletedEventIds.size + deletedAddresses.size + } + + var bannerDismissed by remember { mutableStateOf(false) } + LaunchedEffect(deletedCount) { + if (deletedCount == 0) bannerDismissed = false + } + DisappearingScaffold( isInvertedLayout = false, topBar = { @@ -122,6 +150,19 @@ private fun RenderBookmarkScreen( accountViewModel = accountViewModel, ) { Column(Modifier.padding(it).fillMaxHeight()) { + if (!bannerDismissed) { + DeletedItemsBanner( + count = deletedCount, + onRemove = { + accountViewModel.removeDeletedBookmarks( + deletedEventIds.toSet(), + deletedAddresses.toSet(), + ) + bannerDismissed = true + }, + onDismiss = { bannerDismissed = true }, + ) + } HorizontalPager(state = pagerState) { page -> when (page) { 0 -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt index 18795d5e87..6ef2e1fd2e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt @@ -44,10 +44,12 @@ import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow @@ -56,7 +58,9 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.ui.components.ClickableBox +import com.vitorpamplona.amethyst.ui.components.DeletedItemsBanner import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection @@ -70,6 +74,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.TabRowHeight +import com.vitorpamplona.quartz.nip01Core.core.Address import kotlinx.coroutines.launch @Composable @@ -158,6 +163,11 @@ fun BookmarkGroupScreenView( ).consumeWindowInsets(padding) .imePadding(), ) { + DeletedBookmarksBanner( + bookmarkGroupViewModel = bookmarkGroupViewModel, + bookmarkType = bookmarkType, + accountViewModel = accountViewModel, + ) when (bookmarkType) { BookmarkType.PostBookmark -> { RenderPostList( @@ -217,6 +227,67 @@ fun BookmarkGroupScreenView( } } +@Composable +private fun DeletedBookmarksBanner( + bookmarkGroupViewModel: BookmarkGroupViewModel, + bookmarkType: BookmarkType, + accountViewModel: AccountViewModel, +) { + val postsFlow = remember(bookmarkGroupViewModel) { bookmarkGroupViewModel.publicPosts() } + val privatePostsFlow = remember(bookmarkGroupViewModel) { bookmarkGroupViewModel.privatePosts() } + val articlesFlow = remember(bookmarkGroupViewModel) { bookmarkGroupViewModel.publicArticles() } + val privateArticlesFlow = remember(bookmarkGroupViewModel) { bookmarkGroupViewModel.privateArticles() } + + val publicPosts by postsFlow.collectAsStateWithLifecycle() + val privatePosts by privatePostsFlow.collectAsStateWithLifecycle() + val publicArticles by articlesFlow.collectAsStateWithLifecycle() + val privateArticles by privateArticlesFlow.collectAsStateWithLifecycle() + + val cache = accountViewModel.account.cache + + val deletedEventIds = remember(publicPosts, privatePosts, publicArticles, privateArticles, bookmarkType) { mutableSetOf() } + val deletedAddresses = remember(publicPosts, privatePosts, publicArticles, privateArticles, bookmarkType) { mutableSetOf
() } + val deletedCount = + remember(publicPosts, privatePosts, publicArticles, privateArticles, bookmarkType) { + deletedEventIds.clear() + deletedAddresses.clear() + val scope = + when (bookmarkType) { + BookmarkType.PostBookmark -> publicPosts + privatePosts + BookmarkType.ArticleBookmark -> publicArticles + privateArticles + } + scope.forEach { note -> + val event = note.event + if (event != null && cache.hasBeenDeleted(event)) { + deletedEventIds.add(note.idHex) + if (note is AddressableNote) deletedAddresses.add(note.address) + } + } + deletedEventIds.size + deletedAddresses.size + } + + var bannerDismissed by remember(bookmarkType) { mutableStateOf(false) } + LaunchedEffect(deletedCount) { + if (deletedCount == 0) bannerDismissed = false + } + + if (!bannerDismissed) { + DeletedItemsBanner( + count = deletedCount, + onRemove = { + accountViewModel.launchSigner { + bookmarkGroupViewModel.removeDeletedBookmarksFromGroup( + deletedEventIds = deletedEventIds.toSet(), + deletedAddresses = deletedAddresses.toSet(), + ) + } + bannerDismissed = true + }, + onDismiss = { bannerDismissed = true }, + ) + } +} + @Composable private fun TitleAndDescription(viewModel: BookmarkGroupViewModel) { val selectedSetState = viewModel.selectedBookmarkGroupFlow.collectAsStateWithLifecycle() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt index d58040c705..9cba1b5ad4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt @@ -155,6 +155,19 @@ class BookmarkGroupViewModel( ) } + suspend fun removeDeletedBookmarksFromGroup( + groupIdentifier: String = bookmarkGroupIdentifier, + deletedEventIds: Set, + deletedAddresses: Set
, + ) { + account.labeledBookmarkLists.removeDeletedBookmarksFromList( + groupIdentifier, + deletedEventIds, + deletedAddresses, + account, + ) + } + @Suppress("UNCHECKED_CAST") class Initializer( val account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/OldBookmarkListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/OldBookmarkListScreen.kt index 3e0b8176a3..e4d281a92f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/OldBookmarkListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/OldBookmarkListScreen.kt @@ -40,15 +40,19 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.amethyst.ui.components.DeletedItemsBanner import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton @@ -88,7 +92,7 @@ fun OldBookmarkListScreen( // Preload all bookmarked events so they don't load one-by-one when scrolling PreloadOldBookmarkEvents(bookmarkState, accountViewModel) - RenderOldBookmarkScreen(publicFeedViewModel, privateFeedViewModel, accountViewModel, nav) + RenderOldBookmarkScreen(publicFeedViewModel, privateFeedViewModel, bookmarkState, accountViewModel, nav) } @SuppressLint("LocalContextGetResourceValueCall") @@ -97,6 +101,7 @@ fun OldBookmarkListScreen( private fun RenderOldBookmarkScreen( publicFeedViewModel: OldBookmarkPublicFeedViewModel, privateFeedViewModel: OldBookmarkPrivateFeedViewModel, + bookmarkState: com.vitorpamplona.amethyst.commons.model.nip51Lists.OldBookmarkListState.BookmarkList?, accountViewModel: AccountViewModel, nav: INav, ) { @@ -104,6 +109,29 @@ private fun RenderOldBookmarkScreen( val coroutineScope = rememberCoroutineScope() val context = LocalContext.current + val cache = accountViewModel.account.cache + val deletedEventIds = remember(bookmarkState) { mutableSetOf() } + val deletedAddresses = remember(bookmarkState) { mutableSetOf() } + val deletedCount = + remember(bookmarkState) { + deletedEventIds.clear() + deletedAddresses.clear() + val all = bookmarkState?.public.orEmpty() + bookmarkState?.private.orEmpty() + all.forEach { note -> + val event = note.event + if (event != null && cache.hasBeenDeleted(event)) { + deletedEventIds.add(note.idHex) + if (note is AddressableNote) deletedAddresses.add(note.address) + } + } + deletedEventIds.size + deletedAddresses.size + } + + var bannerDismissed by remember { mutableStateOf(false) } + LaunchedEffect(deletedCount) { + if (deletedCount == 0) bannerDismissed = false + } + DisappearingScaffold( isInvertedLayout = false, topBar = { @@ -156,6 +184,19 @@ private fun RenderOldBookmarkScreen( accountViewModel = accountViewModel, ) { Column(Modifier.padding(it).fillMaxHeight()) { + if (!bannerDismissed) { + DeletedItemsBanner( + count = deletedCount, + onRemove = { + accountViewModel.removeDeletedOldBookmarks( + deletedEventIds.toSet(), + deletedAddresses.toSet(), + ) + bannerDismissed = true + }, + onDismiss = { bannerDismissed = true }, + ) + } HorizontalPager(state = pagerState) { page -> when (page) { 0 -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pinnednotes/PinnedNotesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pinnednotes/PinnedNotesScreen.kt index 97b1880d1d..31c89cb5c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pinnednotes/PinnedNotesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pinnednotes/PinnedNotesScreen.kt @@ -27,13 +27,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.amethyst.ui.components.DeletedItemsBanner import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton @@ -63,15 +66,29 @@ fun PinnedNotesScreen( // Preload all pinned events so they don't load one-by-one when scrolling PreloadPinnedEvents(pinState, accountViewModel) - RenderPinnedNotesScreen(pinnedNotesFeedViewModel, accountViewModel, nav) + RenderPinnedNotesScreen(pinnedNotesFeedViewModel, pinState, accountViewModel, nav) } @Composable private fun RenderPinnedNotesScreen( pinnedNotesFeedViewModel: PinnedNotesFeedViewModel, + pinState: List?, accountViewModel: AccountViewModel, nav: INav, ) { + var bannerDismissed by remember { mutableStateOf(false) } + val deletedPins = + remember(pinState) { + pinState + ?.filter { note -> + note.event?.let(accountViewModel.account.cache::hasBeenDeleted) == true + }.orEmpty() + } + + LaunchedEffect(deletedPins) { + if (deletedPins.isEmpty()) bannerDismissed = false + } + DisappearingScaffold( isInvertedLayout = false, topBar = { @@ -80,6 +97,16 @@ private fun RenderPinnedNotesScreen( accountViewModel = accountViewModel, ) { Column(Modifier.padding(it).fillMaxHeight()) { + if (!bannerDismissed) { + DeletedItemsBanner( + count = deletedPins.size, + onRemove = { + accountViewModel.removeDeletedPins(deletedPins.toSet()) + bannerDismissed = true + }, + onDismiss = { bannerDismissed = true }, + ) + } RefresheableFeedView( pinnedNotesFeedViewModel, null, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e2ffc91efc..fcf878f132 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -435,6 +435,10 @@ Pin to Profile Unpin from Profile + %1$d item(s) in this list have been deleted by their author. + Remove from list + Dismiss + Bookmark Lists Icon for bookmark list New Bookmark List diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt index c3fdee824a..8b56b6e2ba 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark @@ -298,4 +300,46 @@ class BookmarkListState( null } } + + suspend fun removeDeletedBookmarks( + deletedEventIds: Set, + deletedAddresses: Set
, + ): BookmarkListEvent? { + val currentList = getBookmarkList() ?: return null + if (deletedEventIds.isEmpty() && deletedAddresses.isEmpty()) return null + + val newPublicTags = filterOutDeletedBookmarks(currentList.tags, deletedEventIds, deletedAddresses) + val oldPrivateTags = currentList.privateTags(signer) + + return if (oldPrivateTags == null) { + if (newPublicTags.size == currentList.tags.size) return null + BookmarkListEvent.resign( + content = currentList.content, + tags = newPublicTags, + signer = signer, + ) + } else { + val newPrivateTags = filterOutDeletedBookmarks(oldPrivateTags, deletedEventIds, deletedAddresses) + if (newPublicTags.size == currentList.tags.size && newPrivateTags.size == oldPrivateTags.size) return null + BookmarkListEvent.resign( + tags = newPublicTags, + privateTags = newPrivateTags, + signer = signer, + ) + } + } } + +internal fun filterOutDeletedBookmarks( + tags: TagArray, + deletedEventIds: Set, + deletedAddresses: Set
, +): TagArray = + tags + .filter { tag -> + when (val bookmark = BookmarkIdTag.parse(tag)) { + is EventBookmark -> bookmark.eventId !in deletedEventIds + is AddressBookmark -> bookmark.address !in deletedAddresses + null -> true + } + }.toTypedArray() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt index e9fe062b86..67f499deba 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark @@ -210,4 +211,32 @@ class OldBookmarkListState( } else { publicBookmarkEventIdSet.value.contains(note.idHex) } + + suspend fun removeDeletedBookmarks( + deletedEventIds: Set, + deletedAddresses: Set
, + ): OldBookmarkListEvent? { + val currentList = getBookmarkList() ?: return null + if (deletedEventIds.isEmpty() && deletedAddresses.isEmpty()) return null + + val newPublicTags = filterOutDeletedBookmarks(currentList.tags, deletedEventIds, deletedAddresses) + val oldPrivateTags = currentList.privateTags(signer) + + return if (oldPrivateTags == null) { + if (newPublicTags.size == currentList.tags.size) return null + OldBookmarkListEvent.resign( + content = currentList.content, + tags = newPublicTags, + signer = signer, + ) + } else { + val newPrivateTags = filterOutDeletedBookmarks(oldPrivateTags, deletedEventIds, deletedAddresses) + if (newPublicTags.size == currentList.tags.size && newPrivateTags.size == oldPrivateTags.size) return null + OldBookmarkListEvent.resign( + tags = newPublicTags, + privateTags = newPrivateTags, + signer = signer, + ) + } + } }