- 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
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<Note> {
@Stable
class ThreadInfo(
val root: Note,
val allNotes: ImmutableSet<Note>,
)
fun findThreadFor(noteId: String): ThreadInfo? {
checkNotInMainThread()
val (result, elapsed) =
measureTimedValue {
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet<Note>()
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<Note>,
) {
if (note !in thread) {
thread.add(note)
note.replyTo?.forEach { loadUp(it, thread) }
}
}
fun loadDown(

View File

@ -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,
)

View File

@ -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<TypedFilter> {
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?) {

View File

@ -38,8 +38,9 @@ class ThreadFeedFilter(
override fun feed(): List<Note> {
val cachedSignatures: MutableMap<Note, LevelSignature> = 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)
}
}

View File

@ -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<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)
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 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<Int>,
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