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