From 953458214126d6d7610147da2d998fab0ac1f521 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 11 Sep 2025 20:25:05 +0200 Subject: [PATCH 01/25] Collect batches of images Send them to new image gallery --- .../amethyst/model/HashtagIcon.kt | 4 +- .../amethyst/ui/components/ImageGallery.kt | 237 ++++++++++++++++ .../amethyst/ui/components/RichTextViewer.kt | 255 ++++++++++++++---- 3 files changed, 446 insertions(+), 50 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt 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) { From 062182a7ec8f343a96597e4daa8368718caa33c3 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 11 Sep 2025 20:50:21 +0200 Subject: [PATCH 02/25] fix for when images are separated by empty lines refactor to reduce duplication: Extract RenderParagraphWithFlowRow Extract collectConsecutiveImageParagraphs --- .../amethyst/ui/components/RichTextViewer.kt | 166 +++++++++++------- 1 file changed, 101 insertions(+), 65 deletions(-) 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 0266b474f..fb7442f25 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 @@ -329,27 +329,13 @@ fun RenderRegularWithGallery( 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 - } - } + val (imageParagraphs, totalProcessedCount) = collectConsecutiveImageParagraphs(state.paragraphs, i) // Combine all image words from consecutive paragraphs val allImageWords = imageParagraphs.flatMap { it.words }.toImmutableList() if (allImageWords.size > 1) { - // Multiple images - render as gallery + // Multiple images - render as gallery (no FlowRow wrapper needed) RenderWordsWithImageGallery( allImageWords, state, @@ -361,65 +347,115 @@ fun RenderRegularWithGallery( ) } 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, - ) - } - } + RenderParagraphWithFlowRow( + paragraph, + paragraph.words.toImmutableList(), + spaceWidth, + state, + backgroundColor, + quotesLeft, + callbackUri, + accountViewModel, + nav, + ) } - i = j // Skip processed paragraphs + i += totalProcessedCount // Skip processed paragraphs (including empty ones) } 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, - ) - } - } + RenderParagraphWithFlowRow( + paragraph, + paragraph.words.toImmutableList(), + spaceWidth, + state, + backgroundColor, + quotesLeft, + callbackUri, + accountViewModel, + nav, + ) i++ } } } } +@Composable +private fun RenderParagraphWithFlowRow( + paragraph: ParagraphState, + words: ImmutableList, + spaceWidth: Dp, + state: RichTextViewerState, + backgroundColor: MutableState, + quotesLeft: Int, + callbackUri: String?, + accountViewModel: AccountViewModel, + nav: INav, +) { + CompositionLocalProvider( + LocalLayoutDirection provides + if (paragraph.isRTL) { + LayoutDirection.Rtl + } else { + LayoutDirection.Ltr + }, + LocalTextStyle provides LocalTextStyle.current, + ) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(spaceWidth), + ) { + RenderWordsWithImageGallery( + words, + state, + backgroundColor, + quotesLeft, + callbackUri, + accountViewModel, + nav, + ) + } + } +} + +private fun collectConsecutiveImageParagraphs( + paragraphs: ImmutableList, + startIndex: Int, +): Pair, Int> { + val imageParagraphs = mutableListOf() + var j = startIndex + while (j < paragraphs.size) { + val currentParagraph = paragraphs[j] + val isEmpty = + currentParagraph.words.isEmpty() || + ( + currentParagraph.words.size == 1 && + currentParagraph.words.first() is RegularTextSegment && + currentParagraph.words + .first() + .segmentText + .isBlank() + ) + + val isCurrentImageOnly = + currentParagraph.words.isNotEmpty() && + currentParagraph.words.all { word -> + word is ImageSegment || word is Base64Segment + } + + if (isCurrentImageOnly) { + imageParagraphs.add(currentParagraph) + j++ + } else if (isEmpty) { + // Skip empty paragraphs but continue looking for consecutive images + j++ + } else { + // Hit a non-empty, non-image paragraph - stop collecting + break + } + } + return Pair(imageParagraphs, j - startIndex) // Return paragraphs and total processed count +} + @OptIn(ExperimentalLayoutApi::class) @Composable fun RenderRegular( From 1f6d7d3fd2951a614006a8d3d9223d31c31ea74d Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 11 Sep 2025 20:58:15 +0200 Subject: [PATCH 03/25] Simplify RenderRegularWithGallery Simplify collectConsecutiveImageParagraphs: just return the end index Extracting common signature parameters into a RenderContext data class Extracting GalleryImage helper function --- .../amethyst/ui/components/ImageGallery.kt | 122 +++++----- .../amethyst/ui/components/RichTextViewer.kt | 210 +++++++++--------- 2 files changed, 167 insertions(+), 165 deletions(-) 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 index 173a97ba4..caf820a26 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt @@ -42,6 +42,26 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size5dp import kotlinx.collections.immutable.ImmutableList +@Composable +private fun GalleryImage( + image: MediaUrlImage, + allImages: ImmutableList, + modifier: Modifier, + roundedCorner: Boolean, + contentScale: ContentScale, + accountViewModel: AccountViewModel, +) { + Box(modifier = modifier) { + ZoomableContentView( + content = image, + images = allImages, + roundedCorner = roundedCorner, + contentScale = contentScale, + accountViewModel = accountViewModel, + ) + } +} + @Composable fun ImageGallery( images: ImmutableList, @@ -55,15 +75,14 @@ fun ImageGallery( } 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, - ) - } + GalleryImage( + image = images.first(), + allImages = images, + modifier = modifier.fillMaxWidth(), + roundedCorner = roundedCorner, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) } images.size == 2 -> { // Two images - side by side in 4:3 ratio @@ -116,15 +135,14 @@ private fun TwoImageGallery( 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, - ) - } + GalleryImage( + image = images[index], + allImages = images, + modifier = Modifier.weight(1f).fillMaxSize(), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) } } } @@ -141,15 +159,14 @@ private fun ThreeImageGallery( 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, - ) - } + GalleryImage( + image = images[0], + allImages = images, + modifier = Modifier.weight(2f).fillMaxSize(), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) // Two smaller images on the right Column( @@ -157,15 +174,14 @@ private fun ThreeImageGallery( 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, - ) - } + GalleryImage( + image = images[index + 1], + allImages = images, + modifier = Modifier.weight(1f).fillMaxSize(), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) } } } @@ -189,15 +205,14 @@ private fun FourImageGallery( ) { 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, - ) - } + GalleryImage( + image = images[imageIndex], + allImages = images, + modifier = Modifier.weight(1f).fillMaxSize(), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) } } } @@ -222,15 +237,14 @@ private fun ManyImageGallery( modifier = Modifier.padding(Size5dp), ) { items(images) { image -> - Box(modifier = Modifier.aspectRatio(1f)) { - ZoomableContentView( - content = image, - images = images, - roundedCorner = roundedCorner, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) - } + GalleryImage( + image = image, + allImages = images, + modifier = Modifier.aspectRatio(1f), + 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 fb7442f25..acb71545c 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 @@ -114,6 +114,15 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +data class RenderContext( + val state: RichTextViewerState, + val backgroundColor: MutableState, + val quotesLeft: Int, + val callbackUri: String?, + val accountViewModel: AccountViewModel, + val nav: INav, +) + fun isMarkdown(content: String): Boolean = content.startsWith("> ") || content.startsWith("# ") || @@ -313,84 +322,78 @@ fun RenderRegularWithGallery( ) { val state by remember(content, tags) { mutableStateOf(CachedRichTextParser.parseText(content, tags, callbackUri)) } + val context = + RenderContext( + state = state, + backgroundColor = backgroundColor, + quotesLeft = quotesLeft, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + val spaceWidth = measureSpaceWidth(LocalTextStyle.current) Column { - // Process paragraphs and group consecutive image-only paragraphs + // Process each paragraph uniformly 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, totalProcessedCount) = collectConsecutiveImageParagraphs(state.paragraphs, i) - - // Combine all image words from consecutive paragraphs - val allImageWords = imageParagraphs.flatMap { it.words }.toImmutableList() - - if (allImageWords.size > 1) { - // Multiple images - render as gallery (no FlowRow wrapper needed) - RenderWordsWithImageGallery( - allImageWords, - state, - backgroundColor, - quotesLeft, - callbackUri, - accountViewModel, - nav, - ) - } else { - // Single image - render normally - RenderParagraphWithFlowRow( - paragraph, - paragraph.words.toImmutableList(), - spaceWidth, - state, - backgroundColor, - quotesLeft, - callbackUri, - accountViewModel, - nav, - ) - } - - i += totalProcessedCount // Skip processed paragraphs (including empty ones) - } else { - // Non-image paragraph - render normally - RenderParagraphWithFlowRow( - paragraph, - paragraph.words.toImmutableList(), - spaceWidth, - state, - backgroundColor, - quotesLeft, - callbackUri, - accountViewModel, - nav, + i = + renderParagraphWithFlowRow( + paragraphs = state.paragraphs, + paragraphIndex = i, + spaceWidth = spaceWidth, + context = context, ) - i++ - } } } } @Composable -private fun RenderParagraphWithFlowRow( +private fun renderParagraphWithFlowRow( + paragraphs: ImmutableList, + paragraphIndex: Int, + spaceWidth: Dp, + context: RenderContext, +): Int { + val paragraph = paragraphs[paragraphIndex] + + // 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 for gallery + val (imageParagraphs, endIndex) = collectConsecutiveImageParagraphs(paragraphs, paragraphIndex) + val allImageWords = imageParagraphs.flatMap { it.words }.toImmutableList() + + if (allImageWords.size > 1) { + // Multiple images - render as gallery (no FlowRow wrapper needed) + RenderWordsWithImageGallery( + allImageWords, + context, + ) + } else { + // Single image - render with FlowRow wrapper + RenderSingleParagraphWithFlowRow(paragraph, paragraph.words.toImmutableList(), spaceWidth, context) + } + + return endIndex // Return next index to process + } else { + // Non-image paragraph - render normally with FlowRow + RenderSingleParagraphWithFlowRow(paragraph, paragraph.words.toImmutableList(), spaceWidth, context) + return paragraphIndex + 1 // Return next index to process + } +} + +@Composable +private fun RenderSingleParagraphWithFlowRow( paragraph: ParagraphState, words: ImmutableList, spaceWidth: Dp, - state: RichTextViewerState, - backgroundColor: MutableState, - quotesLeft: Int, - callbackUri: String?, - accountViewModel: AccountViewModel, - nav: INav, + context: RenderContext, ) { CompositionLocalProvider( LocalLayoutDirection provides @@ -406,12 +409,7 @@ private fun RenderParagraphWithFlowRow( ) { RenderWordsWithImageGallery( words, - state, - backgroundColor, - quotesLeft, - callbackUri, - accountViewModel, - nav, + context, ) } } @@ -453,7 +451,7 @@ private fun collectConsecutiveImageParagraphs( break } } - return Pair(imageParagraphs, j - startIndex) // Return paragraphs and total processed count + return Pair(imageParagraphs, j) // Return collected paragraphs and next index to process } @OptIn(ExperimentalLayoutApi::class) @@ -551,12 +549,7 @@ private fun RenderWordWithoutPreview( @Composable private fun RenderWordsWithImageGallery( words: ImmutableList, - state: RichTextViewerState, - backgroundColor: MutableState, - quotesLeft: Int, - callbackUri: String? = null, - accountViewModel: AccountViewModel, - nav: INav, + context: RenderContext, ) { var i = 0 while (i < words.size) { @@ -577,25 +570,25 @@ private fun RenderWordsWithImageGallery( imageSegments .mapNotNull { segment -> val imageUrl = segment.segmentText - state.imagesForPager[imageUrl] as? MediaUrlImage + context.state.imagesForPager[imageUrl] as? MediaUrlImage }.toImmutableList() if (imageContents.isNotEmpty()) { ImageGallery( images = imageContents, - accountViewModel = accountViewModel, + accountViewModel = context.accountViewModel, roundedCorner = true, ) } } else { // Single image - render normally - RenderWordWithPreview(word, state, backgroundColor, quotesLeft, callbackUri, accountViewModel, nav) + RenderWordWithPreview(word, context) } i = j // Skip processed images } else { // Non-image word - render normally - RenderWordWithPreview(word, state, backgroundColor, quotesLeft, callbackUri, accountViewModel, nav) + RenderWordWithPreview(word, context) i++ } } @@ -604,30 +597,25 @@ private fun RenderWordsWithImageGallery( @Composable private fun RenderWordWithPreview( word: Segment, - state: RichTextViewerState, - backgroundColor: MutableState, - quotesLeft: Int, - callbackUri: String? = null, - accountViewModel: AccountViewModel, - nav: INav, + context: RenderContext, ) { when (word) { - is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) - is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, callbackUri, accountViewModel) - is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) - is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel) - is WithdrawSegment -> MayBeWithdrawal(word.segmentText, accountViewModel) - is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) + is ImageSegment -> ZoomableContentView(word.segmentText, context.state, context.accountViewModel) + is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, context.callbackUri, context.accountViewModel) + is EmojiSegment -> RenderCustomEmoji(word.segmentText, context.state) + is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, context.accountViewModel) + is WithdrawSegment -> MayBeWithdrawal(word.segmentText, context.accountViewModel) + is CashuSegment -> CashuPreview(word.segmentText, context.accountViewModel) is EmailSegment -> ClickableEmail(word.segmentText) - is SecretEmoji -> DisplaySecretEmoji(word, state, callbackUri, true, quotesLeft, backgroundColor, accountViewModel, nav) + is SecretEmoji -> DisplaySecretEmoji(word, context.state, context.callbackUri, true, context.quotesLeft, context.backgroundColor, context.accountViewModel, context.nav) is PhoneSegment -> ClickablePhone(word.segmentText) - is BechSegment -> BechLink(word.segmentText, true, quotesLeft, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, nav) - is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) - is HashIndexEventSegment -> TagLink(word, true, quotesLeft, backgroundColor, accountViewModel, nav) + is BechSegment -> BechLink(word.segmentText, true, context.quotesLeft, context.backgroundColor, context.accountViewModel, context.nav) + is HashTagSegment -> HashTag(word, context.nav) + is HashIndexUserSegment -> TagLink(word, context.accountViewModel, context.nav) + is HashIndexEventSegment -> TagLink(word, true, context.quotesLeft, context.backgroundColor, context.accountViewModel, context.nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) is RegularTextSegment -> Text(word.segmentText) - is Base64Segment -> ZoomableContentView(word.segmentText, state, accountViewModel) + is Base64Segment -> ZoomableContentView(word.segmentText, context.state, context.accountViewModel) } } @@ -786,15 +774,20 @@ fun CoreSecretMessage( accountViewModel: AccountViewModel, nav: INav, ) { + val context = + RenderContext( + state = localSecretContent, + backgroundColor = backgroundColor, + quotesLeft = quotesLeft, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + if (localSecretContent.paragraphs.size == 1) { RenderWordsWithImageGallery( localSecretContent.paragraphs[0].words.toImmutableList(), - localSecretContent, - backgroundColor, - quotesLeft, - callbackUri, - accountViewModel, - nav, + context, ) } else if (localSecretContent.paragraphs.size > 1) { val spaceWidth = measureSpaceWidth(LocalTextStyle.current) @@ -807,12 +800,7 @@ fun CoreSecretMessage( ) { RenderWordsWithImageGallery( paragraph.words.toImmutableList(), - localSecretContent, - backgroundColor, - quotesLeft, - callbackUri, - accountViewModel, - nav, + context, ) } } From f092326dcdd35a8e61b74ea9a6c101f9677469d7 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 10 Sep 2025 20:55:25 +0200 Subject: [PATCH 04/25] Fixing scrollable gallery issue --- .../amethyst/ui/components/ImageGallery.kt | 57 +++++++++++-------- 1 file changed, 33 insertions(+), 24 deletions(-) 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 index caf820a26..2f079d4d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt @@ -27,16 +27,9 @@ 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 @@ -226,25 +219,41 @@ private fun ManyImageGallery( roundedCorner: Boolean, modifier: Modifier, ) { - Surface( + // Calculate optimal grid layout for many images + val columns = + when { + images.size <= 6 -> 3 // 3 columns for 5-6 images + images.size <= 9 -> 3 // 3 columns for 7-9 images + else -> 4 // 4 columns for 10+ images + } + + val rows = (images.size + columns - 1) / columns // Ceiling division + + Column( modifier = modifier.aspectRatio(4f / 3f), - color = MaterialTheme.colorScheme.surface, + verticalArrangement = Arrangement.spacedBy(Size5dp), ) { - LazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Adaptive(100.dp), - verticalItemSpacing = Size5dp, - horizontalArrangement = Arrangement.spacedBy(Size5dp), - modifier = Modifier.padding(Size5dp), - ) { - items(images) { image -> - GalleryImage( - image = image, - allImages = images, - modifier = Modifier.aspectRatio(1f), - roundedCorner = roundedCorner, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) + repeat(rows) { rowIndex -> + Row( + modifier = Modifier.weight(1f).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Size5dp), + ) { + repeat(columns) { colIndex -> + val imageIndex = rowIndex * columns + colIndex + if (imageIndex < images.size) { + GalleryImage( + image = images[imageIndex], + allImages = images, + modifier = Modifier.weight(1f).fillMaxSize(), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } else { + // Empty space for incomplete rows + Box(modifier = Modifier.weight(1f)) + } + } } } } From 8cb9d1356767700dfbb6518868ce49c16a9f1b1b Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 11 Sep 2025 21:01:12 +0200 Subject: [PATCH 05/25] Use constrained LazyVerticalGrid with calculated height for more than 20 images for performance reasons added vertical spacing for some padding Fixing scrollable gallery issue --- .../amethyst/ui/components/ImageGallery.kt | 186 +++++++++++------- 1 file changed, 112 insertions(+), 74 deletions(-) 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 index 2f079d4d5..26868953f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt @@ -27,11 +27,18 @@ 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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items 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.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import kotlinx.collections.immutable.ImmutableList @@ -62,56 +69,59 @@ fun ImageGallery( modifier: Modifier = Modifier, roundedCorner: Boolean = true, ) { - when { - images.isEmpty() -> { - // No images to display - } - images.size == 1 -> { - // Single image - display full width - GalleryImage( - image = images.first(), - allImages = images, - modifier = modifier.fillMaxWidth(), - 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, - ) + // Add vertical padding around the entire gallery for better text separation + Column(modifier = modifier.padding(vertical = Size10dp)) { + when { + images.isEmpty() -> { + // No images to display + } + images.size == 1 -> { + // Single image - display full width + GalleryImage( + image = images.first(), + allImages = images, + modifier = Modifier.fillMaxWidth(), + 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, + ) + } } } } @@ -227,34 +237,62 @@ private fun ManyImageGallery( else -> 4 // 4 columns for 10+ images } - val rows = (images.size + columns - 1) / columns // Ceiling division + if (images.size <= 20) { + // For smaller sets, use non-lazy Column/Row approach (simpler, no constraint issues) + val rows = (images.size + columns - 1) / columns // Ceiling division - Column( - modifier = modifier.aspectRatio(4f / 3f), - verticalArrangement = Arrangement.spacedBy(Size5dp), - ) { - repeat(rows) { rowIndex -> - Row( - modifier = Modifier.weight(1f).fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(Size5dp), - ) { - repeat(columns) { colIndex -> - val imageIndex = rowIndex * columns + colIndex - if (imageIndex < images.size) { - GalleryImage( - image = images[imageIndex], - allImages = images, - modifier = Modifier.weight(1f).fillMaxSize(), - roundedCorner = roundedCorner, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) - } else { - // Empty space for incomplete rows - Box(modifier = Modifier.weight(1f)) + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + repeat(rows) { rowIndex -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Size5dp), + ) { + repeat(columns) { colIndex -> + val imageIndex = rowIndex * columns + colIndex + if (imageIndex < images.size) { + GalleryImage( + image = images[imageIndex], + allImages = images, + modifier = Modifier.weight(1f).aspectRatio(1f), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } else { + // Empty space for incomplete rows + Box(modifier = Modifier.weight(1f)) + } } } } } + } else { + // For larger sets, use LazyVerticalGrid with explicit height constraint + val rows = (images.size + columns - 1) / columns + // Calculate height: (image height + spacing) * rows - last spacing + // Assume square images with 5dp spacing + val gridHeight = (100 * rows + 5 * (rows - 1)).dp + + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + modifier = modifier.height(gridHeight), // Explicit height constraint + verticalArrangement = Arrangement.spacedBy(Size5dp), + horizontalArrangement = Arrangement.spacedBy(Size5dp), + userScrollEnabled = false, + ) { + items(images) { image -> + GalleryImage( + image = image, + allImages = images, + modifier = Modifier.aspectRatio(1f), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + } } } From 4dace5aaf81b95ff55fbde121d9a001b8044d14b Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 11 Sep 2025 21:03:06 +0200 Subject: [PATCH 06/25] refactor using takeWhile Fix edge case with white space before images --- .../amethyst/ui/components/RichTextViewer.kt | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) 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 acb71545c..ddc151c76 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 @@ -358,11 +358,8 @@ private fun renderParagraphWithFlowRow( ): Int { val paragraph = paragraphs[paragraphIndex] - // Check if this paragraph contains only images - val isImageOnlyParagraph = - paragraph.words.all { word -> - word is ImageSegment || word is Base64Segment - } + // Check if this paragraph contains only images (ignoring whitespace) + val isImageOnlyParagraph = isImageOnlyParagraph(paragraph) if (isImageOnlyParagraph && paragraph.words.isNotEmpty()) { // Collect consecutive image-only paragraphs for gallery @@ -415,6 +412,22 @@ private fun RenderSingleParagraphWithFlowRow( } } +private fun isImageOnlyParagraph(paragraph: ParagraphState): Boolean { + // A paragraph is "image only" if all non-whitespace words are images + val nonWhitespaceWords = + paragraph.words.filter { word -> + when (word) { + is RegularTextSegment -> word.segmentText.isNotBlank() + else -> true // All other word types (images, links, etc.) are considered non-whitespace + } + } + + return nonWhitespaceWords.isNotEmpty() && + nonWhitespaceWords.all { word -> + word is ImageSegment || word is Base64Segment + } +} + private fun collectConsecutiveImageParagraphs( paragraphs: ImmutableList, startIndex: Int, @@ -435,10 +448,7 @@ private fun collectConsecutiveImageParagraphs( ) val isCurrentImageOnly = - currentParagraph.words.isNotEmpty() && - currentParagraph.words.all { word -> - word is ImageSegment || word is Base64Segment - } + currentParagraph.words.isNotEmpty() && isImageOnlyParagraph(currentParagraph) if (isCurrentImageOnly) { imageParagraphs.add(currentParagraph) @@ -556,13 +566,15 @@ private fun RenderWordsWithImageGallery( 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++ - } + // Collect consecutive images (skipping whitespace) using takeWhile + fun isImageOrWhitespace(segment: Segment): Boolean = + segment is ImageSegment || + segment is Base64Segment || + (segment is RegularTextSegment && segment.segmentText.isBlank()) + + val consecutiveSegments = words.drop(i).takeWhile { isImageOrWhitespace(it) } + val imageSegments = consecutiveSegments.filter { it is ImageSegment || it is Base64Segment } + val j = i + consecutiveSegments.size if (imageSegments.size > 1) { // Multiple images - render as gallery From 8f1027b55dc9e2c3fce72d180754e9c63ae981f3 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 11 Sep 2025 21:52:36 +0200 Subject: [PATCH 07/25] refactor Removed duplicate GalleryImage boilerplate. Use .chunked() Pull out constants (ASPECT_RATIO, IMAGE_SPACING). --- .../amethyst/ui/components/ImageGallery.kt | 214 ++++++++---------- 1 file changed, 90 insertions(+), 124 deletions(-) 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 index 26868953f..d7bbaf7f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt @@ -22,31 +22,37 @@ package com.vitorpamplona.amethyst.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize 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.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.unit.dp +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.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import kotlinx.collections.immutable.ImmutableList +private const val ASPECT_RATIO = 4f / 3f +private val IMAGE_SPACING: Dp = Size5dp + @Composable private fun GalleryImage( image: MediaUrlImage, allImages: ImmutableList, - modifier: Modifier, + modifier: Modifier = Modifier, roundedCorner: Boolean, contentScale: ContentScale, accountViewModel: AccountViewModel, @@ -69,77 +75,48 @@ fun ImageGallery( modifier: Modifier = Modifier, roundedCorner: Boolean = true, ) { - // Add vertical padding around the entire gallery for better text separation + if (images.isEmpty()) return + Column(modifier = modifier.padding(vertical = Size10dp)) { - when { - images.isEmpty() -> { - // No images to display - } - images.size == 1 -> { - // Single image - display full width - GalleryImage( - image = images.first(), - allImages = images, - modifier = Modifier.fillMaxWidth(), - 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, - ) - } + when (images.size) { + 1 -> SingleImageGallery(images, accountViewModel, roundedCorner) + 2 -> TwoImageGallery(images, accountViewModel, roundedCorner) + 3 -> ThreeImageGallery(images, accountViewModel, roundedCorner) + 4 -> FourImageGallery(images, accountViewModel, roundedCorner) + else -> ManyImageGallery(images, accountViewModel, roundedCorner) } } } +@Composable +private fun SingleImageGallery( + images: ImmutableList, + accountViewModel: AccountViewModel, + roundedCorner: Boolean, +) { + GalleryImage( + image = images.first(), + allImages = images, + modifier = Modifier.fillMaxWidth(), + roundedCorner = roundedCorner, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) +} + @Composable private fun TwoImageGallery( images: ImmutableList, accountViewModel: AccountViewModel, roundedCorner: Boolean, - modifier: Modifier, ) { Row( - modifier = modifier.aspectRatio(4f / 3f), - horizontalArrangement = Arrangement.spacedBy(Size5dp), + modifier = Modifier.aspectRatio(ASPECT_RATIO), + horizontalArrangement = Arrangement.spacedBy(IMAGE_SPACING), ) { - repeat(2) { index -> + images.take(2).forEach { image -> GalleryImage( - image = images[index], + image = image, allImages = images, modifier = Modifier.weight(1f).fillMaxSize(), roundedCorner = roundedCorner, @@ -155,13 +132,11 @@ private fun ThreeImageGallery( images: ImmutableList, accountViewModel: AccountViewModel, roundedCorner: Boolean, - modifier: Modifier, ) { Row( - modifier = modifier.aspectRatio(4f / 3f), - horizontalArrangement = Arrangement.spacedBy(Size5dp), + modifier = Modifier.aspectRatio(ASPECT_RATIO), + horizontalArrangement = Arrangement.spacedBy(IMAGE_SPACING), ) { - // Large image on the left GalleryImage( image = images[0], allImages = images, @@ -171,14 +146,13 @@ private fun ThreeImageGallery( accountViewModel = accountViewModel, ) - // Two smaller images on the right Column( modifier = Modifier.weight(1f).fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(Size5dp), + verticalArrangement = Arrangement.spacedBy(IMAGE_SPACING), ) { - repeat(2) { index -> + images.drop(1).forEach { image -> GalleryImage( - image = images[index + 1], + image = image, allImages = images, modifier = Modifier.weight(1f).fillMaxSize(), roundedCorner = roundedCorner, @@ -195,21 +169,19 @@ private fun FourImageGallery( images: ImmutableList, accountViewModel: AccountViewModel, roundedCorner: Boolean, - modifier: Modifier, ) { Column( - modifier = modifier.aspectRatio(4f / 3f), - verticalArrangement = Arrangement.spacedBy(Size5dp), + modifier = Modifier.aspectRatio(ASPECT_RATIO), + verticalArrangement = Arrangement.spacedBy(IMAGE_SPACING), ) { - repeat(2) { rowIndex -> + images.chunked(2).forEach { rowImages -> Row( modifier = Modifier.weight(1f).fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(Size5dp), + horizontalArrangement = Arrangement.spacedBy(IMAGE_SPACING), ) { - repeat(2) { colIndex -> - val imageIndex = rowIndex * 2 + colIndex + rowImages.forEach { image -> GalleryImage( - image = images[imageIndex], + image = image, allImages = images, modifier = Modifier.weight(1f).fillMaxSize(), roundedCorner = roundedCorner, @@ -227,71 +199,65 @@ private fun ManyImageGallery( images: ImmutableList, accountViewModel: AccountViewModel, roundedCorner: Boolean, - modifier: Modifier, ) { - // Calculate optimal grid layout for many images val columns = when { - images.size <= 6 -> 3 // 3 columns for 5-6 images - images.size <= 9 -> 3 // 3 columns for 7-9 images - else -> 4 // 4 columns for 10+ images + images.size <= 9 -> 3 + else -> 4 } if (images.size <= 20) { - // For smaller sets, use non-lazy Column/Row approach (simpler, no constraint issues) - val rows = (images.size + columns - 1) / columns // Ceiling division - - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(Size5dp), - ) { - repeat(rows) { rowIndex -> + // Non-lazy for small sets + Column(verticalArrangement = Arrangement.spacedBy(Size5dp)) { + images.chunked(columns).forEach { rowImages -> Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Size5dp), ) { - repeat(columns) { colIndex -> - val imageIndex = rowIndex * columns + colIndex - if (imageIndex < images.size) { - GalleryImage( - image = images[imageIndex], - allImages = images, - modifier = Modifier.weight(1f).aspectRatio(1f), - roundedCorner = roundedCorner, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) - } else { - // Empty space for incomplete rows - Box(modifier = Modifier.weight(1f)) - } + rowImages.forEach { image -> + GalleryImage( + image = image, + allImages = images, + modifier = Modifier.weight(1f).aspectRatio(1f), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + repeat(columns - rowImages.size) { + Spacer(Modifier.weight(1f)) } } } } } else { - // For larger sets, use LazyVerticalGrid with explicit height constraint - val rows = (images.size + columns - 1) / columns - // Calculate height: (image height + spacing) * rows - last spacing - // Assume square images with 5dp spacing - val gridHeight = (100 * rows + 5 * (rows - 1)).dp + // Lazy for large sets — expands fully, no independent scroll + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val totalSpacing = Size5dp * (columns - 1) + val imageSize = (maxWidth - totalSpacing) / columns + val rows = (images.size + columns - 1) / columns + val gridHeight = (imageSize * rows) + (Size5dp * (rows - 1)) - LazyVerticalGrid( - columns = GridCells.Fixed(columns), - modifier = modifier.height(gridHeight), // Explicit height constraint - verticalArrangement = Arrangement.spacedBy(Size5dp), - horizontalArrangement = Arrangement.spacedBy(Size5dp), - userScrollEnabled = false, - ) { - items(images) { image -> - GalleryImage( - image = image, - allImages = images, - modifier = Modifier.aspectRatio(1f), - roundedCorner = roundedCorner, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + modifier = + Modifier + .fillMaxWidth() + .height(gridHeight), + verticalArrangement = Arrangement.spacedBy(Size5dp), + horizontalArrangement = Arrangement.spacedBy(Size5dp), + userScrollEnabled = false, + ) { + items(images) { image -> + GalleryImage( + image = image, + allImages = images, + modifier = Modifier.size(imageSize), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } } } } From ee9e102eb22d2b054f16ea05b867e162c60601fa Mon Sep 17 00:00:00 2001 From: davotoula Date: Fri, 12 Sep 2025 10:54:17 +0200 Subject: [PATCH 08/25] removed lazy component from 20+ images case to avoid nesting lazy components --- .../amethyst/ui/components/ImageGallery.kt | 57 +++---------------- 1 file changed, 9 insertions(+), 48 deletions(-) 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 index d7bbaf7f6..2f355ecf0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt @@ -22,19 +22,13 @@ package com.vitorpamplona.amethyst.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize 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.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale @@ -206,58 +200,25 @@ private fun ManyImageGallery( else -> 4 } - if (images.size <= 20) { - // Non-lazy for small sets - Column(verticalArrangement = Arrangement.spacedBy(Size5dp)) { - images.chunked(columns).forEach { rowImages -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(Size5dp), - ) { - rowImages.forEach { image -> - GalleryImage( - image = image, - allImages = images, - modifier = Modifier.weight(1f).aspectRatio(1f), - roundedCorner = roundedCorner, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) - } - repeat(columns - rowImages.size) { - Spacer(Modifier.weight(1f)) - } - } - } - } - } else { - // Lazy for large sets — expands fully, no independent scroll - BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { - val totalSpacing = Size5dp * (columns - 1) - val imageSize = (maxWidth - totalSpacing) / columns - val rows = (images.size + columns - 1) / columns - val gridHeight = (imageSize * rows) + (Size5dp * (rows - 1)) - - LazyVerticalGrid( - columns = GridCells.Fixed(columns), - modifier = - Modifier - .fillMaxWidth() - .height(gridHeight), - verticalArrangement = Arrangement.spacedBy(Size5dp), + Column(verticalArrangement = Arrangement.spacedBy(Size5dp)) { + images.chunked(columns).forEach { rowImages -> + Row( + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Size5dp), - userScrollEnabled = false, ) { - items(images) { image -> + rowImages.forEach { image -> GalleryImage( image = image, allImages = images, - modifier = Modifier.size(imageSize), + modifier = Modifier.weight(1f).aspectRatio(1f), roundedCorner = roundedCorner, contentScale = ContentScale.Crop, accountViewModel = accountViewModel, ) } + repeat(columns - rowImages.size) { + Spacer(Modifier.weight(1f)) + } } } } From 16e3cba651c52c3bf5eaf5bc25cb66bb9b335b67 Mon Sep 17 00:00:00 2001 From: davotoula Date: Fri, 12 Sep 2025 12:14:05 +0200 Subject: [PATCH 09/25] found a place where image gallery was used for single images --- .../amethyst/ui/components/RichTextViewer.kt | 55 ++++++++++++++----- 1 file changed, 42 insertions(+), 13 deletions(-) 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 ddc151c76..051289452 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 @@ -404,10 +404,9 @@ private fun RenderSingleParagraphWithFlowRow( FlowRow( horizontalArrangement = Arrangement.spacedBy(spaceWidth), ) { - RenderWordsWithImageGallery( - words, - context, - ) + words.forEach { word -> + RenderWordWithPreview(word, context) + } } } } @@ -797,7 +796,7 @@ fun CoreSecretMessage( ) if (localSecretContent.paragraphs.size == 1) { - RenderWordsWithImageGallery( + RenderSecretParagraphOptimized( localSecretContent.paragraphs[0].words.toImmutableList(), context, ) @@ -806,14 +805,44 @@ fun CoreSecretMessage( Column(CashuCardBorders) { localSecretContent.paragraphs.forEach { paragraph -> - FlowRow( - modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), - horizontalArrangement = Arrangement.spacedBy(spaceWidth), - ) { - RenderWordsWithImageGallery( - paragraph.words.toImmutableList(), - context, - ) + RenderSecretParagraphOptimized( + paragraph.words.toImmutableList(), + context, + spaceWidth, + paragraph.isRTL, + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun RenderSecretParagraphOptimized( + words: ImmutableList, + context: RenderContext, + spaceWidth: Dp? = null, + isRTL: Boolean = false, +) { + // Check if we need single-image optimization + val imageSegments = words.filter { it is ImageSegment || it is Base64Segment } + + if (imageSegments.size > 1) { + // Multiple images - use gallery logic + RenderWordsWithImageGallery(words, context) + } else { + // Single or no images - render directly with FlowRow for optimal performance + val actualSpaceWidth = spaceWidth ?: measureSpaceWidth(LocalTextStyle.current) + + CompositionLocalProvider( + LocalLayoutDirection provides if (isRTL) LayoutDirection.Rtl else LayoutDirection.Ltr, + LocalTextStyle provides LocalTextStyle.current, + ) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(actualSpaceWidth), + ) { + words.forEach { word -> + RenderWordWithPreview(word, context) } } } From 6dd2f47fc99fe9881f1b1ecd893015252d37ccc2 Mon Sep 17 00:00:00 2001 From: davotoula Date: Fri, 12 Sep 2025 21:39:57 +0200 Subject: [PATCH 10/25] catch exception when sharing image Show toast --- .../ui/components/ZoomableContentView.kt | 31 ++++++++++++------- amethyst/src/main/res/values/strings.xml | 1 + 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 888e81e29..12fa577dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.components import android.content.Context import android.content.Intent +import android.util.Log +import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.fadeIn @@ -786,21 +788,26 @@ private suspend fun shareImageFile( videoUri: String, mimeType: String?, ) { - // Get sharable URI and file extension - val (uri, fileExtension) = ShareHelper.getSharableUriFromUrl(context, videoUri) + try { + // Get sharable URI and file extension + val (uri, fileExtension) = ShareHelper.getSharableUriFromUrl(context, videoUri) - // Determine mime type, use provided or derive from extension - val determinedMimeType = mimeType ?: "image/$fileExtension" + // Determine mime type, use provided or derive from extension + val determinedMimeType = mimeType ?: "image/$fileExtension" - // Create share intent - val shareIntent = - Intent(Intent.ACTION_SEND).apply { - type = determinedMimeType - putExtra(Intent.EXTRA_STREAM, uri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } + // Create share intent + val shareIntent = + Intent(Intent.ACTION_SEND).apply { + type = determinedMimeType + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } - context.startActivity(Intent.createChooser(shareIntent, null)) + context.startActivity(Intent.createChooser(shareIntent, null)) + } catch (e: Exception) { + Log.w("ZoomableContentView", "Failed to share image: $videoUri", e) + Toast.makeText(context, context.getString(R.string.unable_to_share_image), Toast.LENGTH_SHORT).show() + } } private fun verifyHash(content: MediaUrlContent): Boolean? { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0c41605dc..1cb4a92c5 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1238,6 +1238,7 @@ Chat Relay The relay that all users of this chat connect to Share image… + Unable to share image, please try again later… Search hashtag: #%1$s From afd20d3f8fda9fd91df8d8e3c85f77b9e42e6dbe Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 13 Sep 2025 10:46:27 -0400 Subject: [PATCH 11/25] Avoids using JSON parsers with DataStore to speed up loading time (loading the parser itself takes ~300ms) --- .gitignore | 1 + .../model/preferences/UISharedPreferences.kt | 119 +++++++++++++++--- .../amethyst/service/location/LocationFlow.kt | 8 +- 3 files changed, 104 insertions(+), 24 deletions(-) diff --git a/.gitignore b/.gitignore index 3a4da2a3f..b4386f523 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ /.idea/ChatHistory_schema_v2.xml /.idea/artifacts/* /.idea/kotlinNotebook.xml +/.idea/ChatHistory_schema_v3.xml .DS_Store /build /captures diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index 791f72ca5..c0e1d4dcc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -27,16 +27,23 @@ import androidx.compose.runtime.Stable import androidx.core.os.LocaleListCompat import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore -import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.model.BooleanType +import com.vitorpamplona.amethyst.model.ConnectivityType +import com.vitorpamplona.amethyst.model.FeatureSetType +import com.vitorpamplona.amethyst.model.ProfileGalleryType +import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.UiSettings import com.vitorpamplona.amethyst.model.UiSettingsFlow +import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk.save import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow -import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper +import com.vitorpamplona.amethyst.ui.tor.TorType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -48,6 +55,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.runBlocking +import kotlin.coroutines.cancellation.CancellationException val Context.sharedPreferencesDataStore: DataStore by preferencesDataStore(name = "shared_settings") @@ -57,7 +65,18 @@ class UiSharedPreferences( val scope: CoroutineScope, ) { companion object { - val UI_SETTINGS = stringPreferencesKey("ui_settings") + // loads faster when individualized + val UI_THEME = stringPreferencesKey("ui.theme") + val UI_LANGUAGE = stringPreferencesKey("ui.language") + val UI_SHOW_IMAGES = stringPreferencesKey("ui.show_images") + val UI_START_PLAYBACK = stringPreferencesKey("ui.start_playback") + val UI_SHOW_URL_PREVIEW = stringPreferencesKey("ui.show_url_preview") + val UI_HIDE_NAVIGATION_BARS = stringPreferencesKey("ui.hide_navigation_bars") + val UI_SHOW_PROFILE_PICTURES = stringPreferencesKey("ui.show_profile_pictures") + val UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR = booleanPreferencesKey("ui.dont_show_push_notification_selector") + val UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS = booleanPreferencesKey("ui.dont_ask_for_notification_permissions") + val UI_FEATURE_SET = stringPreferencesKey("ui.feature_set") + val UI_GALLERY_SET = stringPreferencesKey("ui.gallery_set") } // UI Preferences. Makes sure to wait for it to avoid blinking themes and language preferences @@ -96,30 +115,54 @@ class UiSharedPreferences( try { // Get the preference flow and take the first value. val preferences = context.sharedPreferencesDataStore.data.first() - val newVersion = preferences[UI_SETTINGS]?.let { JsonMapper.mapper.readValue(it) } - if (newVersion != null) { - newVersion - } else { + UiSettings( + theme = preferences[UI_THEME]?.let { ThemeType.valueOf(it) } ?: ThemeType.SYSTEM, + preferredLanguage = preferences[UI_LANGUAGE]?.ifBlank { null }, + automaticallyShowImages = preferences[UI_SHOW_IMAGES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, + automaticallyStartPlayback = preferences[UI_START_PLAYBACK]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, + automaticallyShowUrlPreview = preferences[UI_SHOW_URL_PREVIEW]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, + automaticallyHideNavigationBars = preferences[UI_HIDE_NAVIGATION_BARS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, + automaticallyShowProfilePictures = preferences[UI_SHOW_PROFILE_PICTURES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, + dontShowPushNotificationSelector = preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] ?: false, + dontAskForNotificationPermissions = preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] ?: false, + featureSet = preferences[UI_FEATURE_SET]?.let { FeatureSetType.valueOf(it) } ?: FeatureSetType.SIMPLIFIED, + gallerySet = preferences[UI_GALLERY_SET]?.let { ProfileGalleryType.valueOf(it) } ?: ProfileGalleryType.CLASSIC, + ) + } catch (e: Exception) { + if (e is CancellationException) throw e + // Log any errors that occur while reading the DataStore. + Log.e("SharedPreferences", "Error reading DataStore preferences: ${e.message}") + + try { val oldVersion = LocalPreferences.loadSharedSettings() if (oldVersion != null) { save(oldVersion) } oldVersion + } catch (e: Exception) { + if (e is CancellationException) throw e + null } - } catch (e: Exception) { - // Log any errors that occur while reading the DataStore. - Log.e("SharedPreferences", "Error reading DataStore preferences: ${e.message}") - null } suspend fun save(sharedSettings: UiSettings) { try { - val str = JsonMapper.mapper.writeValueAsString(sharedSettings) context.sharedPreferencesDataStore.edit { preferences -> - preferences[UI_SETTINGS] = str + preferences[UI_THEME] = sharedSettings.theme.name + preferences[UI_LANGUAGE] = sharedSettings.preferredLanguage ?: "" + preferences[UI_SHOW_IMAGES] = sharedSettings.automaticallyShowImages.name + preferences[UI_START_PLAYBACK] = sharedSettings.automaticallyStartPlayback.name + preferences[UI_SHOW_URL_PREVIEW] = sharedSettings.automaticallyShowUrlPreview.name + preferences[UI_HIDE_NAVIGATION_BARS] = sharedSettings.automaticallyHideNavigationBars.name + preferences[UI_SHOW_PROFILE_PICTURES] = sharedSettings.automaticallyShowProfilePictures.name + preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] = sharedSettings.dontShowPushNotificationSelector + preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] = sharedSettings.dontAskForNotificationPermissions + preferences[UI_FEATURE_SET] = sharedSettings.featureSet.name + preferences[UI_GALLERY_SET] = sharedSettings.gallerySet.name } } catch (e: Exception) { + if (e is CancellationException) throw e // Log any errors that occur while reading the DataStore. Log.e("SharedPreferences", "Error saving DataStore preferences: ${e.message}") } @@ -132,7 +175,20 @@ class TorSharedPreferences( val scope: CoroutineScope, ) { companion object { - val TOR_SETTINGS = stringPreferencesKey("tor_settings") + // loads faster when individualized + val TOR_TYPE_KEY = stringPreferencesKey("tor.torType") + val EXTERNAL_SOCKS_PORT_KEY = intPreferencesKey("tor.externalSocksPort") + val ONION_RELAYS_VIA_TOR_KEY = booleanPreferencesKey("tor.onionRelaysViaTor") + val DM_RELAYS_VIA_TOR_KEY = booleanPreferencesKey("tor.dmRelaysViaTor") + val NEW_RELAYS_VIA_TOR_KEY = booleanPreferencesKey("tor.newRelaysViaTor") + val TRUSTED_RELAYS_VIA_TOR_KEY = booleanPreferencesKey("tor.trustedRelaysViaTor") + val URL_PREVIEWS_VIA_TOR_KEY = booleanPreferencesKey("tor.urlPreviewsViaTor") + val PROFILE_PICS_VIA_TOR_KEY = booleanPreferencesKey("tor.profilePicsViaTor") + val IMAGES_VIA_TOR_KEY = booleanPreferencesKey("tor.imagesViaTor") + val VIDEOS_VIA_TOR_KEY = booleanPreferencesKey("tor.videosViaTor") + val MONEY_OPERATIONS_VIA_TOR_KEY = booleanPreferencesKey("tor.moneyOperationsViaTor") + val NIP05_VERIFICATIONS_VIA_TOR_KEY = booleanPreferencesKey("tor.nip05VerificationsViaTor") + val MEDIA_UPLOADS_VIA_TOR_KEY = booleanPreferencesKey("tor.mediaUploadsViaTor") } // Tor Preferences. Makes sure to wait for it to avoid connecting with random IPs @@ -158,10 +214,23 @@ class TorSharedPreferences( try { // Get the preference flow and take the first value. val preferences = context.sharedPreferencesDataStore.data.first() - preferences[TOR_SETTINGS]?.let { - JsonMapper.mapper.readValue(it) - } + TorSettings( + torType = preferences[TOR_TYPE_KEY]?.let { TorType.valueOf(it) } ?: TorType.INTERNAL, + externalSocksPort = preferences[EXTERNAL_SOCKS_PORT_KEY] ?: 9050, + onionRelaysViaTor = preferences[ONION_RELAYS_VIA_TOR_KEY] ?: true, + dmRelaysViaTor = preferences[DM_RELAYS_VIA_TOR_KEY] ?: true, + newRelaysViaTor = preferences[NEW_RELAYS_VIA_TOR_KEY] ?: true, + trustedRelaysViaTor = preferences[TRUSTED_RELAYS_VIA_TOR_KEY] ?: false, + urlPreviewsViaTor = preferences[URL_PREVIEWS_VIA_TOR_KEY] ?: false, + profilePicsViaTor = preferences[PROFILE_PICS_VIA_TOR_KEY] ?: false, + imagesViaTor = preferences[IMAGES_VIA_TOR_KEY] ?: false, + videosViaTor = preferences[VIDEOS_VIA_TOR_KEY] ?: false, + moneyOperationsViaTor = preferences[MONEY_OPERATIONS_VIA_TOR_KEY] ?: false, + nip05VerificationsViaTor = preferences[NIP05_VERIFICATIONS_VIA_TOR_KEY] ?: false, + nip96UploadsViaTor = preferences[MEDIA_UPLOADS_VIA_TOR_KEY] ?: false, + ) } catch (e: Exception) { + if (e is CancellationException) throw e // Log any errors that occur while reading the DataStore. Log.e("SharedPreferences", "Error reading DataStore preferences: ${e.message}") null @@ -169,11 +238,23 @@ class TorSharedPreferences( suspend fun save(torSettings: TorSettings) { try { - val str = JsonMapper.mapper.writeValueAsString(torSettings) context.sharedPreferencesDataStore.edit { preferences -> - preferences[TOR_SETTINGS] = str + preferences[TOR_TYPE_KEY] = torSettings.torType.name + preferences[EXTERNAL_SOCKS_PORT_KEY] = torSettings.externalSocksPort + preferences[ONION_RELAYS_VIA_TOR_KEY] = torSettings.onionRelaysViaTor + preferences[DM_RELAYS_VIA_TOR_KEY] = torSettings.dmRelaysViaTor + preferences[NEW_RELAYS_VIA_TOR_KEY] = torSettings.newRelaysViaTor + preferences[TRUSTED_RELAYS_VIA_TOR_KEY] = torSettings.trustedRelaysViaTor + preferences[URL_PREVIEWS_VIA_TOR_KEY] = torSettings.urlPreviewsViaTor + preferences[PROFILE_PICS_VIA_TOR_KEY] = torSettings.profilePicsViaTor + preferences[IMAGES_VIA_TOR_KEY] = torSettings.imagesViaTor + preferences[VIDEOS_VIA_TOR_KEY] = torSettings.videosViaTor + preferences[MONEY_OPERATIONS_VIA_TOR_KEY] = torSettings.moneyOperationsViaTor + preferences[NIP05_VERIFICATIONS_VIA_TOR_KEY] = torSettings.nip05VerificationsViaTor + preferences[MEDIA_UPLOADS_VIA_TOR_KEY] = torSettings.nip96UploadsViaTor } } catch (e: Exception) { + if (e is CancellationException) throw e // Log any errors that occur while reading the DataStore. Log.e("SharedPreferences", "Error saving DataStore preferences: ${e.message}") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt index b1efde02d..de6d05027 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt @@ -47,11 +47,9 @@ class LocationFlow( val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager val locationCallback = - object : LocationListener { - override fun onLocationChanged(location: Location) { - Log.d("LocationFlow", "onLocationChanged $location") - launch { send(location) } - } + LocationListener { location -> + Log.d("LocationFlow", "onLocationChanged $location") + launch { send(location) } } locationManager.allProviders.forEach { From 613978d7ba9f3e95f50130489f37861b141f35d7 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sat, 13 Sep 2025 14:48:30 +0000 Subject: [PATCH 12/25] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-pl-rPL/strings.xml | 1 + amethyst/src/main/res/values-zh-rCN/strings.xml | 1 + 2 files changed, 2 insertions(+) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index c38a05009..82fe2b56d 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -437,6 +437,7 @@ Nie Lista obserwowanych Obserwowane + Wszyscy obserwujący Obserwuje przez proxy W pobliżu Wszystkie diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 9877508b4..0cac7d7bb 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -440,6 +440,7 @@ 关注列表 所有关注 + 所有用户关注 通过代理关注 周围的人 全球 From c5b60c3bd85e61ecab5152c04e36250eb539dd93 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 13 Sep 2025 10:50:19 -0400 Subject: [PATCH 13/25] Refactor the UISharedPreferences into two files --- .../model/preferences/TorSharedPreferences.kt | 137 ++++++++++++++++++ .../model/preferences/UISharedPreferences.kt | 96 ------------ 2 files changed, 137 insertions(+), 96 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt new file mode 100644 index 000000000..af7fdaa96 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt @@ -0,0 +1,137 @@ +/** + * 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.model.preferences + +import android.content.Context +import android.util.Log +import androidx.compose.runtime.Stable +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk.save +import com.vitorpamplona.amethyst.ui.tor.TorSettings +import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow +import com.vitorpamplona.amethyst.ui.tor.TorType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.runBlocking +import kotlin.coroutines.cancellation.CancellationException + +@Stable +class TorSharedPreferences( + val context: Context, + val scope: CoroutineScope, +) { + companion object { + // loads faster when individualized + val TOR_TYPE_KEY = stringPreferencesKey("tor.torType") + val EXTERNAL_SOCKS_PORT_KEY = intPreferencesKey("tor.externalSocksPort") + val ONION_RELAYS_VIA_TOR_KEY = booleanPreferencesKey("tor.onionRelaysViaTor") + val DM_RELAYS_VIA_TOR_KEY = booleanPreferencesKey("tor.dmRelaysViaTor") + val NEW_RELAYS_VIA_TOR_KEY = booleanPreferencesKey("tor.newRelaysViaTor") + val TRUSTED_RELAYS_VIA_TOR_KEY = booleanPreferencesKey("tor.trustedRelaysViaTor") + val URL_PREVIEWS_VIA_TOR_KEY = booleanPreferencesKey("tor.urlPreviewsViaTor") + val PROFILE_PICS_VIA_TOR_KEY = booleanPreferencesKey("tor.profilePicsViaTor") + val IMAGES_VIA_TOR_KEY = booleanPreferencesKey("tor.imagesViaTor") + val VIDEOS_VIA_TOR_KEY = booleanPreferencesKey("tor.videosViaTor") + val MONEY_OPERATIONS_VIA_TOR_KEY = booleanPreferencesKey("tor.moneyOperationsViaTor") + val NIP05_VERIFICATIONS_VIA_TOR_KEY = booleanPreferencesKey("tor.nip05VerificationsViaTor") + val MEDIA_UPLOADS_VIA_TOR_KEY = booleanPreferencesKey("tor.mediaUploadsViaTor") + } + + // Tor Preferences. Makes sure to wait for it to avoid connecting with random IPs + val value = + runBlocking { + TorSettingsFlow.build(torPreferences() ?: TorSettings()) + } + + @OptIn(FlowPreview::class) + val saving = + value.propertyWatchFlow + .debounce(1000) + .distinctUntilChanged() + .onEach(::save) + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + value.toSettings(), + ) + + suspend fun torPreferences(): TorSettings? = + try { + // Get the preference flow and take the first value. + val preferences = context.sharedPreferencesDataStore.data.first() + TorSettings( + torType = preferences[TOR_TYPE_KEY]?.let { TorType.valueOf(it) } ?: TorType.INTERNAL, + externalSocksPort = preferences[EXTERNAL_SOCKS_PORT_KEY] ?: 9050, + onionRelaysViaTor = preferences[ONION_RELAYS_VIA_TOR_KEY] ?: true, + dmRelaysViaTor = preferences[DM_RELAYS_VIA_TOR_KEY] ?: true, + newRelaysViaTor = preferences[NEW_RELAYS_VIA_TOR_KEY] ?: true, + trustedRelaysViaTor = preferences[TRUSTED_RELAYS_VIA_TOR_KEY] ?: false, + urlPreviewsViaTor = preferences[URL_PREVIEWS_VIA_TOR_KEY] ?: false, + profilePicsViaTor = preferences[PROFILE_PICS_VIA_TOR_KEY] ?: false, + imagesViaTor = preferences[IMAGES_VIA_TOR_KEY] ?: false, + videosViaTor = preferences[VIDEOS_VIA_TOR_KEY] ?: false, + moneyOperationsViaTor = preferences[MONEY_OPERATIONS_VIA_TOR_KEY] ?: false, + nip05VerificationsViaTor = preferences[NIP05_VERIFICATIONS_VIA_TOR_KEY] ?: false, + nip96UploadsViaTor = preferences[MEDIA_UPLOADS_VIA_TOR_KEY] ?: false, + ) + } catch (e: Exception) { + if (e is CancellationException) throw e + // Log any errors that occur while reading the DataStore. + Log.e("SharedPreferences", "Error reading DataStore preferences: ${e.message}") + null + } + + suspend fun save(torSettings: TorSettings) { + try { + context.sharedPreferencesDataStore.edit { preferences -> + preferences[TOR_TYPE_KEY] = torSettings.torType.name + preferences[EXTERNAL_SOCKS_PORT_KEY] = torSettings.externalSocksPort + preferences[ONION_RELAYS_VIA_TOR_KEY] = torSettings.onionRelaysViaTor + preferences[DM_RELAYS_VIA_TOR_KEY] = torSettings.dmRelaysViaTor + preferences[NEW_RELAYS_VIA_TOR_KEY] = torSettings.newRelaysViaTor + preferences[TRUSTED_RELAYS_VIA_TOR_KEY] = torSettings.trustedRelaysViaTor + preferences[URL_PREVIEWS_VIA_TOR_KEY] = torSettings.urlPreviewsViaTor + preferences[PROFILE_PICS_VIA_TOR_KEY] = torSettings.profilePicsViaTor + preferences[IMAGES_VIA_TOR_KEY] = torSettings.imagesViaTor + preferences[VIDEOS_VIA_TOR_KEY] = torSettings.videosViaTor + preferences[MONEY_OPERATIONS_VIA_TOR_KEY] = torSettings.moneyOperationsViaTor + preferences[NIP05_VERIFICATIONS_VIA_TOR_KEY] = torSettings.nip05VerificationsViaTor + preferences[MEDIA_UPLOADS_VIA_TOR_KEY] = torSettings.nip96UploadsViaTor + } + } catch (e: Exception) { + if (e is CancellationException) throw e + // Log any errors that occur while reading the DataStore. + Log.e("SharedPreferences", "Error saving DataStore preferences: ${e.message}") + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index c0e1d4dcc..281e2a20d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -29,7 +29,6 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.vitorpamplona.amethyst.LocalPreferences @@ -41,9 +40,6 @@ import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.UiSettings import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk.save -import com.vitorpamplona.amethyst.ui.tor.TorSettings -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow -import com.vitorpamplona.amethyst.ui.tor.TorType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -168,95 +164,3 @@ class UiSharedPreferences( } } } - -@Stable -class TorSharedPreferences( - val context: Context, - val scope: CoroutineScope, -) { - companion object { - // loads faster when individualized - val TOR_TYPE_KEY = stringPreferencesKey("tor.torType") - val EXTERNAL_SOCKS_PORT_KEY = intPreferencesKey("tor.externalSocksPort") - val ONION_RELAYS_VIA_TOR_KEY = booleanPreferencesKey("tor.onionRelaysViaTor") - val DM_RELAYS_VIA_TOR_KEY = booleanPreferencesKey("tor.dmRelaysViaTor") - val NEW_RELAYS_VIA_TOR_KEY = booleanPreferencesKey("tor.newRelaysViaTor") - val TRUSTED_RELAYS_VIA_TOR_KEY = booleanPreferencesKey("tor.trustedRelaysViaTor") - val URL_PREVIEWS_VIA_TOR_KEY = booleanPreferencesKey("tor.urlPreviewsViaTor") - val PROFILE_PICS_VIA_TOR_KEY = booleanPreferencesKey("tor.profilePicsViaTor") - val IMAGES_VIA_TOR_KEY = booleanPreferencesKey("tor.imagesViaTor") - val VIDEOS_VIA_TOR_KEY = booleanPreferencesKey("tor.videosViaTor") - val MONEY_OPERATIONS_VIA_TOR_KEY = booleanPreferencesKey("tor.moneyOperationsViaTor") - val NIP05_VERIFICATIONS_VIA_TOR_KEY = booleanPreferencesKey("tor.nip05VerificationsViaTor") - val MEDIA_UPLOADS_VIA_TOR_KEY = booleanPreferencesKey("tor.mediaUploadsViaTor") - } - - // Tor Preferences. Makes sure to wait for it to avoid connecting with random IPs - val value = - runBlocking { - TorSettingsFlow.build(torPreferences() ?: TorSettings()) - } - - @OptIn(FlowPreview::class) - val saving = - value.propertyWatchFlow - .debounce(1000) - .distinctUntilChanged() - .onEach(::save) - .flowOn(Dispatchers.IO) - .stateIn( - scope, - SharingStarted.Eagerly, - value.toSettings(), - ) - - suspend fun torPreferences(): TorSettings? = - try { - // Get the preference flow and take the first value. - val preferences = context.sharedPreferencesDataStore.data.first() - TorSettings( - torType = preferences[TOR_TYPE_KEY]?.let { TorType.valueOf(it) } ?: TorType.INTERNAL, - externalSocksPort = preferences[EXTERNAL_SOCKS_PORT_KEY] ?: 9050, - onionRelaysViaTor = preferences[ONION_RELAYS_VIA_TOR_KEY] ?: true, - dmRelaysViaTor = preferences[DM_RELAYS_VIA_TOR_KEY] ?: true, - newRelaysViaTor = preferences[NEW_RELAYS_VIA_TOR_KEY] ?: true, - trustedRelaysViaTor = preferences[TRUSTED_RELAYS_VIA_TOR_KEY] ?: false, - urlPreviewsViaTor = preferences[URL_PREVIEWS_VIA_TOR_KEY] ?: false, - profilePicsViaTor = preferences[PROFILE_PICS_VIA_TOR_KEY] ?: false, - imagesViaTor = preferences[IMAGES_VIA_TOR_KEY] ?: false, - videosViaTor = preferences[VIDEOS_VIA_TOR_KEY] ?: false, - moneyOperationsViaTor = preferences[MONEY_OPERATIONS_VIA_TOR_KEY] ?: false, - nip05VerificationsViaTor = preferences[NIP05_VERIFICATIONS_VIA_TOR_KEY] ?: false, - nip96UploadsViaTor = preferences[MEDIA_UPLOADS_VIA_TOR_KEY] ?: false, - ) - } catch (e: Exception) { - if (e is CancellationException) throw e - // Log any errors that occur while reading the DataStore. - Log.e("SharedPreferences", "Error reading DataStore preferences: ${e.message}") - null - } - - suspend fun save(torSettings: TorSettings) { - try { - context.sharedPreferencesDataStore.edit { preferences -> - preferences[TOR_TYPE_KEY] = torSettings.torType.name - preferences[EXTERNAL_SOCKS_PORT_KEY] = torSettings.externalSocksPort - preferences[ONION_RELAYS_VIA_TOR_KEY] = torSettings.onionRelaysViaTor - preferences[DM_RELAYS_VIA_TOR_KEY] = torSettings.dmRelaysViaTor - preferences[NEW_RELAYS_VIA_TOR_KEY] = torSettings.newRelaysViaTor - preferences[TRUSTED_RELAYS_VIA_TOR_KEY] = torSettings.trustedRelaysViaTor - preferences[URL_PREVIEWS_VIA_TOR_KEY] = torSettings.urlPreviewsViaTor - preferences[PROFILE_PICS_VIA_TOR_KEY] = torSettings.profilePicsViaTor - preferences[IMAGES_VIA_TOR_KEY] = torSettings.imagesViaTor - preferences[VIDEOS_VIA_TOR_KEY] = torSettings.videosViaTor - preferences[MONEY_OPERATIONS_VIA_TOR_KEY] = torSettings.moneyOperationsViaTor - preferences[NIP05_VERIFICATIONS_VIA_TOR_KEY] = torSettings.nip05VerificationsViaTor - preferences[MEDIA_UPLOADS_VIA_TOR_KEY] = torSettings.nip96UploadsViaTor - } - } catch (e: Exception) { - if (e is CancellationException) throw e - // Log any errors that occur while reading the DataStore. - Log.e("SharedPreferences", "Error saving DataStore preferences: ${e.message}") - } - } -} From 8e39da347aa8d12eeb5ebc25b2a630077cef3f14 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 13 Sep 2025 18:16:43 +0200 Subject: [PATCH 14/25] don't use image gallery for secret message --- .../amethyst/ui/components/RichTextViewer.kt | 58 +++++-------------- 1 file changed, 16 insertions(+), 42 deletions(-) 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 051289452..e1bfa656b 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 @@ -796,53 +796,27 @@ fun CoreSecretMessage( ) if (localSecretContent.paragraphs.size == 1) { - RenderSecretParagraphOptimized( - localSecretContent.paragraphs[0].words.toImmutableList(), - context, - ) + localSecretContent.paragraphs[0].words.forEach { word -> + RenderWordWithPreview( + word, + context, + ) + } } else if (localSecretContent.paragraphs.size > 1) { val spaceWidth = measureSpaceWidth(LocalTextStyle.current) Column(CashuCardBorders) { localSecretContent.paragraphs.forEach { paragraph -> - RenderSecretParagraphOptimized( - paragraph.words.toImmutableList(), - context, - spaceWidth, - paragraph.isRTL, - ) - } - } - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun RenderSecretParagraphOptimized( - words: ImmutableList, - context: RenderContext, - spaceWidth: Dp? = null, - isRTL: Boolean = false, -) { - // Check if we need single-image optimization - val imageSegments = words.filter { it is ImageSegment || it is Base64Segment } - - if (imageSegments.size > 1) { - // Multiple images - use gallery logic - RenderWordsWithImageGallery(words, context) - } else { - // Single or no images - render directly with FlowRow for optimal performance - val actualSpaceWidth = spaceWidth ?: measureSpaceWidth(LocalTextStyle.current) - - CompositionLocalProvider( - LocalLayoutDirection provides if (isRTL) LayoutDirection.Rtl else LayoutDirection.Ltr, - LocalTextStyle provides LocalTextStyle.current, - ) { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(actualSpaceWidth), - ) { - words.forEach { word -> - RenderWordWithPreview(word, context) + FlowRow( + modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), + horizontalArrangement = Arrangement.spacedBy(spaceWidth), + ) { + paragraph.words.forEach { word -> + RenderWordWithPreview( + word, + context, + ) + } } } } From a21b6115c7e886fc33e54b44088a1c7eaebf776c Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 13 Sep 2025 20:44:00 +0200 Subject: [PATCH 15/25] Single pass to avoid multiple intermediate lists O(n) traversal (no drop/takeWhile/filter allocations) Mutable buffer for collecting images --- .../amethyst/ui/components/RichTextViewer.kt | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) 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 e1bfa656b..a8540377a 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 @@ -561,22 +561,27 @@ private fun RenderWordsWithImageGallery( context: RenderContext, ) { var i = 0 - while (i < words.size) { + val n = words.size + + while (i < n) { val word = words[i] if (word is ImageSegment || word is Base64Segment) { - // Collect consecutive images (skipping whitespace) using takeWhile - fun isImageOrWhitespace(segment: Segment): Boolean = - segment is ImageSegment || - segment is Base64Segment || - (segment is RegularTextSegment && segment.segmentText.isBlank()) + // Collect consecutive image/whitespace segments without extra list allocations + val imageSegments = mutableListOf() + var j = i - val consecutiveSegments = words.drop(i).takeWhile { isImageOrWhitespace(it) } - val imageSegments = consecutiveSegments.filter { it is ImageSegment || it is Base64Segment } - val j = i + consecutiveSegments.size + while (j < n) { + val seg = words[j] + when { + seg is ImageSegment || seg is Base64Segment -> imageSegments.add(seg) + seg is RegularTextSegment && seg.segmentText.isBlank() -> { /* skip whitespace */ } + else -> break + } + j++ + } if (imageSegments.size > 1) { - // Multiple images - render as gallery val imageContents = imageSegments .mapNotNull { segment -> @@ -592,13 +597,11 @@ private fun RenderWordsWithImageGallery( ) } } else { - // Single image - render normally - RenderWordWithPreview(word, context) + RenderWordWithPreview(imageSegments.firstOrNull() ?: word, context) } - i = j // Skip processed images + i = j // jump past processed run } else { - // Non-image word - render normally RenderWordWithPreview(word, context) i++ } From bfc920ca042e2c0727889ee492fa8937a94ae4ad Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 13 Sep 2025 21:09:27 +0200 Subject: [PATCH 16/25] Added logging of image gallery computation time --- .../vitorpamplona/amethyst/ui/components/RichTextViewer.kt | 5 +++++ 1 file changed, 5 insertions(+) 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 a8540377a..617149b5d 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 @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.components +import android.util.Log import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -560,6 +561,8 @@ private fun RenderWordsWithImageGallery( words: ImmutableList, context: RenderContext, ) { + val startTime = System.currentTimeMillis() + var i = 0 val n = words.size @@ -606,6 +609,8 @@ private fun RenderWordsWithImageGallery( i++ } } + + Log.d("RichTextViewer", "RenderWordsWithImageGallery took ${System.currentTimeMillis() - startTime}ms for ${words.size} segments") } @Composable From 8f0a829a4d479ab2904cb2a9f359f971ec333f18 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 13 Sep 2025 23:32:17 +0200 Subject: [PATCH 17/25] Optimised collectConsecutiveImageParagraphs: continue statements for early loop continuation --- .../amethyst/ui/components/RichTextViewer.kt | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) 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 617149b5d..b78ff0b43 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 @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.components -import android.util.Log import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -434,34 +433,36 @@ private fun collectConsecutiveImageParagraphs( ): Pair, Int> { val imageParagraphs = mutableListOf() var j = startIndex + while (j < paragraphs.size) { val currentParagraph = paragraphs[j] - val isEmpty = - currentParagraph.words.isEmpty() || - ( - currentParagraph.words.size == 1 && - currentParagraph.words.first() is RegularTextSegment && - currentParagraph.words - .first() - .segmentText - .isBlank() - ) + val words = currentParagraph.words - val isCurrentImageOnly = - currentParagraph.words.isNotEmpty() && isImageOnlyParagraph(currentParagraph) + // Fast path for empty check + if (words.isEmpty()) { + j++ + continue + } - if (isCurrentImageOnly) { + // Check for single whitespace word + if (words.size == 1) { + val firstWord = words.first() + if (firstWord is RegularTextSegment && firstWord.segmentText.isBlank()) { + j++ + continue + } + } + + // Check if it's an image-only paragraph + if (isImageOnlyParagraph(currentParagraph)) { imageParagraphs.add(currentParagraph) j++ - } else if (isEmpty) { - // Skip empty paragraphs but continue looking for consecutive images - j++ } else { - // Hit a non-empty, non-image paragraph - stop collecting break } } - return Pair(imageParagraphs, j) // Return collected paragraphs and next index to process + + return imageParagraphs to j } @OptIn(ExperimentalLayoutApi::class) @@ -561,8 +562,6 @@ private fun RenderWordsWithImageGallery( words: ImmutableList, context: RenderContext, ) { - val startTime = System.currentTimeMillis() - var i = 0 val n = words.size @@ -609,8 +608,6 @@ private fun RenderWordsWithImageGallery( i++ } } - - Log.d("RichTextViewer", "RenderWordsWithImageGallery took ${System.currentTimeMillis() - startTime}ms for ${words.size} segments") } @Composable From 7e80ed2af91554843f3c84b8a151b53f990e8e50 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 14 Sep 2025 11:40:20 +0200 Subject: [PATCH 18/25] Fix for images are mixed with text in a single paragraph nevent1qqs04pmdf8guxpyhjuvpeg72ccwuzje7vhlm09szxl2d8x28cse5fzgpz9mhxue69uhkummnw3ezuamfdejj7q3qwl89d7yazg500lehg08p45dj2jzhhyqg2erj067458e3wd30djnsxpqqqqqqzmdjgxd --- .../amethyst/ui/components/RichTextViewer.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 b78ff0b43..a67be1828 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 @@ -360,6 +360,8 @@ private fun renderParagraphWithFlowRow( // Check if this paragraph contains only images (ignoring whitespace) val isImageOnlyParagraph = isImageOnlyParagraph(paragraph) + // Check if this paragraph contains multiple images (mixed with text is ok) + val hasMultipleImages = hasMultipleImages(paragraph) if (isImageOnlyParagraph && paragraph.words.isNotEmpty()) { // Collect consecutive image-only paragraphs for gallery @@ -378,6 +380,13 @@ private fun renderParagraphWithFlowRow( } return endIndex // Return next index to process + } else if (hasMultipleImages && paragraph.words.isNotEmpty()) { + // Mixed paragraph with multiple images - use RenderWordsWithImageGallery for smart grouping + RenderWordsWithImageGallery( + paragraph.words.toImmutableList(), + context, + ) + return paragraphIndex + 1 // Return next index to process } else { // Non-image paragraph - render normally with FlowRow RenderSingleParagraphWithFlowRow(paragraph, paragraph.words.toImmutableList(), spaceWidth, context) @@ -427,6 +436,15 @@ private fun isImageOnlyParagraph(paragraph: ParagraphState): Boolean { } } +private fun hasMultipleImages(paragraph: ParagraphState): Boolean { + // Count the number of image segments in the paragraph + val imageCount = + paragraph.words.count { word -> + word is ImageSegment || word is Base64Segment + } + return imageCount > 1 +} + private fun collectConsecutiveImageParagraphs( paragraphs: ImmutableList, startIndex: Int, From 74cc9535ac43b7f25d0bcb83a1e26b3675aa100b Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 14 Sep 2025 12:10:59 +0200 Subject: [PATCH 19/25] Added a data structure for ParagraphImageAnalysis Single pass analysis All image-related decisions are made based on the single analysis result --- .../amethyst/ui/components/RichTextViewer.kt | 81 ++++++++++--------- 1 file changed, 43 insertions(+), 38 deletions(-) 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 a67be1828..4130cc5a0 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 @@ -358,39 +358,36 @@ private fun renderParagraphWithFlowRow( ): Int { val paragraph = paragraphs[paragraphIndex] - // Check if this paragraph contains only images (ignoring whitespace) - val isImageOnlyParagraph = isImageOnlyParagraph(paragraph) - // Check if this paragraph contains multiple images (mixed with text is ok) - val hasMultipleImages = hasMultipleImages(paragraph) + if (paragraph.words.isEmpty()) { + // Empty paragraph - render normally with FlowRow (will render nothing) + RenderSingleParagraphWithFlowRow(paragraph, paragraph.words.toImmutableList(), spaceWidth, context) + return paragraphIndex + 1 + } - if (isImageOnlyParagraph && paragraph.words.isNotEmpty()) { + val analysis = analyzeParagraphImages(paragraph) + + if (analysis.isImageOnly) { // Collect consecutive image-only paragraphs for gallery val (imageParagraphs, endIndex) = collectConsecutiveImageParagraphs(paragraphs, paragraphIndex) val allImageWords = imageParagraphs.flatMap { it.words }.toImmutableList() if (allImageWords.size > 1) { // Multiple images - render as gallery (no FlowRow wrapper needed) - RenderWordsWithImageGallery( - allImageWords, - context, - ) + RenderWordsWithImageGallery(allImageWords, context) } else { // Single image - render with FlowRow wrapper RenderSingleParagraphWithFlowRow(paragraph, paragraph.words.toImmutableList(), spaceWidth, context) } return endIndex // Return next index to process - } else if (hasMultipleImages && paragraph.words.isNotEmpty()) { + } else if (analysis.hasMultipleImages) { // Mixed paragraph with multiple images - use RenderWordsWithImageGallery for smart grouping - RenderWordsWithImageGallery( - paragraph.words.toImmutableList(), - context, - ) - return paragraphIndex + 1 // Return next index to process + RenderWordsWithImageGallery(paragraph.words.toImmutableList(), context) + return paragraphIndex + 1 } else { - // Non-image paragraph - render normally with FlowRow + // Regular paragraph (no images or single image) - render normally with FlowRow RenderSingleParagraphWithFlowRow(paragraph, paragraph.words.toImmutableList(), spaceWidth, context) - return paragraphIndex + 1 // Return next index to process + return paragraphIndex + 1 } } @@ -420,29 +417,36 @@ private fun RenderSingleParagraphWithFlowRow( } } -private fun isImageOnlyParagraph(paragraph: ParagraphState): Boolean { - // A paragraph is "image only" if all non-whitespace words are images - val nonWhitespaceWords = - paragraph.words.filter { word -> - when (word) { - is RegularTextSegment -> word.segmentText.isNotBlank() - else -> true // All other word types (images, links, etc.) are considered non-whitespace +data class ParagraphImageAnalysis( + val imageCount: Int, + val isImageOnly: Boolean, + val hasMultipleImages: Boolean, +) + +private fun analyzeParagraphImages(paragraph: ParagraphState): ParagraphImageAnalysis { + var imageCount = 0 + var hasNonWhitespaceNonImageContent = false + + paragraph.words.forEach { word -> + when (word) { + is ImageSegment, is Base64Segment -> imageCount++ + is RegularTextSegment -> { + if (word.segmentText.isNotBlank()) { + hasNonWhitespaceNonImageContent = true + } } + else -> hasNonWhitespaceNonImageContent = true // Links, emojis, etc. } + } - return nonWhitespaceWords.isNotEmpty() && - nonWhitespaceWords.all { word -> - word is ImageSegment || word is Base64Segment - } -} + val isImageOnly = imageCount > 0 && !hasNonWhitespaceNonImageContent + val hasMultipleImages = imageCount > 1 -private fun hasMultipleImages(paragraph: ParagraphState): Boolean { - // Count the number of image segments in the paragraph - val imageCount = - paragraph.words.count { word -> - word is ImageSegment || word is Base64Segment - } - return imageCount > 1 + return ParagraphImageAnalysis( + imageCount = imageCount, + isImageOnly = isImageOnly, + hasMultipleImages = hasMultipleImages, + ) } private fun collectConsecutiveImageParagraphs( @@ -471,8 +475,9 @@ private fun collectConsecutiveImageParagraphs( } } - // Check if it's an image-only paragraph - if (isImageOnlyParagraph(currentParagraph)) { + // Check if it's an image-only paragraph using unified analysis + val analysis = analyzeParagraphImages(currentParagraph) + if (analysis.isImageOnly) { imageParagraphs.add(currentParagraph) j++ } else { From 43f8053b304e89be65985338c5204fee92f02e2b Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 14 Sep 2025 13:17:19 +0200 Subject: [PATCH 20/25] Extracted Parsing logic into helper class --- .../amethyst/ui/components/ParagraphParser.kt | 272 ++++++++++++++++++ .../amethyst/ui/components/RichTextViewer.kt | 250 +++------------- 2 files changed, 307 insertions(+), 215 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ParagraphParser.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ParagraphParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ParagraphParser.kt new file mode 100644 index 000000000..a943b37af --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ParagraphParser.kt @@ -0,0 +1,272 @@ +/** + * 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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.material3.LocalTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.MutableState +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import com.vitorpamplona.amethyst.commons.richtext.Base64Segment +import com.vitorpamplona.amethyst.commons.richtext.ImageSegment +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.commons.richtext.ParagraphState +import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment +import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState +import com.vitorpamplona.amethyst.commons.richtext.Segment +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +data class RenderContext( + val state: RichTextViewerState, + val backgroundColor: MutableState, + val quotesLeft: Int, + val callbackUri: String?, + val accountViewModel: AccountViewModel, + val nav: INav, +) + +data class ParagraphImageAnalysis( + val imageCount: Int, + val isImageOnly: Boolean, + val hasMultipleImages: Boolean, +) + +class ParagraphParser { + fun analyzeParagraphImages(paragraph: ParagraphState): ParagraphImageAnalysis { + var imageCount = 0 + var hasNonWhitespaceNonImageContent = false + + paragraph.words.forEach { word -> + when (word) { + is ImageSegment, is Base64Segment -> imageCount++ + is RegularTextSegment -> { + if (word.segmentText.isNotBlank()) { + hasNonWhitespaceNonImageContent = true + } + } + else -> hasNonWhitespaceNonImageContent = true // Links, emojis, etc. + } + } + + val isImageOnly = imageCount > 0 && !hasNonWhitespaceNonImageContent + val hasMultipleImages = imageCount > 1 + + return ParagraphImageAnalysis( + imageCount = imageCount, + isImageOnly = isImageOnly, + hasMultipleImages = hasMultipleImages, + ) + } + + fun collectConsecutiveImageParagraphs( + paragraphs: ImmutableList, + startIndex: Int, + ): Pair, Int> { + val imageParagraphs = mutableListOf() + var j = startIndex + + while (j < paragraphs.size) { + val currentParagraph = paragraphs[j] + val words = currentParagraph.words + + // Fast path for empty check + if (words.isEmpty()) { + j++ + continue + } + + // Check for single whitespace word + if (words.size == 1) { + val firstWord = words.first() + if (firstWord is RegularTextSegment && firstWord.segmentText.isBlank()) { + j++ + continue + } + } + + // Check if it's an image-only paragraph using unified analysis + val analysis = analyzeParagraphImages(currentParagraph) + if (analysis.isImageOnly) { + imageParagraphs.add(currentParagraph) + j++ + } else { + break + } + } + + return imageParagraphs to j + } + + @OptIn(ExperimentalLayoutApi::class) + @Composable + fun processParagraph( + paragraphs: ImmutableList, + paragraphIndex: Int, + spaceWidth: Dp, + context: RenderContext, + renderSingleParagraph: @Composable (ParagraphState, ImmutableList, Dp, RenderContext) -> Unit, + renderImageGallery: @Composable (ImmutableList, RenderContext) -> Unit, + ): Int { + val paragraph = paragraphs[paragraphIndex] + + if (paragraph.words.isEmpty()) { + // Empty paragraph - render normally with FlowRow (will render nothing) + renderSingleParagraph(paragraph, paragraph.words.toImmutableList(), spaceWidth, context) + return paragraphIndex + 1 + } + + val analysis = analyzeParagraphImages(paragraph) + + if (analysis.isImageOnly) { + // Collect consecutive image-only paragraphs for gallery + val (imageParagraphs, endIndex) = collectConsecutiveImageParagraphs(paragraphs, paragraphIndex) + val allImageWords = imageParagraphs.flatMap { it.words }.toImmutableList() + + if (allImageWords.size > 1) { + // Multiple images - render as gallery (no FlowRow wrapper needed) + renderImageGallery(allImageWords, context) + } else { + // Single image - render with FlowRow wrapper + renderSingleParagraph(paragraph, paragraph.words.toImmutableList(), spaceWidth, context) + } + + return endIndex // Return next index to process + } else if (analysis.hasMultipleImages) { + // Mixed paragraph with multiple images - use renderImageGallery for smart grouping + renderImageGallery(paragraph.words.toImmutableList(), context) + return paragraphIndex + 1 + } else { + // Regular paragraph (no images or single image) - render normally with FlowRow + renderSingleParagraph(paragraph, paragraph.words.toImmutableList(), spaceWidth, context) + return paragraphIndex + 1 + } + } + + @Composable + fun ProcessWordsWithImageGrouping( + words: ImmutableList, + context: RenderContext, + renderSingleWord: @Composable (Segment, RenderContext) -> Unit, + renderGallery: @Composable (ImmutableList, AccountViewModel) -> Unit, + ) { + var i = 0 + val n = words.size + + while (i < n) { + val word = words[i] + + if (word is ImageSegment || word is Base64Segment) { + // Collect consecutive image/whitespace segments without extra list allocations + val imageSegments = mutableListOf() + var j = i + + while (j < n) { + val seg = words[j] + when { + seg is ImageSegment || seg is Base64Segment -> imageSegments.add(seg) + seg is RegularTextSegment && seg.segmentText.isBlank() -> { /* skip whitespace */ } + else -> break + } + j++ + } + + if (imageSegments.size > 1) { + val imageContents = + imageSegments + .mapNotNull { segment -> + val imageUrl = segment.segmentText + context.state.imagesForPager[imageUrl] as? MediaUrlImage + }.toImmutableList() + + if (imageContents.isNotEmpty()) { + renderGallery(imageContents, context.accountViewModel) + } + } else { + renderSingleWord(imageSegments.firstOrNull() ?: word, context) + } + + i = j // jump past processed run + } else { + renderSingleWord(word, context) + i++ + } + } + } + + @OptIn(ExperimentalLayoutApi::class) + @Composable + fun RenderSingleParagraphWithFlowRow( + paragraph: ParagraphState, + words: ImmutableList, + spaceWidth: Dp, + context: RenderContext, + renderWord: @Composable (Segment, RenderContext) -> Unit, + ) { + CompositionLocalProvider( + LocalLayoutDirection provides + if (paragraph.isRTL) { + LayoutDirection.Rtl + } else { + LayoutDirection.Ltr + }, + LocalTextStyle provides LocalTextStyle.current, + ) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(spaceWidth), + ) { + words.forEach { word -> + renderWord(word, context) + } + } + } + } + + @Composable + fun ProcessAllParagraphs( + paragraphs: ImmutableList, + spaceWidth: Dp, + context: RenderContext, + renderSingleParagraph: @Composable (ParagraphState, ImmutableList, Dp, RenderContext) -> Unit, + renderImageGallery: @Composable (ImmutableList, RenderContext) -> Unit, + ) { + var i = 0 + while (i < paragraphs.size) { + i = + processParagraph( + paragraphs = paragraphs, + paragraphIndex = i, + spaceWidth = spaceWidth, + context = context, + renderSingleParagraph = renderSingleParagraph, + renderImageGallery = renderImageGallery, + ) + } + } +} 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 4130cc5a0..8a6d84cac 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,8 +73,6 @@ 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 @@ -114,15 +112,6 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -data class RenderContext( - val state: RichTextViewerState, - val backgroundColor: MutableState, - val quotesLeft: Int, - val callbackUri: String?, - val accountViewModel: AccountViewModel, - val nav: INav, -) - fun isMarkdown(content: String): Boolean = content.startsWith("> ") || content.startsWith("# ") || @@ -333,161 +322,27 @@ fun RenderRegularWithGallery( ) val spaceWidth = measureSpaceWidth(LocalTextStyle.current) + val paragraphParser = remember { ParagraphParser() } Column { - // Process each paragraph uniformly - var i = 0 - while (i < state.paragraphs.size) { - i = - renderParagraphWithFlowRow( - paragraphs = state.paragraphs, - paragraphIndex = i, - spaceWidth = spaceWidth, - context = context, + paragraphParser.ProcessAllParagraphs( + paragraphs = state.paragraphs, + spaceWidth = spaceWidth, + context = context, + renderSingleParagraph = { paragraph, words, width, ctx -> + paragraphParser.RenderSingleParagraphWithFlowRow( + paragraph = paragraph, + words = words, + spaceWidth = width, + context = ctx, + renderWord = { word, renderContext -> RenderWordWithPreview(word, renderContext) }, ) - } - } -} - -@Composable -private fun renderParagraphWithFlowRow( - paragraphs: ImmutableList, - paragraphIndex: Int, - spaceWidth: Dp, - context: RenderContext, -): Int { - val paragraph = paragraphs[paragraphIndex] - - if (paragraph.words.isEmpty()) { - // Empty paragraph - render normally with FlowRow (will render nothing) - RenderSingleParagraphWithFlowRow(paragraph, paragraph.words.toImmutableList(), spaceWidth, context) - return paragraphIndex + 1 - } - - val analysis = analyzeParagraphImages(paragraph) - - if (analysis.isImageOnly) { - // Collect consecutive image-only paragraphs for gallery - val (imageParagraphs, endIndex) = collectConsecutiveImageParagraphs(paragraphs, paragraphIndex) - val allImageWords = imageParagraphs.flatMap { it.words }.toImmutableList() - - if (allImageWords.size > 1) { - // Multiple images - render as gallery (no FlowRow wrapper needed) - RenderWordsWithImageGallery(allImageWords, context) - } else { - // Single image - render with FlowRow wrapper - RenderSingleParagraphWithFlowRow(paragraph, paragraph.words.toImmutableList(), spaceWidth, context) - } - - return endIndex // Return next index to process - } else if (analysis.hasMultipleImages) { - // Mixed paragraph with multiple images - use RenderWordsWithImageGallery for smart grouping - RenderWordsWithImageGallery(paragraph.words.toImmutableList(), context) - return paragraphIndex + 1 - } else { - // Regular paragraph (no images or single image) - render normally with FlowRow - RenderSingleParagraphWithFlowRow(paragraph, paragraph.words.toImmutableList(), spaceWidth, context) - return paragraphIndex + 1 - } -} - -@Composable -private fun RenderSingleParagraphWithFlowRow( - paragraph: ParagraphState, - words: ImmutableList, - spaceWidth: Dp, - context: RenderContext, -) { - CompositionLocalProvider( - LocalLayoutDirection provides - if (paragraph.isRTL) { - LayoutDirection.Rtl - } else { - LayoutDirection.Ltr }, - LocalTextStyle provides LocalTextStyle.current, - ) { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(spaceWidth), - ) { - words.forEach { word -> - RenderWordWithPreview(word, context) - } - } + renderImageGallery = { words, ctx -> RenderWordsWithImageGallery(words, ctx) }, + ) } } -data class ParagraphImageAnalysis( - val imageCount: Int, - val isImageOnly: Boolean, - val hasMultipleImages: Boolean, -) - -private fun analyzeParagraphImages(paragraph: ParagraphState): ParagraphImageAnalysis { - var imageCount = 0 - var hasNonWhitespaceNonImageContent = false - - paragraph.words.forEach { word -> - when (word) { - is ImageSegment, is Base64Segment -> imageCount++ - is RegularTextSegment -> { - if (word.segmentText.isNotBlank()) { - hasNonWhitespaceNonImageContent = true - } - } - else -> hasNonWhitespaceNonImageContent = true // Links, emojis, etc. - } - } - - val isImageOnly = imageCount > 0 && !hasNonWhitespaceNonImageContent - val hasMultipleImages = imageCount > 1 - - return ParagraphImageAnalysis( - imageCount = imageCount, - isImageOnly = isImageOnly, - hasMultipleImages = hasMultipleImages, - ) -} - -private fun collectConsecutiveImageParagraphs( - paragraphs: ImmutableList, - startIndex: Int, -): Pair, Int> { - val imageParagraphs = mutableListOf() - var j = startIndex - - while (j < paragraphs.size) { - val currentParagraph = paragraphs[j] - val words = currentParagraph.words - - // Fast path for empty check - if (words.isEmpty()) { - j++ - continue - } - - // Check for single whitespace word - if (words.size == 1) { - val firstWord = words.first() - if (firstWord is RegularTextSegment && firstWord.segmentText.isBlank()) { - j++ - continue - } - } - - // Check if it's an image-only paragraph using unified analysis - val analysis = analyzeParagraphImages(currentParagraph) - if (analysis.isImageOnly) { - imageParagraphs.add(currentParagraph) - j++ - } else { - break - } - } - - return imageParagraphs to j -} - @OptIn(ExperimentalLayoutApi::class) @Composable fun RenderRegular( @@ -585,52 +440,20 @@ private fun RenderWordsWithImageGallery( words: ImmutableList, context: RenderContext, ) { - var i = 0 - val n = words.size + val paragraphParser = remember { ParagraphParser() } - while (i < n) { - val word = words[i] - - if (word is ImageSegment || word is Base64Segment) { - // Collect consecutive image/whitespace segments without extra list allocations - val imageSegments = mutableListOf() - var j = i - - while (j < n) { - val seg = words[j] - when { - seg is ImageSegment || seg is Base64Segment -> imageSegments.add(seg) - seg is RegularTextSegment && seg.segmentText.isBlank() -> { /* skip whitespace */ } - else -> break - } - j++ - } - - if (imageSegments.size > 1) { - val imageContents = - imageSegments - .mapNotNull { segment -> - val imageUrl = segment.segmentText - context.state.imagesForPager[imageUrl] as? MediaUrlImage - }.toImmutableList() - - if (imageContents.isNotEmpty()) { - ImageGallery( - images = imageContents, - accountViewModel = context.accountViewModel, - roundedCorner = true, - ) - } - } else { - RenderWordWithPreview(imageSegments.firstOrNull() ?: word, context) - } - - i = j // jump past processed run - } else { - RenderWordWithPreview(word, context) - i++ - } - } + paragraphParser.ProcessWordsWithImageGrouping( + words = words, + context = context, + renderSingleWord = { word, ctx -> RenderWordWithPreview(word, ctx) }, + renderGallery = { imageContents, accountViewModel -> + ImageGallery( + images = imageContents, + accountViewModel = accountViewModel, + roundedCorner = true, + ) + }, + ) } @Composable @@ -832,20 +655,17 @@ fun CoreSecretMessage( } } else if (localSecretContent.paragraphs.size > 1) { val spaceWidth = measureSpaceWidth(LocalTextStyle.current) + val paragraphParser = remember { ParagraphParser() } Column(CashuCardBorders) { localSecretContent.paragraphs.forEach { paragraph -> - FlowRow( - modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), - horizontalArrangement = Arrangement.spacedBy(spaceWidth), - ) { - paragraph.words.forEach { word -> - RenderWordWithPreview( - word, - context, - ) - } - } + paragraphParser.RenderSingleParagraphWithFlowRow( + paragraph = paragraph, + words = paragraph.words.toImmutableList(), + spaceWidth = spaceWidth, + context = context, + renderWord = { word, ctx -> RenderWordWithPreview(word, ctx) }, + ) } } } From f0b95a3593be2136751d893944e7cfbfbb902459 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 15 Sep 2025 12:42:18 +0000 Subject: [PATCH 21/25] New Crowdin translations by GitHub Action --- .../src/main/res/values-cs-rCZ/strings.xml | 2 ++ .../src/main/res/values-de-rDE/strings.xml | 2 ++ .../src/main/res/values-fr-rFR/strings.xml | 21 +++++++++++++++++++ .../src/main/res/values-pt-rBR/strings.xml | 2 ++ .../src/main/res/values-sv-rSE/strings.xml | 2 ++ .../src/main/res/values-zh-rCN/strings.xml | 1 + 6 files changed, 30 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 23e828e01..1a5c1026c 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -438,6 +438,7 @@ Ne Seznam sledovaných Všechna sledování + Všech Uživatelů Sledování Sleduje přes proxy Kolem mě Globální @@ -1018,6 +1019,7 @@ Relé chatu Relé, ke kterému se všichni uživatelé tohoto chatu připojují Sdílet obrázek… + Nelze sdílet obrázek, zkuste to prosím později… Vyhledávání hashtag: #%1$s Nepřekládat z Zde zobrazené jazyky nebudou přeloženy. Vyberte jazyk, který chcete odstranit a nechat je znovu přeložit. diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 77199f2a6..de777d235 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -444,6 +444,7 @@ anz der Bedingungen ist erforderlich Nein Folgen-Liste Alle Folgen + Alle Benutzer Folgen Folgt über Proxy In der Nähe Weltweit @@ -1023,6 +1024,7 @@ anz der Bedingungen ist erforderlich Chat-Relais Das Relais, mit dem sich alle Benutzer dieses Chats verbinden Bild teilen… + Bild kann nicht geteilt werden, bitte versuchen Sie es später erneut… Suche Hashtag: #%1$s Nicht übersetzen von Die hier angezeigten Sprachen werden nicht übersetzt. Wählen Sie eine Sprache, um sie zu entfernen und lassen Sie sie erneut übersetzen. diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index f4a15147b..c24ae691b 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -148,6 +148,7 @@ Impossible d\'enregistrer la vidéo Charger une image Prendre une photo + Enregistrer une vidéo Enregistrer un message Enregistrer un message Cliquez et maintenez pour enregistrer un message @@ -163,6 +164,8 @@ Utilisateurs bloqués Nouveaux sujets Conversations + Flux + File d\'attente des mods Notes Réponses Les vôtres @@ -437,6 +440,7 @@ Non Liste des Abonnés Tous les abonnements + Tous les abonnements de l\'utilisateur Suivre via Proxy Autour de moi Général @@ -655,6 +659,8 @@ Explication aux membres Changement de nom pour les nouveaux objectifs. Coller depuis le presse-papiers + URI NIP-47 invalide + L\'URI %1$s n\'est pas une URI de connexion NIP-47 valide. Pour l\'interface de l\'App Sombre, Clair ou thème Système Charger automatiquement les images et les GIFs @@ -672,6 +678,9 @@ Média ajouté à votre galerie de profil Créé le Règles + À propos de nous + Lignes directrices + Modérateurs Se connecter avec Amber Mettez à jour votre statut Erreur d\'analyse du message d\'erreur @@ -884,10 +893,13 @@ Téléversement de MP Paramètres du relais Relais publics d\'accueil + L\'utilisateur publie son contenu sur ces relais Ce type de relais stocke tout votre contenu. Amethyst enverra vos messages ici et d\'autres utiliseront ces relais pour trouver votre contenu. Insérez entre 1 et 3 relais : il peut s\'agir de relais personnels, de relais payants ou de relais publics. Relais de messagerie publique + L\'utilisateur reçoit les notifications sur ces relais Ce type de relais reçoit toutes les réponses, commentaires, likes et zaps de vos messages. Insérez de 1 à 3 relais et assurez-vous qu\'ils acceptent les messages de n\'importe qui. Relais de la boîte de réception MP + L\'utilisateur reçoit les MPs sur ces relais Insérez de 1 à 3 relais pour servir de boîte de réception privée. Les autres utilisateurs utiliseront ces relais pour vous envoyer des MPs. Les relais de la boîte de réception MP doivent accepter tout message de tout le monde, mais ne vous permet que de les télécharger. De bonnes options sont:\n - inbox.nostr.wine (payant)\n - auth.nostr1.com (gratuit)\n - you.nostr1.com (relais personnels - payant) Relais privés Insérez entre 1 et 3 relais pour stocker des événements que personne d\'autre ne peut voir, comme vos Brouillons et/ou paramètres d\'application. Idéalement, ces relais sont soit locaux soit nécessitent une authentification avant de télécharger le contenu de chaque utilisateur. @@ -1009,9 +1021,18 @@ Relais de chat Le relais auquel tous les utilisateurs de ce chat se connectent Partager l\'image… + Impossible de partager l\'image, veuillez réessayer plus tard… Hashtag de recherche : #%1$s Ne pas traduire depuis Les langues affichées ici ne seront pas traduites. Sélectionnez une langue pour la supprimer et l\'avoir traduite à nouveau. Pause Lecture + Ouvrir le menu déroulant + Option %1$s sur %2$s + Filtre du flux, %1$s sélectionné + Filtre du flux, %1$s + Rapport d\'erreur trouvé + Voulez-vous envoyer le rapport d\'erreur récent à Amethyst dans un MP ? Aucune information personnelle ne sera partagée + Envoyer + Ce message disparaîtra dans %1$d jours diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 8425d583c..3cc81998a 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -438,6 +438,7 @@ Não Lista de seguidores Seguindo + Seguindo do Usuários Segue via proxy Perto de mim Global @@ -1018,6 +1019,7 @@ Relé de chat O relé a qual todos os usuários deste chat se conectam Compartilhar imagem… + Não é possível compartilhar a imagem, por favor tente novamente mais tarde… Pesquisar hashtag: #%1$s Não Traduzir de Os idiomas mostrados aqui não serão traduzidos. Selecione um idioma para removê-lo e traduzi-lo novamente. diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 3c7ce5d03..3695dd940 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -438,6 +438,7 @@ Nej Följ lista Alla följare + Alla Användare Följare Följer via proxy Runt mig Global @@ -1017,6 +1018,7 @@ Chatt Relä Reläet som alla användare av den här chatten ansluter till Dela bild… + Kunde inte dela bilden, försök igen senare… Sök hashtag: #%1$s Översätt inte från Språk som visas här kommer inte att översättas. Välj ett språk för att ta bort det och få det översatt igen. diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 0cac7d7bb..cfc05388b 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -1021,6 +1021,7 @@ 聊天中继 此聊天所有用户都连接到的中继 分享图片… + 无法分享图片,请稍后重试… 搜索话题标签:#%1$s 不要翻译 此处显示的语言不会被翻译,请选择一种目标语言重新翻译并去除这个提示。 From a7b89cb7781cacbaaae087acdc22876ef80d2b7a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 15 Sep 2025 08:50:16 -0400 Subject: [PATCH 22/25] Removing unused imports --- .../amethyst/model/preferences/UISharedPreferences.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index 281e2a20d..c1c70ca53 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -39,7 +39,6 @@ import com.vitorpamplona.amethyst.model.ProfileGalleryType import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.UiSettings import com.vitorpamplona.amethyst.model.UiSettingsFlow -import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk.save import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview From 7616d01166c610f2151e02f57b1887369f9c8d7c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 15 Sep 2025 11:58:43 -0400 Subject: [PATCH 23/25] Implements NIP-44 extension for bigger payloads https://github.com/nostr-protocol/nips/pull/1907 --- .../quartz/nip44Encryption/Nip44v2Test.kt | 8 +- .../resources/nip44.vectors.json | 16 +++ .../quartz/nip44Encryption/Nip44v2.kt | 107 +++++++++++++----- 3 files changed, 97 insertions(+), 34 deletions(-) diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt index bf87b85cf..0d489fc94 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt @@ -143,12 +143,14 @@ class Nip44v2Test { } @Test - fun invalidMessageLengths() { + fun extendedMessageLengths() { for (v in vectors.v2?.invalid?.encryptMsgLengths!!) { val key = RandomInstance.bytes(32) try { - nip44v2.encrypt("a".repeat(v), key) - fail("Should Throw for $v") + val input = "a".repeat(v) + val result = nip44v2.encrypt(input, key) + val decrypted = nip44v2.decrypt(result, key) + assertEquals(input, decrypted) } catch (e: Exception) { assertNotNull(e) } diff --git a/quartz/src/androidInstrumentedTest/resources/nip44.vectors.json b/quartz/src/androidInstrumentedTest/resources/nip44.vectors.json index 8fcabe8a1..4d105420f 100644 --- a/quartz/src/androidInstrumentedTest/resources/nip44.vectors.json +++ b/quartz/src/androidInstrumentedTest/resources/nip44.vectors.json @@ -510,6 +510,22 @@ "repeat": 16383, "plaintext_sha256": "a249558d161b77297bc0cb311dde7d77190f6571b25c7e4429cd19044634a61f", "payload_sha256": "b3348422471da1f3c59d79acfe2fe103f3cd24488109e5b18734cdb5953afd15" + }, + { + "conversation_key": "56adbe3720339363ab9c3b8526ffce9fd77600927488bfc4b59f7a68ffe5eae0", + "nonce": "ad68da81833c2a8ff609c3d2c0335fd44fe5954f85bb580c6a8d467aa9fc5dd0", + "pattern": "!", + "repeat": 65536, + "plaintext_sha256": "b007fe445ff5b583c095c4688c75d8afef66d4c93eb6aeebcea0942e670e1cd3", + "payload_sha256": "f816ffbcb053a0669488992e2d7c57c2d950d3d4af6e19a16297f3e565a4103f" + }, + { + "conversation_key": "56adbe3720339363ab9c3b8526ffce9fd77600927488bfc4b59f7a68ffe5eae0", + "nonce": "ad68da81833c2a8ff609c3d2c0335fd44fe5954f85bb580c6a8d467aa9fc5dd0", + "pattern": "a", + "repeat": 20000000, + "plaintext_sha256": "aded0ea9b4d06589b13d00bab483faf479d61ed5de21f1760aa7018a28e330e5", + "payload_sha256": "9e683311894d52e48a825837883c539263c7787c7fe024e1590d96776a31684b" } ] }, diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt index e0ca8c2bd..c732abbc0 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt @@ -26,8 +26,6 @@ import com.vitorpamplona.quartz.utils.LibSodiumInstance import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.Secp256k1Instance import kotlinx.coroutines.CancellationException -import java.nio.ByteBuffer -import java.nio.ByteOrder import java.util.Base64 import kotlin.math.floor import kotlin.math.log2 @@ -42,6 +40,9 @@ class Nip44v2 { private val minPlaintextSize: Int = 0x0001 // 1b msg => padded to 32b private val maxPlaintextSize: Int = 0xffff // 65535 (64kb-1) => padded to 64kb + private val extMinPlaintextSize: Int = 0x00000001 // 1b msg => padded to 32b + private val extMaxPlaintextSize: Long = 0xffffffff // 4294967294 => padded + fun clearCache() { sharedKeyCache.clearCache() } @@ -151,42 +152,49 @@ class Nip44v2 { check(unpaddedLen > 0) { "Message is empty ($unpaddedLen): $plaintext" } - check(unpaddedLen <= maxPlaintextSize) { "Message is too long ($unpaddedLen): $plaintext" } - val prefix = - ByteBuffer - .allocate(2) - .order(ByteOrder.BIG_ENDIAN) - .putShort(unpaddedLen.toShort()) - .array() + if (unpaddedLen <= maxPlaintextSize) { + // 2 bytes in big endian + intTo2BytesBigEndian(unpaddedLen) + } else if (unpaddedLen <= extMaxPlaintextSize) { + // Extension to allow > 65KB payloads + // 2+4 bytes in big endian + byteArrayOf(0, 0) + intTo4BytesBigEndian(unpaddedLen) + } else { + throw IllegalArgumentException("Message is too long ($unpaddedLen): $plaintext") + } + val suffix = ByteArray(calcPaddedLen(unpaddedLen) - unpaddedLen) - return ByteBuffer.wrap(prefix + unpadded + suffix).array() + return prefix + unpadded + suffix } - private fun bytesToInt( - byte1: Byte, - byte2: Byte, - bigEndian: Boolean, - ): Int = - if (bigEndian) { - (byte1.toInt() and 0xFF shl 8 or (byte2.toInt() and 0xFF)) - } else { - (byte2.toInt() and 0xFF shl 8 or (byte1.toInt() and 0xFF)) - } - fun unpad(padded: ByteArray): String { - val unpaddedLen: Int = bytesToInt(padded[0], padded[1], true) - val unpadded = padded.sliceArray(2 until 2 + unpaddedLen) + val unpaddedLenPreExt: Int = bytesToIntBigEndian(padded[0], padded[1]) - check( - unpaddedLen in minPlaintextSize..maxPlaintextSize && - unpadded.size == unpaddedLen && - padded.size == 2 + calcPaddedLen(unpaddedLen), - ) { - "invalid padding ${unpadded.size} != $unpaddedLen" + return if (unpaddedLenPreExt == 0) { + // NIP-44 extension to handle bigger than 65K payloads + val unpaddedLenExt: Int = bytesToIntBigEndian(padded[2], padded[3], padded[4], padded[5]) + + check(unpaddedLenExt in extMinPlaintextSize..extMaxPlaintextSize) { + "Invalid size $unpaddedLenExt not between $extMinPlaintextSize and $extMaxPlaintextSize" + } + + check(padded.size == 6 + calcPaddedLen(unpaddedLenExt)) { + "Invalid padding ${calcPaddedLen(unpaddedLenExt)} != $unpaddedLenExt" + } + + String(padded, 6, unpaddedLenExt) + } else { + check(unpaddedLenPreExt in minPlaintextSize..maxPlaintextSize) { + "Invalid size $unpaddedLenPreExt not between $minPlaintextSize and $maxPlaintextSize" + } + + check(padded.size == 2 + calcPaddedLen(unpaddedLenPreExt)) { + "Invalid padding ${calcPaddedLen(unpaddedLenPreExt)} != $unpaddedLenPreExt" + } + + String(padded, 2, unpaddedLenPreExt) } - - return unpadded.decodeToString() } fun hmacAad( @@ -264,4 +272,41 @@ class Nip44v2 { byteArrayOf(V.toByte()) + nonce + ciphertext + mac, ) } + + // ----------------------------------- + // FASTER METHODS THAN BUFFER WRAPPING + // ----------------------------------- + private fun bytesToIntBigEndian( + byte1: Byte, + byte2: Byte, + ): Int = (byte1.toInt() and 0xFF shl 8 or (byte2.toInt() and 0xFF)) + + private fun bytesToIntBigEndian( + byte1: Byte, + byte2: Byte, + byte3: Byte, + byte4: Byte, + ): Int { + val result = + ((byte1.toLong() and 0xFF) shl 24) or + ((byte2.toLong() and 0xFF) shl 16) or + ((byte3.toLong() and 0xFF) shl 8) or + (byte4.toLong() and 0xFF) + + check(result <= Int.MAX_VALUE) { + "JVM cannot handle more than 2GB payloads. Current length: $result" + } + + return result.toInt() + } + + private fun intTo2BytesBigEndian(value: Int): ByteArray = byteArrayOf((value shr 8).toByte(), (value and 0xFF).toByte()) + + private fun intTo4BytesBigEndian(value: Int): ByteArray = + byteArrayOf( + (value shr 24).toByte(), + (value shr 16).toByte(), + (value shr 8).toByte(), + (value and 0xFF).toByte(), + ) } From c84554576cb41a84e39dcab25e26551735974d3b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 15 Sep 2025 12:08:04 -0400 Subject: [PATCH 24/25] Fixes flow.value warnings. --- .../amethyst/service/playback/composable/VideoView.kt | 2 +- .../amethyst/ui/components/LoadUrlPreview.kt | 2 +- .../vitorpamplona/amethyst/ui/components/MyAsyncImage.kt | 2 +- .../amethyst/ui/components/ZoomableContentView.kt | 4 ++-- .../ui/components/markdown/MarkdownMediaRenderer.kt | 2 +- .../ui/navigation/drawer/AccountSwitchBottomSheet.kt | 2 +- .../amethyst/ui/navigation/drawer/DrawerContent.kt | 2 +- .../ui/navigation/topbars/UserDrawerSearchTopBar.kt | 2 +- .../com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt | 2 +- .../com/vitorpamplona/amethyst/ui/note/NoteCompose.kt | 2 +- .../com/vitorpamplona/amethyst/ui/note/RelayListRow.kt | 2 +- .../vitorpamplona/amethyst/ui/note/UserProfilePicture.kt | 2 +- .../amethyst/ui/note/types/CommunityHeader.kt | 4 ++-- .../java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt | 5 +---- .../vitorpamplona/amethyst/ui/screen/UiSettingsState.kt | 8 ++++++++ .../loggedIn/chats/feed/types/RenderCreateChannelNote.kt | 4 ++-- .../ephemChat/header/ShortEphemeralChatChannelHeader.kt | 2 +- .../nip28PublicChat/header/LongPublicChatChannelHeader.kt | 2 +- .../header/ShortPublicChatChannelHeader.kt | 2 +- .../chats/publicChannels/send/ChannelFileUploadDialog.kt | 2 +- .../screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt | 4 ++-- .../ui/screen/loggedIn/profile/gallery/GalleryThumb.kt | 4 ++-- .../loggedIn/profile/header/badges/DisplayBadges.kt | 2 +- .../amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt | 2 +- .../ui/screen/loggedIn/relays/RelayInformationScreen.kt | 2 +- .../loggedIn/relays/common/BasicRelaySetupInfoDialog.kt | 2 +- .../amethyst/ui/screen/loggedIn/search/SearchScreen.kt | 6 +++--- 27 files changed, 41 insertions(+), 36 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt index ea0b3dd99..8caef9721 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt @@ -105,7 +105,7 @@ fun VideoView( val automaticallyStartPlayback = remember { mutableStateOf( - if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback.value, + if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt index 23b9998c6..a1c1264c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt @@ -39,7 +39,7 @@ fun LoadUrlPreview( callbackUri: String? = null, accountViewModel: AccountViewModel, ) { - if (!accountViewModel.settings.showUrlPreview.value) { + if (!accountViewModel.settings.showUrlPreview()) { ClickableUrl(urlText, url) } else { LoadUrlPreviewDirect(url, urlText, callbackUri, accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt index 4e847ec37..4bf3ca5f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt @@ -57,7 +57,7 @@ fun MyAsyncImage( onError: (@Composable () -> Unit)?, ) { val ratio = MediaAspectRatioCache.get(imageUrl) - val showImage = remember { mutableStateOf(accountViewModel.settings.showImages.value) } + val showImage = remember { mutableStateOf(accountViewModel.settings.showImages()) } CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) { if (it) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 12fa577dc..ac6c65960 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -227,7 +227,7 @@ fun LocalImageView( val showImage = remember { mutableStateOf( - if (alwayShowImage) true else accountViewModel.settings.showImages.value, + if (alwayShowImage) true else accountViewModel.settings.showImages(), ) } @@ -340,7 +340,7 @@ fun UrlImageView( val showImage = remember { mutableStateOf( - if (alwayShowImage) true else accountViewModel.settings.showImages.value, + if (alwayShowImage) true else accountViewModel.settings.showImages(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt index 70468d31b..7bb60c2c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt @@ -128,7 +128,7 @@ class MarkdownMediaRenderer( ) } } else { - if (!accountViewModel.settings.showUrlPreview.value) { + if (!accountViewModel.settings.showUrlPreview()) { renderAsCompleteLink(title ?: uri, uri, richTextStringBuilder) } else { renderInlineFullWidth(richTextStringBuilder) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt index 4bd13e604..cda6c74fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt @@ -216,7 +216,7 @@ private fun AccountPicture( model = profilePicture, contentDescription = stringRes(R.string.profile_image), modifier = AccountPictureModifier, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 3bed8277a..29a0943ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -245,7 +245,7 @@ fun ProfileContentTemplate( .clip(shape = CircleShape) .border(3.dp, MaterialTheme.colorScheme.onBackground, CircleShape) .clickable(onClick = onClick), - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt index e7172f76f..bd570bf1e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt @@ -85,7 +85,7 @@ private fun LoggedInUserPictureDrawer( contentDescription = stringRes(id = R.string.your_profile_image), modifier = HeaderPictureModifier, contentScale = ContentScale.Crop, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 28844d0dc..ae6e09a15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -603,7 +603,7 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture( contentDescription = stringRes(id = R.string.profile_image), modifier = MaterialTheme.colorScheme.profile35dpModifier, contentScale = ContentScale.Crop, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index b2e1a90c2..6e9f9fa58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -1307,7 +1307,7 @@ private fun ChannelNotePicture( model = model, contentDescription = stringRes(R.string.group_picture), modifier = MaterialTheme.colorScheme.channelNotePictureModifier, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt index fa72f5264..a9d5370f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt @@ -149,7 +149,7 @@ fun RenderRelay( RenderRelayIcon( displayUrl = relayInfo.id ?: relay.url, iconUrl = relayInfo.icon, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), pingInMs = 0, loadRobohash = accountViewModel.settings.isNotPerformanceMode(), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index 0ee7c5ba5..c5a5b4f6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -358,7 +358,7 @@ fun InnerUserPicture( }, modifier = myImageModifier, contentScale = ContentScale.Crop, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt index afe51ac8d..b6a857905 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt @@ -326,7 +326,7 @@ fun ShortCommunityHeader( contentDescription = stringRes(R.string.profile_image), contentScale = ContentScale.Crop, modifier = HeaderPictureModifier, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), ) } @@ -376,7 +376,7 @@ fun ShortCommunityHeaderNoActions( contentDescription = stringRes(R.string.profile_image), contentScale = ContentScale.Crop, modifier = HeaderPictureModifier, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt index 2a12a8798..e6f53b753 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt @@ -88,10 +88,7 @@ private fun WikiNoteHeader( ), ) { Column { - val automaticallyShowUrlPreview = - remember { accountViewModel.settings.showUrlPreview.value } - - if (automaticallyShowUrlPreview) { + if (accountViewModel.settings.showUrlPreview()) { image?.let { MyAsyncImage( imageUrl = it, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt index 2e9fa918c..9f2302b78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt @@ -131,4 +131,12 @@ class UiSettingsState( fun isCompleteUIMode() = uiSettingsFlow.featureSet.value == FeatureSetType.COMPLETE fun isImmersiveScrollingActive() = uiSettingsFlow.automaticallyHideNavigationBars.value == BooleanType.ALWAYS + + fun showProfilePictures() = showProfilePictures.value + + fun showUrlPreview() = showUrlPreview.value + + fun startVideoPlayback() = startVideoPlayback.value + + fun showImages() = showImages.value } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt index 4f21b9fc8..7c4c24916 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt @@ -158,7 +158,7 @@ fun RenderChannelData( model = it, contentDescription = stringRes(R.string.channel_image), modifier = MaterialTheme.colorScheme.largeProfilePictureModifier, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), ) } @@ -253,7 +253,7 @@ fun RenderRelayLinePublicChat( relay.displayUrl(), relayInfo.icon, clickableModifier, - showPicture = accountViewModel.settings.showProfilePictures.value, + showPicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt index 94e645d35..32fcefd1f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt @@ -101,7 +101,7 @@ private fun DrawRelayIcon( contentDescription = stringRes(R.string.profile_image), contentScale = ContentScale.Crop, modifier = HeaderPictureModifier, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt index 23ee37873..e6946e763 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt @@ -87,7 +87,7 @@ fun LongPublicChatChannelHeader( model = it, contentDescription = stringRes(R.string.channel_image), modifier = MaterialTheme.colorScheme.largeProfilePictureModifier, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt index eb3d272b4..22106578a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt @@ -70,7 +70,7 @@ fun ShortPublicChatChannelHeader( contentDescription = stringRes(R.string.profile_image), contentScale = ContentScale.Crop, modifier = HeaderPictureModifier, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt index 0327d45f8..3e7b79bd3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt @@ -72,7 +72,7 @@ fun ChannelFileUploadDialog( contentDescription = stringRes(R.string.profile_image), contentScale = ContentScale.Crop, modifier = HeaderPictureModifier, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 935e2742a..4dca81854 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -195,7 +195,7 @@ private fun ChannelRoomCompose( channelLastTime = lastMessage.createdAt(), channelLastContent = "$authorName: $description", hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), onClick = { nav.nav(routeFor(channel)) }, ) @@ -227,7 +227,7 @@ private fun ChannelRoomCompose( channelLastTime = lastMessage.createdAt(), channelLastContent = "$authorName: $description", hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), onClick = { nav.nav(routeFor(channel)) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt index 315b86065..575d63492 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt @@ -197,7 +197,7 @@ fun UrlImageView( val showImage = remember { mutableStateOf( - if (alwayShowImage) true else accountViewModel.settings.showImages.value, + if (alwayShowImage) true else accountViewModel.settings.showImages(), ) } @@ -267,7 +267,7 @@ fun UrlVideoView( val automaticallyStartPlayback = remember(content) { - mutableStateOf(accountViewModel.settings.startVideoPlayback.value) + mutableStateOf(accountViewModel.settings.startVideoPlayback()) } Box(defaultModifier, contentAlignment = Alignment.Center) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt index b1ed91bd7..74895503b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt @@ -201,7 +201,7 @@ private fun RenderBadgeImage( model = image, contentDescription = description, modifier = BadgePictureModifier, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt index 76d2d7cf8..ac561432a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt @@ -164,7 +164,7 @@ fun ShowQRDialog( model = user.profilePicture(), contentDescription = stringRes(R.string.profile_image), modifier = MaterialTheme.colorScheme.largeProfilePictureModifier, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index bd112c836..e39803d0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -156,7 +156,7 @@ fun RelayInformationScreen( RenderRelayIcon( displayUrl = relay.displayUrl(), iconUrl = relayInfo.icon, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), RelayStats.get(relay).pingInMs, iconModifier = LargeRelayIconModifier, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt index 12bd271a5..6e036b25b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt @@ -35,7 +35,7 @@ fun BasicRelaySetupInfoDialog( ) { BasicRelaySetupInfoClickableRow( item = item, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), onDelete = onDelete, accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index 7bfd3dc09..ab47c8eb7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -292,7 +292,7 @@ private fun DisplaySearchResults( channelLastTime = null, channelLastContent = item.summary(), hasNewMessages = false, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), onClick = { nav.nav(routeFor(item)) }, ) @@ -321,7 +321,7 @@ private fun DisplaySearchResults( channelLastTime = null, channelLastContent = stringRes(R.string.ephemeral_relay_chat), hasNewMessages = false, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), onClick = { nav.nav(routeFor(item)) }, ) @@ -348,7 +348,7 @@ private fun DisplaySearchResults( channelLastTime = null, channelLastContent = item.summary(), hasNewMessages = false, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), onClick = { nav.nav(routeFor(item)) }, ) From 0c4cd4f9408ad534cfbe7d1db42af2d929bf2d13 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 15 Sep 2025 16:09:54 +0000 Subject: [PATCH 25/25] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-hu-rHU/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 734fbb971..a242f754a 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -440,6 +440,7 @@ Nem Követési lista Követettek bejegyzései + Összes követett felhasználó Követés proxyn keresztül Közelben lévők bejegyzései Globális @@ -1020,6 +1021,7 @@ Csevegési átjátszó Az átjátszó, amelyhez a csevegés összes felhasználója csatlakozik Kép megosztása… + Nem lehetett megosztani a képet, próbálja meg újra később… Hashtag keresése: #%1$s Innentől NE fordítsa le Az itt látható nyelvek nem lesznek lefordítva. Az eltávolításához és az újbóli fordításhoz válasszon ki egy nyelvet.