Merge pull request #2857 from vitorpamplona/claude/fix-notecompose-scroll-jitter-8lw3R

Optimize Compose state management and flow subscriptions
This commit is contained in:
Vitor Pamplona
2026-05-12 09:24:25 -04:00
committed by GitHub
15 changed files with 276 additions and 139 deletions
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.richtext.UrlParser
object CachedRichTextParser { object CachedRichTextParser {
private val richTextCache = LruCache<Int, RichTextViewerState>(50) private val richTextCache = LruCache<Int, RichTextViewerState>(50)
private val isMarkdownCache = LruCache<Int, Boolean>(200)
private fun hashCodeCache( private fun hashCodeCache(
content: String, content: String,
@@ -69,6 +70,25 @@ object CachedRichTextParser {
newUrls newUrls
} }
} }
// Shared across every RichTextViewer instance so that the same content quoted in multiple
// notes only pays for the scan once. The decision is purely a function of `content`.
fun isMarkdown(content: String): Boolean {
val key = content.hashCode()
isMarkdownCache[key]?.let { return it }
val result = computeIsMarkdown(content)
isMarkdownCache.put(key, result)
return result
}
private fun computeIsMarkdown(content: String): Boolean =
content.startsWith("> ") ||
content.startsWith("# ") ||
content.contains("##") ||
content.contains("__") ||
content.contains("**") ||
content.contains("```") ||
content.contains("](")
} }
object CachedUrlParser { object CachedUrlParser {
@@ -22,8 +22,10 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
@@ -52,10 +54,8 @@ fun observeNote(
EventFinderFilterAssemblerSubscription(note, accountViewModel) EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device // Subscribe in the LocalCache for changes that arrive in the device
return note val flow = remember(note) { note.flow().metadata.stateFlow }
.flow() return flow.collectAsStateWithLifecycle()
.metadata.stateFlow
.collectAsStateWithLifecycle()
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@@ -187,10 +187,8 @@ fun observeNoteReplies(
EventFinderFilterAssemblerSubscription(note, accountViewModel) EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device // Subscribe in the LocalCache for changes that arrive in the device
return note val flow = remember(note) { note.flow().replies.stateFlow }
.flow() return flow.collectAsStateWithLifecycle()
.replies.stateFlow
.collectAsStateWithLifecycle()
} }
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@@ -225,10 +223,8 @@ fun observeNoteReactions(
EventFinderFilterAssemblerSubscription(note, accountViewModel) EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device // Subscribe in the LocalCache for changes that arrive in the device
return note val flow = remember(note) { note.flow().reactions.stateFlow }
.flow() return flow.collectAsStateWithLifecycle()
.reactions.stateFlow
.collectAsStateWithLifecycle()
} }
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@@ -265,10 +261,8 @@ fun observeNoteZaps(
EventFinderFilterAssemblerSubscription(note, accountViewModel) EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device // Subscribe in the LocalCache for changes that arrive in the device
return note val flow = remember(note) { note.flow().zaps.stateFlow }
.flow() return flow.collectAsStateWithLifecycle()
.zaps.stateFlow
.collectAsStateWithLifecycle()
} }
@Composable @Composable
@@ -280,10 +274,8 @@ fun observeNoteReposts(
EventFinderFilterAssemblerSubscription(note, accountViewModel) EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device // Subscribe in the LocalCache for changes that arrive in the device
return note val flow = remember(note) { note.flow().boosts.stateFlow }
.flow() return flow.collectAsStateWithLifecycle()
.boosts.stateFlow
.collectAsStateWithLifecycle()
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@@ -365,27 +357,36 @@ fun observeNoteOts(
EventFinderFilterAssemblerSubscription(note, accountViewModel) EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device // Subscribe in the LocalCache for changes that arrive in the device
return note val flow = remember(note) { note.flow().ots.stateFlow }
.flow() return flow.collectAsStateWithLifecycle()
.ots
.stateFlow
.collectAsStateWithLifecycle()
} }
// Resolves the actual modification list off the main thread and filters identical results,
// so the caller's LaunchedEffect only fires when the list of edits truly changes.
// `sample(500)` collapses bursts — a heavily-edited note can emit hundreds of times during
// initial relay sync, and we only need the last state per ~half second.
// Returns `null` until the first IO resolution completes — callers should treat that as
// "still loading" and not flip their UI to "no edits".
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable @Composable
fun observeNoteEdits( fun observeNoteModifications(
note: Note, note: Note,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
): State<NoteState?> { ): State<List<Note>?> {
// Subscribe in the relay for changes in this note. // Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel) EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device return produceState<List<Note>?>(initialValue = null, note) {
return note note
.flow() .flow()
.edits .edits
.stateFlow .stateFlow
.collectAsStateWithLifecycle() .sample(500)
.mapLatest { LocalCache.findLatestModificationForNote(note) }
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
.collect { value = it }
}
} }
@Composable @Composable
@@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.ui.navigation.findParameterValue import com.vitorpamplona.amethyst.ui.navigation.findParameterValue
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.elements.NowProvider
import com.vitorpamplona.amethyst.ui.screen.AccountScreen import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
@@ -76,10 +77,12 @@ class MainActivity : AppCompatActivity() {
setContent { setContent {
StringResSetup() StringResSetup()
AmethystTheme { AmethystTheme {
NowProvider {
AccountScreen(Amethyst.instance.sessionManager) AccountScreen(Amethyst.instance.sessionManager)
} }
} }
} }
}
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
override fun onResume() { override fun onResume() {
@@ -122,15 +122,6 @@ import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
fun isMarkdown(content: String): Boolean =
content.startsWith("> ") ||
content.startsWith("# ") ||
content.contains("##") ||
content.contains("__") ||
content.contains("**") ||
content.contains("```") ||
content.contains("](")
@Composable @Composable
fun RichTextViewer( fun RichTextViewer(
content: String, content: String,
@@ -145,7 +136,7 @@ fun RichTextViewer(
nav: INav, nav: INav,
) { ) {
Column(modifier = modifier) { Column(modifier = modifier) {
if (remember(content) { isMarkdown(content) }) { if (remember(content) { CachedRichTextParser.isMarkdown(content) }) {
RenderContentAsMarkdown(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, accountViewModel, nav) RenderContentAsMarkdown(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, accountViewModel, nav)
} else { } else {
RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, authorPubKey, accountViewModel, nav) RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, authorPubKey, accountViewModel, nav)
@@ -59,7 +59,8 @@ fun WatchBlockAndReport(
nav: INav, nav: INav,
normalNote: @Composable (canPreview: Boolean) -> Unit, normalNote: @Composable (canPreview: Boolean) -> Unit,
) { ) {
val isHidden by accountViewModel.createIsHiddenFlow(note).collectAsStateWithLifecycle() val isHidden by remember(note) { accountViewModel.createIsHiddenFlow(note) }
.collectAsStateWithLifecycle()
val showAnyway = val showAnyway =
remember { remember {
@@ -66,8 +66,8 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelPicture import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelPicture
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeCommunityApprovalNeedStatus import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeCommunityApprovalNeedStatus
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEdits
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteModifications
import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
@@ -489,30 +489,31 @@ fun calculateBackgroundColor(
): MutableState<Color> { ): MutableState<Color> {
val defaultBackgroundColor = MaterialTheme.colorScheme.background val defaultBackgroundColor = MaterialTheme.colorScheme.background
val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor
// Only fade in/out the "new item" highlight for items that track read state.
// Inner notes (reposts/quotes) pass routeForLastRead = null and reuse the parent color directly,
// so the LaunchedEffect would just park a coroutine for 5s per item during scroll.
val isNew =
remember(createdAt, routeForLastRead) {
routeForLastRead != null && accountViewModel.loadAndMarkAsRead(routeForLastRead, createdAt)
}
val bgColor = val bgColor =
remember(createdAt) { remember(createdAt) {
mutableStateOf( mutableStateOf(
if (routeForLastRead != null) {
val isNew = accountViewModel.loadAndMarkAsRead(routeForLastRead, createdAt)
if (isNew) { if (isNew) {
if (parentBackgroundColor != null) { newItemColor.compositeOver(parentBackgroundColor?.value ?: defaultBackgroundColor)
newItemColor.compositeOver(parentBackgroundColor.value)
} else { } else {
newItemColor.compositeOver(defaultBackgroundColor) parentBackgroundColor?.value ?: Color.Transparent
}
} else {
parentBackgroundColor?.value ?: defaultBackgroundColor.copy(alpha = 0f)
}
} else {
parentBackgroundColor?.value ?: defaultBackgroundColor.copy(alpha = 0f)
}, },
) )
} }
if (isNew) {
LaunchedEffect(createdAt) { LaunchedEffect(createdAt) {
delay(5000) delay(5000)
bgColor.value = parentBackgroundColor?.value ?: defaultBackgroundColor.copy(alpha = 0f) bgColor.value = parentBackgroundColor?.value ?: Color.Transparent
}
} }
return bgColor return bgColor
@@ -1782,26 +1783,28 @@ fun observeEdits(
) )
} }
val updatedNote by observeNoteEdits(baseNote, accountViewModel) // Upstream resolves on IO and `distinctUntilChanged`s, so this LaunchedEffect only
// fires when the actual modification list changes — no more recomputing + reassigning
// editState on every unrelated emission of the edits flow.
val modifications by observeNoteModifications(baseNote, accountViewModel)
LaunchedEffect(key1 = updatedNote) { LaunchedEffect(modifications) {
updatedNote?.note?.let { val mods = modifications ?: return@LaunchedEffect
val newModifications = accountViewModel.findModificationEventsForNote(it) if (mods.isEmpty()) {
if (newModifications.isEmpty()) {
if (editState.value !is GenericLoadable.Empty) { if (editState.value !is GenericLoadable.Empty) {
editState.value = GenericLoadable.Empty() editState.value = GenericLoadable.Empty()
} }
} else { } else {
if (editState.value is GenericLoadable.Loaded) { val current = editState.value
(editState.value as? GenericLoadable.Loaded<EditState>)?.loaded?.updateModifications(newModifications) if (current is GenericLoadable.Loaded) {
current.loaded.updateModifications(mods)
} else { } else {
val state = EditState() val state = EditState()
state.updateModifications(newModifications) state.updateModifications(mods)
editState.value = GenericLoadable.Loaded(state) editState.value = GenericLoadable.Loaded(state)
} }
} }
} }
}
return editState return editState
} }
@@ -59,7 +59,11 @@ import com.vitorpamplona.amethyst.ui.theme.Size17Modifier
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.noteComposeRelayBox import com.vitorpamplona.amethyst.ui.theme.noteComposeRelayBox
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.flow.mapNotNull import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.sample
@Composable @Composable
fun RelayBadges( fun RelayBadges(
@@ -81,7 +85,7 @@ fun RelayBadges(
} }
} }
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class, FlowPreview::class)
@Composable @Composable
fun RenderAllRelayList( fun RenderAllRelayList(
baseNote: Note, baseNote: Note,
@@ -90,16 +94,26 @@ fun RenderAllRelayList(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val noteRelays by baseNote val flow =
remember(baseNote) {
baseNote
.flow() .flow()
.relays.stateFlow .relays.stateFlow
.collectAsStateWithLifecycle() .sample(500)
.map { it.note.relays }
.distinctUntilChanged()
}
val relays by flow.collectAsStateWithLifecycle(baseNote.relays)
FlowRow(modifier, verticalArrangement = verticalArrangement) { FlowRow(modifier, verticalArrangement = verticalArrangement) {
noteRelays.note.relays.forEach { RenderRelay(it, accountViewModel, nav) } relays.forEach { RenderRelay(it, accountViewModel, nav) }
} }
} }
// Single sampled subscription instead of one per slot: emits the first 3 relays from the note.
// Throttled to 500ms because relay arrivals can churn a list of an actively-fanned-out note.
@OptIn(FlowPreview::class)
@Composable @Composable
fun RenderClosedRelayList( fun RenderClosedRelayList(
baseNote: Note, baseNote: Note,
@@ -108,32 +122,32 @@ fun RenderClosedRelayList(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val flow =
remember(baseNote) {
baseNote
.flow()
.relays.stateFlow
.sample(500)
.map { it.note.relays.take(3) }
.distinctUntilChanged()
}
val initial = remember(baseNote) { baseNote.relays.take(3) }
val relays by flow.collectAsStateWithLifecycle(initial)
Row(modifier, verticalAlignment = verticalAlignment) { Row(modifier, verticalAlignment = verticalAlignment) {
WatchAndRenderRelay(baseNote, 0, accountViewModel, nav) RenderRelaySlot(relays.getOrNull(0), accountViewModel, nav)
WatchAndRenderRelay(baseNote, 1, accountViewModel, nav) RenderRelaySlot(relays.getOrNull(1), accountViewModel, nav)
WatchAndRenderRelay(baseNote, 2, accountViewModel, nav) RenderRelaySlot(relays.getOrNull(2), accountViewModel, nav)
} }
} }
@Composable @Composable
fun WatchAndRenderRelay( private fun RenderRelaySlot(
baseNote: Note, relay: NormalizedRelayUrl?,
relayIndex: Int,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val flow =
remember(baseNote, relayIndex) {
baseNote
.flow()
.relays.stateFlow
.mapNotNull {
it.note.relays.getOrNull(relayIndex)
}
}
val relay by flow.collectAsStateWithLifecycle(baseNote.relays.getOrNull(relayIndex))
CrossfadeIfEnabled(targetState = relay, label = "RenderRelay", modifier = Size17Modifier, accountViewModel = accountViewModel) { CrossfadeIfEnabled(targetState = relay, label = "RenderRelay", modifier = Size17Modifier, accountViewModel = accountViewModel) {
if (it != null) { if (it != null) {
RenderRelay(it, accountViewModel, nav) RenderRelay(it, accountViewModel, nav)
@@ -99,7 +99,8 @@ fun ShouldShowExpandButton(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val showExpandButton by accountViewModel.createMustShowExpandButtonFlows(baseNote).collectAsStateWithLifecycle() val flow = remember(baseNote) { accountViewModel.createMustShowExpandButtonFlows(baseNote) }
val showExpandButton by flow.collectAsStateWithLifecycle()
if (showExpandButton) { if (showExpandButton) {
content() content()
@@ -0,0 +1,50 @@
/*
* 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.note.elements
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.State
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.delay
private const val TICK_INTERVAL_MS = 30_000L
// Shared coarse-grained "now" ticker. One coroutine refreshes the value at TICK_INTERVAL_MS,
// every TimeAgo on screen reads from it. Because TimeAgo wraps the formatted string in
// `derivedStateOf`, the Text only recomposes when the displayed string actually changes
// (e.g. crossing 1m → 2m) — not on every tick.
val LocalNowSeconds = compositionLocalOf<State<Long>> { mutableStateOf(TimeUtils.now()) }
@Composable
fun NowProvider(content: @Composable () -> Unit) {
val now =
produceState(TimeUtils.now()) {
while (true) {
delay(TICK_INTERVAL_MS)
value = TimeUtils.now()
}
}
CompositionLocalProvider(LocalNowSeconds provides now, content = content)
}
@@ -25,7 +25,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue 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.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -46,7 +45,16 @@ fun TimeAgo(note: Note) {
@Composable @Composable
fun TimeAgo(time: Long) { fun TimeAgo(time: Long) {
val context = LocalContext.current val context = LocalContext.current
val timeStr by remember(time) { mutableStateOf(timeAgo(time, context = context)) } // Subscribe to the shared coarse ticker; `derivedStateOf` ensures the Text only
// recomposes when the formatted string actually flips (e.g. 1m → 2m), not on every tick.
val nowState = LocalNowSeconds.current
val timeStr by
remember(time, context, nowState) {
derivedStateOf {
nowState.value
timeAgo(time, context = context)
}
}
Text( Text(
text = timeStr, text = timeStr,
@@ -61,9 +69,15 @@ fun NormalTimeAgo(
modifier: Modifier, modifier: Modifier,
) { ) {
val nowStr = stringRes(id = R.string.now) val nowStr = stringRes(id = R.string.now)
val nowState = LocalNowSeconds.current
val time by val time by
remember(baseNote) { derivedStateOf { timeAgoShort(baseNote.createdAt() ?: 0L, nowStr) } } remember(baseNote, nowStr, nowState) {
derivedStateOf {
nowState.value
timeAgoShort(baseNote.createdAt() ?: 0L, nowStr)
}
}
Text( Text(
text = time, text = time,
@@ -1328,11 +1328,6 @@ class AccountViewModel(
fun cachedModificationEventsForNote(note: Note) = LocalCache.cachedModificationEventsForNote(note) fun cachedModificationEventsForNote(note: Note) = LocalCache.cachedModificationEventsForNote(note)
suspend fun findModificationEventsForNote(note: Note): List<Note> =
withContext(Dispatchers.IO) {
LocalCache.findLatestModificationForNote(note)
}
fun checkGetOrCreatePublicChatChannel(key: HexKey): PublicChatChannel = LocalCache.getOrCreatePublicChatChannel(key) fun checkGetOrCreatePublicChatChannel(key: HexKey): PublicChatChannel = LocalCache.getOrCreatePublicChatChannel(key)
fun checkGetOrCreateLiveActivityChannel(key: Address): LiveActivitiesChannel = LocalCache.getOrCreateLiveChannel(key) fun checkGetOrCreateLiveActivityChannel(key: Address): LiveActivitiesChannel = LocalCache.getOrCreateLiveChannel(key)
@@ -26,6 +26,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme 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.derivedStateOf
import androidx.compose.runtime.getValue
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
@@ -35,6 +37,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.note.elements.LocalNowSeconds
import com.vitorpamplona.amethyst.ui.note.timeAgoShort import com.vitorpamplona.amethyst.ui.note.timeAgoShort
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -46,7 +49,14 @@ import com.vitorpamplona.quartz.nip40Expiration.expiration
@Composable @Composable
fun ChatTimeAgo(baseNote: Note) { fun ChatTimeAgo(baseNote: Note) {
val nowStr = stringRes(id = R.string.now) val nowStr = stringRes(id = R.string.now)
val time = remember(baseNote) { timeAgoShort(baseNote.createdAt() ?: 0L, nowStr) } val nowState = LocalNowSeconds.current
val time by
remember(baseNote, nowStr, nowState) {
derivedStateOf {
nowState.value
timeAgoShort(baseNote.createdAt() ?: 0L, nowStr)
}
}
Text( Text(
text = time, text = time,
@@ -27,6 +27,7 @@ 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.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -60,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull
import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent
import com.vitorpamplona.amethyst.ui.note.elements.LocalNowSeconds
import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay
@@ -483,7 +485,14 @@ private fun TimeAgo(channelLastTime: Long?) {
if (channelLastTime == null) return if (channelLastTime == null) return
val context = LocalContext.current val context = LocalContext.current
val timeAgo = remember(channelLastTime) { timeAgo(channelLastTime, context) } val nowState = LocalNowSeconds.current
val timeAgo by
remember(channelLastTime, context, nowState) {
derivedStateOf {
nowState.value
timeAgo(channelLastTime, context)
}
}
Text( Text(
text = timeAgo, text = timeAgo,
color = MaterialTheme.colorScheme.grayText, color = MaterialTheme.colorScheme.grayText,
@@ -22,35 +22,47 @@ package com.vitorpamplona.amethyst.commons.compose
import androidx.collection.LruCache import androidx.collection.LruCache
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.produceState import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
// On a cache hit, short-circuit with a remembered State<V?> and skip the produceState coroutine.
// Only on a miss do we launch the suspending update.
@Composable @Composable
fun <K : Any, V : Any> produceCachedStateAsync( fun <K : Any, V : Any> produceCachedStateAsync(
cache: AsyncCachedState<K, V>, cache: AsyncCachedState<K, V>,
key: K, key: K,
): State<V?> = ): State<V?> {
@Suppress("ProduceStateDoesNotAssignValue") val state = remember(key) { mutableStateOf(cache.cached(key)) }
produceState(initialValue = cache.cached(key), key1 = key) { if (state.value == null) {
LaunchedEffect(key) {
val newValue = cache.update(key) val newValue = cache.update(key)
if (newValue != value) { if (state.value != newValue) {
value = newValue state.value = newValue
} }
} }
}
return state
}
@Composable @Composable
fun <K : Any, V : Any> produceCachedStateAsync( fun <K : Any, V : Any> produceCachedStateAsync(
cache: AsyncCachedState<K, V>, cache: AsyncCachedState<K, V>,
key: String, key: String,
updateValue: K, updateValue: K,
): State<V?> = ): State<V?> {
@Suppress("ProduceStateDoesNotAssignValue") val state = remember(key) { mutableStateOf(cache.cached(updateValue)) }
produceState(initialValue = cache.cached(updateValue), key1 = key) { if (state.value == null) {
LaunchedEffect(key) {
val newValue = cache.update(updateValue) val newValue = cache.update(updateValue)
if (newValue != value) { if (state.value != newValue) {
value = newValue state.value = newValue
} }
} }
}
return state
}
interface AsyncCachedState<K : Any, V : Any> { interface AsyncCachedState<K : Any, V : Any> {
fun cached(k: K): V? fun cached(k: K): V?
@@ -22,35 +22,48 @@ package com.vitorpamplona.amethyst.commons.compose
import androidx.collection.LruCache import androidx.collection.LruCache
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.produceState import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
// On a cache hit (the common case during scroll for things like Bech32 link previews),
// short-circuit with a remembered State<V?> and skip the produceState coroutine entirely.
// Only on a miss do we launch the suspending update.
@Composable @Composable
fun <K : Any, V : Any> produceCachedState( fun <K : Any, V : Any> produceCachedState(
cache: CachedState<K, V>, cache: CachedState<K, V>,
key: K, key: K,
): State<V?> = ): State<V?> {
@Suppress("ProduceStateDoesNotAssignValue") val state = remember(key) { mutableStateOf(cache.cached(key)) }
produceState(initialValue = cache.cached(key), key1 = key) { if (state.value == null) {
LaunchedEffect(key) {
val newValue = cache.update(key) val newValue = cache.update(key)
if (value != newValue) { if (state.value != newValue) {
value = newValue state.value = newValue
} }
} }
}
return state
}
@Composable @Composable
fun <K : Any, V : Any> produceCachedState( fun <K : Any, V : Any> produceCachedState(
cache: CachedState<K, V>, cache: CachedState<K, V>,
key: String, key: String,
updateValue: K, updateValue: K,
): State<V?> = ): State<V?> {
@Suppress("ProduceStateDoesNotAssignValue") val state = remember(key) { mutableStateOf(cache.cached(updateValue)) }
produceState(initialValue = cache.cached(updateValue), key1 = key) { if (state.value == null) {
LaunchedEffect(key) {
val newValue = cache.update(updateValue) val newValue = cache.update(updateValue)
if (value != newValue) { if (state.value != newValue) {
value = newValue state.value = newValue
} }
} }
}
return state
}
interface CachedState<K : Any, V : Any> { interface CachedState<K : Any, V : Any> {
fun cached(k: K): V? fun cached(k: K): V?