mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2025-11-10 02:37:22 +01:00
- Improves thread preloading
- Fixes jumping of scroll when the thread updates
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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?) {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>> =
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user