diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt index 90ab75bcb1..ed48469c6a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.amethyst.model +import com.vitorpamplona.amethyst.model.LocalCache.observeEvents import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent @@ -30,45 +32,58 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicBoolean /** * Cross-screen index of the most recent NIP-34 status event (kinds * 1630-1633) per target id, kept up to date from - * [LocalCache.live.newEventBundles]. Status events are not tracked in + * [LocalCache.observeEvents]. Status events are not tracked in * `Note.replies` (see `LocalCache.computeReplyTo`), so the only way to * find them otherwise would be a full cache scan per row. + * + * The kind-indexed [observeEvents] subscription replaces both the full-cache + * `onStart` scan and the per-bundle type filtering the old + * `LocalCache.live.newEventBundles` flow needed: the observable's `init()` seeds + * the matching set from the index and re-emits the whole list on every new + * status event, so we just reduce it to the latest-per-target map each time. */ object GitStatusIndex { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val started = AtomicBoolean(false) + private val statusKinds = + listOf( + GitStatusEvent.KIND_OPEN, + GitStatusEvent.KIND_APPLIED, + GitStatusEvent.KIND_CLOSED, + GitStatusEvent.KIND_DRAFT, + ) + private val mutableLatestByTarget = MutableStateFlow?>(null) val latestByTarget: StateFlow?> = mutableLatestByTarget.asStateFlow() fun startIfNeeded() { if (!started.compareAndSet(false, true)) return scope.launch { - // Subscribe to bundle updates BEFORE the initial scan via onStart, so any - // events that arrive between scan start and collector attach are picked up. - LocalCache.live.newEventBundles - .onStart { - val initial = HashMap() - LocalCache.notes.forEach { _, note -> - val event = note.event as? GitStatusEvent ?: return@forEach - val target = event.rootEventId() ?: return@forEach - val current = initial[target] - if (current == null || event.createdAt > current.createdAt) { - initial[target] = event - } - } - mutableLatestByTarget.value = initial - }.collect { bundle -> processBundle(bundle) } + LocalCache + .observeEvents(Filter(kinds = statusKinds)) + .collect { events -> mutableLatestByTarget.value = latestByTarget(events) } } } + private fun latestByTarget(events: List): Map { + val latest = HashMap() + for (event in events) { + val target = event.rootEventId() ?: continue + val current = latest[target] + if (current == null || event.createdAt > current.createdAt) { + latest[target] = event + } + } + return latest + } + /** * Whether the latest status for [targetId] marks it as closed (kind 1632) * or applied/resolved/merged (kind 1631). Items with no status event, or @@ -84,20 +99,4 @@ object GitStatusIndex { val event = map?.get(targetId) ?: return false return event is GitStatusClosedEvent || event is GitStatusAppliedEvent } - - private fun processBundle(bundle: Set) { - val snapshot = mutableLatestByTarget.value ?: emptyMap() - var modified: HashMap? = null - for (note in bundle) { - val event = note.event as? GitStatusEvent ?: continue - val target = event.rootEventId() ?: continue - val map = modified ?: snapshot - val current = map[target] - if (current == null || event.createdAt > current.createdAt) { - if (modified == null) modified = HashMap(snapshot) - modified[target] = event - } - } - modified?.let { mutableLatestByTarget.value = it } - } } 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 9dd55c401d..b66e6d5b55 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 @@ -55,6 +55,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.BookmarkRepositoriesFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import kotlinx.coroutines.launch @@ -76,18 +77,31 @@ fun BookmarkListScreen( factory = BookmarkPrivateFeedViewModel.Factory(accountViewModel.account), ) + val repositoriesFeedViewModel: BookmarkRepositoriesFeedViewModel = + viewModel( + key = "NostrBookmarkRepositoriesFeedViewModel", + factory = BookmarkRepositoriesFeedViewModel.Factory(accountViewModel.account), + ) + val bookmarkState by accountViewModel.account.bookmarkState.bookmarks .collectAsStateWithLifecycle(null) + val repositoryBookmarks by accountViewModel.account.gitRepositoryListState.publicRepositoryAddressSet + .collectAsStateWithLifecycle() + LaunchedEffect(bookmarkState) { publicFeedViewModel.invalidateData() privateFeedViewModel.invalidateData() } - // Preload all bookmarked events so they don't load one-by-one when scrolling - PreloadBookmarkEvents(bookmarkState, accountViewModel) + LaunchedEffect(repositoryBookmarks) { + repositoriesFeedViewModel.invalidateData() + } - RenderBookmarkScreen(publicFeedViewModel, privateFeedViewModel, bookmarkState, accountViewModel, nav) + // Preload all bookmarked events so they don't load one-by-one when scrolling + PreloadBookmarkEvents(bookmarkState, repositoryBookmarks, accountViewModel) + + RenderBookmarkScreen(publicFeedViewModel, privateFeedViewModel, repositoriesFeedViewModel, bookmarkState, accountViewModel, nav) } @Composable @@ -95,11 +109,12 @@ fun BookmarkListScreen( private fun RenderBookmarkScreen( publicFeedViewModel: BookmarkPublicFeedViewModel, privateFeedViewModel: BookmarkPrivateFeedViewModel, + repositoriesFeedViewModel: BookmarkRepositoriesFeedViewModel, bookmarkState: com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState.BookmarkList?, accountViewModel: AccountViewModel, nav: INav, ) { - val pagerState = rememberPagerState { 2 } + val pagerState = rememberPagerState { 3 } val coroutineScope = rememberCoroutineScope() val cache = accountViewModel.account.cache @@ -146,6 +161,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.repository_bookmarks)) }, + ) } } }, @@ -171,6 +191,15 @@ private fun RenderBookmarkScreen( nav = nav, ) } + + 2 -> { + RefresheableFeedView( + repositoriesFeedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + } } } @@ -198,14 +227,18 @@ private fun RenderBookmarkScreen( @Composable private fun PreloadBookmarkEvents( bookmarkState: com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState.BookmarkList?, + repositoryBookmarks: Set, accountViewModel: AccountViewModel, ) { val eventFinder = accountViewModel.dataSources().eventFinder val account = accountViewModel.account val queries = - remember(bookmarkState) { - val allNotes = bookmarkState?.public.orEmpty() + bookmarkState?.private.orEmpty() + remember(bookmarkState, repositoryBookmarks) { + val allNotes = + bookmarkState?.public.orEmpty() + + bookmarkState?.private.orEmpty() + + repositoryBookmarks.map { account.cache.getOrCreateAddressableNote(it) } allNotes .filter { it.event == null } .map { EventFinderQueryState(it, account) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkRepositoriesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkRepositoriesFeedFilter.kt new file mode 100644 index 0000000000..efc085e715 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkRepositoriesFeedFilter.kt @@ -0,0 +1,45 @@ +/* + * 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 + +/** + * The user's bookmarked (starred) git repositories — the public NIP-51 kind 10018 + * [com.vitorpamplona.amethyst.commons.model.nip51Lists.GitRepositoryListState] addresses + * resolved to their addressable notes, newest first. Mirrors [BookmarkPublicFeedFilter] but + * sources its list from the repository bookmark state instead of the kind-10003 note bookmarks. + */ +class BookmarkRepositoriesFeedFilter( + val account: Account, +) : FeedFilter() { + override fun feedKey(): String = + account.gitRepositoryListState.publicRepositoryAddressSet.value + .hashCode() + .toString() + + override fun feed(): List = + account.gitRepositoryListState.publicRepositoryAddressSet.value + .map { account.cache.getOrCreateAddressableNote(it) } + .sortedByDescending { it.createdAt() ?: 0L } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkRepositoriesFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkRepositoriesFeedViewModel.kt new file mode 100644 index 0000000000..ea793be1d6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkRepositoriesFeedViewModel.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 BookmarkRepositoriesFeedViewModel( + val account: Account, +) : AndroidFeedViewModel(BookmarkRepositoriesFeedFilter(account)) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = BookmarkRepositoriesFeedViewModel(account) as T + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 759e094a7b..2435ef4c32 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1016,6 +1016,7 @@ Private Bookmarks Public Bookmarks + Repositories Add to Private Bookmarks Add to Public Bookmarks Remove from Private Bookmarks