virtual scrolling for timelines™

This commit is contained in:
Henry Jameson 2026-09-10 23:07:08 +03:00
commit f6279c4478
4 changed files with 121 additions and 55 deletions

View file

@ -1,6 +1,14 @@
import { get } from 'lodash-es'
import { storeToRefs } from 'pinia'
import { computed, provide, ref, toRefs, useTemplateRef, watch } from 'vue'
import {
computed,
onUnmounted,
provide,
ref,
toRefs,
useTemplateRef,
watch,
} from 'vue'
import { useRouter } from 'vue-router'
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
@ -62,7 +70,8 @@ export default {
PostStatusForm,
RichContent,
},
setup(props) {
emits: ['heightChange', 'suspendableStateChange'],
setup(props, { emit }) {
// # Helpers
const getStatusObject = (id) => useStatusesStore().allStatuses.get(id)
const getConversationId = (statusId) => {
@ -190,6 +199,27 @@ export default {
const { fontSize } = useInterfaceSizes()
// External virtual scrolling
const unsuspendableIds = ref(new Set())
const suspendable = computed(
() => !isExpanded.value && unsuspendableIds.value.size === 0,
)
const rootElement = useTemplateRef('root')
const updateVirtualHeight = (e) => {
const [entry] = e
emit('heightChange', {
id: statusId.value,
height: entry.contentRect.height,
element: rootElement,
})
}
const resizeObserver = ref(new ResizeObserver(updateVirtualHeight))
watch(rootElement, () => resizeObserver.value.observe(rootElement.value))
watch(suspendable, (value) =>
emit('suspendableStateChange', { suspend: value, id: statusId.value }),
)
onUnmounted(() => resizeObserver.value.disconnect())
// Placeholder heights.
const mutedStatusHeight = computed(() => fontSize.value * 1.5)
const normalStatusHeight = computed(() => fontSize.value * 10)
@ -215,6 +245,15 @@ export default {
anchorRepeatId: currentStatus.value?.id,
getPlaceholderHeight,
})
const changeSuspendStateLinearLocal = (e) => {
changeSuspendStateLinear(e)
const { id, suspend } = e
if (suspend) {
unsuspendableIds.value.add(id)
} else {
unsuspendableIds.value.delete(id)
}
}
// # Tree style stuff
const isTreeView = computed(() => displayStyle.value === 'tree')
@ -240,20 +279,24 @@ export default {
scrollCompensation: treeScrollCompensation,
getPlaceholderHeight,
})
const currentLevel = computed(() => [currentStatus.value].filter(Boolean))
const currentLevelElement = useTemplateRef('currentLevel')
const {
heightChart: heightChartCurrentLevel,
changeSuspendState: changeSuspendStateCurrentLevel,
updateVirtualHeight: updateVirtualHeightCurrentLevel,
} = useVirtualScrolling({
list: currentLevel,
body: currentLevelElement,
scrollPositionInstance: scroller,
scrollCompensation: false,
getPlaceholderHeight,
})
const changeSuspendStateAncestorsLocal = (e) => {
changeSuspendStateAncestors(e)
const { id, suspend } = e
if (suspend) {
unsuspendableIds.value.add(id)
} else {
unsuspendableIds.value.delete(id)
}
}
const changeSuspendStateCurrentLevelLocal = (e) => {
changeSuspendStateLinear(e)
const { id, suspend } = e
if (suspend) {
unsuspendableIds.value.add(id)
} else {
unsuspendableIds.value.delete(id)
}
}
const treeViewIsSimple = computed(
() => !mergedConfig.value.conversationTreeAdvanced,
@ -299,7 +342,7 @@ export default {
// ## Linear virtual scrolling
heightChartLinear,
changeSuspendStateLinear,
changeSuspendStateLinearLocal,
updateVirtualHeightLinear,
// # Tree style stuff
@ -307,11 +350,10 @@ export default {
// ## Tree virtual scrolling
heightChartAncestors,
changeSuspendStateAncestors,
changeSuspendStateAncestorsLocal,
updateVirtualHeightAncestors,
heightChartCurrentLevel,
changeSuspendStateCurrentLevel,
updateVirtualHeightCurrentLevel,
changeSuspendStateCurrentLevelLocal,
// ## Tree state
// ### Topology

View file

@ -112,7 +112,7 @@
@goto="setFocused"
@dive="diveIntoStatus(element.item.id)"
@suspendable-state-change="changeSuspendStateAncestors"
@suspendable-state-change="changeSuspendStateAncestorsLocal"
@height-change="updateVirtualHeightAncestors"
/>
<div
@ -122,32 +122,16 @@
/>
</article>
</div>
<div
class="currentLevel"
ref="currentLevel"
>
<!-- Technically this will always have a single element but -->
<!-- it's more convenient for us to use a v-for here -->
<template v-for="element in heightChartCurrentLevel">
<ThreadTree
v-if="element.type === 'item'"
:status-id="currentStatus.id"
:depth="0"
<ThreadTree
:status-id="currentStatus.id"
:depth="0"
@goto="setFocused"
@dive="diveIntoStatus"
@toggle-expanded="toggleExpanded"
@show-thread-recursively="showThreadRecursively"
@suspendable-state-change="changeSuspendStateCurrentLevel"
@height-change="updateVirtualHeightCurrentLevel"
/>
<div
v-if="element.type === 'spacer'"
class="virtual-spacer"
:style="{ height: element.height + 'px' }"
/>
</template>
</div>
@goto="setFocused"
@dive="diveIntoStatus"
@toggle-expanded="toggleExpanded"
@show-thread-recursively="showThreadRecursively"
@suspendable-state-change="changeSuspendStateCurrentLevelLocal"
/>
</div>
<div
v-else-if="isLinearView"
@ -171,7 +155,7 @@
:data-status-id="element.id"
@goto="setFocused"
@toggle-expanded="toggleExpanded"
@suspendable-state-change="changeSuspendStateLinear"
@suspendable-state-change="changeSuspendStateLinearLocal"
@height-change="updateVirtualHeightLinear"
/>
<div

View file

@ -4,9 +4,9 @@ import {
computed,
onMounted,
onUnmounted,
provide,
ref,
toRefs,
useTemplateRef,
watch,
} from 'vue'
import { useI18n } from 'vue-i18n'
@ -22,6 +22,10 @@ import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useTimelinesStore } from 'src/stores/timelines.js'
import { useInterfaceSizes } from 'src/composables/useInterfaceSizes.js'
import { useScrollPosition } from 'src/composables/useScrollPosition.js'
import { useVirtualScrolling } from 'src/composables/useVirtualScrolling.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faArrowUp,
@ -64,6 +68,27 @@ const Timeline = {
.filter(({ pinned }) => (skipPinned.value ? !pinned : true))
})
// Virtual scrolling
const { fontSize } = useInterfaceSizes()
// Placeholder heights.
const mutedStatusHeight = computed(() => fontSize.value * 1.5)
const normalStatusHeight = computed(() => fontSize.value * 10)
const getPlaceholderHeight = (id) =>
filteredVisibleStatuses.value.find((item) => item.id === id)?.muted
? mutedStatusHeight
: normalStatusHeight
const body = useTemplateRef('timeline')
const { heightChart, changeSuspendState, updateVirtualHeight } =
useVirtualScrolling({
list: filteredVisibleStatuses,
body,
scrollPositionInstance: useScrollPosition(),
scrollCompensation: false,
getPlaceholderHeight,
})
// Counter
const count = computed(() => timeline.value.order.length)
const newStatusCount = computed(() => timeline.value.newStatusCount)
@ -240,6 +265,10 @@ const Timeline = {
timeline,
filteredVisibleStatuses,
heightChart,
updateVirtualHeight,
changeSuspendState,
count,
showLoadButton,

View file

@ -82,12 +82,23 @@
ref="timeline"
role="feed"
>
<Conversation
v-for="status in filteredVisibleStatuses"
:key="status.id"
:status-id="status.id"
role="listitem"
/>
<template
v-for="element in heightChart"
:key="element.id"
>
<Conversation
v-if="element.type === 'item'"
:status-id="element.item.id"
role="listitem"
@suspendable-state-change="changeSuspendState"
@height-change="updateVirtualHeight"
/>
<div
v-if="element.type === 'spacer'"
class="virtual-spacer"
:style="{ height: element.height + 'px' }"
/>
</template>
</div>
<template v-if="!hideEmpty && count === 0">
<div