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 {
|
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 {
|
||||||
|
|||||||
+31
-30
@@ -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,
|
||||||
|
|||||||
-5
@@ -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)
|
||||||
|
|||||||
+11
-1
@@ -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,
|
||||||
|
|||||||
+10
-1
@@ -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,
|
||||||
|
|||||||
+23
-11
@@ -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?
|
||||||
|
|||||||
+24
-11
@@ -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?
|
||||||
|
|||||||
Reference in New Issue
Block a user