From b9fa242e6862e4e69c9f40d99c324a136defb0f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 22:22:08 +0000 Subject: [PATCH 1/2] 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 Claude-Session: https://claude.ai/code/session_013gs6pxiq58X18Fkz9wdZhU --- .../amethyst/model/GitStatusIndex.kt | 18 +++ .../loggedIn/gitRepo/GitRepositoryScreen.kt | 112 +++++++++++++++--- .../gitRepo/dal/RepositoryIssuesFeedFilter.kt | 7 +- .../dal/RepositoryIssuesFeedViewModel.kt | 20 +++- .../dal/RepositoryPatchesFeedFilter.kt | 17 ++- .../dal/RepositoryPatchesFeedViewModel.kt | 20 +++- amethyst/src/main/res/values/strings.xml | 2 + 7 files changed, 166 insertions(+), 30 deletions(-) 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 From a8db6674c40c3cba4fc88d563d14d181c02fc6f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 22:55:46 +0000 Subject: [PATCH 2/2] feat: compact Issue/PR list rows on the git repository screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the repository screen's Issues and Patches & PRs tabs with a lightweight one-line-per-item row (author picture, name, NIP-05, subject, time, status pill and the shared 3-dot options) instead of the full NoteCompose renderer, which is tuned for items shown inside a regular feed. Injected via RefresheableFeedView's existing onLoaded slot, so the feed filters and status-split view models are untouched. The rows reuse and re-layer the same gating NoteCompose applies — event loading (WatchNoteEvent), mute/block/report hiding (CheckHiddenFeedWatchBlockAndReport) and the long-press quick-action menu — so blocked authors and reported items are hidden here exactly as elsewhere. No body or media is rendered, so sensitive content never reaches this list. Adds GitPatchEvent.subject(), which parses the patch title from the git-format-patch Subject header (stripping the [PATCH n/m] prefix and unfolding continuation lines), with unit tests. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013gs6pxiq58X18Fkz9wdZhU --- .../screen/loggedIn/gitRepo/GitItemListRow.kt | 210 ++++++++++++++++++ .../loggedIn/gitRepo/GitRepositoryScreen.kt | 3 + amethyst/src/main/res/values/strings.xml | 1 + .../quartz/nip34Git/patch/GitPatchEvent.kt | 30 +++ .../patch/GitPatchEventSubjectTest.kt | 109 +++++++++ 5 files changed, 353 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/GitPatchEventSubjectTest.kt 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 d82ae6744b..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 @@ -285,6 +285,9 @@ private fun StatusSplitFeed( routeForLastRead = null, accountViewModel = accountViewModel, nav = nav, + onLoaded = { loaded, listState -> + GitItemFeedLoaded(loaded, listState, accountViewModel, nav) + }, ) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e0aa9a7bac..55d233659b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2549,6 +2549,7 @@ 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()) + } +}