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

View file

@ -112,7 +112,7 @@
@goto="setFocused" @goto="setFocused"
@dive="diveIntoStatus(element.item.id)" @dive="diveIntoStatus(element.item.id)"
@suspendable-state-change="changeSuspendStateAncestors" @suspendable-state-change="changeSuspendStateAncestorsLocal"
@height-change="updateVirtualHeightAncestors" @height-change="updateVirtualHeightAncestors"
/> />
<div <div
@ -122,32 +122,16 @@
/> />
</article> </article>
</div> </div>
<div <ThreadTree
class="currentLevel" :status-id="currentStatus.id"
ref="currentLevel" :depth="0"
>
<!-- 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"
@goto="setFocused" @goto="setFocused"
@dive="diveIntoStatus" @dive="diveIntoStatus"
@toggle-expanded="toggleExpanded" @toggle-expanded="toggleExpanded"
@show-thread-recursively="showThreadRecursively" @show-thread-recursively="showThreadRecursively"
@suspendable-state-change="changeSuspendStateCurrentLevel" @suspendable-state-change="changeSuspendStateCurrentLevelLocal"
@height-change="updateVirtualHeightCurrentLevel" />
/>
<div
v-if="element.type === 'spacer'"
class="virtual-spacer"
:style="{ height: element.height + 'px' }"
/>
</template>
</div>
</div> </div>
<div <div
v-else-if="isLinearView" v-else-if="isLinearView"
@ -171,7 +155,7 @@
:data-status-id="element.id" :data-status-id="element.id"
@goto="setFocused" @goto="setFocused"
@toggle-expanded="toggleExpanded" @toggle-expanded="toggleExpanded"
@suspendable-state-change="changeSuspendStateLinear" @suspendable-state-change="changeSuspendStateLinearLocal"
@height-change="updateVirtualHeightLinear" @height-change="updateVirtualHeightLinear"
/> />
<div <div

View file

@ -4,9 +4,9 @@ import {
computed, computed,
onMounted, onMounted,
onUnmounted, onUnmounted,
provide,
ref, ref,
toRefs, toRefs,
useTemplateRef,
watch, watch,
} from 'vue' } from 'vue'
import { useI18n } from 'vue-i18n' 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 { useStatusesStore } from 'src/stores/statuses.js'
import { useTimelinesStore } from 'src/stores/timelines.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 { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faArrowUp, faArrowUp,
@ -64,6 +68,27 @@ const Timeline = {
.filter(({ pinned }) => (skipPinned.value ? !pinned : true)) .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 // Counter
const count = computed(() => timeline.value.order.length) const count = computed(() => timeline.value.order.length)
const newStatusCount = computed(() => timeline.value.newStatusCount) const newStatusCount = computed(() => timeline.value.newStatusCount)
@ -240,6 +265,10 @@ const Timeline = {
timeline, timeline,
filteredVisibleStatuses, filteredVisibleStatuses,
heightChart,
updateVirtualHeight,
changeSuspendState,
count, count,
showLoadButton, showLoadButton,

View file

@ -82,12 +82,23 @@
ref="timeline" ref="timeline"
role="feed" role="feed"
> >
<Conversation <template
v-for="status in filteredVisibleStatuses" v-for="element in heightChart"
:key="status.id" :key="element.id"
:status-id="status.id" >
role="listitem" <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> </div>
<template v-if="!hideEmpty && count === 0"> <template v-if="!hideEmpty && count === 0">
<div <div