- Improves thread preloading

- Fixes jumping of scroll when the thread updates
This commit is contained in:
Vitor Pamplona
2024-11-12 12:54:41 -05:00
parent b31ace79c3
commit bf861d6bc5
6 changed files with 136 additions and 62 deletions

View File

@@ -20,12 +20,14 @@
*/ */
package com.vitorpamplona.amethyst.model package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.events.AddressableEvent import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.RepostEvent
import kotlin.time.measureTimedValue import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toImmutableSet
class ThreadAssembler { class ThreadAssembler {
private fun searchRoot( private fun searchRoot(
@@ -71,32 +73,50 @@ class ThreadAssembler {
return null return null
} }
fun findThreadFor(noteId: String): Set<Note> { @Stable
class ThreadInfo(
val root: Note,
val allNotes: ImmutableSet<Note>,
)
fun findThreadFor(noteId: String): ThreadInfo? {
checkNotInMainThread() checkNotInMainThread()
val (result, elapsed) = val note = LocalCache.checkGetOrCreateNote(noteId) ?: return null
measureTimedValue {
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet<Note>()
if (note.event != null) { return if (note.event != null) {
val thread = OnlyLatestVersionSet() val thread = OnlyLatestVersionSet()
val threadRoot = searchRoot(note, thread) ?: note val threadRoot = searchRoot(note, thread) ?: note
loadDown(threadRoot, thread) loadUp(note, thread)
// adds the replies of the note in case the search for Root
// did not added them.
note.replies.forEach { loadDown(it, thread) }
thread loadDown(threadRoot, thread)
} else { // adds the replies of the note in case the search for Root
setOf(note) // 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<Note>,
) {
if (note !in thread) {
thread.add(note)
note.replyTo?.forEach { loadUp(it, thread) }
}
} }
fun loadDown( fun loadDown(

View File

@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.RepostEvent
import java.lang.Long.min
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -54,13 +55,23 @@ object ThreadLevelCalculator {
now: Long, now: Long,
): LevelSignature { ): LevelSignature {
val replyTo = note.replyTo 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 ( if (
note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty() note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
) { ) {
return LevelSignature( return LevelSignature(
signature = "/" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) + ";", signature = "/" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) + ";",
createdAt = note.createdAt(), createdAt = createdAt,
author = note.author, author = noteAuthor,
) )
} }
@@ -86,23 +97,21 @@ object ThreadLevelCalculator {
val parentSignature = parent?.signature?.removeSuffix(";") ?: "" val parentSignature = parent?.signature?.removeSuffix(";") ?: ""
val threadOrder = val threadOrder =
if (parent?.author == note.author && note.createdAt() != null) { if (noteAuthor != null && parent?.author == noteAuthor) {
// author of the thread first, in **ascending** order // author of the thread first, in **ascending** order
"9" + "9" + formattedDateTime((parent.createdAt ?: 0) + (now - createdAt)) + note.idHex.substring(0, 8)
formattedDateTime((parent?.createdAt ?: 0) + (now - (note.createdAt() ?: 0))) + } else if (noteAuthor != null && noteAuthor.pubkeyHex == account.pubkeyHex) {
note.idHex.substring(0, 8) "8" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) // my replies
} else if (note.author?.pubkeyHex == account.pubkeyHex) { } else if (noteAuthor != null && noteAuthor.pubkeyHex in accountFollowingSet) {
"8" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // my replies "7" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) // my follows replies.
} else if (note.author?.pubkeyHex in accountFollowingSet) {
"7" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // my follows replies.
} else { } 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 = val mySignature =
LevelSignature( LevelSignature(
signature = parentSignature + "/" + threadOrder + ";", signature = "$parentSignature/$threadOrder;",
createdAt = note.createdAt(), createdAt = createdAt,
author = note.author, author = note.author,
) )

View File

@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.ThreadAssembler import com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter import com.vitorpamplona.ammolite.relays.TypedFilter
@@ -28,26 +29,55 @@ import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
object NostrThreadDataSource : AmethystNostrDataSource("SingleThreadFeed") { object NostrThreadDataSource : AmethystNostrDataSource("SingleThreadFeed") {
private var eventToWatch: String? = null private var eventToWatch: String? = null
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? { fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter> {
val threadToLoad = eventToWatch ?: return null val threadToLoad = eventToWatch ?: return emptyList()
val branch = ThreadAssembler().findThreadFor(threadToLoad) ?: return emptyList()
val eventsToLoad = val eventsToLoad =
ThreadAssembler() branch.allNotes
.findThreadFor(threadToLoad)
.filter { it.event == null } .filter { it.event == null }
.map { it.idHex } .map { it.idHex }
.toSet() .toSet()
.ifEmpty { null } .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( return listOfNotNull(
types = COMMON_FEED_TYPES, eventsToLoad?.let {
filter = TypedFilter(
SincePerRelayFilter( types = COMMON_FEED_TYPES,
ids = eventsToLoad.toList(), 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() { override fun updateChannelFilters() {
loadEventsChannel.typedFilters = loadEventsChannel.typedFilters = createLoadEventsIfNotLoadedFilter()
listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null }
} }
fun loadThread(noteId: String?) { fun loadThread(noteId: String?) {

View File

@@ -38,8 +38,9 @@ class ThreadFeedFilter(
override fun feed(): List<Note> { override fun feed(): List<Note> {
val cachedSignatures: MutableMap<Note, LevelSignature> = mutableMapOf() val cachedSignatures: MutableMap<Note, LevelSignature> = mutableMapOf()
val followingKeySet = account.liveKind3Follows.value.authors val followingKeySet = account.liveKind3Follows.value.authors
val eventsToWatch = ThreadAssembler().findThreadFor(noteId) val eventsToWatch = ThreadAssembler().findThreadFor(noteId) ?: return emptyList()
val eventsInHex = eventsToWatch.map { it.idHex }.toSet()
val eventsInHex = eventsToWatch.allNotes.map { it.idHex }.toSet()
val now = TimeUtils.now() val now = TimeUtils.now()
// Currently orders by date of each event, descending, at each level of the reply stack // Currently orders by date of each event, descending, at each level of the reply stack
@@ -56,6 +57,6 @@ class ThreadFeedFilter(
).signature ).signature
} }
return eventsToWatch.sortedWith(order) return eventsToWatch.allNotes.sortedWith(order)
} }
} }

View File

@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen package com.vitorpamplona.amethyst.ui.screen
import android.util.Log import android.util.Log
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -67,6 +68,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -274,7 +276,19 @@ abstract class LevelFeedViewModel(
) : FeedViewModel(localFilter) { ) : FeedViewModel(localFilter) {
var llState: LazyListState by mutableStateOf(LazyListState(0, 0)) var llState: LazyListState by mutableStateOf(LazyListState(0, 0))
// val cachedLevels = mutableMapOf<Note, MutableStateFlow<Int>>() 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) @OptIn(ExperimentalCoroutinesApi::class)
val levelCacheFlow: StateFlow<Map<Note, Int>> = val levelCacheFlow: StateFlow<Map<Note, Int>> =

View File

@@ -198,7 +198,6 @@ import com.vitorpamplona.quartz.events.VideoEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent import com.vitorpamplona.quartz.events.WikiNoteEvent
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -218,7 +217,7 @@ fun ThreadFeedView(
nav = nav, nav = nav,
routeForLastRead = null, routeForLastRead = null,
onLoaded = { 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, noteId: String,
loaded: FeedState.Loaded, loaded: FeedState.Loaded,
listState: LazyListState, listState: LazyListState,
createLevelFlow: (Note) -> Flow<Int>, viewModel: LevelFeedViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val items by loaded.feed.collectAsStateWithLifecycle() 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. // 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 // 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 // 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. // records before setting up the position on the feed.
// //
// It jumps around, but it is the best we can do. // 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) { if (position > items.list.size - 3) {
listState.scrollToItem(position, 0) 0
} else { } else {
listState.scrollToItem(position, -200) -200
} }
}
listState.scrollToItem(position, offset)
} }
} }
@@ -268,7 +269,7 @@ fun RenderThreadFeed(
state = listState, state = listState,
) { ) {
itemsIndexed(items.list, key = { _, item -> item.idHex }) { index, item -> itemsIndexed(items.list, key = { _, item -> item.idHex }) { index, item ->
val level = createLevelFlow(item).collectAsStateWithLifecycle(0) val level = viewModel.levelFlowForItem(item).collectAsStateWithLifecycle(0)
val modifier = val modifier =
Modifier Modifier