diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt
index 4ebc0596b..b6c8aa7be 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt
@@ -20,19 +20,33 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Schedule
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.graphics.Color
+import androidx.compose.ui.draw.clip
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 com.vitorpamplona.amethyst.R
@@ -40,17 +54,23 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
+import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.WatchAuthor
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground
+import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
-import com.vitorpamplona.amethyst.ui.theme.Size55dp
-import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
-import com.vitorpamplona.amethyst.ui.theme.authorNotePictureForImageHeader
+import com.vitorpamplona.amethyst.ui.theme.Font12SP
+import com.vitorpamplona.amethyst.ui.theme.Size5dp
+import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
+import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
+private const val WORDS_PER_MINUTE = 225
+private val COVER_ASPECT_RATIO = 16f / 9f
+
@Composable
fun RenderLongFormContent(
note: Note,
@@ -62,6 +82,7 @@ fun RenderLongFormContent(
LongFormHeader(noteEvent, note, accountViewModel)
}
+@OptIn(ExperimentalLayoutApi::class)
@Composable
fun LongFormHeader(
noteEvent: LongTextNoteEvent,
@@ -74,54 +95,168 @@ fun LongFormHeader(
remember(noteEvent) {
noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null }
}
+ val topics = remember(noteEvent) { noteEvent.topics().distinct().take(3) }
+ val readingMinutes = remember(noteEvent) { estimateReadingMinutes(noteEvent.content) }
Column(MaterialTheme.colorScheme.replyModifier) {
- image?.let {
- Box {
- MyAsyncImage(
- imageUrl = it,
- contentDescription = stringRes(R.string.preview_card_image_for, it),
- contentScale = ContentScale.FillWidth,
- mainImageModifier = Modifier.fillMaxWidth(),
- loadedImageModifier = Modifier,
- accountViewModel = accountViewModel,
- onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel) },
- onError = { DefaultImageHeader(note, accountViewModel) },
- )
+ LongFormCoverImage(image, note, accountViewModel)
- WatchAuthor(baseNote = note, accountViewModel) { user ->
- Box(authorNotePictureForImageHeader.align(Alignment.BottomStart)) {
- BaseUserPicture(user, Size55dp, accountViewModel, Modifier)
- }
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 14.dp, vertical = 12.dp),
+ ) {
+ if (topics.isNotEmpty()) {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(Size5dp),
+ verticalArrangement = Arrangement.spacedBy(Size5dp),
+ modifier = Modifier.padding(bottom = 8.dp),
+ ) {
+ topics.forEach { TopicChip(it) }
}
}
- } ?: run {
- DefaultImageHeader(note, accountViewModel, Modifier.fillMaxWidth())
- }
- title?.let {
- Text(
- text = it,
- style = MaterialTheme.typography.bodyLarge,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(start = 10.dp, end = 10.dp, top = 10.dp),
- )
- }
- summary?.let {
- Spacer(modifier = StdVertSpacer)
- Text(
- text = it,
- style = MaterialTheme.typography.bodySmall,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
- color = Color.Gray,
- maxLines = 3,
- overflow = TextOverflow.Ellipsis,
- )
+ title?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+
+ summary?.let {
+ Spacer(Modifier.padding(top = 6.dp))
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.grayText,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+
+ Spacer(Modifier.padding(top = 12.dp))
+ AuthorMetaRow(note, readingMinutes, accountViewModel)
}
}
}
+
+@Composable
+private fun LongFormCoverImage(
+ image: String?,
+ note: Note,
+ accountViewModel: AccountViewModel,
+) {
+ val imageShape = RoundedCornerShape(topStart = 15.dp, topEnd = 15.dp)
+ val imageModifier =
+ Modifier
+ .fillMaxWidth()
+ .aspectRatio(COVER_ASPECT_RATIO)
+ .clip(imageShape)
+
+ Box(imageModifier) {
+ if (image != null) {
+ MyAsyncImage(
+ imageUrl = image,
+ contentDescription = stringRes(R.string.preview_card_image_for, image),
+ contentScale = ContentScale.Crop,
+ mainImageModifier = Modifier.fillMaxWidth(),
+ loadedImageModifier = imageModifier,
+ accountViewModel = accountViewModel,
+ onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel, imageModifier) },
+ onError = { DefaultImageHeader(note, accountViewModel, imageModifier) },
+ )
+ } else {
+ DefaultImageHeader(note, accountViewModel, imageModifier)
+ }
+ }
+}
+
+@Composable
+private fun TopicChip(topic: String) {
+ Text(
+ text = "#$topic",
+ style = MaterialTheme.typography.labelSmall,
+ fontSize = Font12SP,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.primary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .clip(CircleShape)
+ .border(1.dp, MaterialTheme.colorScheme.subtleBorder, CircleShape)
+ .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.08f))
+ .padding(horizontal = 10.dp, vertical = 4.dp),
+ )
+}
+
+@Composable
+private fun AuthorMetaRow(
+ note: Note,
+ readingMinutes: Int,
+ accountViewModel: AccountViewModel,
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ WatchAuthor(baseNote = note, accountViewModel) { user ->
+ BaseUserPicture(user, 25.dp, accountViewModel, Modifier)
+ }
+
+ Spacer(Modifier.padding(start = 8.dp))
+
+ NoteUsernameDisplay(
+ baseNote = note,
+ weight = Modifier.weight(1f, fill = false),
+ textColor = MaterialTheme.colorScheme.onSurface,
+ accountViewModel = accountViewModel,
+ )
+
+ MetaSeparator()
+ TimeAgo(note)
+
+ MetaSeparator()
+ ReadingTimeBadge(readingMinutes)
+ }
+}
+
+@Composable
+private fun MetaSeparator() {
+ Text(
+ text = " ยท ",
+ color = MaterialTheme.colorScheme.grayText,
+ style = MaterialTheme.typography.bodySmall,
+ )
+}
+
+@Composable
+private fun ReadingTimeBadge(minutes: Int) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = Icons.Outlined.Schedule,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.grayText,
+ modifier = Modifier.size(14.dp),
+ )
+ Spacer(Modifier.padding(start = 4.dp))
+ Text(
+ text = stringRes(R.string.long_form_reading_minutes, minutes),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.grayText,
+ maxLines = 1,
+ )
+ }
+}
+
+private fun estimateReadingMinutes(content: String): Int {
+ if (content.isBlank()) return 1
+ val words = content.split(Regex("\\s+")).count { it.isNotBlank() }
+ return maxOf(1, (words + WORDS_PER_MINUTE - 1) / WORDS_PER_MINUTE)
+}
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index 668b064ad..464606744 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -1079,6 +1079,8 @@
Hashtag-exclusive Post
Only followers of the hashtag will see it. Your general followers won\'t see it.
+ %1$d min read
+
Loading location
No Location Permissions