From bf861d6bc53a0b32c7b87fdd1bf43d68a1fb7025 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 12 Nov 2024 12:54:41 -0500 Subject: [PATCH] - Improves thread preloading - Fixes jumping of scroll when the thread updates --- .../amethyst/model/ThreadAssembler.kt | 58 +++++++++++++------ .../amethyst/model/ThreadLevelCalculator.kt | 37 +++++++----- .../amethyst/service/NostrThreadDataSource.kt | 57 +++++++++++++----- .../amethyst/ui/dal/ThreadFeedFilter.kt | 7 ++- .../amethyst/ui/screen/FeedViewModel.kt | 16 ++++- .../loggedIn/threadview/ThreadFeedView.kt | 23 ++++---- 6 files changed, 136 insertions(+), 62 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt index 8694a0825..dfad0c60b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt @@ -20,12 +20,14 @@ */ package com.vitorpamplona.amethyst.model +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.events.AddressableEvent import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.RepostEvent -import kotlin.time.measureTimedValue +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.toImmutableSet class ThreadAssembler { private fun searchRoot( @@ -71,32 +73,50 @@ class ThreadAssembler { return null } - fun findThreadFor(noteId: String): Set { + @Stable + class ThreadInfo( + val root: Note, + val allNotes: ImmutableSet, + ) + + fun findThreadFor(noteId: String): ThreadInfo? { checkNotInMainThread() - val (result, elapsed) = - measureTimedValue { - val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet() + val note = LocalCache.checkGetOrCreateNote(noteId) ?: return null - if (note.event != null) { - val thread = OnlyLatestVersionSet() + return if (note.event != null) { + val thread = OnlyLatestVersionSet() - val threadRoot = searchRoot(note, thread) ?: note + val threadRoot = searchRoot(note, thread) ?: note - loadDown(threadRoot, thread) - // adds the replies of the note in case the search for Root - // did not added them. - note.replies.forEach { loadDown(it, thread) } + loadUp(note, thread) - thread - } else { - setOf(note) - } - } + loadDown(threadRoot, thread) + // adds the replies of the note in case the search for Root + // did not added them. + note.replies.forEach { loadDown(it, thread) } - println("Model Refresh: Thread loaded in $elapsed") + ThreadInfo( + root = note, + allNotes = thread.toImmutableSet(), + ) + } else { + ThreadInfo( + root = note, + allNotes = setOf(note).toImmutableSet(), + ) + } + } - return result + fun loadUp( + note: Note, + thread: MutableSet, + ) { + if (note !in thread) { + thread.add(note) + + note.replyTo?.forEach { loadUp(it, thread) } + } } fun loadDown( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadLevelCalculator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadLevelCalculator.kt index 209b50cec..594f06784 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadLevelCalculator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ThreadLevelCalculator.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.RepostEvent +import java.lang.Long.min import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -54,13 +55,23 @@ object ThreadLevelCalculator { now: Long, ): LevelSignature { val replyTo = note.replyTo + + // estimates the min date by replies if it doesn't exist. + val createdAt = + min( + note.replies.minOfOrNull { it.createdAt() ?: now } ?: now, + note.reactions.values.minOfOrNull { it.minOfOrNull { it.createdAt() ?: now } ?: now } ?: now, + ) + + val noteAuthor = note.author + if ( note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty() ) { return LevelSignature( - signature = "/" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) + ";", - createdAt = note.createdAt(), - author = note.author, + signature = "/" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) + ";", + createdAt = createdAt, + author = noteAuthor, ) } @@ -86,23 +97,21 @@ object ThreadLevelCalculator { val parentSignature = parent?.signature?.removeSuffix(";") ?: "" val threadOrder = - if (parent?.author == note.author && note.createdAt() != null) { + if (noteAuthor != null && parent?.author == noteAuthor) { // author of the thread first, in **ascending** order - "9" + - formattedDateTime((parent?.createdAt ?: 0) + (now - (note.createdAt() ?: 0))) + - note.idHex.substring(0, 8) - } else if (note.author?.pubkeyHex == account.pubkeyHex) { - "8" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // my replies - } else if (note.author?.pubkeyHex in accountFollowingSet) { - "7" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // my follows replies. + "9" + formattedDateTime((parent.createdAt ?: 0) + (now - createdAt)) + note.idHex.substring(0, 8) + } else if (noteAuthor != null && noteAuthor.pubkeyHex == account.pubkeyHex) { + "8" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) // my replies + } else if (noteAuthor != null && noteAuthor.pubkeyHex in accountFollowingSet) { + "7" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) // my follows replies. } else { - "0" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // everyone else. + "0" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) // everyone else. } val mySignature = LevelSignature( - signature = parentSignature + "/" + threadOrder + ";", - createdAt = note.createdAt(), + signature = "$parentSignature/$threadOrder;", + createdAt = createdAt, author = note.author, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt index 19e0fc3f1..3e509ed3c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.ThreadAssembler import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES import com.vitorpamplona.ammolite.relays.TypedFilter @@ -28,26 +29,55 @@ import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter object NostrThreadDataSource : AmethystNostrDataSource("SingleThreadFeed") { private var eventToWatch: String? = null - fun createLoadEventsIfNotLoadedFilter(): TypedFilter? { - val threadToLoad = eventToWatch ?: return null + fun createLoadEventsIfNotLoadedFilter(): List { + val threadToLoad = eventToWatch ?: return emptyList() + + val branch = ThreadAssembler().findThreadFor(threadToLoad) ?: return emptyList() val eventsToLoad = - ThreadAssembler() - .findThreadFor(threadToLoad) + branch.allNotes .filter { it.event == null } .map { it.idHex } .toSet() .ifEmpty { null } - ?: return null - if (eventsToLoad.isEmpty()) return null + val address = if (branch.root is AddressableNote) branch.root.idHex else null + val event = if (branch.root !is AddressableNote) branch.root.idHex else branch.root.event?.id() - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - ids = eventsToLoad.toList(), - ), + return listOfNotNull( + eventsToLoad?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + SincePerRelayFilter( + ids = it.toList(), + ), + ) + }, + event?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + SincePerRelayFilter( + tags = + mapOf( + "e" to listOf(event), + ), + ), + ) + }, + address?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + SincePerRelayFilter( + tags = + mapOf( + "a" to listOf(address), + ), + ), + ) + }, ) } @@ -59,8 +89,7 @@ object NostrThreadDataSource : AmethystNostrDataSource("SingleThreadFeed") { } override fun updateChannelFilters() { - loadEventsChannel.typedFilters = - listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null } + loadEventsChannel.typedFilters = createLoadEventsIfNotLoadedFilter() } fun loadThread(noteId: String?) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt index 2ee4d12b8..21374035d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt @@ -38,8 +38,9 @@ class ThreadFeedFilter( override fun feed(): List { val cachedSignatures: MutableMap = mutableMapOf() val followingKeySet = account.liveKind3Follows.value.authors - val eventsToWatch = ThreadAssembler().findThreadFor(noteId) - val eventsInHex = eventsToWatch.map { it.idHex }.toSet() + val eventsToWatch = ThreadAssembler().findThreadFor(noteId) ?: return emptyList() + + val eventsInHex = eventsToWatch.allNotes.map { it.idHex }.toSet() val now = TimeUtils.now() // Currently orders by date of each event, descending, at each level of the reply stack @@ -56,6 +57,6 @@ class ThreadFeedFilter( ).signature } - return eventsToWatch.sortedWith(order) + return eventsToWatch.allNotes.sortedWith(order) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index 2a5504c82..7e323cf97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen import android.util.Log +import androidx.compose.foundation.interaction.DragInteraction import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -67,6 +68,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch @@ -274,7 +276,19 @@ abstract class LevelFeedViewModel( ) : FeedViewModel(localFilter) { var llState: LazyListState by mutableStateOf(LazyListState(0, 0)) - // val cachedLevels = mutableMapOf>() + val hasDragged = mutableStateOf(false) + + val selectedIDHex = + llState.interactionSource.interactions + .onEach { + if (it is DragInteraction.Start) { + hasDragged.value = true + } + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + null, + ) @OptIn(ExperimentalCoroutinesApi::class) val levelCacheFlow: StateFlow> = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 83dcfb017..fb55ef542 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -198,7 +198,6 @@ import com.vitorpamplona.quartz.events.VideoEvent import com.vitorpamplona.quartz.events.WikiNoteEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext @@ -218,7 +217,7 @@ fun ThreadFeedView( nav = nav, routeForLastRead = null, onLoaded = { - RenderThreadFeed(noteId, it, viewModel.llState, viewModel::levelFlowForItem, accountViewModel, nav) + RenderThreadFeed(noteId, it, viewModel.llState, viewModel, accountViewModel, nav) }, ) } @@ -229,13 +228,15 @@ fun RenderThreadFeed( noteId: String, loaded: FeedState.Loaded, listState: LazyListState, - createLevelFlow: (Note) -> Flow, + viewModel: LevelFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { val items by loaded.feed.collectAsStateWithLifecycle() - LaunchedEffect(noteId, items.list) { + val position = items.list.indexOfFirst { it.idHex == noteId } + + LaunchedEffect(noteId, position) { // hack to allow multiple scrolls to Item while posts on the screen load. // This is important when clicking on a reply of an older thread in Notifications // In that case, this screen will open with 0-1 items, and the scrollToItem below @@ -249,16 +250,16 @@ fun RenderThreadFeed( // records before setting up the position on the feed. // // It jumps around, but it is the best we can do. - if (listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 && items.list.size > 3) { - val position = items.list.indexOfFirst { it.idHex == noteId } - if (position >= 0) { + if (position >= 0 && !viewModel.hasDragged.value) { + val offset = if (position > items.list.size - 3) { - listState.scrollToItem(position, 0) + 0 } else { - listState.scrollToItem(position, -200) + -200 } - } + + listState.scrollToItem(position, offset) } } @@ -268,7 +269,7 @@ fun RenderThreadFeed( state = listState, ) { itemsIndexed(items.list, key = { _, item -> item.idHex }) { index, item -> - val level = createLevelFlow(item).collectAsStateWithLifecycle(0) + val level = viewModel.levelFlowForItem(item).collectAsStateWithLifecycle(0) val modifier = Modifier