feat: split git repo Issues and Patches & PRs tabs by status

Within the repository route's Issues and Patches & PRs tabs, add an
Open / Closed & Resolved segmented selector so items are partitioned by
their latest NIP-34 status. Open covers no-status, open (1630) and draft
(1633); Closed & Resolved covers closed (1632) and applied/merged (1631).

The feed filters now take a showClosed flag and consult GitStatusIndex.
Because a status event (kinds 1630-1633) doesn't mutate the issue/patch
note, the additive feed update can't move an item between buckets on its
own, so each view model watches GitStatusIndex.latestByTarget and forces
a full re-partition whenever it changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gs6pxiq58X18Fkz9wdZhU
This commit is contained in:
Claude
2026-06-26 22:22:08 +00:00
parent 2e69f85255
commit b9fa242e68
7 changed files with 166 additions and 30 deletions
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -67,6 +69,22 @@ object GitStatusIndex {
}
}
/**
* Whether the latest status for [targetId] marks it as closed (kind 1632)
* or applied/resolved/merged (kind 1631). Items with no status event, or
* whose latest status is open (1630) or draft (1633), are considered open.
*
* Reads from the synchronous snapshot in [latestByTarget]; pass an explicit
* [map] to avoid re-reading the value across a batch.
*/
fun isClosedOrResolved(
targetId: HexKey,
map: Map<HexKey, GitStatusEvent>? = latestByTarget.value,
): Boolean {
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
@@ -26,17 +26,24 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
@@ -54,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.TitleIconModifier
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal.RepositoryIssuesFeedViewModel
@@ -88,22 +96,36 @@ private fun PrepareGitRepositoryScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val issuesViewModel: RepositoryIssuesFeedViewModel =
val openIssuesViewModel: RepositoryIssuesFeedViewModel =
viewModel(
key = note.idHex + "GitRepoIssues",
factory = RepositoryIssuesFeedViewModel.Factory(note, accountViewModel.account),
key = note.idHex + "GitRepoIssuesOpen",
factory = RepositoryIssuesFeedViewModel.Factory(note, accountViewModel.account, showClosed = false),
)
val patchesViewModel: RepositoryPatchesFeedViewModel =
val closedIssuesViewModel: RepositoryIssuesFeedViewModel =
viewModel(
key = note.idHex + "GitRepoPatches",
factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account),
key = note.idHex + "GitRepoIssuesClosed",
factory = RepositoryIssuesFeedViewModel.Factory(note, accountViewModel.account, showClosed = true),
)
val openPatchesViewModel: RepositoryPatchesFeedViewModel =
viewModel(
key = note.idHex + "GitRepoPatchesOpen",
factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account, showClosed = false),
)
val closedPatchesViewModel: RepositoryPatchesFeedViewModel =
viewModel(
key = note.idHex + "GitRepoPatchesClosed",
factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account, showClosed = true),
)
GitRepositoryScreen(
note = note,
issuesViewModel = issuesViewModel,
patchesViewModel = patchesViewModel,
openIssuesViewModel = openIssuesViewModel,
closedIssuesViewModel = closedIssuesViewModel,
openPatchesViewModel = openPatchesViewModel,
closedPatchesViewModel = closedPatchesViewModel,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -113,13 +135,17 @@ private fun PrepareGitRepositoryScreen(
@Composable
private fun GitRepositoryScreen(
note: AddressableNote,
issuesViewModel: RepositoryIssuesFeedViewModel,
patchesViewModel: RepositoryPatchesFeedViewModel,
openIssuesViewModel: RepositoryIssuesFeedViewModel,
closedIssuesViewModel: RepositoryIssuesFeedViewModel,
openPatchesViewModel: RepositoryPatchesFeedViewModel,
closedPatchesViewModel: RepositoryPatchesFeedViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(issuesViewModel)
WatchLifecycleAndUpdateModel(patchesViewModel)
WatchLifecycleAndUpdateModel(openIssuesViewModel)
WatchLifecycleAndUpdateModel(closedIssuesViewModel)
WatchLifecycleAndUpdateModel(openPatchesViewModel)
WatchLifecycleAndUpdateModel(closedPatchesViewModel)
val event by observeNoteEvent<GitRepositoryEvent>(note, accountViewModel)
@@ -192,18 +218,20 @@ private fun GitRepositoryScreen(
}
1 -> {
RefresheableFeedView(
viewModel = issuesViewModel,
routeForLastRead = null,
StatusSplitFeed(
persistKey = note.idHex + "GitRepoIssuesStatus",
openViewModel = openIssuesViewModel,
closedViewModel = closedIssuesViewModel,
accountViewModel = accountViewModel,
nav = nav,
)
}
2 -> {
RefresheableFeedView(
viewModel = patchesViewModel,
routeForLastRead = null,
StatusSplitFeed(
persistKey = note.idHex + "GitRepoPatchesStatus",
openViewModel = openPatchesViewModel,
closedViewModel = closedPatchesViewModel,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -213,6 +241,54 @@ private fun GitRepositoryScreen(
}
}
/**
* Wraps a feed in an Open / Closed &amp; Resolved segmented selector, swapping between two
* status-scoped feed view models. Each view model already filters by NIP-34 status, so the
* selector only chooses which one is rendered. The selection survives configuration changes
* and tab swipes via [persistKey].
*/
@Composable
private fun StatusSplitFeed(
persistKey: String,
openViewModel: FeedViewModel,
closedViewModel: FeedViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
var showClosed by rememberSaveable(persistKey) { mutableStateOf(false) }
Column(Modifier.fillMaxSize()) {
SingleChoiceSegmentedButtonRow(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 4.dp),
) {
SegmentedButton(
selected = !showClosed,
onClick = { showClosed = false },
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
) {
Text(stringRes(R.string.git_repo_filter_open))
}
SegmentedButton(
selected = showClosed,
onClick = { showClosed = true },
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
) {
Text(stringRes(R.string.git_repo_filter_closed))
}
}
RefresheableFeedView(
viewModel = if (showClosed) closedViewModel else openViewModel,
routeForLastRead = null,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Composable
private fun TopBarTitle(
event: GitRepositoryEvent?,
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.GitStatusIndex
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
@@ -31,10 +32,11 @@ import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
class RepositoryIssuesFeedFilter(
val repositoryNote: AddressableNote,
val account: Account,
val showClosed: Boolean,
) : AdditiveFeedFilter<Note>() {
private val repositoryAddressId = repositoryNote.address.toValue()
override fun feedKey(): String = account.userProfile().pubkeyHex + "-issues-" + repositoryNote.idHex
override fun feedKey(): String = account.userProfile().pubkeyHex + "-issues-" + (if (showClosed) "closed-" else "open-") + repositoryNote.idHex
override fun feed(): List<Note> {
val result =
@@ -48,7 +50,8 @@ class RepositoryIssuesFeedFilter(
private fun matches(note: Note): Boolean {
val event = note.event as? GitIssueEvent ?: return false
return event.repositoryHex() == repositoryAddressId
if (event.repositoryHex() != repositoryAddressId) return false
return GitStatusIndex.isClosedOrResolved(note.idHex) == showClosed
}
override fun sort(items: Set<Note>): List<Note> = items.sortedByDefaultFeedOrder()
@@ -22,19 +22,35 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.GitStatusIndex
import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class RepositoryIssuesFeedViewModel(
val note: AddressableNote,
val account: Account,
) : AndroidFeedViewModel(RepositoryIssuesFeedFilter(note, account)) {
val showClosed: Boolean,
) : AndroidFeedViewModel(RepositoryIssuesFeedFilter(note, account, showClosed)) {
init {
// Status events (kinds 1630-1633) don't mutate the issue note, so the additive
// feed update can't move an item between the Open/Closed buckets on its own.
// Watch the status index and force a full re-partition whenever it changes.
GitStatusIndex.startIfNeeded()
viewModelScope.launch(Dispatchers.IO) {
GitStatusIndex.latestByTarget.collect { invalidateData() }
}
}
class Factory(
val note: AddressableNote,
val account: Account,
val showClosed: Boolean,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = RepositoryIssuesFeedViewModel(note, account) as T
override fun <T : ViewModel> create(modelClass: Class<T>): T = RepositoryIssuesFeedViewModel(note, account, showClosed) as T
}
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.GitStatusIndex
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
@@ -32,10 +33,11 @@ import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
class RepositoryPatchesFeedFilter(
val repositoryNote: AddressableNote,
val account: Account,
val showClosed: Boolean,
) : AdditiveFeedFilter<Note>() {
private val repositoryAddressId = repositoryNote.address.toValue()
override fun feedKey(): String = account.userProfile().pubkeyHex + "-patches-" + repositoryNote.idHex
override fun feedKey(): String = account.userProfile().pubkeyHex + "-patches-" + (if (showClosed) "closed-" else "open-") + repositoryNote.idHex
override fun feed(): List<Note> {
val result =
@@ -49,11 +51,14 @@ class RepositoryPatchesFeedFilter(
private fun matches(note: Note): Boolean {
val event = note.event ?: return false
return when (event) {
is GitPatchEvent -> event.repositoryAddress()?.toValue() == repositoryAddressId
is GitPullRequestEvent -> event.repositoryAddress()?.toValue() == repositoryAddressId
else -> false
}
val belongsToRepo =
when (event) {
is GitPatchEvent -> event.repositoryAddress()?.toValue() == repositoryAddressId
is GitPullRequestEvent -> event.repositoryAddress()?.toValue() == repositoryAddressId
else -> false
}
if (!belongsToRepo) return false
return GitStatusIndex.isClosedOrResolved(note.idHex) == showClosed
}
override fun sort(items: Set<Note>): List<Note> = items.sortedByDefaultFeedOrder()
@@ -22,19 +22,35 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.GitStatusIndex
import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class RepositoryPatchesFeedViewModel(
val note: AddressableNote,
val account: Account,
) : AndroidFeedViewModel(RepositoryPatchesFeedFilter(note, account)) {
val showClosed: Boolean,
) : AndroidFeedViewModel(RepositoryPatchesFeedFilter(note, account, showClosed)) {
init {
// Status events (kinds 1630-1633) don't mutate the patch/PR note, so the additive
// feed update can't move an item between the Open/Closed buckets on its own.
// Watch the status index and force a full re-partition whenever it changes.
GitStatusIndex.startIfNeeded()
viewModelScope.launch(Dispatchers.IO) {
GitStatusIndex.latestByTarget.collect { invalidateData() }
}
}
class Factory(
val note: AddressableNote,
val account: Account,
val showClosed: Boolean,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = RepositoryPatchesFeedViewModel(note, account) as T
override fun <T : ViewModel> create(modelClass: Class<T>): T = RepositoryPatchesFeedViewModel(note, account, showClosed) as T
}
}
+2
View File
@@ -2547,6 +2547,8 @@
<string name="git_repo_tab_overview">Overview</string>
<string name="git_repo_tab_issues">Issues</string>
<string name="git_repo_tab_patches">Patches &amp; PRs</string>
<string name="git_repo_filter_open">Open</string>
<string name="git_repo_filter_closed">Closed &amp; Resolved</string>
<string name="git_repo_section_about">About</string>
<string name="git_repo_section_links">Links</string>
<string name="git_repo_section_maintainers">Maintainers</string>