mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2025-03-18 05:41:56 +01:00
- Improves thread preloading
- Fixes jumping of scroll when the thread updates
This commit is contained in:
parent
b31ace79c3
commit
bf861d6bc5
@ -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(
|
||||
|
@ -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,
|
||||
)
|
||||
|
||||
|
@ -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?) {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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>> =
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user