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 bcc7b0fa09..90ab75bcb1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt @@ -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? = latestByTarget.value, + ): Boolean { + 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index ed8aa26439..d82ae6744b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -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(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?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedFilter.kt index 694deb147d..2d09f63cad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedFilter.kt @@ -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() { 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 { 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): List = items.sortedByDefaultFeedOrder() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedViewModel.kt index 24d8239407..7794864652 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedViewModel.kt @@ -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 create(modelClass: Class): T = RepositoryIssuesFeedViewModel(note, account) as T + override fun create(modelClass: Class): T = RepositoryIssuesFeedViewModel(note, account, showClosed) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedFilter.kt index 0ec051e09f..3ac1f57b9f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedFilter.kt @@ -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() { 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 { 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): List = items.sortedByDefaultFeedOrder() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedViewModel.kt index 629d7bb4d5..2231f60009 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedViewModel.kt @@ -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 create(modelClass: Class): T = RepositoryPatchesFeedViewModel(note, account) as T + override fun create(modelClass: Class): T = RepositoryPatchesFeedViewModel(note, account, showClosed) as T } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e465952482..e0aa9a7bac 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2547,6 +2547,8 @@ Overview Issues Patches & PRs + Open + Closed & Resolved About Links Maintainers