diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt index 567b61ac3..b000d16c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt @@ -50,21 +50,19 @@ import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment import com.vitorpamplona.amethyst.ui.components.HashTag import com.vitorpamplona.amethyst.ui.components.RenderRegular import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList @Preview @Composable fun RenderHashTagIconsPreview() { - val accountViewModel = mockAccountViewModel() ThemeComparisonColumn { RenderRegular( "Testing rendering of hashtags: #flowerstr #Bitcoin, #nostr, #lightning, #zap, #amethyst, #cashu, #plebs, #coffee, #skullofsatoshi, #grownostr, #footstr, #tunestr, #weed, #mate, #gamestr, #gamechain", EmptyTagList, ) { word, state -> when (word) { - is HashTagSegment -> HashTag(word, accountViewModel, EmptyNav) + is HashTagSegment -> HashTag(word, EmptyNav) is RegularTextSegment -> Text(word.segmentText) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt new file mode 100644 index 000000000..173a97ba4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt @@ -0,0 +1,237 @@ +/** + * 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.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun ImageGallery( + images: ImmutableList, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, + roundedCorner: Boolean = true, +) { + when { + images.isEmpty() -> { + // No images to display + } + images.size == 1 -> { + // Single image - display full width + Box(modifier = modifier.fillMaxWidth()) { + ZoomableContentView( + content = images.first(), + images = images, + roundedCorner = roundedCorner, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } + } + images.size == 2 -> { + // Two images - side by side in 4:3 ratio + TwoImageGallery( + images = images, + accountViewModel = accountViewModel, + roundedCorner = roundedCorner, + modifier = modifier, + ) + } + images.size == 3 -> { + // Three images - one large, two small + ThreeImageGallery( + images = images, + accountViewModel = accountViewModel, + roundedCorner = roundedCorner, + modifier = modifier, + ) + } + images.size == 4 -> { + // Four images - 2x2 grid + FourImageGallery( + images = images, + accountViewModel = accountViewModel, + roundedCorner = roundedCorner, + modifier = modifier, + ) + } + else -> { + // Many images - use staggered grid with 4:3 ratio + ManyImageGallery( + images = images, + accountViewModel = accountViewModel, + roundedCorner = roundedCorner, + modifier = modifier, + ) + } + } +} + +@Composable +private fun TwoImageGallery( + images: ImmutableList, + accountViewModel: AccountViewModel, + roundedCorner: Boolean, + modifier: Modifier, +) { + Row( + modifier = modifier.aspectRatio(4f / 3f), + horizontalArrangement = Arrangement.spacedBy(Size5dp), + ) { + repeat(2) { index -> + Box(modifier = Modifier.weight(1f).fillMaxSize()) { + ZoomableContentView( + content = images[index], + images = images, + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + } + } +} + +@Composable +private fun ThreeImageGallery( + images: ImmutableList, + accountViewModel: AccountViewModel, + roundedCorner: Boolean, + modifier: Modifier, +) { + Row( + modifier = modifier.aspectRatio(4f / 3f), + horizontalArrangement = Arrangement.spacedBy(Size5dp), + ) { + // Large image on the left + Box(modifier = Modifier.weight(2f).fillMaxSize()) { + ZoomableContentView( + content = images[0], + images = images, + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + + // Two smaller images on the right + Column( + modifier = Modifier.weight(1f).fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + repeat(2) { index -> + Box(modifier = Modifier.weight(1f).fillMaxSize()) { + ZoomableContentView( + content = images[index + 1], + images = images, + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + } + } + } +} + +@Composable +private fun FourImageGallery( + images: ImmutableList, + accountViewModel: AccountViewModel, + roundedCorner: Boolean, + modifier: Modifier, +) { + Column( + modifier = modifier.aspectRatio(4f / 3f), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + repeat(2) { rowIndex -> + Row( + modifier = Modifier.weight(1f).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Size5dp), + ) { + repeat(2) { colIndex -> + val imageIndex = rowIndex * 2 + colIndex + Box(modifier = Modifier.weight(1f).fillMaxSize()) { + ZoomableContentView( + content = images[imageIndex], + images = images, + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + } + } + } + } +} + +@Composable +private fun ManyImageGallery( + images: ImmutableList, + accountViewModel: AccountViewModel, + roundedCorner: Boolean, + modifier: Modifier, +) { + Surface( + modifier = modifier.aspectRatio(4f / 3f), + color = MaterialTheme.colorScheme.surface, + ) { + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Adaptive(100.dp), + verticalItemSpacing = Size5dp, + horizontalArrangement = Arrangement.spacedBy(Size5dp), + modifier = Modifier.padding(Size5dp), + ) { + items(images) { image -> + Box(modifier = Modifier.aspectRatio(1f)) { + ZoomableContentView( + content = image, + images = images, + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 720f01c58..0266b474f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -73,6 +73,8 @@ import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment import com.vitorpamplona.amethyst.commons.richtext.ImageSegment import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment import com.vitorpamplona.amethyst.commons.richtext.LinkSegment +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.commons.richtext.ParagraphState import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState @@ -107,6 +109,8 @@ import com.vitorpamplona.amethyst.ui.theme.innerPostModifier import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -143,8 +147,6 @@ fun RichTextViewer( @Preview @Composable fun RenderStrangeNamePreview() { - val nav = EmptyNav - Column(modifier = Modifier.padding(10.dp)) { RenderRegular( "If you want to stream or download the music from nostr:npub1sctag667a7np6p6ety2up94pnwwxhd2ep8n8afr2gtr47cwd4ewsvdmmjm can you here", @@ -167,7 +169,6 @@ fun RenderStrangeNamePreview() { @Composable fun RenderRegularPreview() { val nav = EmptyNav - val accountViewModel = mockAccountViewModel() Column(modifier = Modifier.padding(10.dp)) { RenderRegular( @@ -194,7 +195,7 @@ fun RenderRegularPreview() { ) } - is HashTagSegment -> HashTag(word, accountViewModel, nav) + is HashTagSegment -> HashTag(word, nav) // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -208,7 +209,7 @@ fun RenderRegularPreview() { @Composable fun RenderRegularPreview2() { val nav = EmptyNav - val accountViewModel = mockAccountViewModel() + RenderRegular( "#Amethyst v0.84.1: ncryptsec support (NIP-49)", EmptyTagList, @@ -223,7 +224,7 @@ fun RenderRegularPreview2() { is EmailSegment -> ClickableEmail(word.segmentText) is PhoneSegment -> ClickablePhone(word.segmentText) // is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, accountViewModel, nav) + is HashTagSegment -> HashTag(word, nav) // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -264,7 +265,7 @@ fun RenderRegularPreview3() { is EmailSegment -> ClickableEmail(word.segmentText) is PhoneSegment -> ClickablePhone(word.segmentText) // is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, accountViewModel, nav) + is HashTagSegment -> HashTag(word, nav) // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -284,18 +285,10 @@ private fun RenderRegular( accountViewModel: AccountViewModel, nav: INav, ) { - RenderRegular(content, tags, callbackUri) { word, state -> - if (canPreview) { - RenderWordWithPreview( - word, - state, - backgroundColor, - quotesLeft, - callbackUri, - accountViewModel, - nav, - ) - } else { + if (canPreview) { + RenderRegularWithGallery(content, tags, backgroundColor, quotesLeft, callbackUri, accountViewModel, nav) + } else { + RenderRegular(content, tags, callbackUri) { word, state -> RenderWordWithoutPreview( word, state, @@ -307,6 +300,126 @@ private fun RenderRegular( } } +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RenderRegularWithGallery( + content: String, + tags: ImmutableListOfLists, + backgroundColor: MutableState, + quotesLeft: Int, + callbackUri: String? = null, + accountViewModel: AccountViewModel, + nav: INav, +) { + val state by remember(content, tags) { mutableStateOf(CachedRichTextParser.parseText(content, tags, callbackUri)) } + + val spaceWidth = measureSpaceWidth(LocalTextStyle.current) + + Column { + // Process paragraphs and group consecutive image-only paragraphs + var i = 0 + while (i < state.paragraphs.size) { + val paragraph = state.paragraphs[i] + + // Check if this paragraph contains only images + val isImageOnlyParagraph = + paragraph.words.all { word -> + word is ImageSegment || word is Base64Segment + } + + if (isImageOnlyParagraph && paragraph.words.isNotEmpty()) { + // Collect consecutive image-only paragraphs + val imageParagraphs = mutableListOf() + var j = i + while (j < state.paragraphs.size) { + val currentParagraph = state.paragraphs[j] + val isCurrentImageOnly = + currentParagraph.words.all { word -> + word is ImageSegment || word is Base64Segment + } + if (isCurrentImageOnly && currentParagraph.words.isNotEmpty()) { + imageParagraphs.add(currentParagraph) + j++ + } else { + break + } + } + + // Combine all image words from consecutive paragraphs + val allImageWords = imageParagraphs.flatMap { it.words }.toImmutableList() + + if (allImageWords.size > 1) { + // Multiple images - render as gallery + RenderWordsWithImageGallery( + allImageWords, + state, + backgroundColor, + quotesLeft, + callbackUri, + accountViewModel, + nav, + ) + } else { + // Single image - render normally + CompositionLocalProvider( + LocalLayoutDirection provides + if (paragraph.isRTL) { + LayoutDirection.Rtl + } else { + LayoutDirection.Ltr + }, + LocalTextStyle provides LocalTextStyle.current, + ) { + FlowRow( + modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), + horizontalArrangement = Arrangement.spacedBy(spaceWidth), + ) { + RenderWordsWithImageGallery( + paragraph.words.toImmutableList(), + state, + backgroundColor, + quotesLeft, + callbackUri, + accountViewModel, + nav, + ) + } + } + } + + i = j // Skip processed paragraphs + } else { + // Non-image paragraph - render normally + CompositionLocalProvider( + LocalLayoutDirection provides + if (paragraph.isRTL) { + LayoutDirection.Rtl + } else { + LayoutDirection.Ltr + }, + LocalTextStyle provides LocalTextStyle.current, + ) { + FlowRow( + modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), + horizontalArrangement = Arrangement.spacedBy(spaceWidth), + ) { + RenderWordsWithImageGallery( + paragraph.words.toImmutableList(), + state, + backgroundColor, + quotesLeft, + callbackUri, + accountViewModel, + nav, + ) + } + } + i++ + } + } + } +} + @OptIn(ExperimentalLayoutApi::class) @Composable fun RenderRegular( @@ -391,7 +504,7 @@ private fun RenderWordWithoutPreview( is SecretEmoji -> Text(word.segmentText) is PhoneSegment -> ClickablePhone(word.segmentText) is BechSegment -> BechLink(word.segmentText, false, 0, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, accountViewModel, nav) + is HashTagSegment -> HashTag(word, nav) is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) is HashIndexEventSegment -> TagLink(word, false, 0, backgroundColor, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -399,6 +512,59 @@ private fun RenderWordWithoutPreview( } } +@Composable +private fun RenderWordsWithImageGallery( + words: ImmutableList, + state: RichTextViewerState, + backgroundColor: MutableState, + quotesLeft: Int, + callbackUri: String? = null, + accountViewModel: AccountViewModel, + nav: INav, +) { + var i = 0 + while (i < words.size) { + val word = words[i] + + if (word is ImageSegment || word is Base64Segment) { + // Collect consecutive images + val imageSegments = mutableListOf() + var j = i + while (j < words.size && (words[j] is ImageSegment || words[j] is Base64Segment)) { + imageSegments.add(words[j]) + j++ + } + + if (imageSegments.size > 1) { + // Multiple images - render as gallery + val imageContents = + imageSegments + .mapNotNull { segment -> + val imageUrl = segment.segmentText + state.imagesForPager[imageUrl] as? MediaUrlImage + }.toImmutableList() + + if (imageContents.isNotEmpty()) { + ImageGallery( + images = imageContents, + accountViewModel = accountViewModel, + roundedCorner = true, + ) + } + } else { + // Single image - render normally + RenderWordWithPreview(word, state, backgroundColor, quotesLeft, callbackUri, accountViewModel, nav) + } + + i = j // Skip processed images + } else { + // Non-image word - render normally + RenderWordWithPreview(word, state, backgroundColor, quotesLeft, callbackUri, accountViewModel, nav) + i++ + } + } +} + @Composable private fun RenderWordWithPreview( word: Segment, @@ -420,7 +586,7 @@ private fun RenderWordWithPreview( is SecretEmoji -> DisplaySecretEmoji(word, state, callbackUri, true, quotesLeft, backgroundColor, accountViewModel, nav) is PhoneSegment -> ClickablePhone(word.segmentText) is BechSegment -> BechLink(word.segmentText, true, quotesLeft, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, accountViewModel, nav) + is HashTagSegment -> HashTag(word, nav) is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) is HashIndexEventSegment -> TagLink(word, true, quotesLeft, backgroundColor, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -585,17 +751,15 @@ fun CoreSecretMessage( nav: INav, ) { if (localSecretContent.paragraphs.size == 1) { - localSecretContent.paragraphs[0].words.forEach { word -> - RenderWordWithPreview( - word, - localSecretContent, - backgroundColor, - quotesLeft, - callbackUri, - accountViewModel, - nav, - ) - } + RenderWordsWithImageGallery( + localSecretContent.paragraphs[0].words.toImmutableList(), + localSecretContent, + backgroundColor, + quotesLeft, + callbackUri, + accountViewModel, + nav, + ) } else if (localSecretContent.paragraphs.size > 1) { val spaceWidth = measureSpaceWidth(LocalTextStyle.current) @@ -605,17 +769,15 @@ fun CoreSecretMessage( modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), horizontalArrangement = Arrangement.spacedBy(spaceWidth), ) { - paragraph.words.forEach { word -> - RenderWordWithPreview( - word, - localSecretContent, - backgroundColor, - quotesLeft, - callbackUri, - accountViewModel, - nav, - ) - } + RenderWordsWithImageGallery( + paragraph.words.toImmutableList(), + localSecretContent, + backgroundColor, + quotesLeft, + callbackUri, + accountViewModel, + nav, + ) } } } @@ -625,7 +787,6 @@ fun CoreSecretMessage( @Composable fun HashTag( segment: HashTagSegment, - accountViewModel: AccountViewModel, nav: INav, ) { val primary = MaterialTheme.colorScheme.primary @@ -693,8 +854,8 @@ fun TagLink( } else { Row { DisplayUserFromTag(it, accountViewModel, nav) - word.extras?.let { - Text(text = it) + word.extras?.let { it2 -> + Text(text = it2) } } } @@ -708,7 +869,7 @@ fun LoadNote( content: @Composable (Note?) -> Unit, ) { var note by - remember(baseNoteHex) { mutableStateOf(accountViewModel.getNoteIfExists(baseNoteHex)) } + remember(baseNoteHex) { mutableStateOf(accountViewModel.getNoteIfExists(baseNoteHex)) } if (note == null) { LaunchedEffect(key1 = baseNoteHex) {