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..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. @@ -2293,6 +2312,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 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..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 @@ -127,4 +127,21 @@ class PinListState( signer = signer, ) } + + suspend fun removeDeletedPins(deletedNotes: Set): PinListEvent? { + val currentList = getPinList() ?: return null + if (deletedNotes.isEmpty()) return null + + 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, + ) + } + } }