feat(desktop): visual overhaul of thread detail view matching Layers design

- Create CommentsCard: OutlinedCard with "Comments N" header + badge,
  "Most recent" label, reply input slot, comment items slot
- Create CommentItem: lightweight comment row with avatar, name, handle,
  time, content, Reply/Like/Zap actions (replaces heavy FeedNoteCard for replies)
- Restyle InlineReplyInput: cyan "Send" pill button instead of plain icon
- Revise RelatedContentRow: image-overlay cards (200x140dp) with AsyncImage
  background, dark gradient overlay, white title + author + zaps
- Restructure ThreadScreen: root note card → CommentsCard → Related section

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-06-01 11:34:18 +03:00
co-authored by Claude Opus 4.6
parent 9194dac8f9
commit 5aa2f519e7
5 changed files with 513 additions and 132 deletions
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -34,7 +33,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -57,7 +55,7 @@ import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.feeds.DesktopThreadFilter
@@ -70,6 +68,8 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubsc
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
import com.vitorpamplona.amethyst.desktop.ui.thread.CommentItem
import com.vitorpamplona.amethyst.desktop.ui.thread.CommentsCard
import com.vitorpamplona.amethyst.desktop.ui.thread.InlineReplyInput
import com.vitorpamplona.amethyst.desktop.ui.thread.RelatedContentSection
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
@@ -321,85 +321,95 @@ fun ThreadScreen(
}
}
// Inline reply input
if (account != null && rootNote != null) {
item(key = "inline-reply") {
val myPubKey = account.pubKeyHex
val myUser = remember(myPubKey) { localCache.getUserIfExists(myPubKey) }
val myAvatarUrl = remember(myUser) { myUser?.profilePicture() }
// Comments card (replies + inline reply input)
item(key = "comments-card") {
Spacer(Modifier.height(12.dp))
CommentsCard(
commentCount = replyNotes.size,
replyContent = {
if (account != null && rootNote != null) {
val myPubKey = account.pubKeyHex
val myUser =
remember(myPubKey) { localCache.getUserIfExists(myPubKey) }
val myAvatarUrl = remember(myUser) { myUser?.profilePicture() }
InlineReplyInput(
myAvatarUrl = myAvatarUrl,
onSend = { content ->
withContext(Dispatchers.IO) {
val rootEvent = rootNote.event ?: return@withContext
val template =
TextNoteEvent.build(content) {
val etag = ETag(rootEvent.id)
etag.relay = null
etag.author = rootEvent.pubKey
eTag(etag)
pTag(PTag(rootEvent.pubKey, relayHint = null))
InlineReplyInput(
myAvatarUrl = myAvatarUrl,
onSend = { content ->
withContext(Dispatchers.IO) {
val rootEvent =
rootNote.event ?: return@withContext
val template =
TextNoteEvent.build(content) {
val etag = ETag(rootEvent.id)
etag.relay = null
etag.author = rootEvent.pubKey
eTag(etag)
pTag(
PTag(
rootEvent.pubKey,
relayHint = null,
),
)
}
val signedEvent = account.signer.sign(template)
localCache.consume(signedEvent, relay = null)
relayManager.broadcastToAll(signedEvent)
}
val signedEvent = account.signer.sign(template)
localCache.consume(signedEvent, relay = null)
relayManager.broadcastToAll(signedEvent)
}
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
)
HorizontalDivider(thickness = 1.dp)
}
}
// Reply notes with level indicators
items(replyNotes, key = { it.idHex }) { note ->
val level = calculateLevel(note)
Column(
modifier =
Modifier
.drawReplyLevel(
level = level,
color = MaterialTheme.colorScheme.outlineVariant,
selected = MaterialTheme.colorScheme.outlineVariant,
).clickable {
note.event?.let { onNavigateToThread(it.id) }
},
},
)
}
},
) {
FeedNoteCard(
note = note,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onReply = { note.event?.let { onReply(it) } },
onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onImageClick = { urls, index ->
lightboxState = LightboxState(urls, index)
},
onMediaClick = { urls, index, seekPos ->
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.playVideo(urls[index], seekPos)
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.toggleFullscreen()
},
)
}
HorizontalDivider(thickness = 1.dp)
}
if (replyNotes.isEmpty()) {
Text(
"No replies yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 16.dp),
)
} else {
replyNotes.forEachIndexed { index, note ->
val event = note.event
val author =
remember(event?.pubKey) {
event?.pubKey?.let { localCache.getUserIfExists(it) }
}
// Empty/loading state for replies
if (replyNotes.isEmpty()) {
item {
Spacer(Modifier.height(32.dp))
Text(
"No replies yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
// Observe reactions for this reply
val flowSet = remember(note) { note.flow() }
val reactionsState by flowSet.reactions.stateFlow.collectAsState()
val zapsState by flowSet.zaps.stateFlow.collectAsState()
DisposableEffect(note) { onDispose { note.clearFlow() } }
val reactionCount =
remember(reactionsState) { note.countReactions() }
val zapAmount = remember(zapsState) { note.zapsAmount }
CommentItem(
authorName =
author?.toBestDisplayName()
?: event?.pubKey?.take(8)
?: "",
authorHandle =
author?.pubkeyNpub()?.take(16)?.let { "@$it..." }
?: "",
authorAvatarUrl = author?.profilePicture(),
authorPubKeyHex = event?.pubKey ?: "",
content = event?.content ?: "",
timeAgo = (event?.createdAt ?: 0L).toTimeAgo(),
reactionCount = reactionCount,
zapAmount = zapAmount.toLong(),
onAuthorClick = {
event?.pubKey?.let { onNavigateToProfile(it) }
},
)
if (index < replyNotes.lastIndex) {
Spacer(Modifier.height(12.dp))
}
}
}
}
}
@@ -0,0 +1,171 @@
/*
* 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.desktop.ui.thread
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
@Composable
fun CommentItem(
authorName: String,
authorHandle: String,
authorAvatarUrl: String?,
authorPubKeyHex: String,
content: String,
timeAgo: String,
reactionCount: Int,
zapAmount: Long,
isLiked: Boolean = false,
isZapped: Boolean = false,
onReply: () -> Unit = {},
onLike: () -> Unit = {},
onZap: () -> Unit = {},
onAuthorClick: () -> Unit = {},
modifier: Modifier = Modifier,
) {
Row(modifier = modifier) {
UserAvatar(
userHex = authorPubKeyHex,
pictureUrl = authorAvatarUrl,
size = 36.dp,
modifier = Modifier.clickable(onClick = onAuthorClick),
)
Spacer(Modifier.width(8.dp))
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = authorName,
style = MaterialTheme.typography.labelMedium,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
modifier = Modifier.clickable(onClick = onAuthorClick),
)
Text(
text = " @$authorHandle · $timeAgo",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(4.dp))
Text(
text = content,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onReply) {
Icon(
symbol = MaterialSymbols.Chat,
contentDescription = "Reply",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(4.dp))
Text(
text = "Reply",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable(onClick = onLike),
) {
val likeColor =
if (isLiked) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
val likeSymbol =
if (isLiked) {
MaterialSymbols.Favorite
} else {
MaterialSymbols.FavoriteBorder
}
Icon(
symbol = likeSymbol,
contentDescription = "Like",
modifier = Modifier.size(16.dp),
tint = likeColor,
)
if (reactionCount > 0) {
Spacer(Modifier.width(4.dp))
Text(
text = reactionCount.toString(),
style = MaterialTheme.typography.labelSmall,
color = likeColor,
)
}
}
Spacer(Modifier.width(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable(onClick = onZap),
) {
val zapColor =
if (isZapped) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Icon(
symbol = MaterialSymbols.Bolt,
contentDescription = "Zap",
modifier = Modifier.size(16.dp),
tint = zapColor,
)
if (zapAmount > 0) {
Spacer(Modifier.width(4.dp))
Text(
text = formatZapAmount(zapAmount),
style = MaterialTheme.typography.labelSmall,
color = zapColor,
)
}
}
}
}
}
}
private fun formatZapAmount(sats: Long): String =
when {
sats >= 1_000_000 -> "${sats / 1_000_000}M"
sats >= 1_000 -> "${sats / 1_000}k"
else -> sats.toString()
}
@@ -0,0 +1,103 @@
/*
* 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.desktop.ui.thread
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.unit.dp
@Composable
fun CommentsCard(
commentCount: Int,
replyContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
val outlineVariant = MaterialTheme.colorScheme.outlineVariant
val cardBorder = remember(outlineVariant) { BorderStroke(1.dp, outlineVariant) }
val cardColors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface)
OutlinedCard(
modifier = modifier.fillMaxWidth(),
border = cardBorder,
colors = cardColors,
shape = RoundedCornerShape(12.dp),
) {
Column(Modifier.padding(16.dp)) {
// Header
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Comments",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.width(8.dp))
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Text(
text = commentCount.toString(),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
)
}
}
Spacer(Modifier.height(4.dp))
Text(
text = "Most recent",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
// Reply input slot
replyContent()
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
// Comment items
content()
}
}
}
@@ -24,11 +24,14 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -141,28 +144,30 @@ fun InlineReplyInput(
maxLines = 5,
)
Spacer(Modifier.width(8.dp))
IconButton(
Button(
onClick = { doSend() },
enabled = text.isNotBlank() && !isSending,
modifier = Modifier.size(36.dp),
shape = RoundedCornerShape(20.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
modifier = Modifier.height(36.dp),
) {
if (isSending) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
} else {
Icon(
MaterialSymbols.AutoMirrored.Send,
contentDescription = "Send reply",
modifier = Modifier.size(20.dp),
tint =
if (text.isNotBlank()) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text("Send", style = MaterialTheme.typography.labelMedium)
}
}
}
@@ -20,12 +20,15 @@
*/
package com.vitorpamplona.amethyst.desktop.ui.thread
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -42,13 +45,20 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.feeds.related.CompactNoteData
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHashes
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
@@ -105,14 +115,24 @@ fun RelatedContentSection(
.take(limit)
.map { note ->
val event = note.event
val content = event?.content?.take(80) ?: ""
val firstLine = content.lineSequence().firstOrNull()?.take(60) ?: ""
val content = event?.content ?: ""
val firstLine =
content
.take(80)
.lineSequence()
.firstOrNull()
?.take(60) ?: ""
val author = localCache.getUserIfExists(event?.pubKey ?: "")
val imageUrl =
UrlParser()
.parseValidUrls(content)
.withScheme
.firstOrNull { RichTextParser.isImageUrl(it) }
CompactNoteData(
id = note.idHex,
title = firstLine.ifBlank { "Note" },
authorName = author?.toBestDisplayName() ?: event?.pubKey?.take(8) ?: "",
thumbnailUrl = null,
thumbnailUrl = imageUrl,
zapCount = if (note.zapsAmount > java.math.BigDecimal.ZERO) "${note.zapsAmount.toLong()}" else "",
)
}
@@ -123,12 +143,38 @@ fun RelatedContentSection(
if (relatedItems.isNotEmpty()) {
val primaryHashtag = noteHashtags.firstOrNull()
Column(modifier = modifier.fillMaxWidth().padding(vertical = 8.dp)) {
Text(
text = if (primaryHashtag != null) "Related from #$primaryHashtag" else "More from this author",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
if (primaryHashtag != null) {
Row {
Text(
text = "Related from ",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = "#$primaryHashtag",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
}
} else {
Text(
text = "More from this author",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onBackground,
)
}
Text(
text = "View all >",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.clickable { /* TODO: navigate to full related list */ },
)
}
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -151,47 +197,93 @@ private fun CompactRelatedCard(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val shape = MaterialTheme.shapes.medium
Card(
modifier =
modifier
.width(160.dp)
.width(200.dp)
.height(140.dp)
.clickable(onClick = onClick),
shape = shape,
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = item.title,
style = MaterialTheme.typography.bodySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface,
Box(modifier = Modifier.fillMaxSize()) {
// Background: image or gradient placeholder
if (item.thumbnailUrl != null) {
AsyncImage(
model = item.thumbnailUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().clip(shape),
)
} else {
Box(
modifier =
Modifier.fillMaxSize().background(
Brush.verticalGradient(
colors =
listOf(
MaterialTheme.colorScheme.surfaceVariant,
MaterialTheme.colorScheme.surface,
),
),
),
)
}
// Dark gradient overlay at bottom
Box(
modifier =
Modifier
.fillMaxWidth()
.height(72.dp)
.align(Alignment.BottomCenter)
.background(
Brush.verticalGradient(
colors =
listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.6f),
),
),
),
)
Spacer(Modifier.height(4.dp))
Text(
text = item.authorName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (item.zapCount.isNotBlank()) {
// Text over the gradient
Column(
modifier =
Modifier
.align(Alignment.BottomStart)
.padding(10.dp),
) {
Text(
text = item.title,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = Color.White,
)
Spacer(Modifier.height(2.dp))
Row {
Icon(
MaterialSymbols.Bolt,
contentDescription = null,
modifier = Modifier.height(12.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = "${item.zapCount} sats",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
val subtitle =
buildString {
append(item.authorName)
if (item.zapCount.isNotBlank()) {
append(" · ")
append(item.zapCount)
append(" zaps")
}
}
Text(
text = subtitle,
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.8f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}