From a8db6674c40c3cba4fc88d563d14d181c02fc6f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 22:55:46 +0000 Subject: [PATCH] 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()) + } +}