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/GitItemListRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt new file mode 100644 index 0000000000..47bf1b2159 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt @@ -0,0 +1,210 @@ +/* + * 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.gitRepo + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport +import com.vitorpamplona.amethyst.ui.note.LongPressToQuickAction +import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay +import com.vitorpamplona.amethyst.ui.note.ObserveDisplayNip05Status +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.WatchAuthor +import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent +import com.vitorpamplona.amethyst.ui.note.clickableNoteModifier +import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo +import com.vitorpamplona.amethyst.ui.note.types.GitStatusPill +import com.vitorpamplona.amethyst.ui.note.types.StatusKind +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.Size40dp +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent + +/** + * Compact feed renderer for the repository screen's Issues and Patches & PRs tabs. + * + * Unlike the generic [com.vitorpamplona.amethyst.ui.note.NoteCompose], which is tuned + * for items appearing inside a regular feed, this shows a simple one-line-per-item list: + * author picture, name, NIP-05, subject, time and the shared 3-dot options. It still + * layers the same gating NoteCompose applies — event loading ([WatchNoteEvent]), + * mute/block/report hiding ([CheckHiddenFeedWatchBlockAndReport]) and the long-press + * quick-action menu ([LongPressToQuickAction]) — so blocked authors and reported items + * are hidden here exactly as they are elsewhere. No note body or media is rendered, so + * sensitive content never reaches this list in the first place. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun GitItemFeedLoaded( + loaded: FeedState.Loaded, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + + LazyColumn( + contentPadding = rememberFeedContentPadding(FeedPadding), + state = listState, + ) { + itemsIndexed( + items.list, + key = { _, item -> item.idHex }, + contentType = { _, item -> item.event?.kind ?: -1 }, + ) { _, item -> + Row(Modifier.fillMaxWidth().animateItem()) { + GitItemRow( + note = item, + isHiddenFeed = items.showHidden, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + HorizontalDivider(thickness = DividerThickness) + } + } +} + +@Composable +private fun GitItemRow( + note: Note, + isHiddenFeed: Boolean, + accountViewModel: AccountViewModel, + nav: INav, +) { + val modifier = Modifier.fillMaxWidth() + + WatchNoteEvent( + baseNote = note, + accountViewModel = accountViewModel, + nav = nav, + modifier = modifier, + ) { + CheckHiddenFeedWatchBlockAndReport( + note = note, + modifier = modifier, + showHiddenWarning = false, + ignoreAllBlocksAndReports = isHiddenFeed, + accountViewModel = accountViewModel, + nav = nav, + ) { _ -> + LongPressToQuickAction(baseNote = note, accountViewModel = accountViewModel, nav = nav) { showPopup -> + GitItemRowContent( + note = note, + modifier = modifier, + showPopup = showPopup, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} + +@Composable +private fun GitItemRowContent( + note: Note, + modifier: Modifier, + showPopup: () -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { + Row( + modifier = + clickableNoteModifier(note, modifier, accountViewModel, showPopup, nav) + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + WatchAuthor(note, accountViewModel) { author -> + UserPicture( + user = author, + size = Size40dp, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + NoteUsernameDisplay(note, Modifier.weight(1f), accountViewModel = accountViewModel) + TimeAgo(note) + MoreOptionsButton(note, accountViewModel = accountViewModel, nav = nav) + } + + ObserveDisplayNip05Status(note, accountViewModel, nav) + + val subject = remember(note.event) { gitSubjectOf(note.event) } + Text( + text = subject ?: stringRes(R.string.git_untitled), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + GitStatusPill(targetIdHex = note.idHex, defaultIfMissing = StatusKind.OPEN) + } + } +} + +private fun gitSubjectOf(event: Event?): String? = + when (event) { + is GitIssueEvent -> event.subject()?.takeIf { it.isNotBlank() } + is GitPullRequestEvent -> event.subject()?.takeIf { it.isNotBlank() } + is GitPatchEvent -> event.subject()?.takeIf { it.isNotBlank() } + else -> 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..87d70f071f 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,57 @@ 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, + onLoaded = { loaded, listState -> + GitItemFeedLoaded(loaded, listState, accountViewModel, 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..55d233659b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2547,6 +2547,9 @@ Overview Issues Patches & PRs + Open + Closed & Resolved + Untitled About Links Maintainers diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/GitPatchEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/GitPatchEvent.kt index fe22921a31..8a48cda7b6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/GitPatchEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/GitPatchEvent.kt @@ -115,11 +115,41 @@ class GitPatchEvent( /** `true` if this event is tagged `["t", "root-revision"]` (root of a revision series). */ fun isRootRevision(): Boolean = tags.any { HashtagTag.isTagged(it, ROOT_REVISION) } + /** + * Human-readable patch title. NIP-34 patches carry the raw `git format-patch` + * output in [content]; the title lives in the RFC-5322 `Subject:` header + * (e.g. `Subject: [PATCH 2/3] Fix the thing`). Returns that subject with any + * `[PATCH …]` bracket prefix removed and folded continuation lines unwrapped, + * or `null` when no `Subject:` header is present. + */ + fun subject(): String? { + val builder = StringBuilder() + var found = false + for (line in content.lineSequence()) { + if (!found) { + if (line.startsWith(SUBJECT_HEADER)) { + builder.append(line.substring(SUBJECT_HEADER.length).trim()) + found = true + } + } else if (line.startsWith(" ") || line.startsWith("\t")) { + // RFC-5322 folded header: continuation lines begin with whitespace. + builder.append(' ').append(line.trim()) + } else { + break + } + } + if (!found) return null + return PATCH_PREFIX.replace(builder.toString().trim(), "").trim().ifBlank { null } + } + companion object { const val KIND = 1617 const val ROOT = "root" const val ROOT_REVISION = "root-revision" + private const val SUBJECT_HEADER = "Subject:" + private val PATCH_PREFIX = Regex("^\\[PATCH[^]]*]\\s*") + /** * Build a NIP-34 kind-1617 patch event with all required tags. * diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/GitPatchEventSubjectTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/GitPatchEventSubjectTest.kt new file mode 100644 index 0000000000..70da1c0779 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/GitPatchEventSubjectTest.kt @@ -0,0 +1,109 @@ +/* + * 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.quartz.nip34Git.patch + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class GitPatchEventSubjectTest { + private fun patchWith(content: String) = + GitPatchEvent( + id = "00", + pubKey = "00", + createdAt = 0, + tags = emptyArray(), + content = content, + sig = "00", + ) + + @Test + fun stripsPatchSeriesPrefix() { + val content = + """ + From 9e8f7a6b Mon Sep 17 00:00:00 2001 + From: Alice + Date: Mon, 1 Jan 2024 00:00:00 +0000 + Subject: [PATCH 2/3] Fix the broken thing + + The body of the patch goes here. + """.trimIndent() + + assertEquals("Fix the broken thing", patchWith(content).subject()) + } + + @Test + fun handlesPlainPatchPrefix() { + val content = + """ + From 9e8f7a6b Mon Sep 17 00:00:00 2001 + Subject: [PATCH] Add a feature + + diff --git a/x b/x + """.trimIndent() + + assertEquals("Add a feature", patchWith(content).subject()) + } + + @Test + fun unfoldsContinuationLines() { + val content = + """ + Subject: [PATCH] A very long subject line that the mail + formatter folded across two physical lines + + body + """.trimIndent() + + assertEquals( + "A very long subject line that the mail formatter folded across two physical lines", + patchWith(content).subject(), + ) + } + + @Test + fun keepsSubjectWithoutBracketPrefix() { + val content = + """ + Subject: Just a plain subject + + body + """.trimIndent() + + assertEquals("Just a plain subject", patchWith(content).subject()) + } + + @Test + fun returnsNullWhenNoSubjectHeader() { + val content = + """ + diff --git a/x b/x + index 000..111 + """.trimIndent() + + assertNull(patchWith(content).subject()) + } + + @Test + fun returnsNullWhenSubjectIsEmpty() { + assertNull(patchWith("Subject: [PATCH] ").subject()) + } +}