Merge pull request #2857 from vitorpamplona/claude/fix-notecompose-scroll-jitter-8lw3R
Optimize Compose state management and flow subscriptions
This commit is contained in:
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.richtext.UrlParser
|
||||
|
||||
object CachedRichTextParser {
|
||||
private val richTextCache = LruCache<Int, RichTextViewerState>(50)
|
||||
private val isMarkdownCache = LruCache<Int, Boolean>(200)
|
||||
|
||||
private fun hashCodeCache(
|
||||
content: String,
|
||||
@@ -69,6 +70,25 @@ object CachedRichTextParser {
|
||||
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 {
|
||||
|
||||
+31
-30
@@ -22,8 +22,10 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
@@ -52,10 +54,8 @@ fun observeNote(
|
||||
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
return note
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val flow = remember(note) { note.flow().metadata.stateFlow }
|
||||
return flow.collectAsStateWithLifecycle()
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@@ -187,10 +187,8 @@ fun observeNoteReplies(
|
||||
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
return note
|
||||
.flow()
|
||||
.replies.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val flow = remember(note) { note.flow().replies.stateFlow }
|
||||
return flow.collectAsStateWithLifecycle()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
@@ -225,10 +223,8 @@ fun observeNoteReactions(
|
||||
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
return note
|
||||
.flow()
|
||||
.reactions.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val flow = remember(note) { note.flow().reactions.stateFlow }
|
||||
return flow.collectAsStateWithLifecycle()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
@@ -265,10 +261,8 @@ fun observeNoteZaps(
|
||||
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
return note
|
||||
.flow()
|
||||
.zaps.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val flow = remember(note) { note.flow().zaps.stateFlow }
|
||||
return flow.collectAsStateWithLifecycle()
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -280,10 +274,8 @@ fun observeNoteReposts(
|
||||
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
return note
|
||||
.flow()
|
||||
.boosts.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val flow = remember(note) { note.flow().boosts.stateFlow }
|
||||
return flow.collectAsStateWithLifecycle()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -365,27 +357,36 @@ fun observeNoteOts(
|
||||
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
return note
|
||||
.flow()
|
||||
.ots
|
||||
.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val flow = remember(note) { note.flow().ots.stateFlow }
|
||||
return flow.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
|
||||
fun observeNoteEdits(
|
||||
fun observeNoteModifications(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
): State<NoteState?> {
|
||||
): State<List<Note>?> {
|
||||
// Subscribe in the relay for changes in this note.
|
||||
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
return note
|
||||
return produceState<List<Note>?>(initialValue = null, note) {
|
||||
note
|
||||
.flow()
|
||||
.edits
|
||||
.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
.sample(500)
|
||||
.mapLatest { LocalCache.findLatestModificationForNote(note) }
|
||||
.distinctUntilChanged()
|
||||
.flowOn(Dispatchers.IO)
|
||||
.collect { value = it }
|
||||
}
|
||||
}
|
||||
|
||||
@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.routes.Route
|
||||
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.theme.AmethystTheme
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
@@ -76,10 +77,12 @@ class MainActivity : AppCompatActivity() {
|
||||
setContent {
|
||||
StringResSetup()
|
||||
AmethystTheme {
|
||||
NowProvider {
|
||||
AccountScreen(Amethyst.instance.sessionManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onResume() {
|
||||
|
||||
@@ -122,15 +122,6 @@ import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
fun isMarkdown(content: String): Boolean =
|
||||
content.startsWith("> ") ||
|
||||
content.startsWith("# ") ||
|
||||
content.contains("##") ||
|
||||
content.contains("__") ||
|
||||
content.contains("**") ||
|
||||
content.contains("```") ||
|
||||
content.contains("](")
|
||||
|
||||
@Composable
|
||||
fun RichTextViewer(
|
||||
content: String,
|
||||
@@ -145,7 +136,7 @@ fun RichTextViewer(
|
||||
nav: INav,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
if (remember(content) { isMarkdown(content) }) {
|
||||
if (remember(content) { CachedRichTextParser.isMarkdown(content) }) {
|
||||
RenderContentAsMarkdown(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, accountViewModel, nav)
|
||||
} else {
|
||||
RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, authorPubKey, accountViewModel, nav)
|
||||
|
||||
@@ -59,7 +59,8 @@ fun WatchBlockAndReport(
|
||||
nav: INav,
|
||||
normalNote: @Composable (canPreview: Boolean) -> Unit,
|
||||
) {
|
||||
val isHidden by accountViewModel.createIsHiddenFlow(note).collectAsStateWithLifecycle()
|
||||
val isHidden by remember(note) { accountViewModel.createIsHiddenFlow(note) }
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
val showAnyway =
|
||||
remember {
|
||||
|
||||
@@ -66,8 +66,8 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
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.observeNoteEdits
|
||||
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.GenericLoadable
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
@@ -489,30 +489,31 @@ fun calculateBackgroundColor(
|
||||
): MutableState<Color> {
|
||||
val defaultBackgroundColor = MaterialTheme.colorScheme.background
|
||||
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 =
|
||||
remember(createdAt) {
|
||||
mutableStateOf(
|
||||
if (routeForLastRead != null) {
|
||||
val isNew = accountViewModel.loadAndMarkAsRead(routeForLastRead, createdAt)
|
||||
|
||||
if (isNew) {
|
||||
if (parentBackgroundColor != null) {
|
||||
newItemColor.compositeOver(parentBackgroundColor.value)
|
||||
newItemColor.compositeOver(parentBackgroundColor?.value ?: defaultBackgroundColor)
|
||||
} else {
|
||||
newItemColor.compositeOver(defaultBackgroundColor)
|
||||
}
|
||||
} else {
|
||||
parentBackgroundColor?.value ?: defaultBackgroundColor.copy(alpha = 0f)
|
||||
}
|
||||
} else {
|
||||
parentBackgroundColor?.value ?: defaultBackgroundColor.copy(alpha = 0f)
|
||||
parentBackgroundColor?.value ?: Color.Transparent
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isNew) {
|
||||
LaunchedEffect(createdAt) {
|
||||
delay(5000)
|
||||
bgColor.value = parentBackgroundColor?.value ?: defaultBackgroundColor.copy(alpha = 0f)
|
||||
bgColor.value = parentBackgroundColor?.value ?: Color.Transparent
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
updatedNote?.note?.let {
|
||||
val newModifications = accountViewModel.findModificationEventsForNote(it)
|
||||
if (newModifications.isEmpty()) {
|
||||
LaunchedEffect(modifications) {
|
||||
val mods = modifications ?: return@LaunchedEffect
|
||||
if (mods.isEmpty()) {
|
||||
if (editState.value !is GenericLoadable.Empty) {
|
||||
editState.value = GenericLoadable.Empty()
|
||||
}
|
||||
} else {
|
||||
if (editState.value is GenericLoadable.Loaded) {
|
||||
(editState.value as? GenericLoadable.Loaded<EditState>)?.loaded?.updateModifications(newModifications)
|
||||
val current = editState.value
|
||||
if (current is GenericLoadable.Loaded) {
|
||||
current.loaded.updateModifications(mods)
|
||||
} else {
|
||||
val state = EditState()
|
||||
state.updateModifications(newModifications)
|
||||
state.updateModifications(mods)
|
||||
editState.value = GenericLoadable.Loaded(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.noteComposeRelayBox
|
||||
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
|
||||
fun RelayBadges(
|
||||
@@ -81,7 +85,7 @@ fun RelayBadges(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@OptIn(ExperimentalLayoutApi::class, FlowPreview::class)
|
||||
@Composable
|
||||
fun RenderAllRelayList(
|
||||
baseNote: Note,
|
||||
@@ -90,16 +94,26 @@ fun RenderAllRelayList(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteRelays by baseNote
|
||||
val flow =
|
||||
remember(baseNote) {
|
||||
baseNote
|
||||
.flow()
|
||||
.relays.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
.sample(500)
|
||||
.map { it.note.relays }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
val relays by flow.collectAsStateWithLifecycle(baseNote.relays)
|
||||
|
||||
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
|
||||
fun RenderClosedRelayList(
|
||||
baseNote: Note,
|
||||
@@ -108,32 +122,32 @@ fun RenderClosedRelayList(
|
||||
accountViewModel: AccountViewModel,
|
||||
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) {
|
||||
WatchAndRenderRelay(baseNote, 0, accountViewModel, nav)
|
||||
WatchAndRenderRelay(baseNote, 1, accountViewModel, nav)
|
||||
WatchAndRenderRelay(baseNote, 2, accountViewModel, nav)
|
||||
RenderRelaySlot(relays.getOrNull(0), accountViewModel, nav)
|
||||
RenderRelaySlot(relays.getOrNull(1), accountViewModel, nav)
|
||||
RenderRelaySlot(relays.getOrNull(2), accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WatchAndRenderRelay(
|
||||
baseNote: Note,
|
||||
relayIndex: Int,
|
||||
private fun RenderRelaySlot(
|
||||
relay: NormalizedRelayUrl?,
|
||||
accountViewModel: AccountViewModel,
|
||||
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) {
|
||||
if (it != null) {
|
||||
RenderRelay(it, accountViewModel, nav)
|
||||
|
||||
@@ -99,7 +99,8 @@ fun ShouldShowExpandButton(
|
||||
accountViewModel: AccountViewModel,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val showExpandButton by accountViewModel.createMustShowExpandButtonFlows(baseNote).collectAsStateWithLifecycle()
|
||||
val flow = remember(baseNote) { accountViewModel.createMustShowExpandButtonFlows(baseNote) }
|
||||
val showExpandButton by flow.collectAsStateWithLifecycle()
|
||||
|
||||
if (showExpandButton) {
|
||||
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.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -46,7 +45,16 @@ fun TimeAgo(note: Note) {
|
||||
@Composable
|
||||
fun TimeAgo(time: Long) {
|
||||
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 = timeStr,
|
||||
@@ -61,9 +69,15 @@ fun NormalTimeAgo(
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val nowStr = stringRes(id = R.string.now)
|
||||
val nowState = LocalNowSeconds.current
|
||||
|
||||
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 = time,
|
||||
|
||||
-5
@@ -1328,11 +1328,6 @@ class AccountViewModel(
|
||||
|
||||
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 checkGetOrCreateLiveActivityChannel(key: Address): LiveActivitiesChannel = LocalCache.getOrCreateLiveChannel(key)
|
||||
|
||||
+11
-1
@@ -26,6 +26,8 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
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.MaterialSymbols
|
||||
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.timeAheadNoDot
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -46,7 +49,14 @@ import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||
@Composable
|
||||
fun ChatTimeAgo(baseNote: Note) {
|
||||
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 = time,
|
||||
|
||||
+10
-1
@@ -27,6 +27,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.NonClickableUserPictures
|
||||
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.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay
|
||||
@@ -483,7 +485,14 @@ private fun TimeAgo(channelLastTime: Long?) {
|
||||
if (channelLastTime == null) return
|
||||
|
||||
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 = timeAgo,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
|
||||
+23
-11
@@ -22,35 +22,47 @@ package com.vitorpamplona.amethyst.commons.compose
|
||||
|
||||
import androidx.collection.LruCache
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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
|
||||
fun <K : Any, V : Any> produceCachedStateAsync(
|
||||
cache: AsyncCachedState<K, V>,
|
||||
key: K,
|
||||
): State<V?> =
|
||||
@Suppress("ProduceStateDoesNotAssignValue")
|
||||
produceState(initialValue = cache.cached(key), key1 = key) {
|
||||
): State<V?> {
|
||||
val state = remember(key) { mutableStateOf(cache.cached(key)) }
|
||||
if (state.value == null) {
|
||||
LaunchedEffect(key) {
|
||||
val newValue = cache.update(key)
|
||||
if (newValue != value) {
|
||||
value = newValue
|
||||
if (state.value != newValue) {
|
||||
state.value = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <K : Any, V : Any> produceCachedStateAsync(
|
||||
cache: AsyncCachedState<K, V>,
|
||||
key: String,
|
||||
updateValue: K,
|
||||
): State<V?> =
|
||||
@Suppress("ProduceStateDoesNotAssignValue")
|
||||
produceState(initialValue = cache.cached(updateValue), key1 = key) {
|
||||
): State<V?> {
|
||||
val state = remember(key) { mutableStateOf(cache.cached(updateValue)) }
|
||||
if (state.value == null) {
|
||||
LaunchedEffect(key) {
|
||||
val newValue = cache.update(updateValue)
|
||||
if (newValue != value) {
|
||||
value = newValue
|
||||
if (state.value != newValue) {
|
||||
state.value = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
interface AsyncCachedState<K : Any, V : Any> {
|
||||
fun cached(k: K): V?
|
||||
|
||||
+24
-11
@@ -22,35 +22,48 @@ package com.vitorpamplona.amethyst.commons.compose
|
||||
|
||||
import androidx.collection.LruCache
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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
|
||||
fun <K : Any, V : Any> produceCachedState(
|
||||
cache: CachedState<K, V>,
|
||||
key: K,
|
||||
): State<V?> =
|
||||
@Suppress("ProduceStateDoesNotAssignValue")
|
||||
produceState(initialValue = cache.cached(key), key1 = key) {
|
||||
): State<V?> {
|
||||
val state = remember(key) { mutableStateOf(cache.cached(key)) }
|
||||
if (state.value == null) {
|
||||
LaunchedEffect(key) {
|
||||
val newValue = cache.update(key)
|
||||
if (value != newValue) {
|
||||
value = newValue
|
||||
if (state.value != newValue) {
|
||||
state.value = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <K : Any, V : Any> produceCachedState(
|
||||
cache: CachedState<K, V>,
|
||||
key: String,
|
||||
updateValue: K,
|
||||
): State<V?> =
|
||||
@Suppress("ProduceStateDoesNotAssignValue")
|
||||
produceState(initialValue = cache.cached(updateValue), key1 = key) {
|
||||
): State<V?> {
|
||||
val state = remember(key) { mutableStateOf(cache.cached(updateValue)) }
|
||||
if (state.value == null) {
|
||||
LaunchedEffect(key) {
|
||||
val newValue = cache.update(updateValue)
|
||||
if (value != newValue) {
|
||||
value = newValue
|
||||
if (state.value != newValue) {
|
||||
state.value = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
interface CachedState<K : Any, V : Any> {
|
||||
fun cached(k: K): V?
|
||||
|
||||
Reference in New Issue
Block a user