feat: separate pinned notes into their own screen

Previously the Bookmarks screen bundled private, public, and pinned notes
into a single three-tab view. Pinned notes (NIP-51 kind 10001) are a
distinct concept from bookmarks (NIP-51 kind 10003) and deserve their
own destination.

- Remove the Pinned Notes tab from BookmarkListScreen (now two tabs)
- Add new PinnedNotesScreen and Route.PinnedNotes
- Move PinnedNotesFeedFilter/ViewModel to a new pinnednotes package
- List Pinned Notes as a sibling entry alongside Bookmarks and Old
  Bookmarks in ListOfBookmarkGroupsScreen so tapping Bookmarks in the
  drawer continues to surface all three lists
This commit is contained in:
Claude
2026-04-15 02:47:22 +00:00
parent de94b28fc2
commit d17b869dc2
9 changed files with 190 additions and 42 deletions
@@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadat
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pinnednotes.PinnedNotesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.AddMemberScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.CreateGroupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.EditGroupInfoScreen
@@ -258,6 +259,7 @@ fun BuildNavigation(
composableFromEnd<Route.OtsSettings> { OtsSettingsScreen(nav) }
composableFromEnd<Route.Bookmarks> { BookmarkListScreen(accountViewModel, nav) }
composableFromEnd<Route.OldBookmarks> { OldBookmarkListScreen(accountViewModel, nav) }
composableFromEnd<Route.PinnedNotes> { PinnedNotesScreen(accountViewModel, nav) }
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
@@ -82,6 +82,8 @@ sealed class Route {
@Serializable object OldBookmarks : Route()
@Serializable object PinnedNotes : Route()
@Serializable object BookmarkGroups : Route()
@Serializable object ImportFollowsSelectUser : Route()
@@ -27,7 +27,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SecondaryScrollableTabRow
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -38,7 +38,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
@@ -50,7 +49,6 @@ import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal.BookmarkPrivateFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal.BookmarkPublicFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal.PinnedNotesFeedViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import kotlinx.coroutines.launch
@@ -72,31 +70,18 @@ fun BookmarkListScreen(
factory = BookmarkPrivateFeedViewModel.Factory(accountViewModel.account),
)
val pinnedNotesFeedViewModel: PinnedNotesFeedViewModel =
viewModel(
key = "NostrPinnedNotesFeedViewModel",
factory = PinnedNotesFeedViewModel.Factory(accountViewModel.account),
)
val bookmarkState by accountViewModel.account.bookmarkState.bookmarks
.collectAsStateWithLifecycle(null)
val pinState by accountViewModel.account.pinState.pinnedNotesList
.collectAsStateWithLifecycle(null)
LaunchedEffect(bookmarkState) {
publicFeedViewModel.invalidateData()
privateFeedViewModel.invalidateData()
}
LaunchedEffect(pinState) {
pinnedNotesFeedViewModel.invalidateData()
}
// Preload all bookmarked events so they don't load one-by-one when scrolling
PreloadBookmarkEvents(bookmarkState, accountViewModel)
// Preload all bookmarked and pinned events so they don't load one-by-one when scrolling
PreloadBookmarkEvents(bookmarkState, pinState, accountViewModel)
RenderBookmarkScreen(publicFeedViewModel, privateFeedViewModel, pinnedNotesFeedViewModel, accountViewModel, nav)
RenderBookmarkScreen(publicFeedViewModel, privateFeedViewModel, accountViewModel, nav)
}
@Composable
@@ -104,11 +89,10 @@ fun BookmarkListScreen(
private fun RenderBookmarkScreen(
publicFeedViewModel: BookmarkPublicFeedViewModel,
privateFeedViewModel: BookmarkPrivateFeedViewModel,
pinnedNotesFeedViewModel: PinnedNotesFeedViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val pagerState = rememberPagerState { 3 }
val pagerState = rememberPagerState { 2 }
val coroutineScope = rememberCoroutineScope()
DisappearingScaffold(
@@ -116,11 +100,10 @@ private fun RenderBookmarkScreen(
topBar = {
Column {
TopBarWithBackButton(stringRes(id = R.string.bookmarks_title), nav::popBack)
SecondaryScrollableTabRow(
SecondaryTabRow(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
selectedTabIndex = pagerState.currentPage,
edgePadding = 8.dp,
modifier = TabRowHeight,
) {
Tab(
@@ -133,11 +116,6 @@ private fun RenderBookmarkScreen(
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
text = { Text(text = stringRes(R.string.public_bookmarks)) },
)
Tab(
selected = pagerState.currentPage == 2,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } },
text = { Text(text = stringRes(R.string.pinned_notes)) },
)
}
}
},
@@ -163,15 +141,6 @@ private fun RenderBookmarkScreen(
nav = nav,
)
}
2 -> {
RefresheableFeedView(
pinnedNotesFeedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
@@ -181,15 +150,14 @@ private fun RenderBookmarkScreen(
@Composable
private fun PreloadBookmarkEvents(
bookmarkState: com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState.BookmarkList?,
pinState: List<com.vitorpamplona.amethyst.model.Note>?,
accountViewModel: AccountViewModel,
) {
val eventFinder = accountViewModel.dataSources().eventFinder
val account = accountViewModel.account
val queries =
remember(bookmarkState, pinState) {
val allNotes = (bookmarkState?.public.orEmpty() + bookmarkState?.private.orEmpty() + pinState.orEmpty())
remember(bookmarkState) {
val allNotes = bookmarkState?.public.orEmpty() + bookmarkState?.private.orEmpty()
allNotes
.filter { it.event == null }
.map { EventFinderQueryState(it, account) }
@@ -31,6 +31,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.BookmarkBorder
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
@@ -44,6 +45,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.PinListState
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
import com.vitorpamplona.amethyst.ui.stringRes
@@ -57,9 +59,11 @@ import kotlinx.coroutines.flow.StateFlow
fun ListOfBookmarkGroupsFeedView(
defaultBookmarks: BookmarkListState,
oldBookmarks: OldBookmarkListState,
pinnedNotes: PinListState,
groupListFeedSource: StateFlow<List<LabeledBookmarkList>>,
openDefaultBookmarks: () -> Unit,
openOldBookmarks: () -> Unit,
openPinnedNotes: () -> Unit,
onOpenItem: (String, BookmarkType) -> Unit,
onRenameItem: (targetBookmarkGroup: LabeledBookmarkList) -> Unit,
onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList) -> Unit,
@@ -82,6 +86,11 @@ fun ListOfBookmarkGroupsFeedView(
HorizontalDivider(thickness = DividerThickness)
}
item {
PinnedNotesList(pinnedNotes, openPinnedNotes)
HorizontalDivider(thickness = DividerThickness)
}
itemsIndexed(
bookmarkGroupFeedState,
key = { _: Int, item: LabeledBookmarkList -> item.identifier },
@@ -144,6 +153,50 @@ fun DefaultBookmarkList(
)
}
@Composable
fun PinnedNotesList(
pinnedNotes: PinListState,
openPinnedNotes: () -> Unit,
) {
val pinState by pinnedNotes.pinnedNotesList.collectAsStateWithLifecycle()
ListItem(
modifier = Modifier.clickable(onClick = openPinnedNotes),
headlineContent = {
Text(stringRes(R.string.pinned_notes), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
supportingContent = {
Column(
modifier = Modifier.fillMaxWidth(),
) {
Text(
stringRes(R.string.pinned_notes_explainer),
overflow = TextOverflow.Ellipsis,
maxLines = 2,
)
}
},
leadingContent = {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Outlined.PushPin,
contentDescription = stringRes(R.string.bookmark_list_icon_label),
modifier = Size40Modifier,
)
Spacer(StdVertSpacer)
BookmarkMembershipStatusAndNumberDisplay(
modifier = Modifier.align(Alignment.CenterHorizontally),
postBookmarksSize = pinState.size,
articleBookmarksSize = 0,
)
}
},
)
}
@Composable
fun OldBookmarkList(
oldBookmarks: OldBookmarkListState,
@@ -36,6 +36,7 @@ import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.PinListState
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -53,9 +54,11 @@ fun ListOfBookmarkGroupsScreen(
ListOfBookmarkGroupsFeed(
defaultBookmarks = accountViewModel.account.bookmarkState,
oldBookmarks = accountViewModel.account.oldBookmarkState,
pinnedNotes = accountViewModel.account.pinState,
listSource = accountViewModel.account.labeledBookmarkLists.listFeedFlow,
openDefaultBookmarks = { nav.nav(Route.Bookmarks) },
openOldBookmarks = { nav.nav(Route.OldBookmarks) },
openPinnedNotes = { nav.nav(Route.PinnedNotes) },
addBookmarkGroup = { nav.nav(Route.BookmarkGroupMetadataEdit()) },
openBookmarkGroup = { identifier, bookmarkType ->
nav.nav(Route.BookmarkGroupView(identifier, bookmarkType))
@@ -92,9 +95,11 @@ fun ListOfBookmarkGroupsScreen(
fun ListOfBookmarkGroupsFeed(
defaultBookmarks: BookmarkListState,
oldBookmarks: OldBookmarkListState,
pinnedNotes: PinListState,
listSource: StateFlow<List<LabeledBookmarkList>>,
openDefaultBookmarks: () -> Unit,
openOldBookmarks: () -> Unit,
openPinnedNotes: () -> Unit,
addBookmarkGroup: () -> Unit,
openBookmarkGroup: (identifier: String, bookmarkType: BookmarkType) -> Unit,
renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit,
@@ -121,9 +126,11 @@ fun ListOfBookmarkGroupsFeed(
ListOfBookmarkGroupsFeedView(
defaultBookmarks = defaultBookmarks,
oldBookmarks = oldBookmarks,
pinnedNotes = pinnedNotes,
groupListFeedSource = listSource,
openDefaultBookmarks = openDefaultBookmarks,
openOldBookmarks = openOldBookmarks,
openPinnedNotes = openPinnedNotes,
onOpenItem = openBookmarkGroup,
onRenameItem = renameBookmarkGroup,
onItemDescriptionChange = changeBookmarkGroupDescription,
@@ -0,0 +1,115 @@
/*
* 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.pinnednotes
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pinnednotes.dal.PinnedNotesFeedViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun PinnedNotesScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val pinnedNotesFeedViewModel: PinnedNotesFeedViewModel =
viewModel(
key = "NostrPinnedNotesFeedViewModel",
factory = PinnedNotesFeedViewModel.Factory(accountViewModel.account),
)
val pinState by accountViewModel.account.pinState.pinnedNotesList
.collectAsStateWithLifecycle(null)
LaunchedEffect(pinState) {
pinnedNotesFeedViewModel.invalidateData()
}
// Preload all pinned events so they don't load one-by-one when scrolling
PreloadPinnedEvents(pinState, accountViewModel)
RenderPinnedNotesScreen(pinnedNotesFeedViewModel, accountViewModel, nav)
}
@Composable
private fun RenderPinnedNotesScreen(
pinnedNotesFeedViewModel: PinnedNotesFeedViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
TopBarWithBackButton(stringRes(id = R.string.pinned_notes), nav::popBack)
},
accountViewModel = accountViewModel,
) {
Column(Modifier.padding(it).fillMaxHeight()) {
RefresheableFeedView(
pinnedNotesFeedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Composable
private fun PreloadPinnedEvents(
pinState: List<Note>?,
accountViewModel: AccountViewModel,
) {
val eventFinder = accountViewModel.dataSources().eventFinder
val account = accountViewModel.account
val queries =
remember(pinState) {
pinState
.orEmpty()
.filter { it.event == null }
.map { EventFinderQueryState(it, account) }
}
DisposableEffect(queries) {
eventFinder.subscribe(queries)
onDispose {
eventFinder.unsubscribe(queries)
}
}
}
@@ -18,7 +18,7 @@
* 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.bookmarkgroups.default.dal
package com.vitorpamplona.amethyst.ui.screen.loggedIn.pinnednotes.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
@@ -18,7 +18,7 @@
* 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.bookmarkgroups.default.dal
package com.vitorpamplona.amethyst.ui.screen.loggedIn.pinnednotes.dal
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
+1
View File
@@ -429,6 +429,7 @@
<string name="remove_from_public_bookmarks">Remove from Public Bookmarks</string>
<string name="pinned_notes">Pinned Notes</string>
<string name="pinned_notes_explainer">Your pinned notes</string>
<string name="pin_to_profile">Pin to Profile</string>
<string name="unpin_from_profile">Unpin from Profile</string>