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())
+ }
+}