From bf540db557e4129ddec078fab36d8ea11d9369fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 14:54:03 +0000 Subject: [PATCH 1/4] 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 865d7c0b98..0c5a3a04ed 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 dea8fee724..816dc08e2f 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 4ad82bbff9..62d91d39aa 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 26a947d0c8..40f080cfb6 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 fad48ce655..115fc235c3 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 3015959a23..f5fc594951 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 8edda46dd0..6b7d0896af 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 80c232c970..eec7c4b845 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 7585c9b55e..49981b8de6 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 1c23992180..d00bd6d2e8 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) { From 6aecfe016ba4c1715741a7913d25e6d739bc0a85 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 15:34:22 +0000 Subject: [PATCH 2/4] =?UTF-8?q?perf(note=20types):=20second=20pass=20?= =?UTF-8?q?=E2=80=94=20fix=20more=20missing=20remember=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the first perf pass. Same goal: cut allocation cost for note rows that scroll inside LazyColumn feeds. - AppDefinition: key the `remember { tags.toImmutableListOfLists() }` block by `note` so it actually invalidates when the note changes - NIP90ContentDiscoveryResponse: drop the `remember(note) { Modifier.fillMaxWidth() }` wrapper — `Modifier.fillMaxWidth()` is a constant call - PeopleList: key the `derivedStateOf` for `name` by `noteEvent`, and switch `LaunchedEffect(Unit)` to `LaunchedEffect(noteEvent)` so the participants reload when the underlying event changes - PinList: replace `val pins by remember { mutableStateOf(noteEvent .pinnedEvents()) }` with `val pins = remember(noteEvent) { … }` — the `mutableStateOf` wrapper was unnecessary and the missing key meant `pins` could go stale on event updates - LongForm: return the `topics` list as `ImmutableList` so Compose treats it as a stable parameter to the consuming `forEach` - RelayList: drop the `mutableStateOf(RelayListCard(…))` wrap inside 4 `remember` blocks (DisplayRelaySet, DisplayNIP65RelayList write/ read, DisplayDMRelayList) — the value never changes after creation; also key by `noteEvent` rather than `baseNote`, and cache `noteEvent.description()` - Torrent: wrap `noteEvent.title() + totalSizeBytes()`, content comparison and `files().toImmutableList()` in `remember(noteEvent)` so they don't recompute and reallocate on every recomposition --- .../amethyst/ui/note/types/AppDefinition.kt | 2 +- .../amethyst/ui/note/types/LongForm.kt | 10 ++++- .../types/NIP90ContentDiscoveryResponse.kt | 3 +- .../amethyst/ui/note/types/PeopleList.kt | 4 +- .../amethyst/ui/note/types/PinList.kt | 2 +- .../amethyst/ui/note/types/RelayList.kt | 42 ++++++++----------- .../amethyst/ui/note/types/Torrent.kt | 25 ++++++----- 7 files changed, 47 insertions(+), 41 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt index acf1df765c..469f6fa9d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt @@ -216,7 +216,7 @@ fun RenderAppDefinition( CreateTextWithEmoji( text = it, tags = - remember { + remember(note) { (note.event?.tags ?: emptyArray()).toImmutableListOfLists() }, fontWeight = FontWeight.Bold, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt index f36864286d..6ba4460276 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt @@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import kotlinx.collections.immutable.toImmutableList private const val WORDS_PER_MINUTE = 225 private val COVER_ASPECT_RATIO = 16f / 9f @@ -92,7 +93,14 @@ fun LongFormHeader( remember(noteEvent) { noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } } - val topics = remember(noteEvent) { noteEvent.topics().distinct().take(3) } + val topics = + remember(noteEvent) { + noteEvent + .topics() + .distinct() + .take(3) + .toImmutableList() + } val readingMinutes = remember(noteEvent) { estimateReadingMinutes(noteEvent.content) } Column(MaterialTheme.colorScheme.replyModifier) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt index 5f73c4c0c4..91ea6a7a47 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt @@ -54,7 +54,6 @@ fun RenderNIP90ContentDiscoveryResponse( note = note, accountViewModel = accountViewModel, ) { - val modifier = remember(note) { Modifier.fillMaxWidth() } val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } @@ -62,7 +61,7 @@ fun RenderNIP90ContentDiscoveryResponse( content = noteEvent.content, 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/PeopleList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt index dbd7fb2d96..93abb98b3b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt @@ -81,7 +81,7 @@ fun DisplayPeopleList( members.take(3) } - val name by remember { derivedStateOf { "#${noteEvent.titleOrName() ?: noteEvent.dTag()}" } } + val name by remember(noteEvent) { derivedStateOf { "#${noteEvent.titleOrName() ?: noteEvent.dTag()}" } } Text( text = name, @@ -95,7 +95,7 @@ fun DisplayPeopleList( textAlign = TextAlign.Center, ) - LaunchedEffect(Unit) { + LaunchedEffect(noteEvent) { accountViewModel.loadUsers(noteEvent.taggedUserIds()) { members = it } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt index 105a7c898a..9f19533c7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt @@ -65,7 +65,7 @@ fun RenderPinListEvent( ) { val noteEvent = baseNote.event as? PinListEvent ?: return - val pins by remember { mutableStateOf(noteEvent.pinnedEvents()) } + val pins = remember(noteEvent) { noteEvent.pinnedEvents() } var expanded by remember { mutableStateOf(false) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt index ca15807f1d..6b1521866a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt @@ -75,12 +75,10 @@ fun DisplayRelaySet( ) { val noteEvent = baseNote.event as? RelaySetEvent ?: return - val relays by + val relays = remember(noteEvent) { - mutableStateOf( - RelayListCard( - noteEvent.relays().toImmutableList(), - ), + RelayListCard( + noteEvent.relays().toImmutableList(), ) } @@ -89,10 +87,12 @@ fun DisplayRelaySet( noteEvent.tags.firstTagValueFor("title", "name") ?: "#${noteEvent.dTag()}" } + val description = remember(noteEvent) { noteEvent.description() } + DisplayRelaySet( relays, relayListName, - noteEvent.description(), + description, backgroundColor, accountViewModel, nav, @@ -108,21 +108,17 @@ fun DisplayNIP65RelayList( ) { val noteEvent = baseNote.event as? AdvertisedRelayListEvent ?: return - val writeRelays by - remember(baseNote) { - mutableStateOf( - RelayListCard( - noteEvent.writeRelaysNorm() ?: emptyList(), - ), + val writeRelays = + remember(noteEvent) { + RelayListCard( + noteEvent.writeRelaysNorm() ?: emptyList(), ) } - val readRelays by - remember(baseNote) { - mutableStateOf( - RelayListCard( - noteEvent.readRelaysNorm() ?: emptyList(), - ), + val readRelays = + remember(noteEvent) { + RelayListCard( + noteEvent.readRelaysNorm() ?: emptyList(), ) } @@ -154,12 +150,10 @@ fun DisplayDMRelayList( ) { val noteEvent = baseNote.event as? ChatMessageRelayListEvent ?: return - val relays by - remember(baseNote) { - mutableStateOf( - RelayListCard( - noteEvent.relays(), - ), + val relays = + remember(noteEvent) { + RelayListCard( + noteEvent.relays(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt index 49ebe1cbb5..de4f5c3ed2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt @@ -153,19 +153,24 @@ fun RenderTorrent( ) { val noteEvent = note.event as? TorrentEvent ?: return - val name = (noteEvent.title() ?: TorrentEvent.ALT_DESCRIPTION) - val size = " (" + countToHumanReadableBytes(noteEvent.totalSizeBytes()) + ")" - - val description = - if (noteEvent.content != name) { - noteEvent.content - } else { - null + val title = + remember(noteEvent) { + val name = noteEvent.title() ?: TorrentEvent.ALT_DESCRIPTION + val size = " (" + countToHumanReadableBytes(noteEvent.totalSizeBytes()) + ")" + name + size } + val description = + remember(noteEvent) { + val name = noteEvent.title() ?: TorrentEvent.ALT_DESCRIPTION + if (noteEvent.content != name) noteEvent.content else null + } + + val files = remember(noteEvent) { noteEvent.files().toImmutableList() } + DisplayFileList( - noteEvent.files().toImmutableList(), - name + size, + files, + title, description, noteEvent::toMagnetLink, backgroundColor, From 06340dbdf9770e9126c070fbc64e5da0514857a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 15:44:28 +0000 Subject: [PATCH 3/4] fix(chats): stable per-chatroom LazyColumn key for messages list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chatroom list keyed each row by `if (index == 0) index else item.idHex`. Two problems: 1. Position 0 was hardcoded to key `0`, so when a new chatroom moved to the top, the existing composition slot was reused with state from the previous chatroom — observed as "the row updated and reordered but still shows the old result" right at the top. 2. For other positions, the key was the latest message's `idHex`. When a new message arrived in any chatroom the chatroom's representative Note got replaced (different idHex), so Compose threw away the row and rebuilt it from scratch — wasted work. Fix: derive a stable key from chatroom identity instead of message id — nostr group id for marmot rooms, channel id for public/ephemeral channels, sorted user set for DMs. Falls back to `item.idHex` for unrecognized event types (drafts etc.). Reorders now move the row; new-message updates re-use the slot. --- .../chats/rooms/feed/ChatroomListFeedView.kt | 55 +++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index b702de4d4e..2845170f86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -25,14 +25,16 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError @@ -45,6 +47,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderCompose import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent @Composable fun ChatroomListFeedView( @@ -103,14 +111,16 @@ private fun FeedLoaded( ) { val items by loaded.feed.collectAsStateWithLifecycle() + val myPubKey = accountViewModel.userProfile().pubkeyHex + LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { - itemsIndexed( + items( items.list, - key = { index, item -> if (index == 0) index else item.idHex }, - ) { _, item -> + key = { item -> chatroomLazyKey(item, myPubKey) }, + ) { item -> Row(Modifier.fillMaxWidth()) { ChatroomHeaderCompose( item, @@ -125,3 +135,40 @@ private fun FeedLoaded( } } } + +// Stable per-chatroom key — derived from chatroom identity, not the latest +// message id, so reorders move the row instead of recreating it. +private fun chatroomLazyKey( + item: Note, + myPubKey: HexKey, +): String { + item.inGatherers + ?.firstNotNullOfOrNull { it as? MarmotGroupChatroom } + ?.let { return "marmot:${it.nostrGroupId}" } + + return when (val event = item.event) { + is ChannelMessageEvent -> { + "ch:${event.channelId() ?: item.idHex}" + } + + is ChannelMetadataEvent -> { + "ch:${event.channelId() ?: item.idHex}" + } + + is ChannelCreateEvent -> { + "ch:${event.id}" + } + + is EphemeralChatEvent -> { + "eph:${event.roomId()?.toKey() ?: item.idHex}" + } + + is ChatroomKeyable -> { + "dm:${event.chatroomKey(myPubKey).users.sorted().joinToString(",")}" + } + + else -> { + item.idHex + } + } +} From 46f305fe1a36454ada6221ad9961c1c85ad9971e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 15:51:15 +0000 Subject: [PATCH 4/4] perf(chats): typed sealed key instead of concatenated string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix used `"ch:${id}"`, `"dm:${users.sorted().joinToString}"` etc., which allocates a StringBuilder + char[] + new String per call — worst case for the DM branch which also allocates a sorted List on top. Replace with a sealed `ChatroomLazyKey` and per-type data classes that just wrap the existing String / RoomId / ChatroomKey. Equality and hashCode are auto-generated, so Compose still moves rows correctly on reorder, and we drop most of the per-key allocations: ch:abc -> PublicChannelLazyKey(abc) # 1 wrapper, reused String dm:userA,userB -> PrivateChatLazyKey(chatroomKey) # 1 wrapper, reused ChatroomKey eph:roomId -> EphemeralChannelLazyKey(roomId) # 1 wrapper, reused RoomId --- .../chats/rooms/feed/ChatroomListFeedView.kt | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index 2845170f86..b852165f1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -48,7 +48,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderC import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent @@ -137,38 +139,63 @@ private fun FeedLoaded( } // Stable per-chatroom key — derived from chatroom identity, not the latest -// message id, so reorders move the row instead of recreating it. +// message id, so reorders move the row instead of recreating it. Uses a +// sealed wrapper around an existing String/RoomId/ChatroomKey to avoid the +// StringBuilder + concatenation allocations of a typed-prefix string key. +private sealed interface ChatroomLazyKey + +private data class MarmotChatroomLazyKey( + val groupId: HexKey, +) : ChatroomLazyKey + +private data class PublicChannelLazyKey( + val channelId: HexKey, +) : ChatroomLazyKey + +private data class EphemeralChannelLazyKey( + val roomId: RoomId, +) : ChatroomLazyKey + +private data class PrivateChatLazyKey( + val key: ChatroomKey, +) : ChatroomLazyKey + +private data class FallbackChatroomLazyKey( + val noteIdHex: HexKey, +) : ChatroomLazyKey + private fun chatroomLazyKey( item: Note, myPubKey: HexKey, -): String { +): ChatroomLazyKey { item.inGatherers ?.firstNotNullOfOrNull { it as? MarmotGroupChatroom } - ?.let { return "marmot:${it.nostrGroupId}" } + ?.let { return MarmotChatroomLazyKey(it.nostrGroupId) } return when (val event = item.event) { is ChannelMessageEvent -> { - "ch:${event.channelId() ?: item.idHex}" + PublicChannelLazyKey(event.channelId() ?: item.idHex) } is ChannelMetadataEvent -> { - "ch:${event.channelId() ?: item.idHex}" + PublicChannelLazyKey(event.channelId() ?: item.idHex) } is ChannelCreateEvent -> { - "ch:${event.id}" + PublicChannelLazyKey(event.id) } is EphemeralChatEvent -> { - "eph:${event.roomId()?.toKey() ?: item.idHex}" + event.roomId()?.let { EphemeralChannelLazyKey(it) } + ?: FallbackChatroomLazyKey(item.idHex) } is ChatroomKeyable -> { - "dm:${event.chatroomKey(myPubKey).users.sorted().joinToString(",")}" + PrivateChatLazyKey(event.chatroomKey(myPubKey)) } else -> { - item.idHex + FallbackChatroomLazyKey(item.idHex) } } }