From 52ca540bcad33b8a37a2bcda7981f773cc175fdc Mon Sep 17 00:00:00 2001 From: Sweetbread Date: Sun, 19 Apr 2026 17:41:08 +0300 Subject: [PATCH] Fix message loading --- .../risdeveau/pixeldragon/ui/layout/Room.kt | 168 +++++++++++++++++- 1 file changed, 160 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt b/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt index 12a3cf4..44ad2ca 100755 --- a/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt +++ b/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt @@ -83,6 +83,7 @@ import de.connect2x.trixnity.client.store.TimelineEvent import de.connect2x.trixnity.client.store.eventId import de.connect2x.trixnity.core.model.RoomId import de.connect2x.trixnity.core.model.events.ClientEvent +import de.connect2x.trixnity.core.model.events.m.FullyReadEventContent import de.connect2x.trixnity.core.model.events.m.room.AvatarEventContent import de.connect2x.trixnity.core.model.events.m.room.CanonicalAliasEventContent import de.connect2x.trixnity.core.model.events.m.room.HistoryVisibilityEventContent @@ -101,6 +102,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.jsoup.Jsoup import org.jsoup.nodes.Element @@ -117,6 +119,10 @@ import kotlin.math.roundToInt private const val WEBVIEW_HEIGHT_TITLE_PREFIX = "pd-height:" private const val MESSAGE_GROUP_WINDOW_MS = 5 * 60 * 1000L +private const val TIMELINE_PAGE_SIZE = 50 +private const val TIMELINE_TOP_PREFETCH_THRESHOLD = 4 +private const val TIMELINE_BOTTOM_AUTOSCROLL_THRESHOLD = 3 +private const val MAX_INITIAL_UNREAD_SEARCH_LIMIT = 500 private val TIMELINE_AVATAR_SIZE = 32.dp private val MESSAGE_FIELD_TOP_PADDING = 8.dp @@ -182,9 +188,14 @@ private val headingMetrics = mapOf( @Composable fun Room(modifier: Modifier = Modifier, rid: String) { val roomId = remember(rid) { RoomId(rid) } - val limit = remember { MutableStateFlow(50) } + var requestedLimit by remember(roomId) { mutableStateOf(TIMELINE_PAGE_SIZE) } + val limit = remember(roomId) { MutableStateFlow(TIMELINE_PAGE_SIZE) } - val eventsFlow = remember(roomId) { + LaunchedEffect(requestedLimit) { + limit.value = requestedLimit + } + + val eventsFlow = remember(roomId, client!!) { client!!.room .getLastTimelineEvents(roomId) .toFlowList(limit) @@ -194,23 +205,105 @@ fun Room(modifier: Modifier = Modifier, rid: String) { } } val events by eventsFlow.collectAsState(initial = emptyList()) - val currentUserId = remember { client!!.userId.toString() } - val timelineItems = remember(events, currentUserId) { - buildTimelineItems(events = events, currentUserId = currentUserId) + + val readMarkerFlow = remember(roomId, client!!) { + client!!.room + .getAccountData(roomId, FullyReadEventContent::class) + .map { content -> ReadMarkerState.Ready(content?.eventId?.full) as ReadMarkerState } + } + val readMarkerState by readMarkerFlow.collectAsState(initial = ReadMarkerState.Loading) + val fullyReadEventId = (readMarkerState as? ReadMarkerState.Ready)?.eventId + + val currentUserId = remember(client!!) { client!!.userId.toString() } + val timelineItems = remember(events, currentUserId, fullyReadEventId) { + buildTimelineItems( + events = events, + currentUserId = currentUserId, + fullyReadEventId = fullyReadEventId, + ) } val latestTimelineKey = remember(timelineItems) { timelineItems.lastOrNull()?.latestKey() } val listState = rememberLazyListState() + var didInitialScroll by remember(roomId) { mutableStateOf(false) } + var lastAutoScrollTimelineKey by remember(roomId) { mutableStateOf(null) } + + val isNearBottom by remember(timelineItems, listState) { + derivedStateOf { + val totalItems = listState.layoutInfo.totalItemsCount + if (totalItems == 0) return@derivedStateOf true + + val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index + ?: return@derivedStateOf false + lastVisibleIndex >= totalItems - 1 - TIMELINE_BOTTOM_AUTOSCROLL_THRESHOLD + } + } + + val shouldLoadOlder by remember(listState, didInitialScroll) { + derivedStateOf { + didInitialScroll && + listState.layoutInfo.totalItemsCount > 0 && + listState.firstVisibleItemIndex <= TIMELINE_TOP_PREFETCH_THRESHOLD + } + } Column(modifier) { var message by remember { mutableStateOf("") } - LaunchedEffect(latestTimelineKey) { - if (timelineItems.isNotEmpty()) { - listState.scrollToItem(timelineItems.lastIndex) + LaunchedEffect(timelineItems, readMarkerState, events.size, requestedLimit) { + if (didInitialScroll || timelineItems.isEmpty()) return@LaunchedEffect + if (readMarkerState is ReadMarkerState.Loading) return@LaunchedEffect + + val readMarkerEventId = (readMarkerState as ReadMarkerState.Ready).eventId + val unreadDividerIndex = timelineItems.indexOfFirst { it is UnreadDividerItem } + if (unreadDividerIndex >= 0) { + listState.scrollToItem(unreadDividerIndex) + didInitialScroll = true + lastAutoScrollTimelineKey = latestTimelineKey + return@LaunchedEffect } + + val readMarkerIsLoaded = readMarkerEventId != null && + events.any { it.eventId.full == readMarkerEventId } + val shouldSearchOlderForUnreadMarker = readMarkerEventId != null && + !readMarkerIsLoaded && + events.size >= requestedLimit && + requestedLimit < MAX_INITIAL_UNREAD_SEARCH_LIMIT + + if (shouldSearchOlderForUnreadMarker) { + requestedLimit = (requestedLimit + TIMELINE_PAGE_SIZE) + .coerceAtMost(MAX_INITIAL_UNREAD_SEARCH_LIMIT) + return@LaunchedEffect + } + + listState.scrollToItem(timelineItems.lastIndex) + didInitialScroll = true + lastAutoScrollTimelineKey = latestTimelineKey + } + + LaunchedEffect(shouldLoadOlder, events.size, requestedLimit) { + if (!shouldLoadOlder) return@LaunchedEffect + if (events.size < requestedLimit) return@LaunchedEffect + + requestedLimit += TIMELINE_PAGE_SIZE + } + + LaunchedEffect(latestTimelineKey) { + val currentLatestKey = latestTimelineKey ?: return@LaunchedEffect + val previousLatestKey = lastAutoScrollTimelineKey + + if (!didInitialScroll) { + lastAutoScrollTimelineKey = currentLatestKey + return@LaunchedEffect + } + + if (previousLatestKey != null && previousLatestKey != currentLatestKey && isNearBottom) { + listState.animateScrollToItem(timelineItems.lastIndex) + } + + lastAutoScrollTimelineKey = currentLatestKey } val density = LocalDensity.current @@ -297,6 +390,11 @@ fun Room(modifier: Modifier = Modifier, rid: String) { } } +private sealed interface ReadMarkerState { + data object Loading : ReadMarkerState + data class Ready(val eventId: String?) : ReadMarkerState +} + private sealed interface TimelineUiItem { val key: String } @@ -306,6 +404,10 @@ private data class DateDividerItem( val label: String, ) : TimelineUiItem +private data class UnreadDividerItem( + override val key: String, +) : TimelineUiItem + private data class MessageGroupItem( override val key: String, val senderId: String?, @@ -338,6 +440,7 @@ private data class SystemTimelineItem( private fun buildTimelineItems( events: List, currentUserId: String, + fullyReadEventId: String?, ): List { if (events.isEmpty()) return emptyList() @@ -346,6 +449,8 @@ private fun buildTimelineItems( var previousDayStartMs: Long? = null var lastMessageGroupIndex: Int? = null + var insertUnreadDividerBeforeNextMessage = false + var unreadDividerInserted = false for (event in chronologicalEvents) { val content = event.content?.getOrNull() @@ -361,6 +466,17 @@ private fun buildTimelineItems( lastMessageGroupIndex = null } + if ( + content is RoomMessageEventContent && + insertUnreadDividerBeforeNextMessage && + !unreadDividerInserted + ) { + items += UnreadDividerItem(key = "unread:$fullyReadEventId") + unreadDividerInserted = true + insertUnreadDividerBeforeNextMessage = false + lastMessageGroupIndex = null + } + when { content == null -> { items += SystemTimelineItem( @@ -429,6 +545,11 @@ private fun buildTimelineItems( lastMessageGroupIndex = null } } + + if (!unreadDividerInserted && fullyReadEventId != null && event.eventId.full == fullyReadEventId) { + insertUnreadDividerBeforeNextMessage = true + lastMessageGroupIndex = null + } } return items @@ -441,6 +562,7 @@ private fun TimelineItem( ) { when (item) { is DateDividerItem -> DateDivider(item) + is UnreadDividerItem -> UnreadDivider() is MessageGroupItem -> MessageGroupRow( group = item, hideInlineAvatar = pinnedAvatarGroupKey == item.key, @@ -472,6 +594,36 @@ private fun DateDivider(item: DateDividerItem) { } } +@Composable +private fun UnreadDivider() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .height(1.dp) + .weight(1f) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.45f)), + ) + Text( + text = "Unread messages", + modifier = Modifier.padding(horizontal = 8.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + ) + Box( + modifier = Modifier + .height(1.dp) + .weight(1f) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.45f)), + ) + } +} + @Composable private fun SystemEventRow(item: SystemTimelineItem) { Text(