mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
Merge pull request #3393 from vitorpamplona/claude/repo-tabs-status-split-p2ducw
Add Open/Closed filter to Git repo Issues and Patches tabs
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
|
||||
|
||||
+210
@@ -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
|
||||
}
|
||||
+97
-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,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?,
|
||||
|
||||
+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,9 @@
|
||||
<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_untitled">Untitled</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>
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
+109
@@ -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 <alice@example.com>
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user