feat(git): bookmarked repos tab + observeEvents for GitStatusIndex

- GitStatusIndex now subscribes to a kind-1630..1633-filtered
  LocalCache.observeEvents instead of LocalCache.live.newEventBundles,
  matching the GitPullRequestUpdateIndex change: the indexed observable
  seeds from the cache index and re-emits the full list on each new status
  event, so the manual onStart full-cache scan and per-bundle type
  filtering are gone and the collector just reduces to latest-per-target.
- Bookmark screen gains a third "Repositories" tab listing the user's
  bookmarked (NIP-51 kind 10018) git repositories. New
  BookmarkRepositoriesFeedFilter resolves the public repository address set
  to addressable notes (newest first); the screen invalidates it on
  publicRepositoryAddressSet changes and preloads any uncached repo
  announcements via the EventFinder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
This commit is contained in:
Claude
2026-06-29 23:20:19 +00:00
parent 0d8936e8d3
commit a2f918c1eb
5 changed files with 156 additions and 39 deletions
@@ -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<Map<HexKey, GitStatusEvent>?>(null)
val latestByTarget: StateFlow<Map<HexKey, GitStatusEvent>?> = 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<HexKey, GitStatusEvent>()
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<GitStatusEvent>(Filter(kinds = statusKinds))
.collect { events -> mutableLatestByTarget.value = latestByTarget(events) }
}
}
private fun latestByTarget(events: List<GitStatusEvent>): Map<HexKey, GitStatusEvent> {
val latest = HashMap<HexKey, GitStatusEvent>()
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<Note>) {
val snapshot = mutableLatestByTarget.value ?: emptyMap()
var modified: HashMap<HexKey, GitStatusEvent>? = 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 }
}
}
@@ -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<com.vitorpamplona.quartz.nip01Core.core.Address>,
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) }
@@ -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<Note>() {
override fun feedKey(): String =
account.gitRepositoryListState.publicRepositoryAddressSet.value
.hashCode()
.toString()
override fun feed(): List<Note> =
account.gitRepositoryListState.publicRepositoryAddressSet.value
.map { account.cache.getOrCreateAddressableNote(it) }
.sortedByDescending { it.createdAt() ?: 0L }
}
@@ -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 <T : ViewModel> create(modelClass: Class<T>): T = BookmarkRepositoriesFeedViewModel(account) as T
}
}
+1
View File
@@ -1016,6 +1016,7 @@
</plurals>
<string name="private_bookmarks">Private Bookmarks</string>
<string name="public_bookmarks">Public Bookmarks</string>
<string name="repository_bookmarks">Repositories</string>
<string name="add_to_private_bookmarks">Add to Private Bookmarks</string>
<string name="add_to_public_bookmarks">Add to Public Bookmarks</string>
<string name="remove_from_private_bookmarks">Remove from Private Bookmarks</string>