From bf540db557e4129ddec078fab36d8ea11d9369fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 14:54:03 +0000 Subject: [PATCH] perf(note types): cache event-derived values and hoist static modifiers Reduce per-recomposition allocation cost for note rows that render inside LazyColumn feeds: - AudioTrack: add `noteEvent` keys to `remember` for media/cover/subject/ participants/waveform/content so the cached values invalidate when the underlying event changes - Classifieds: wrap `imageMetas().map { MediaUrlImage(...) }`, title, summary, price and location in `remember(noteEvent)` so they aren't recomputed on every recomposition; hoist the static price-tag modifier to a top-level `val` - Report: collapse the per-recomposition `map { stringRes(...) }` chain into a single `remember(reportTypes, noteEvent)` over a deduplicated set of report types, and key the `base` collection by `noteEvent` - Highlight: key the URL-parse `remember` by `url` so it actually re-validates when the parameter changes - PrivateMessage: key `remember { noteEvent.with(...) }` by `noteEvent`, drop the silly `remember { Modifier.fillMaxWidth() }` wrapper, and key `isLoggedUser` by `note.author` instead of `note.event?.id` - PictureDisplay / FileHeader / Video: drop the unnecessary `mutableStateOf(...)` wrap inside `remember` blocks that produce immutable `BaseMediaContent` values; cache `images.map { it.url }` preload list, and key `title`/`summary`/`image`/`isYouTube` by event - Poll: add the missing `it.label` and `card` keys to `remember` blocks that derive booleans from those parameters - MeetingSpace: hoist the three `MeetingSpace*Flag` modifier chains to top-level `val`s instead of allocating them each composition --- .../amethyst/ui/note/types/AudioTrack.kt | 14 ++-- .../amethyst/ui/note/types/Classifieds.kt | 55 +++++++++------- .../amethyst/ui/note/types/FileHeader.kt | 54 ++++++++-------- .../amethyst/ui/note/types/Highlight.kt | 2 +- .../amethyst/ui/note/types/MeetingSpace.kt | 42 ++++++------ .../amethyst/ui/note/types/PictureDisplay.kt | 41 ++++++------ .../amethyst/ui/note/types/Poll.kt | 4 +- .../amethyst/ui/note/types/PrivateMessage.kt | 7 +- .../amethyst/ui/note/types/Report.kt | 60 +++++++++-------- .../amethyst/ui/note/types/Video.kt | 64 +++++++++---------- 10 files changed, 174 insertions(+), 169 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt index 865d7c0b9..0c5a3a04e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt @@ -80,10 +80,10 @@ fun AudioTrackHeader( accountViewModel: AccountViewModel, nav: INav, ) { - val media = remember { noteEvent.media() } - val cover = remember { noteEvent.cover() } - val subject = remember { noteEvent.subject() } - val participants = remember { noteEvent.participants() } + val media = remember(noteEvent) { noteEvent.media() } + val cover = remember(noteEvent) { noteEvent.cover() } + val subject = remember(noteEvent) { noteEvent.subject() } + val participants = remember(noteEvent) { noteEvent.participants() } var participantUsers by remember { mutableStateOf>>(emptyList()) } @@ -183,9 +183,9 @@ fun AudioHeader( accountViewModel: AccountViewModel, nav: INav, ) { - val media = remember { noteEvent.stream() ?: noteEvent.download() } - val waveform = remember { noteEvent.wavefrom()?.let { WaveformData(it.wave) } } - val content = remember { noteEvent.content.ifBlank { null } } + val media = remember(noteEvent) { noteEvent.stream() ?: noteEvent.download() } + val waveform = remember(noteEvent) { noteEvent.wavefrom()?.let { WaveformData(it.wave) } } + val content = remember(noteEvent) { noteEvent.content.ifBlank { null } } val defaultBackground = MaterialTheme.colorScheme.background val background = remember { mutableStateOf(defaultBackground) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt index dea8fee72..816dc08e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt @@ -51,8 +51,14 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.SmallBorder import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +private val PriceTagModifier = + Modifier + .clip(SmallBorder) + .padding(start = 5.dp) + @Composable fun RenderClassifieds( noteEvent: ClassifiedsEvent, @@ -60,23 +66,31 @@ fun RenderClassifieds( accountViewModel: AccountViewModel, nav: INav, ) { - val imageSet = - noteEvent.imageMetas().ifEmpty { null }?.map { - MediaUrlImage( - url = it.url, - description = it.alt, - hash = it.hash, - blurhash = it.blurhash, - dim = it.dimension, - uri = note.toNostrUri(), - mimeType = it.mimeType, - thumbhash = it.thumbhash, - ) + val imageSet: ImmutableList? = + remember(noteEvent) { + noteEvent + .imageMetas() + .ifEmpty { null } + ?.map { + MediaUrlImage( + url = it.url, + description = it.alt, + hash = it.hash, + blurhash = it.blurhash, + dim = it.dimension, + uri = note.toNostrUri(), + mimeType = it.mimeType, + thumbhash = it.thumbhash, + ) + }?.toImmutableList() } - val title = noteEvent.title() - val summary = noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } - val price = noteEvent.price() - val location = noteEvent.location() + val title = remember(noteEvent) { noteEvent.title() } + val summary = + remember(noteEvent) { + noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } + } + val price = remember(noteEvent) { noteEvent.price() } + val location = remember(noteEvent) { noteEvent.location() } Row( modifier = @@ -94,7 +108,7 @@ fun RenderClassifieds( AutoNonlazyGrid(images.size) { ZoomableContentView( content = images[it], - images = images.toImmutableList(), + images = images, roundedCorner = false, contentScale = ContentScale.Crop, accountViewModel = accountViewModel, @@ -140,12 +154,7 @@ fun RenderClassifieds( maxLines = 1, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, - modifier = - remember { - Modifier - .clip(SmallBorder) - .padding(start = 5.dp) - }, + modifier = PriceTagModifier, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt index 4ad82bbff..62d91d39a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt @@ -21,8 +21,6 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.layout.ContentScale import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent @@ -46,7 +44,7 @@ fun FileHeaderDisplay( val event = (note.event as? FileHeaderEvent) ?: return val fullUrl = event.url() ?: return - val content by + val content: BaseMediaContent = remember(note) { val blurHash = event.blurhash() val thumbHash = event.thumbhash() @@ -57,32 +55,30 @@ fun FileHeaderDisplay( val uri = note.toNostrUri() val mimeType = event.mimeType() - mutableStateOf( - if (isImage) { - MediaUrlImage( - url = fullUrl, - description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri, - mimeType = mimeType, - thumbhash = thumbHash, - ) - } else { - MediaUrlVideo( - url = fullUrl, - description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri, - authorName = note.author?.toBestDisplayName(), - mimeType = mimeType, - thumbhash = thumbHash, - ) - }, - ) + if (isImage) { + MediaUrlImage( + url = fullUrl, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + mimeType = mimeType, + thumbhash = thumbHash, + ) + } else { + MediaUrlVideo( + url = fullUrl, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + authorName = note.author?.toBestDisplayName(), + mimeType = mimeType, + thumbhash = thumbHash, + ) + } } SensitivityWarning(note = note, accountViewModel = accountViewModel) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt index 26a947d0c..40f080cfb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt @@ -357,7 +357,7 @@ fun DisplayEntryForAUrl( } val validatedUrl = - remember { + remember(url) { try { URL(url) } catch (_: Exception) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt index fad48ce65..115fc235c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt @@ -278,6 +278,24 @@ private fun RenderParticipants( } } +private val MeetingSpaceOpenModifier = + Modifier + .clip(SmallBorder) + .background(Color(0xFF4CAF50)) + .padding(horizontal = 5.dp) + +private val MeetingSpacePrivateModifier = + Modifier + .clip(SmallBorder) + .background(Color(0xFFFF9800)) + .padding(horizontal = 5.dp) + +private val MeetingSpaceClosedModifier = + Modifier + .clip(SmallBorder) + .background(Color.Black) + .padding(horizontal = 5.dp) + @Composable fun MeetingSpaceOpenFlag() { Text( @@ -285,13 +303,7 @@ fun MeetingSpaceOpenFlag() { color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp, - modifier = - remember { - Modifier - .clip(SmallBorder) - .background(Color(0xFF4CAF50)) - .padding(horizontal = 5.dp) - }, + modifier = MeetingSpaceOpenModifier, ) } @@ -302,13 +314,7 @@ fun MeetingSpacePrivateFlag() { color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp, - modifier = - remember { - Modifier - .clip(SmallBorder) - .background(Color(0xFFFF9800)) - .padding(horizontal = 5.dp) - }, + modifier = MeetingSpacePrivateModifier, ) } @@ -319,12 +325,6 @@ fun MeetingSpaceClosedFlag() { color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp, - modifier = - remember { - Modifier - .clip(SmallBorder) - .background(Color.Black) - .padding(horizontal = 5.dp) - }, + modifier = MeetingSpaceClosedModifier, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt index 3015959a2..f5fc59495 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt @@ -30,8 +30,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -71,30 +69,29 @@ fun PictureDisplay( val isSensitive = remember(note) { event.isSensitiveOrNSFW() } val reasons = remember(note) { collectContentWarningReasons(event) } - val images by + val images = remember(note) { - mutableStateOf( - event - .imetaTags() - .map { - MediaUrlImage( - url = it.url, - description = it.alt, - hash = it.hash, - blurhash = it.blurhash, - dim = it.dimension, - uri = uri, - mimeType = it.mimeType, - thumbhash = it.thumbhash, - ) - }.toImmutableList(), - ) + event + .imetaTags() + .map { + MediaUrlImage( + url = it.url, + description = it.alt, + hash = it.hash, + blurhash = it.blurhash, + dim = it.dimension, + uri = uri, + mimeType = it.mimeType, + thumbhash = it.thumbhash, + ) + }.toImmutableList() } val first = images.firstOrNull() if (first != null) { - val title = event.title() + val title = remember(event) { event.title() } + val preloadUrls = remember(images) { images.map { it.url } } Column { if (title != null) { @@ -114,7 +111,7 @@ fun PictureDisplay( ContentWarningGate( isSensitive = isSensitive, reasons = reasons, - preloadUrls = listOf(first.url), + preloadUrls = preloadUrls, accountViewModel = accountViewModel, modifier = mediaSizingModifier(ratio, ContentScale.FillWidth), backdrop = (first.thumbhash ?: first.blurhash)?.let { { BlurhashBackdrop(first.blurhash, first.description, first.thumbhash) } }, @@ -131,7 +128,7 @@ fun PictureDisplay( ContentWarningGate( isSensitive = isSensitive, reasons = reasons, - preloadUrls = images.map { it.url }, + preloadUrls = preloadUrls, accountViewModel = accountViewModel, modifier = Modifier.fillMaxWidth().aspectRatio(1f), backdrop = { BlurhashGridBackdrop(images) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt index 8edda46dd..6b7d0896a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt @@ -358,7 +358,7 @@ private fun ColumnScope.RenderSingleChoiceOptions( verticalAlignment = Alignment.CenterVertically, ) { val hasSpaceToClick = - remember { + remember(it.label) { it.label.contains(' ') || it.label.contains('\n') } @@ -436,7 +436,7 @@ private fun RenderResults( labelContent: @Composable (ColumnScope.(code: String, label: String) -> Unit), ) { val showGallery = - remember { + remember(card) { card.options.all { it.label.length < 50 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt index 80c232c97..eec7c4b84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt @@ -85,12 +85,11 @@ fun RenderPrivateMessage( } } - val withMe = remember { noteEvent.with(accountViewModel.userProfile().pubkeyHex) } + val withMe = remember(noteEvent) { noteEvent.with(accountViewModel.userProfile().pubkeyHex) } if (withMe) { LoadDecryptedContent(note, accountViewModel) { eventContent -> - val modifier = remember(note.event?.id) { Modifier.fillMaxWidth() } val isAuthorTheLoggedUser = - remember(note.event?.id) { accountViewModel.isLoggedUser(note.author) } + remember(note.author) { accountViewModel.isLoggedUser(note.author) } val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } @@ -113,7 +112,7 @@ fun RenderPrivateMessage( content = eventContent, canPreview = canPreview && !makeItShort, quotesLeft = quotesLeft, - modifier = modifier, + modifier = Modifier.fillMaxWidth(), tags = tags, backgroundColor = backgroundColor, id = note.idHex, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt index 7585c9b55..49981b8de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt @@ -48,35 +48,43 @@ fun RenderReport( ) { val noteEvent = note.event as? ReportEvent ?: return - val base = remember { (noteEvent.reportedPost() + noteEvent.reportedAuthor()) } + val reportTypes = + remember(noteEvent) { + (noteEvent.reportedPost() + noteEvent.reportedAuthor()) + .mapTo(LinkedHashSet()) { it.type } + } - val reportType = - base - .map { - when (it.type) { - ReportType.EXPLICIT -> stringRes(R.string.explicit_content) - ReportType.NUDITY -> stringRes(R.string.nudity) - ReportType.PROFANITY -> stringRes(R.string.profanity_hateful_speech) - ReportType.SPAM -> stringRes(R.string.spam) - ReportType.IMPERSONATION -> stringRes(R.string.impersonation) - ReportType.ILLEGAL -> stringRes(R.string.illegal_behavior) - ReportType.MALWARE -> stringRes(R.string.malware) - ReportType.OTHER -> stringRes(R.string.other) - ReportType.HARASSMENT -> stringRes(R.string.harassment) - ReportType.VIOLENCE -> stringRes(R.string.violence) - null -> stringRes(R.string.other) - } - }.toSet() - .joinToString(", ") + val explicitContent = stringRes(R.string.explicit_content) + val nudity = stringRes(R.string.nudity) + val profanity = stringRes(R.string.profanity_hateful_speech) + val spam = stringRes(R.string.spam) + val impersonation = stringRes(R.string.impersonation) + val illegal = stringRes(R.string.illegal_behavior) + val malware = stringRes(R.string.malware) + val other = stringRes(R.string.other) + val harassment = stringRes(R.string.harassment) + val violence = stringRes(R.string.violence) val content = - remember { - reportType + ( - note.event - ?.content - ?.ifBlank { null } - ?.let { ": $it" } ?: "" - ) + remember(reportTypes, noteEvent) { + val reportTypeText = + reportTypes.joinToString(", ") { + when (it) { + ReportType.EXPLICIT -> explicitContent + ReportType.NUDITY -> nudity + ReportType.PROFANITY -> profanity + ReportType.SPAM -> spam + ReportType.IMPERSONATION -> impersonation + ReportType.ILLEGAL -> illegal + ReportType.MALWARE -> malware + ReportType.OTHER -> other + ReportType.HARASSMENT -> harassment + ReportType.VIOLENCE -> violence + null -> other + } + } + val extra = noteEvent.content.ifBlank { null }?.let { ": $it" } ?: "" + reportTypeText + extra } TranslatableRichTextViewer( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt index 1c2399218..d00bd6d2e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt @@ -30,8 +30,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -81,45 +79,43 @@ fun VideoDisplay( val imeta = videoEvent.imetaTags().firstOrNull() ?: return - val title = videoEvent.title() - val summary = videoEvent.content.ifBlank { null }?.takeIf { title != it } - val image = imeta.image.firstOrNull() - val isYouTube = imeta.url.contains("youtube.com") || imeta.url.contains("youtu.be") + val title = remember(videoEvent) { videoEvent.title() } + val summary = remember(videoEvent, title) { videoEvent.content.ifBlank { null }?.takeIf { title != it } } + val image = remember(imeta) { imeta.image.firstOrNull() } + val isYouTube = remember(imeta) { imeta.url.contains("youtube.com") || imeta.url.contains("youtu.be") } val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } - val content by + val content: BaseMediaContent = remember(note) { val description = videoEvent.content.ifBlank { null } ?: event.alt() val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) val uri = note.toNostrUri() - mutableStateOf( - if (isImage) { - MediaUrlImage( - url = imeta.url, - description = description, - hash = imeta.hash, - blurhash = imeta.blurhash, - dim = imeta.dimension, - uri = uri, - mimeType = imeta.mimeType, - thumbhash = imeta.thumbhash, - ) - } else { - MediaUrlVideo( - url = imeta.url, - description = description, - hash = imeta.hash, - dim = imeta.dimension, - uri = uri, - authorName = note.author?.toBestDisplayName(), - artworkUri = imeta.image.firstOrNull(), - mimeType = imeta.mimeType, - blurhash = imeta.blurhash, - thumbhash = imeta.thumbhash, - ) - }, - ) + if (isImage) { + MediaUrlImage( + url = imeta.url, + description = description, + hash = imeta.hash, + blurhash = imeta.blurhash, + dim = imeta.dimension, + uri = uri, + mimeType = imeta.mimeType, + thumbhash = imeta.thumbhash, + ) + } else { + MediaUrlVideo( + url = imeta.url, + description = description, + hash = imeta.hash, + dim = imeta.dimension, + uri = uri, + authorName = note.author?.toBestDisplayName(), + artworkUri = imeta.image.firstOrNull(), + mimeType = imeta.mimeType, + blurhash = imeta.blurhash, + thumbhash = imeta.thumbhash, + ) + } } SensitivityWarning(note = note, accountViewModel = accountViewModel) {