feat: compact Issue/PR list rows on the git repository screen

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gs6pxiq58X18Fkz9wdZhU
This commit is contained in:
Claude
2026-06-26 22:55:46 +00:00
parent b9fa242e68
commit a8db6674c4
5 changed files with 353 additions and 0 deletions
@@ -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
}
@@ -285,6 +285,9 @@ private fun StatusSplitFeed(
routeForLastRead = null,
accountViewModel = accountViewModel,
nav = nav,
onLoaded = { loaded, listState ->
GitItemFeedLoaded(loaded, listState, accountViewModel, nav)
},
)
}
}
+1
View File
@@ -2549,6 +2549,7 @@
<string name="git_repo_tab_patches">Patches &amp; PRs</string>
<string name="git_repo_filter_open">Open</string>
<string name="git_repo_filter_closed">Closed &amp; 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.
*
@@ -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())
}
}