mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
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:
co-authored by
Claude Opus 4.6
parent
9194dac8f9
commit
5aa2f519e7
+88
-78
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+171
@@ -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()
|
||||
}
|
||||
+103
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-12
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+134
-42
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user