From 5aa2f519e7cacb5d641303f63bfa9b38832b3bda Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 1 Jun 2026 11:34:18 +0300 Subject: [PATCH] feat(desktop): visual overhaul of thread detail view matching Layers design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../amethyst/desktop/ui/ThreadScreen.kt | 166 +++++++++-------- .../amethyst/desktop/ui/thread/CommentItem.kt | 171 +++++++++++++++++ .../desktop/ui/thread/CommentsCard.kt | 103 ++++++++++ .../desktop/ui/thread/InlineReplyInput.kt | 29 +-- .../desktop/ui/thread/RelatedContentRow.kt | 176 +++++++++++++----- 5 files changed, 513 insertions(+), 132 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentItem.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentsCard.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index 1d920ded56..bdaa9da6c9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -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)) + } + } + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentItem.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentItem.kt new file mode 100644 index 0000000000..16d8b6de08 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentItem.kt @@ -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() + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentsCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentsCard.kt new file mode 100644 index 0000000000..417887335b --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentsCard.kt @@ -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() + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/InlineReplyInput.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/InlineReplyInput.kt index 3f4ef3fe76..17cf1dc3f3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/InlineReplyInput.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/InlineReplyInput.kt @@ -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) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt index e0369ea975..7f21475beb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt @@ -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, + ) } } }