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
This commit is contained in:
Claude
2026-04-26 14:54:03 +00:00
parent 864d14379a
commit bf540db557
10 changed files with 174 additions and 169 deletions
@@ -80,10 +80,10 @@ fun AudioTrackHeader(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val media = remember { noteEvent.media() } val media = remember(noteEvent) { noteEvent.media() }
val cover = remember { noteEvent.cover() } val cover = remember(noteEvent) { noteEvent.cover() }
val subject = remember { noteEvent.subject() } val subject = remember(noteEvent) { noteEvent.subject() }
val participants = remember { noteEvent.participants() } val participants = remember(noteEvent) { noteEvent.participants() }
var participantUsers by remember { mutableStateOf<List<Pair<ParticipantTag, User>>>(emptyList()) } var participantUsers by remember { mutableStateOf<List<Pair<ParticipantTag, User>>>(emptyList()) }
@@ -183,9 +183,9 @@ fun AudioHeader(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val media = remember { noteEvent.stream() ?: noteEvent.download() } val media = remember(noteEvent) { noteEvent.stream() ?: noteEvent.download() }
val waveform = remember { noteEvent.wavefrom()?.let { WaveformData(it.wave) } } val waveform = remember(noteEvent) { noteEvent.wavefrom()?.let { WaveformData(it.wave) } }
val content = remember { noteEvent.content.ifBlank { null } } val content = remember(noteEvent) { noteEvent.content.ifBlank { null } }
val defaultBackground = MaterialTheme.colorScheme.background val defaultBackground = MaterialTheme.colorScheme.background
val background = remember { mutableStateOf(defaultBackground) } val background = remember { mutableStateOf(defaultBackground) }
@@ -51,8 +51,14 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.SmallBorder import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
private val PriceTagModifier =
Modifier
.clip(SmallBorder)
.padding(start = 5.dp)
@Composable @Composable
fun RenderClassifieds( fun RenderClassifieds(
noteEvent: ClassifiedsEvent, noteEvent: ClassifiedsEvent,
@@ -60,23 +66,31 @@ fun RenderClassifieds(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val imageSet = val imageSet: ImmutableList<MediaUrlImage>? =
noteEvent.imageMetas().ifEmpty { null }?.map { remember(noteEvent) {
MediaUrlImage( noteEvent
url = it.url, .imageMetas()
description = it.alt, .ifEmpty { null }
hash = it.hash, ?.map {
blurhash = it.blurhash, MediaUrlImage(
dim = it.dimension, url = it.url,
uri = note.toNostrUri(), description = it.alt,
mimeType = it.mimeType, hash = it.hash,
thumbhash = it.thumbhash, blurhash = it.blurhash,
) dim = it.dimension,
uri = note.toNostrUri(),
mimeType = it.mimeType,
thumbhash = it.thumbhash,
)
}?.toImmutableList()
} }
val title = noteEvent.title() val title = remember(noteEvent) { noteEvent.title() }
val summary = noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } val summary =
val price = noteEvent.price() remember(noteEvent) {
val location = noteEvent.location() noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null }
}
val price = remember(noteEvent) { noteEvent.price() }
val location = remember(noteEvent) { noteEvent.location() }
Row( Row(
modifier = modifier =
@@ -94,7 +108,7 @@ fun RenderClassifieds(
AutoNonlazyGrid(images.size) { AutoNonlazyGrid(images.size) {
ZoomableContentView( ZoomableContentView(
content = images[it], content = images[it],
images = images.toImmutableList(), images = images,
roundedCorner = false, roundedCorner = false,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
@@ -140,12 +154,7 @@ fun RenderClassifieds(
maxLines = 1, maxLines = 1,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = modifier = PriceTagModifier,
remember {
Modifier
.clip(SmallBorder)
.padding(start = 5.dp)
},
) )
} }
} }
@@ -21,8 +21,6 @@
package com.vitorpamplona.amethyst.ui.note.types package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
@@ -46,7 +44,7 @@ fun FileHeaderDisplay(
val event = (note.event as? FileHeaderEvent) ?: return val event = (note.event as? FileHeaderEvent) ?: return
val fullUrl = event.url() ?: return val fullUrl = event.url() ?: return
val content by val content: BaseMediaContent =
remember(note) { remember(note) {
val blurHash = event.blurhash() val blurHash = event.blurhash()
val thumbHash = event.thumbhash() val thumbHash = event.thumbhash()
@@ -57,32 +55,30 @@ fun FileHeaderDisplay(
val uri = note.toNostrUri() val uri = note.toNostrUri()
val mimeType = event.mimeType() val mimeType = event.mimeType()
mutableStateOf<BaseMediaContent>( if (isImage) {
if (isImage) { MediaUrlImage(
MediaUrlImage( url = fullUrl,
url = fullUrl, description = description,
description = description, hash = hash,
hash = hash, blurhash = blurHash,
blurhash = blurHash, dim = dimensions,
dim = dimensions, uri = uri,
uri = uri, mimeType = mimeType,
mimeType = mimeType, thumbhash = thumbHash,
thumbhash = thumbHash, )
) } else {
} else { MediaUrlVideo(
MediaUrlVideo( url = fullUrl,
url = fullUrl, description = description,
description = description, hash = hash,
hash = hash, blurhash = blurHash,
blurhash = blurHash, dim = dimensions,
dim = dimensions, uri = uri,
uri = uri, authorName = note.author?.toBestDisplayName(),
authorName = note.author?.toBestDisplayName(), mimeType = mimeType,
mimeType = mimeType, thumbhash = thumbHash,
thumbhash = thumbHash, )
) }
},
)
} }
SensitivityWarning(note = note, accountViewModel = accountViewModel) { SensitivityWarning(note = note, accountViewModel = accountViewModel) {
@@ -357,7 +357,7 @@ fun DisplayEntryForAUrl(
} }
val validatedUrl = val validatedUrl =
remember { remember(url) {
try { try {
URL(url) URL(url)
} catch (_: Exception) { } catch (_: Exception) {
@@ -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 @Composable
fun MeetingSpaceOpenFlag() { fun MeetingSpaceOpenFlag() {
Text( Text(
@@ -285,13 +303,7 @@ fun MeetingSpaceOpenFlag() {
color = Color.White, color = Color.White,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 16.sp, fontSize = 16.sp,
modifier = modifier = MeetingSpaceOpenModifier,
remember {
Modifier
.clip(SmallBorder)
.background(Color(0xFF4CAF50))
.padding(horizontal = 5.dp)
},
) )
} }
@@ -302,13 +314,7 @@ fun MeetingSpacePrivateFlag() {
color = Color.White, color = Color.White,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 16.sp, fontSize = 16.sp,
modifier = modifier = MeetingSpacePrivateModifier,
remember {
Modifier
.clip(SmallBorder)
.background(Color(0xFFFF9800))
.padding(horizontal = 5.dp)
},
) )
} }
@@ -319,12 +325,6 @@ fun MeetingSpaceClosedFlag() {
color = Color.White, color = Color.White,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 16.sp, fontSize = 16.sp,
modifier = modifier = MeetingSpaceClosedModifier,
remember {
Modifier
.clip(SmallBorder)
.background(Color.Black)
.padding(horizontal = 5.dp)
},
) )
} }
@@ -30,8 +30,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -71,30 +69,29 @@ fun PictureDisplay(
val isSensitive = remember(note) { event.isSensitiveOrNSFW() } val isSensitive = remember(note) { event.isSensitiveOrNSFW() }
val reasons = remember(note) { collectContentWarningReasons(event) } val reasons = remember(note) { collectContentWarningReasons(event) }
val images by val images =
remember(note) { remember(note) {
mutableStateOf( event
event .imetaTags()
.imetaTags() .map {
.map { MediaUrlImage(
MediaUrlImage( url = it.url,
url = it.url, description = it.alt,
description = it.alt, hash = it.hash,
hash = it.hash, blurhash = it.blurhash,
blurhash = it.blurhash, dim = it.dimension,
dim = it.dimension, uri = uri,
uri = uri, mimeType = it.mimeType,
mimeType = it.mimeType, thumbhash = it.thumbhash,
thumbhash = it.thumbhash, )
) }.toImmutableList()
}.toImmutableList(),
)
} }
val first = images.firstOrNull() val first = images.firstOrNull()
if (first != null) { if (first != null) {
val title = event.title() val title = remember(event) { event.title() }
val preloadUrls = remember(images) { images.map { it.url } }
Column { Column {
if (title != null) { if (title != null) {
@@ -114,7 +111,7 @@ fun PictureDisplay(
ContentWarningGate( ContentWarningGate(
isSensitive = isSensitive, isSensitive = isSensitive,
reasons = reasons, reasons = reasons,
preloadUrls = listOf(first.url), preloadUrls = preloadUrls,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, ContentScale.FillWidth), modifier = mediaSizingModifier(ratio, ContentScale.FillWidth),
backdrop = (first.thumbhash ?: first.blurhash)?.let { { BlurhashBackdrop(first.blurhash, first.description, first.thumbhash) } }, backdrop = (first.thumbhash ?: first.blurhash)?.let { { BlurhashBackdrop(first.blurhash, first.description, first.thumbhash) } },
@@ -131,7 +128,7 @@ fun PictureDisplay(
ContentWarningGate( ContentWarningGate(
isSensitive = isSensitive, isSensitive = isSensitive,
reasons = reasons, reasons = reasons,
preloadUrls = images.map { it.url }, preloadUrls = preloadUrls,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
modifier = Modifier.fillMaxWidth().aspectRatio(1f), modifier = Modifier.fillMaxWidth().aspectRatio(1f),
backdrop = { BlurhashGridBackdrop(images) }, backdrop = { BlurhashGridBackdrop(images) },
@@ -358,7 +358,7 @@ private fun ColumnScope.RenderSingleChoiceOptions(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
val hasSpaceToClick = val hasSpaceToClick =
remember { remember(it.label) {
it.label.contains(' ') || it.label.contains('\n') it.label.contains(' ') || it.label.contains('\n')
} }
@@ -436,7 +436,7 @@ private fun RenderResults(
labelContent: @Composable (ColumnScope.(code: String, label: String) -> Unit), labelContent: @Composable (ColumnScope.(code: String, label: String) -> Unit),
) { ) {
val showGallery = val showGallery =
remember { remember(card) {
card.options.all { card.options.all {
it.label.length < 50 it.label.length < 50
} }
@@ -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) { if (withMe) {
LoadDecryptedContent(note, accountViewModel) { eventContent -> LoadDecryptedContent(note, accountViewModel) { eventContent ->
val modifier = remember(note.event?.id) { Modifier.fillMaxWidth() }
val isAuthorTheLoggedUser = val isAuthorTheLoggedUser =
remember(note.event?.id) { accountViewModel.isLoggedUser(note.author) } remember(note.author) { accountViewModel.isLoggedUser(note.author) }
val tags = val tags =
remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
@@ -113,7 +112,7 @@ fun RenderPrivateMessage(
content = eventContent, content = eventContent,
canPreview = canPreview && !makeItShort, canPreview = canPreview && !makeItShort,
quotesLeft = quotesLeft, quotesLeft = quotesLeft,
modifier = modifier, modifier = Modifier.fillMaxWidth(),
tags = tags, tags = tags,
backgroundColor = backgroundColor, backgroundColor = backgroundColor,
id = note.idHex, id = note.idHex,
@@ -48,35 +48,43 @@ fun RenderReport(
) { ) {
val noteEvent = note.event as? ReportEvent ?: return 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 = val explicitContent = stringRes(R.string.explicit_content)
base val nudity = stringRes(R.string.nudity)
.map { val profanity = stringRes(R.string.profanity_hateful_speech)
when (it.type) { val spam = stringRes(R.string.spam)
ReportType.EXPLICIT -> stringRes(R.string.explicit_content) val impersonation = stringRes(R.string.impersonation)
ReportType.NUDITY -> stringRes(R.string.nudity) val illegal = stringRes(R.string.illegal_behavior)
ReportType.PROFANITY -> stringRes(R.string.profanity_hateful_speech) val malware = stringRes(R.string.malware)
ReportType.SPAM -> stringRes(R.string.spam) val other = stringRes(R.string.other)
ReportType.IMPERSONATION -> stringRes(R.string.impersonation) val harassment = stringRes(R.string.harassment)
ReportType.ILLEGAL -> stringRes(R.string.illegal_behavior) val violence = stringRes(R.string.violence)
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 content = val content =
remember { remember(reportTypes, noteEvent) {
reportType + ( val reportTypeText =
note.event reportTypes.joinToString(", ") {
?.content when (it) {
?.ifBlank { null } ReportType.EXPLICIT -> explicitContent
?.let { ": $it" } ?: "" 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( TranslatableRichTextViewer(
@@ -30,8 +30,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -81,45 +79,43 @@ fun VideoDisplay(
val imeta = videoEvent.imetaTags().firstOrNull() ?: return val imeta = videoEvent.imetaTags().firstOrNull() ?: return
val title = videoEvent.title() val title = remember(videoEvent) { videoEvent.title() }
val summary = videoEvent.content.ifBlank { null }?.takeIf { title != it } val summary = remember(videoEvent, title) { videoEvent.content.ifBlank { null }?.takeIf { title != it } }
val image = imeta.image.firstOrNull() val image = remember(imeta) { imeta.image.firstOrNull() }
val isYouTube = imeta.url.contains("youtube.com") || imeta.url.contains("youtu.be") val isYouTube = remember(imeta) { imeta.url.contains("youtube.com") || imeta.url.contains("youtu.be") }
val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
val content by val content: BaseMediaContent =
remember(note) { remember(note) {
val description = videoEvent.content.ifBlank { null } ?: event.alt() val description = videoEvent.content.ifBlank { null } ?: event.alt()
val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url)
val uri = note.toNostrUri() val uri = note.toNostrUri()
mutableStateOf<BaseMediaContent>( if (isImage) {
if (isImage) { MediaUrlImage(
MediaUrlImage( url = imeta.url,
url = imeta.url, description = description,
description = description, hash = imeta.hash,
hash = imeta.hash, blurhash = imeta.blurhash,
blurhash = imeta.blurhash, dim = imeta.dimension,
dim = imeta.dimension, uri = uri,
uri = uri, mimeType = imeta.mimeType,
mimeType = imeta.mimeType, thumbhash = imeta.thumbhash,
thumbhash = imeta.thumbhash, )
) } else {
} else { MediaUrlVideo(
MediaUrlVideo( url = imeta.url,
url = imeta.url, description = description,
description = description, hash = imeta.hash,
hash = imeta.hash, dim = imeta.dimension,
dim = imeta.dimension, uri = uri,
uri = uri, authorName = note.author?.toBestDisplayName(),
authorName = note.author?.toBestDisplayName(), artworkUri = imeta.image.firstOrNull(),
artworkUri = imeta.image.firstOrNull(), mimeType = imeta.mimeType,
mimeType = imeta.mimeType, blurhash = imeta.blurhash,
blurhash = imeta.blurhash, thumbhash = imeta.thumbhash,
thumbhash = imeta.thumbhash, )
) }
},
)
} }
SensitivityWarning(note = note, accountViewModel = accountViewModel) { SensitivityWarning(note = note, accountViewModel = accountViewModel) {