Collect batches of images

Send them to new image gallery
This commit is contained in:
davotoula
2025-09-11 20:25:05 +02:00
parent 4fb629e893
commit 9534582141
3 changed files with 446 additions and 50 deletions

View File

@@ -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)
}
}

View File

@@ -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<MediaUrlImage>,
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<MediaUrlImage>,
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<MediaUrlImage>,
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<MediaUrlImage>,
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<MediaUrlImage>,
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,
)
}
}
}
}
}

View File

@@ -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<String>,
backgroundColor: MutableState<Color>,
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<ParagraphState>()
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<Segment>,
state: RichTextViewerState,
backgroundColor: MutableState<Color>,
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<Segment>()
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<Note?>(accountViewModel.getNoteIfExists(baseNoteHex)) }
remember(baseNoteHex) { mutableStateOf(accountViewModel.getNoteIfExists(baseNoteHex)) }
if (note == null) {
LaunchedEffect(key1 = baseNoteHex) {