mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
@@ -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
|
||||
|
||||
+94
-18
@@ -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 & 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?,
|
||||
|
||||
+5
-2
@@ -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()
|
||||
|
||||
+18
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+11
-6
@@ -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()
|
||||
|
||||
+18
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 & PRs</string>
|
||||
<string name="git_repo_filter_open">Open</string>
|
||||
<string name="git_repo_filter_closed">Closed & 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>
|
||||
|
||||
Reference in New Issue
Block a user