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 c11146d74..ba4d5d825 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState +import com.vitorpamplona.amethyst.model.nip51Lists.PinListState import com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList.BlockPeopleListState import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState @@ -318,6 +319,7 @@ class Account( val labeledBookmarkLists = LabeledBookmarkListsState(signer, cache, scope) val bookmarkState = BookmarkListState(signer, cache, scope) + val pinState = PinListState(signer, cache, scope) val emoji = EmojiPackState(signer, cache, scope) val appSpecific = AppSpecificState(signer, cache, scope, settings) @@ -1809,6 +1811,43 @@ class Account( cache.justConsumeMyOwnEvent(event) } + suspend fun addPin(note: Note) { + if (!isWriteable() || note.isDraft()) return + + sendMyPublicAndPrivateOutbox(pinState.addPin(note)) + } + + suspend fun removePin(note: Note) { + if (!isWriteable() || note.isDraft()) return + + val event = pinState.removePin(note) + if (event != null) { + sendMyPublicAndPrivateOutbox(event) + } + } + + suspend fun createAddPinEvent(note: Note): Pair>? { + if (!isWriteable() || note.isDraft()) return null + + val event = pinState.addPin(note) + val relays = outboxRelays.flow.value + + return event to relays + } + + suspend fun createRemovePinEvent(note: Note): Pair>? { + if (!isWriteable() || note.isDraft()) return null + + val event = pinState.removePin(note) ?: return null + val relays = outboxRelays.flow.value + + return event to relays + } + + fun consumePinEvent(event: Event) { + cache.justConsumeMyOwnEvent(event) + } + suspend fun createAuthEvent( relay: NormalizedRelayUrl, challenge: String, 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 new file mode 100644 index 000000000..fbe711408 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt @@ -0,0 +1,130 @@ +/* + * 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.model.nip51Lists + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.PinListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +@Stable +class PinListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, +) { + val pinList = cache.getOrCreateAddressableNote(PinListEvent.createPinAddress(signer.pubKey)) + + fun getPinListFlow(): StateFlow = pinList.flow().metadata.stateFlow + + fun getPinList(): PinListEvent? = pinList.event as? PinListEvent + + fun pinnedEvents(note: Note): List { + val noteEvent = note.event as? PinListEvent + return noteEvent?.pinnedEvents() ?: emptyList() + } + + @OptIn(FlowPreview::class) + val pinnedNotes: StateFlow> = + getPinListFlow() + .map { noteState -> + pinnedEvents(noteState.note) + }.onStart { + emit(pinnedEvents(pinList)) + }.debounce(100) + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + val pinnedEventIdSet: StateFlow> = + pinnedNotes + .map { pins -> + pins.map { it.eventId }.toSet() + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + @OptIn(FlowPreview::class) + val pinnedNotesList: StateFlow> = + pinnedNotes + .map { pins -> + pins.mapNotNull { cache.checkGetOrCreateNote(it.eventId) }.reversed() + }.onStart { + emit( + pinnedNotes.value.mapNotNull { cache.checkGetOrCreateNote(it.eventId) }.reversed(), + ) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + fun isPinned(note: Note): Boolean = pinnedEventIdSet.value.contains(note.idHex) + + suspend fun addPin(note: Note): PinListEvent { + val currentList = getPinList() + val pin = EventBookmark(note.idHex, note.relayHintUrl()) + + return if (currentList == null) { + PinListEvent.create( + pin = pin, + signer = signer, + ) + } else { + PinListEvent.add( + earlierVersion = currentList, + pin = pin, + signer = signer, + ) + } + } + + suspend fun removePin(note: Note): PinListEvent? { + val currentList = getPinList() ?: return null + val pin = EventBookmark(note.idHex, note.relayHintUrl()) + + return PinListEvent.remove( + earlierVersion = currentList, + pin = pin, + signer = signer, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt index cc8ebc0b8..ed78455db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent @@ -31,6 +32,7 @@ val ReportsAndBookmarksFromKeyKinds = listOf( ReportEvent.KIND, BookmarkListEvent.KIND, + PinListEvent.KIND, ) fun filterBookmarksAndReportsFromKey( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 34381f2de..d656913e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import kotlinx.collections.immutable.ImmutableList @@ -276,6 +277,30 @@ fun observeUserBookmarkCount( return flow.collectAsStateWithLifecycle(0) } +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserPinnedNotesCount( + user: User, + accountViewModel: AccountViewModel, +): State { + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + val flow = + remember(user) { + accountViewModel + .pinnedNotes(user) + .flow() + .metadata.stateFlow + .sample(200) + .mapLatest { noteState -> + (noteState.note.event as? PinListEvent)?.countPins() ?: 0 + }.distinctUntilChanged() + .flowOn(Dispatchers.IO) + } + + return flow.collectAsStateWithLifecycle(0) +} + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Composable fun observeUserIsFollowing( 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 574b56771..f54253a99 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 @@ -34,6 +34,7 @@ import androidx.compose.material.icons.outlined.LockOpen import androidx.compose.material.icons.outlined.PersonAdd import androidx.compose.material.icons.outlined.PersonRemove import androidx.compose.material.icons.outlined.PlaylistAdd +import androidx.compose.material.icons.outlined.PushPin import androidx.compose.material.icons.outlined.Report import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material.icons.outlined.Share @@ -110,6 +111,7 @@ data class DropDownParams( val isFollowingAuthor: Boolean, val isPrivateBookmarkNote: Boolean, val isPublicBookmarkNote: Boolean, + val isPinnedNote: Boolean, val isLoggedUser: Boolean, val isSensitive: Boolean, val showSensitiveContent: Boolean?, @@ -130,6 +132,7 @@ fun NoteDropDownMenu( isFollowingAuthor = false, isPrivateBookmarkNote = false, isPublicBookmarkNote = false, + isPinnedNote = false, isLoggedUser = false, isSensitive = false, showSensitiveContent = null, @@ -265,6 +268,19 @@ fun NoteDropDownMenu( onDismiss() } } + if (state.isLoggedUser) { + if (state.isPinnedNote) { + M3ActionRow(icon = Icons.Outlined.PushPin, text = stringRes(R.string.unpin_from_profile)) { + accountViewModel.removePin(note) + onDismiss() + } + } else { + M3ActionRow(icon = Icons.Outlined.PushPin, text = stringRes(R.string.pin_to_profile)) { + accountViewModel.addPin(note) + onDismiss() + } + } + } val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post) M3ActionRow(icon = Icons.Outlined.BookmarkAdd, text = stringRes(R.string.manage_bookmark_label, noteBookmarkType)) { if (note.event is LongTextNoteEvent) { @@ -329,12 +345,14 @@ fun observeBookmarksFollowsAndAccount( combine( accountViewModel.account.kind3FollowList.flow, accountViewModel.account.bookmarkState.bookmarks, + accountViewModel.account.pinState.pinnedEventIdSet, accountViewModel.showSensitiveContent(), - ) { follows, bookmarks, showSensitiveContent -> + ) { follows, bookmarks, pinnedIds, showSensitiveContent -> DropDownParams( isFollowingAuthor = note.author?.pubkeyHex in follows.authors, isPrivateBookmarkNote = note in bookmarks.private, isPublicBookmarkNote = note in bookmarks.public, + isPinnedNote = note.idHex in pinnedIds, isLoggedUser = accountViewModel.isLoggedUser(note.author), isSensitive = note.event?.isSensitiveOrNSFW() ?: false, showSensitiveContent = showSensitiveContent, @@ -345,6 +363,7 @@ fun observeBookmarksFollowsAndAccount( isFollowingAuthor = note.author?.pubkeyHex?.let { accountViewModel.account.isFollowing(it) } ?: false, isPrivateBookmarkNote = note in accountViewModel.account.bookmarkState.bookmarks.value.private, isPublicBookmarkNote = note in accountViewModel.account.bookmarkState.bookmarks.value.public, + isPinnedNote = accountViewModel.account.pinState.isPinned(note), isLoggedUser = accountViewModel.isLoggedUser(note.author), isSensitive = note.event?.isSensitiveOrNSFW() ?: false, showSensitiveContent = accountViewModel.showSensitiveContent().value, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt index 92d0b18ec..105a7c898 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt @@ -45,15 +45,14 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.ShowMoreButton -import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.PinIcon import com.vitorpamplona.amethyst.ui.note.getGradient import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size15Modifier +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent @OptIn(ExperimentalLayoutApi::class) @@ -66,7 +65,7 @@ fun RenderPinListEvent( ) { val noteEvent = baseNote.event as? PinListEvent ?: return - val pins by remember { mutableStateOf(noteEvent.pins()) } + val pins by remember { mutableStateOf(noteEvent.pinnedEvents()) } var expanded by remember { mutableStateOf(false) } @@ -78,7 +77,7 @@ fun RenderPinListEvent( } Text( - text = "#${noteEvent.dTag()}", + text = "Pinned Notes", fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -103,15 +102,10 @@ fun RenderPinListEvent( Spacer(modifier = Modifier.width(5.dp)) - TranslatableRichTextViewer( - content = pin, - canPreview = true, - quotesLeft = 1, - tags = EmptyTagList, - backgroundColor = backgroundColor, - id = baseNote.idHex, - accountViewModel = accountViewModel, - nav = nav, + Text( + text = NEvent.create(pin.eventId, pin.author, null, pin.relay), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } 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 a030a5824..f0a821869 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 @@ -129,6 +129,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip56Reports.ReportType @@ -846,6 +847,42 @@ class AccountViewModel( fun bookmarks(user: User): Note = LocalCache.getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user.pubkeyHex)) + fun pinnedNotes(user: User): Note = LocalCache.getOrCreateAddressableNote(PinListEvent.createPinAddress(user.pubkeyHex)) + + fun addPin(note: Note) { + if (settings.isCompleteUIMode()) { + launchSigner { + account.createAddPinEvent(note)?.let { (event, relays) -> + broadcastTracker.trackBroadcast( + event = event, + relays = relays, + client = account.client, + ) + account.consumePinEvent(event) + } + } + } else { + launchSigner { account.addPin(note) } + } + } + + fun removePin(note: Note) { + if (settings.isCompleteUIMode()) { + launchSigner { + account.createRemovePinEvent(note)?.let { (event, relays) -> + broadcastTracker.trackBroadcast( + event = event, + relays = relays, + client = account.client, + ) + account.consumePinEvent(event) + } + } + } else { + launchSigner { account.removePin(note) } + } + } + fun addPrivateBookmark(note: Note) { if (settings.isCompleteUIMode()) { launchSigner { 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 4ccee13ed..9ac4ab662 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 @@ -27,8 +27,8 @@ 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.ScrollableTabRow import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -36,6 +36,7 @@ import androidx.compose.runtime.getValue 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 @@ -46,6 +47,7 @@ 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 @@ -67,15 +69,28 @@ 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() } - RenderBookmarkScreen(publicFeedViewModel, privateFeedViewModel, accountViewModel, nav) + LaunchedEffect(pinState) { + pinnedNotesFeedViewModel.invalidateData() + } + + RenderBookmarkScreen(publicFeedViewModel, privateFeedViewModel, pinnedNotesFeedViewModel, accountViewModel, nav) } @Composable @@ -83,10 +98,11 @@ fun BookmarkListScreen( private fun RenderBookmarkScreen( publicFeedViewModel: BookmarkPublicFeedViewModel, privateFeedViewModel: BookmarkPrivateFeedViewModel, + pinnedNotesFeedViewModel: PinnedNotesFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { - val pagerState = rememberPagerState { 2 } + val pagerState = rememberPagerState { 3 } val coroutineScope = rememberCoroutineScope() DisappearingScaffold( @@ -94,10 +110,11 @@ private fun RenderBookmarkScreen( topBar = { Column { TopBarWithBackButton(stringRes(id = R.string.bookmarks_title), nav::popBack) - TabRow( + ScrollableTabRow( containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, selectedTabIndex = pagerState.currentPage, + edgePadding = 8.dp, modifier = TabRowHeight, ) { Tab( @@ -110,6 +127,11 @@ 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)) }, + ) } } }, @@ -135,6 +157,15 @@ private fun RenderBookmarkScreen( nav = nav, ) } + + 2 -> { + RefresheableFeedView( + pinnedNotesFeedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/PinnedNotesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/PinnedNotesFeedFilter.kt new file mode 100644 index 000000000..b20c5492f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/PinnedNotesFeedFilter.kt @@ -0,0 +1,36 @@ +/* + * 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.bookmarkgroups.default.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.FeedFilter + +class PinnedNotesFeedFilter( + val account: Account, +) : FeedFilter() { + override fun feedKey(): String = + account.pinState.pinnedNotesList.value + .hashCode() + .toString() + + override fun feed(): List = account.pinState.pinnedNotesList.value +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/PinnedNotesFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/PinnedNotesFeedViewModel.kt new file mode 100644 index 000000000..ec3a43466 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/PinnedNotesFeedViewModel.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.bookmarkgroups.default.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 PinnedNotesFeedViewModel( + val account: Account, +) : AndroidFeedViewModel(PinnedNotesFeedFilter(account)) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = PinnedNotesFeedViewModel(account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt index 73febbbf7..2d2660370 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt @@ -125,6 +125,7 @@ fun BookmarkGroupItemOptionsMenu( isFollowingAuthor = false, isPrivateBookmarkNote = false, isPublicBookmarkNote = false, + isPinnedNote = false, isLoggedUser = false, isSensitive = false, showSensitiveContent = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt index 0fd732876..90565778b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt @@ -87,6 +87,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.TabMutualCon import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.dal.UserProfileMutualFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.TabNotesNewThreads import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.dal.UserProfileNewThreadsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.pinnedNotes.PinnedNotesTabHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.pinnedNotes.TabPinnedNotes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.pinnedNotes.dal.UserProfilePinnedNotesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays.RelaysTabHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays.TabRelays import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.ReportsTabHeader @@ -223,6 +226,16 @@ fun PrepareViewModels( ), ) + val pinnedNotesFeedViewModel: UserProfilePinnedNotesFeedViewModel = + viewModel( + key = baseUser.pubkeyHex + "UserProfilePinnedNotesFeedViewModel", + factory = + UserProfilePinnedNotesFeedViewModel.Factory( + baseUser, + accountViewModel.account, + ), + ) + val reportsFeedViewModel: UserProfileReportFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserProfileReportFeedViewModel", @@ -244,6 +257,7 @@ fun PrepareViewModels( externalIdentities, zapFeedViewModel, bookmarksFeedViewModel, + pinnedNotesFeedViewModel, galleryFeedViewModel, reportsFeedViewModel, accountViewModel = accountViewModel, @@ -263,6 +277,7 @@ fun ProfileScreen( externalIdentities: UserExternalIdentitiesViewModel, zapFeedViewModel: UserProfileZapsViewModel, bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel, + pinnedNotesFeedViewModel: UserProfilePinnedNotesFeedViewModel, galleryFeedViewModel: UserProfileGalleryFeedViewModel, reportsFeedViewModel: UserProfileReportFeedViewModel, accountViewModel: AccountViewModel, @@ -273,6 +288,7 @@ fun ProfileScreen( WatchLifecycleAndUpdateModel(mutualViewModel) WatchLifecycleAndUpdateModel(appRecommendations) WatchLifecycleAndUpdateModel(bookmarksFeedViewModel) + WatchLifecycleAndUpdateModel(pinnedNotesFeedViewModel) WatchLifecycleAndUpdateModel(galleryFeedViewModel) UserProfileFilterAssemblerSubscription(baseUser, accountViewModel.dataSources().profile) @@ -291,6 +307,7 @@ fun ProfileScreen( followersFeedViewModel, zapFeedViewModel, bookmarksFeedViewModel, + pinnedNotesFeedViewModel, galleryFeedViewModel, reportsFeedViewModel, accountViewModel, @@ -385,12 +402,13 @@ private fun RenderScreen( followersFeedViewModel: UserProfileFollowersUserFeedViewModel, zapFeedViewModel: UserProfileZapsViewModel, bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel, + pinnedNotesFeedViewModel: UserProfilePinnedNotesFeedViewModel, galleryFeedViewModel: UserProfileGalleryFeedViewModel, reportsFeedViewModel: UserProfileReportFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { - val pagerState = rememberPagerState { 11 } + val pagerState = rememberPagerState { 12 } Column { ProfileHeader(baseUser, appRecommendations, externalIdentities, nav, accountViewModel) @@ -412,6 +430,7 @@ private fun RenderScreen( followersFeedViewModel, zapFeedViewModel, bookmarksFeedViewModel, + pinnedNotesFeedViewModel, galleryFeedViewModel, reportsFeedViewModel, accountViewModel, @@ -431,6 +450,7 @@ private fun RenderScreen( followersFeedViewModel, zapFeedViewModel, bookmarksFeedViewModel, + pinnedNotesFeedViewModel, galleryFeedViewModel, reportsFeedViewModel, accountViewModel, @@ -451,6 +471,7 @@ private fun CreateAndRenderPages( followersFeedViewModel: UserProfileFollowersUserFeedViewModel, zapFeedViewModel: UserProfileZapsViewModel, bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel, + pinnedNotesFeedViewModel: UserProfilePinnedNotesFeedViewModel, galleryFeedViewModel: UserProfileGalleryFeedViewModel, reportsFeedViewModel: UserProfileReportFeedViewModel, accountViewModel: AccountViewModel, @@ -472,9 +493,10 @@ private fun CreateAndRenderPages( 5 -> TabFollowers(followersFeedViewModel, accountViewModel, nav) 6 -> TabReceivedZaps(baseUser, zapFeedViewModel, accountViewModel, nav) 7 -> TabBookmarks(bookmarksFeedViewModel, accountViewModel, nav) - 8 -> TabFollowedTags(baseUser, accountViewModel, nav) - 9 -> TabReports(baseUser, reportsFeedViewModel, accountViewModel, nav) - 10 -> TabRelays(baseUser, accountViewModel, nav) + 8 -> TabPinnedNotes(pinnedNotesFeedViewModel, accountViewModel, nav) + 9 -> TabFollowedTags(baseUser, accountViewModel, nav) + 10 -> TabReports(baseUser, reportsFeedViewModel, accountViewModel, nav) + 11 -> TabRelays(baseUser, accountViewModel, nav) } } @@ -504,6 +526,7 @@ private fun CreateAndRenderTabs( followersFeedViewModel: UserProfileFollowersUserFeedViewModel, zapFeedViewModel: UserProfileZapsViewModel, bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel, + pinnedNotesFeedViewModel: UserProfilePinnedNotesFeedViewModel, galleryFeedViewModel: UserProfileGalleryFeedViewModel, reportsFeedViewModel: UserProfileReportFeedViewModel, accountViewModel: AccountViewModel, @@ -520,6 +543,7 @@ private fun CreateAndRenderTabs( { FollowersTabHeader(baseUser, followersFeedViewModel, accountViewModel) }, { ZapTabHeader(zapFeedViewModel, accountViewModel) }, { BookmarkTabHeader(baseUser, accountViewModel) }, + { PinnedNotesTabHeader(baseUser, accountViewModel) }, { FollowedTagsTabHeader(baseUser, accountViewModel) }, { ReportsTabHeader(baseUser, reportsFeedViewModel, accountViewModel) }, { RelaysTabHeader(baseUser, accountViewModel) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt index b7c317ac8..7783b9de9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent @@ -33,6 +34,7 @@ import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendatio val UserProfileListKinds = listOf( BookmarkListEvent.KIND, + PinListEvent.KIND, PeopleListEvent.KIND, FollowListEvent.KIND, HashtagListEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/PinnedNotesTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/PinnedNotesTabHeader.kt new file mode 100644 index 000000000..bddbc283a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/PinnedNotesTabHeader.kt @@ -0,0 +1,40 @@ +/* + * 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.profile.pinnedNotes + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPinnedNotesCount +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun PinnedNotesTabHeader( + baseUser: User, + accountViewModel: AccountViewModel, +) { + val count by observeUserPinnedNotesCount(baseUser, accountViewModel) + + Text(text = "$count ${stringRes(R.string.pinned_notes)}") +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/TabPinnedNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/TabPinnedNotes.kt new file mode 100644 index 000000000..82b52432d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/TabPinnedNotes.kt @@ -0,0 +1,56 @@ +/* + * 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.profile.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.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.pinnedNotes.dal.UserProfilePinnedNotesFeedViewModel + +@Composable +fun TabPinnedNotes( + feedViewModel: UserProfilePinnedNotesFeedViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + LaunchedEffect(Unit) { feedViewModel.invalidateData() } + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp), + ) { + RefresheableFeedView( + feedViewModel, + null, + enablePullRefresh = false, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/dal/UserProfilePinnedNotesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/dal/UserProfilePinnedNotesFeedFilter.kt new file mode 100644 index 000000000..8f2bddb8c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/dal/UserProfilePinnedNotesFeedFilter.kt @@ -0,0 +1,46 @@ +/* + * 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.profile.pinnedNotes.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.FeedFilter +import com.vitorpamplona.quartz.nip51Lists.PinListEvent + +class UserProfilePinnedNotesFeedFilter( + val user: User, + val account: Account, +) : FeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + user.pubkeyHex + + override fun feed(): List { + val note = LocalCache.getOrCreateAddressableNote(PinListEvent.createPinAddress(user.pubkeyHex)) + val noteEvent = note.event as? PinListEvent ?: return emptyList() + + return noteEvent + .pinnedEvents() + .mapNotNull { + LocalCache.checkGetOrCreateNote(it.eventId) + }.reversed() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/dal/UserProfilePinnedNotesFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/dal/UserProfilePinnedNotesFeedViewModel.kt new file mode 100644 index 000000000..ac14c8e65 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/dal/UserProfilePinnedNotesFeedViewModel.kt @@ -0,0 +1,42 @@ +/* + * 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.profile.pinnedNotes.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel + +@Stable +class UserProfilePinnedNotesFeedViewModel( + val user: User, + val account: Account, +) : AndroidFeedViewModel(UserProfilePinnedNotesFeedFilter(user, account)) { + class Factory( + val user: User, + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = UserProfilePinnedNotesFeedViewModel(user, account) as T + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 7c4e50619..b9c8b2923 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -418,6 +418,10 @@ Remove from Private Bookmarks Remove from Public Bookmarks + Pinned Notes + Pin to Profile + Unpin from Profile + Bookmark Lists Icon for bookmark list New Bookmark List diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PinListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PinListEvent.kt index 03194b47c..13ac85447 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PinListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PinListEvent.kt @@ -21,10 +21,15 @@ package com.vitorpamplona.quartz.nip51Lists import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -35,23 +40,70 @@ class PinListEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun pins() = tags.filter { it.size > 1 && it[0] == "pin" }.map { it[1] } +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider { + override fun eventHints() = tags.mapNotNull(EventBookmark::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(EventBookmark::parseId) + + fun countPins() = tags.count(EventBookmark::isTagged) + + fun pinnedEvents(): List = tags.mapNotNull(EventBookmark::parse) + + fun isPinned(eventId: HexKey): Boolean = tags.any { EventBookmark.isTagged(it, eventId) } companion object { - const val KIND = 33888 - const val ALT = "Pinned Posts" + const val KIND = 10001 + const val ALT = "Pinned Notes" + + fun createPinAddress(pubKey: HexKey) = Address(KIND, pubKey, "") suspend fun create( - pins: List, + pin: EventBookmark, signer: NostrSigner, createdAt: Long = TimeUtils.now(), ): PinListEvent { - val tags = mutableListOf>() - pins.forEach { tags.add(arrayOf("pin", it)) } - tags.add(AltTag.assemble(ALT)) + val tags = arrayOf(pin.toTagArray(), AltTag.assemble(ALT)) + return signer.sign(createdAt, KIND, tags, "") + } - return signer.sign(createdAt, KIND, tags.toTypedArray(), "") + suspend fun add( + earlierVersion: PinListEvent, + pin: EventBookmark, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): PinListEvent = + resign( + tags = earlierVersion.tags.plus(pin.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + + suspend fun remove( + earlierVersion: PinListEvent, + pin: EventBookmark, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): PinListEvent = + resign( + tags = earlierVersion.tags.remove(pin.toTagIdOnly()), + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + tags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): PinListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, "") } } }