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