virtual scrolling 2.0, conversations edition

This commit is contained in:
Henry Jameson 2026-09-09 01:15:58 +03:00
commit 9059838188
6 changed files with 221 additions and 84 deletions

View file

@ -13,6 +13,8 @@ import {
import { useRouter } from 'vue-router'
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
import { useScrollPosition } from 'src/components/conversation/useScrollPosition.js'
import { useWindowSize } from 'src/components/conversation/useWindowSize.js'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from 'src/components/quick_view_settings/quick_view_settings.vue'
@ -61,13 +63,7 @@ export default {
type: Boolean,
default: false,
},
virtualHidden: {
// Whether conversation is suspended. Controls rendering of statuses
type: Boolean,
default: false,
},
},
emits: ['update:virtualHeight'],
components: {
ThreadTree,
QuickFilterSettings,
@ -76,8 +72,7 @@ export default {
PostStatusForm,
RichContent,
},
setup(props, ctx) {
const { emit } = ctx
setup(props) {
const { statusId } = toRefs(props)
const router = useRouter()
@ -260,26 +255,63 @@ export default {
}
// # Virtual scrolling stuff
const fontSizeSetting = computed(() => mergedConfig.value.textSize)
const fontSize = computed(() => {
// reading fontSizeSetting to make computed react to it
fontSizeSetting.value
const string = window
.getComputedStyle(document.body)
.getPropertyValue('font-size')
return Number.parseInt(string.slice(0, -2), 10) // remove the 'px'
})
const mutedStatusHeight = computed(() => {
return fontSize.value * 1.5
})
const normalStatusHeight = computed(() => {
return fontSize.value * 10
})
const heights = ref(new Map())
const totalHeight = computed(() =>
conversation.value.reduce((acc, item) => {
if (heights.value.has(item.id)) {
return acc + heights.value.get(item.id)
} else if (item.muted) {
return acc + mutedStatusHeight.value
} else {
return acc + normalStatusHeight.value
}
}, 0),
)
const body = useTemplateRef('body')
const virtualHeight = ref(120)
const hiddenStyle = computed(() => ({
height: virtualHeight.value + 'px',
}))
const updateVirtualHeight = () => {
if (hide) return // no updates when not rendering
if (!status.value) return // not loaded yet
nextTick(() => {
virtualHeight.value = body.value.getBoundingClientRect().height
emit('update:virtualHeight', {
id: status.value.id,
height: virtualHeight.value,
top: body.value.clientTop,
})
})
const updateVirtualHeight = ({ id, height }) => {
heights.value.set(id, height)
}
const { y: topScrollBoundary } = useScrollPosition()
const { height: windowHeight } = useWindowSize()
const realTopScrollBoundary = ref(0)
const realBottomScrollBoundary = ref(0)
const updateBoundaries = () => {
if (!body.value) return // Not mounted yet
const { top } = body.value.getBoundingClientRect()
const distanceItemTopToWindowTop = 0 - top
const distanceItemTopToWindowBottom = windowHeight.value - top
realTopScrollBoundary.value = distanceItemTopToWindowTop
realBottomScrollBoundary.value = distanceItemTopToWindowBottom
}
watch(topScrollBoundary, updateBoundaries)
watch(totalHeight, updateBoundaries)
onMounted(updateBoundaries)
const buffer = normalStatusHeight.value * 2
const unsuspendibleIds = ref(new Set())
const suspendable = computed(() => unsuspendibleIds.value.size === 0)
const onStatusSuspendStateChange = ({ id, suspend }) => {
if (!suspend) {
unsuspendibleIds.value.add(id)
@ -288,10 +320,92 @@ export default {
}
}
const { virtualHidden } = toRefs(props)
const hide = computed(() => virtualHidden.value && suspendable.value)
onMounted(() => {
updateVirtualHeight()
const heightChartLinear = computed(() => {
// Map every height and suspendable state
const chart = conversation.value.map(({ id }) => {
const status = getStatusObject(id)
const height =
(() => {
if (heights.value.has(id)) {
return heights.value.get(id)
} else if (status?.muted) {
return mutedStatusHeight.value
} else {
return normalStatusHeight.value
}
})() + 1 //including border
const suspendable = !unsuspendibleIds.value.has(id)
return { id, height, suspendable, status }
})
// Walk over the list to set top offsets
chart.reduce((sum, item) => {
item.top = sum
return sum + item.height
}, 0)
// Determine visibility state
chart.forEach((heightChartItem) => {
const itemBottomBoundary = heightChartItem.top + heightChartItem.height
const itemTopBoundary = heightChartItem.top
const finalTopScrollBoundary = realTopScrollBoundary.value - buffer
const finalBottomScrollBoundary =
realBottomScrollBoundary.value + buffer
// console.log(
// 'TOP SCROLL',
// itemBottomBoundary > finalTopScrollBoundary,
// itemBottomBoundary, finalTopScrollBoundary,
// )
// console.log(
// 'BOTTOM SCROLL',
// itemTopBoundary < finalBottomScrollBoundary,
// itemTopBoundary, finalBottomScrollBoundary,
// )
// To be visible, item's bottom boundary shoud be below top scroll boundary)
const belowTopBoundary = itemBottomBoundary > finalTopScrollBoundary
// To be visible, item's top boundary shoud be above bottom scroll boundary)
const aboveBottomBoundary = itemTopBoundary < finalBottomScrollBoundary
// This accounts for the case where item's boundaries exceed scroll boundary
heightChartItem.visible = belowTopBoundary && aboveBottomBoundary
})
// Group invisible statuses into spacers
return chart.reduce((acc, heightChartItem) => {
const { suspendable, visible, height, top, bottom, id, status } =
heightChartItem
const present = visible || !suspendable
if (present) {
return [...acc, { type: 'status', height, top, bottom, id, status }]
} else {
const previousItem = acc[acc.length - 1]
const spacer =
previousItem?.type === 'spacer'
? previousItem
: {
type: 'spacer',
top: Number.POSITIVE_INFINITY,
bottom: Number.POSITIVE_INFINITY,
height: 0,
ids: new Set(),
}
spacer.ids.add(id)
spacer.id = [...spacer.ids].join()
spacer.height += height
if (top < spacer.top) spacer.top = top
if (bottom < spacer.bottom) spacer.bottom = bottom
if (previousItem?.type === 'spacer') {
return acc
} else {
return [...acc, spacer]
}
}
}, [])
})
// # Misc UI things
@ -466,17 +580,15 @@ export default {
toggleExpanded,
// # Virtual scrolling stuff
hide,
onStatusSuspendStateChange,
updateVirtualHeight,
virtualHidden,
hiddenStyle,
// # Misc UI things
getStatusClasses,
// # Linear style stuff
isLinearView,
heightChartLinear,
// # Tree style stuff
isTreeView,

View file

@ -1,10 +1,21 @@
<template>
<div
v-if="!hide"
ref="body"
ref="root"
class="Conversation"
:class="{ '-expanded' : isExpanded, '-page': isPage, 'panel' : isExpanded }"
>
<div class="panel panel-body" style="position:fixed;top:10em;left:2em">
<dl v-for="item in heightChartLinear">
<dt>
{{ item.type.toUpperCase() }}
<template v-if="item.type === 'status'">ID {{ item.id }}</template>
<template v-else>Size {{ item.ids.size }}</template>
</dt>
<dd>Top {{ item.top }}</dd>
<dd>Height {{ item.height }}</dd>
<dd>Bottom {{ item.bottom }}</dd>
</dl>
</div>
<div
v-if="isExpanded"
class="panel-heading conversation-heading -sticky"
@ -40,6 +51,7 @@
<div
v-if="isPage && !status"
class="conversation-body"
ref="body"
:class="{ 'panel-body': isExpanded }"
>
<p v-if="!loadStatusError">
@ -56,6 +68,7 @@
<div
v-else
class="conversation-body"
ref="body"
:class="{ 'panel-body': isExpanded }"
>
<div
@ -155,17 +168,23 @@
class="thread-body"
>
<article
v-for="status in conversation"
v-for="element in heightChartLinear"
class="panel-body"
:key="element.id ?? element.ids"
>
<div
v-if="element.type === 'spacer'"
class="virtual-spacer"
:style="{ height: element.height + 'px' }"
/>
<Status
:key="status.id"
v-if="element.type === 'status'"
class="conversation-status"
:class="getStatusClasses(status)"
:status-id="status.id"
:status-id="element.status.id"
:replies="getReplies(status.id)"
:focused="focused === status.id || focused === status.retweeted_status?.id"
:focused="focused === element.id || focused === element.status.retweeted_status?.id"
@goto="setFocused"
@toggle-expanded="toggleExpanded"
@ -176,11 +195,6 @@
</div>
</div>
</div>
<div
v-else
class="Conversation -hidden"
:style="hiddenStyle"
/>
</template>
<script src="./conversation.js"></script>

View file

@ -0,0 +1,21 @@
import { onMounted, onUnmounted, ref } from 'vue'
export function useScrollPosition() {
const x = ref(0)
const y = ref(0)
const update = (e) => {
x.value = window.scrollX
y.value = window.scrollY
}
onMounted(() => {
window.addEventListener('scroll', update)
update()
})
onUnmounted(() => {
window.removeEventListener('scroll', update)
})
return { x, y }
}

View file

@ -0,0 +1,18 @@
import { onMounted, onUnmounted, ref } from 'vue'
export function useWindowSize() {
const height = ref(0)
const width = ref(0)
const update = () => {
height.value = window.innerHeight
width.value = window.innerWidth
}
onMounted(() => window.addEventListener('resize', update))
onUnmounted(() => window.removeEventListener('resize', update))
update()
return { height, width }
}

View file

@ -122,6 +122,7 @@ const Status = {
},
data() {
return {
resizeObserver: new ResizeObserver(this.updateVirtualHeight),
replying: false,
unmuted: false,
mediaPlaying: new Set(),
@ -562,47 +563,22 @@ const Status = {
// FIXME
this.controlledToggleThreadDisplay()
},
scrollIfFocused(focused) {
if (this.$el.getBoundingClientRect == null) return
if (focused) {
const rect = this.$el.getBoundingClientRect()
if (rect.top < 100) {
// Post is above screen, match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.height >= window.innerHeight - 50) {
// Post we want to see is taller than screen so match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.bottom > window.innerHeight - 50) {
// Post is below screen, match its bottom to screen bottom
window.scrollBy(0, rect.bottom - window.innerHeight + 50)
}
}
},
onTransitionEnd() {
this.$nextTick(() => {
this.$emit('heightChange')
updateVirtualHeight(e) {
const [entry] = e
this.$emit('heightChange', {
id: this.status.id,
height: entry.contentRect.height,
element: this.$el,
})
},
},
mounted() {
this.resizeObserver.observe(this.$el)
},
unmounted() {
this.resizeObserver.disconnect()
},
watch: {
status: {
deep: true,
handler() {
this.$emit('heightChange')
},
},
unmuted() {
this.$emit('heightChange')
},
error() {
this.$emit('heightChange')
},
replying() {
this.$emit('heightChange')
},
focused: function (id) {
this.scrollIfFocused(id)
},
'mainStatus.repeat_num': function (num) {
// refetch repeats when repeat_num is changed in any way
if (this.focused && this.repeatedBy.size !== num) {

View file

@ -454,10 +454,7 @@
</StatusPopover>
</div>
<Transition
@after-leave="onTransitionEnd"
name="fade"
>
<Transition name="fade">
<div
v-if="shouldDisplayFavsAndRepeats"
class="favs-repeated-users"
@ -549,7 +546,6 @@
@posted="closeReplyForm"
@draft-done="closeReplyForm"
@close-accepted="closeReplyForm"
@resize="$emit('heightChange')"
/>
</div>
</template>