feat: modernize Git Repository, Issue, and Patch card design

Replace plain text layout in NoteCompose Git renderers with a unified
card style: icon badges, type chips, a compact repository header pill,
and icon-led link rows for web/clone URLs. Patches now surface the
short commit hash in the header.
This commit is contained in:
Claude
2026-05-15 14:37:19 +00:00
parent 3a7e9a90a9
commit 5e65bccb54
@@ -20,13 +20,18 @@
*/ */
package com.vitorpamplona.amethyst.ui.note.types package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -34,12 +39,18 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
@@ -54,14 +65,16 @@ import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.Size8dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
@@ -70,6 +83,101 @@ import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
private val CardShape = QuoteBorder
private val ChipShape = RoundedCornerShape(8.dp)
private val CardPadding = PaddingValues(Size10dp)
private val HeaderSpacing = Arrangement.spacedBy(Size8dp)
private val LinkRowSpacing = Arrangement.spacedBy(Size8dp)
@Composable
private fun GitCardContainer(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val border = MaterialTheme.colorScheme.subtleBorder
Column(
modifier =
modifier
.fillMaxWidth()
.clip(CardShape)
.border(1.dp, border, CardShape)
.padding(CardPadding),
) {
content()
}
}
@Composable
private fun IconBadge(
symbol: MaterialSymbol,
contentDescription: String?,
background: Color,
tint: Color,
size: Dp = 28.dp,
iconSize: Dp = Size16dp,
) {
Row(
modifier =
Modifier
.size(size)
.clip(CircleShape)
.background(background),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Icon(
symbol = symbol,
contentDescription = contentDescription,
modifier = Modifier.size(iconSize),
tint = tint,
)
}
}
@Composable
private fun TypeChip(
text: String,
background: Color,
contentColor: Color,
) {
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = contentColor,
maxLines = 1,
modifier =
Modifier
.clip(ChipShape)
.background(background)
.padding(horizontal = Size8dp, vertical = 2.dp),
)
}
@Composable
private fun LinkRow(
symbol: MaterialSymbol,
contentDescription: String?,
url: String,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = LinkRowSpacing,
) {
Icon(
symbol = symbol,
contentDescription = contentDescription,
modifier = Modifier.size(Size16dp),
tint = MaterialTheme.colorScheme.grayText,
)
ClickableUrl(
url = url,
urlText = url.removePrefix("https://").removePrefix("http://"),
)
}
}
@Composable @Composable
fun RenderGitPatchEvent( fun RenderGitPatchEvent(
baseNote: Note, baseNote: Note,
@@ -101,26 +209,45 @@ private fun RenderShortRepositoryHeader(
nav: INav, nav: INav,
) { ) {
val noteEvent by observeNoteEvent<GitRepositoryEvent>(baseNote, accountViewModel) val noteEvent by observeNoteEvent<GitRepositoryEvent>(baseNote, accountViewModel)
val title = noteEvent?.name() ?: baseNote.dTag()
val summary = noteEvent?.description()
Column( Row(
modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), modifier =
Modifier
.fillMaxWidth()
.clip(ChipShape)
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.04f))
.padding(horizontal = Size10dp, vertical = Size8dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = HeaderSpacing,
) { ) {
val title = noteEvent?.name() ?: baseNote.dTag() IconBadge(
Text( symbol = MaterialSymbols.Code,
text = stringRes(id = R.string.git_repository, title), contentDescription = null,
style = MaterialTheme.typography.titleLarge, background = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
maxLines = 1, tint = MaterialTheme.colorScheme.primary,
overflow = TextOverflow.Ellipsis, size = 24.dp,
modifier = Modifier.fillMaxWidth(), iconSize = 14.dp,
) )
noteEvent?.description()?.let { Column(modifier = Modifier.weight(1f, fill = true)) {
Spacer(modifier = DoubleVertSpacer)
Text( Text(
text = it, text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
summary?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
} }
} }
} }
@@ -136,72 +263,96 @@ private fun RenderGitPatchEvent(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val repository = remember(noteEvent) { noteEvent.repositoryAddress() } GitCardContainer {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = HeaderSpacing,
) {
TypeChip(
text = stringRes(id = R.string.kind_git_patch),
background = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.15f),
contentColor = MaterialTheme.colorScheme.tertiary,
)
if (repository != null) { val commit = remember(noteEvent) { noteEvent.commit() }
LoadAddressableNote(repository, accountViewModel) { commit?.takeIf { it.isNotBlank() }?.let { hash ->
if (it != null) { Text(
RenderShortRepositoryHeader(it, accountViewModel, nav) text = hash.take(7),
Spacer(modifier = DoubleVertSpacer) style = MaterialTheme.typography.labelMedium.copy(fontSize = Font12SP),
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
)
} }
} }
}
LoadDecryptedContent(note, accountViewModel) { body -> val repository = remember(noteEvent) { noteEvent.repositoryAddress() }
val eventContent by if (repository != null) {
remember(note.event) { Spacer(modifier = StdVertSpacer)
derivedStateOf { LoadAddressableNote(repository, accountViewModel) {
val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } if (it != null) {
RenderShortRepositoryHeader(it, accountViewModel, nav)
if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) {
"### $subject\n$body"
} else {
body
}
} }
} }
}
val isAuthorTheLoggedUser = Spacer(modifier = HalfDoubleVertSpacer)
remember(note.event) { accountViewModel.isLoggedUser(note.author) }
if (makeItShort && isAuthorTheLoggedUser) { LoadDecryptedContent(note, accountViewModel) { body ->
Text( val eventContent by
text = eventContent, remember(note.event) {
color = MaterialTheme.colorScheme.placeholderText, derivedStateOf {
maxLines = 2, val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null }
overflow = TextOverflow.Ellipsis,
)
} else {
val callbackUri = remember(note) { note.toNostrUri() }
SensitivityWarning( if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) {
note = note, "### $subject\n$body"
accountViewModel = accountViewModel, } else {
) { body
val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } }
}
}
TranslatableRichTextViewer( val isAuthorTheLoggedUser =
content = eventContent, remember(note.event) { accountViewModel.isLoggedUser(note.author) }
canPreview = canPreview && !makeItShort,
quotesLeft = quotesLeft, if (makeItShort && isAuthorTheLoggedUser) {
modifier = Modifier.fillMaxWidth(), Text(
tags = tags, text = eventContent,
backgroundColor = backgroundColor, color = MaterialTheme.colorScheme.placeholderText,
id = note.idHex, maxLines = 2,
callbackUri = callbackUri, overflow = TextOverflow.Ellipsis,
accountViewModel = accountViewModel,
nav = nav,
) )
} } else {
val callbackUri = remember(note) { note.toNostrUri() }
if (note.event?.hasHashtags() == true) { SensitivityWarning(
DisplayUncitedHashtags( note = note,
event = noteEvent,
content = eventContent,
callbackUri = callbackUri,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, ) {
) val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
TranslatableRichTextViewer(
content = eventContent,
canPreview = canPreview && !makeItShort,
quotesLeft = quotesLeft,
modifier = Modifier.fillMaxWidth(),
tags = tags,
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
}
if (note.event?.hasHashtags() == true) {
DisplayUncitedHashtags(
event = noteEvent,
content = eventContent,
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
}
} }
} }
} }
@@ -242,67 +393,76 @@ private fun RenderGitIssueEvent(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val repository = remember(noteEvent) { noteEvent.repositoryAddress() } GitCardContainer {
TypeChip(
text = stringRes(id = R.string.kind_git_issue),
background = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
contentColor = MaterialTheme.colorScheme.primary,
)
if (repository != null) { val repository = remember(noteEvent) { noteEvent.repositoryAddress() }
LoadAddressableNote(repository, accountViewModel) { if (repository != null) {
if (it != null) { Spacer(modifier = StdVertSpacer)
RenderShortRepositoryHeader(it, accountViewModel, nav) LoadAddressableNote(repository, accountViewModel) {
Spacer(modifier = DoubleVertSpacer) if (it != null) {
} RenderShortRepositoryHeader(it, accountViewModel, nav)
}
}
LoadDecryptedContent(note, accountViewModel) { body ->
val eventContent by
remember(note.event) {
derivedStateOf {
val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null }
if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) {
"### $subject\n$body"
} else {
body
}
} }
} }
}
val isAuthorTheLoggedUser = Spacer(modifier = HalfDoubleVertSpacer)
remember(note.event) { accountViewModel.isLoggedUser(note.author) }
if (makeItShort && isAuthorTheLoggedUser) { LoadDecryptedContent(note, accountViewModel) { body ->
Text( val eventContent by
text = eventContent, remember(note.event) {
color = MaterialTheme.colorScheme.placeholderText, derivedStateOf {
maxLines = 2, val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null }
overflow = TextOverflow.Ellipsis,
)
} else {
val callbackUri = remember(note) { note.toNostrUri() }
SensitivityWarning( if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) {
note = note, "### $subject\n$body"
accountViewModel = accountViewModel, } else {
) { body
val tags = }
remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } }
}
TranslatableRichTextViewer( val isAuthorTheLoggedUser =
content = eventContent, remember(note.event) { accountViewModel.isLoggedUser(note.author) }
canPreview = canPreview && !makeItShort,
quotesLeft = quotesLeft, if (makeItShort && isAuthorTheLoggedUser) {
modifier = Modifier.fillMaxWidth(), Text(
tags = tags, text = eventContent,
backgroundColor = backgroundColor, color = MaterialTheme.colorScheme.placeholderText,
id = note.idHex, maxLines = 2,
callbackUri = callbackUri, overflow = TextOverflow.Ellipsis,
accountViewModel = accountViewModel,
nav = nav,
) )
} } else {
val callbackUri = remember(note) { note.toNostrUri() }
if (note.event?.hasHashtags() == true) { SensitivityWarning(
DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav) note = note,
accountViewModel = accountViewModel,
) {
val tags =
remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
TranslatableRichTextViewer(
content = eventContent,
canPreview = canPreview && !makeItShort,
quotesLeft = quotesLeft,
modifier = Modifier.fillMaxWidth(),
tags = tags,
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
}
if (note.event?.hasHashtags() == true) {
DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav)
}
} }
} }
} }
@@ -331,62 +491,62 @@ private fun RenderGitRepositoryEvent(
val web = noteEvent.web() val web = noteEvent.web()
val clone = noteEvent.clone() val clone = noteEvent.clone()
Row( GitCardContainer {
modifier = Row(
Modifier verticalAlignment = Alignment.CenterVertically,
.clip(shape = QuoteBorder) horizontalArrangement = HeaderSpacing,
.border( modifier = Modifier.fillMaxWidth(),
1.dp, ) {
MaterialTheme.colorScheme.subtleBorder, IconBadge(
QuoteBorder, symbol = MaterialSymbols.Code,
).padding(Size10dp), contentDescription = null,
) { background = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
Column { tint = MaterialTheme.colorScheme.primary,
Text(
text = stringRes(id = R.string.git_repository, title),
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
) )
summary?.let { Column(modifier = Modifier.weight(1f, fill = true)) {
Text( Text(
text = it, text = stringRes(id = R.string.kind_git_repo),
modifier = Modifier.fillMaxWidth().padding(vertical = Size5dp), style = MaterialTheme.typography.labelSmall,
maxLines = 3, color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
)
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
}
HorizontalDivider(thickness = DividerThickness) summary?.let {
Spacer(modifier = HalfDoubleVertSpacer)
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
}
web?.let { if (web != null || clone != null) {
Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { Spacer(modifier = HalfDoubleVertSpacer)
Text( Column(verticalArrangement = Arrangement.spacedBy(Size5dp)) {
text = stringRes(id = R.string.git_web_address), web?.let {
maxLines = 1, LinkRow(
overflow = TextOverflow.Ellipsis, symbol = MaterialSymbols.Public,
) contentDescription = stringRes(id = R.string.git_web_address),
Spacer(modifier = StdHorzSpacer)
ClickableUrl(
url = it, url = it,
urlText = it.removePrefix("https://").removePrefix("http://"),
) )
} }
} clone?.let {
LinkRow(
clone?.let { symbol = MaterialSymbols.AutoMirrored.OpenInNew,
Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { contentDescription = stringRes(id = R.string.git_clone_address),
Text(
text = stringRes(id = R.string.git_clone_address),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = StdHorzSpacer)
ClickableUrl(
url = it, url = it,
urlText = it.removePrefix("https://").removePrefix("http://"),
) )
} }
} }