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 { 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'
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 PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.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' import QuickViewSettings from 'src/components/quick_view_settings/quick_view_settings.vue'
@ -61,13 +63,7 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
virtualHidden: {
// Whether conversation is suspended. Controls rendering of statuses
type: Boolean,
default: false,
},
}, },
emits: ['update:virtualHeight'],
components: { components: {
ThreadTree, ThreadTree,
QuickFilterSettings, QuickFilterSettings,
@ -76,8 +72,7 @@ export default {
PostStatusForm, PostStatusForm,
RichContent, RichContent,
}, },
setup(props, ctx) { setup(props) {
const { emit } = ctx
const { statusId } = toRefs(props) const { statusId } = toRefs(props)
const router = useRouter() const router = useRouter()
@ -260,26 +255,63 @@ export default {
} }
// # Virtual scrolling stuff // # 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 body = useTemplateRef('body')
const virtualHeight = ref(120) const updateVirtualHeight = ({ id, height }) => {
const hiddenStyle = computed(() => ({ heights.value.set(id, height)
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 { 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 unsuspendibleIds = ref(new Set())
const suspendable = computed(() => unsuspendibleIds.value.size === 0)
const onStatusSuspendStateChange = ({ id, suspend }) => { const onStatusSuspendStateChange = ({ id, suspend }) => {
if (!suspend) { if (!suspend) {
unsuspendibleIds.value.add(id) unsuspendibleIds.value.add(id)
@ -288,10 +320,92 @@ export default {
} }
} }
const { virtualHidden } = toRefs(props) const heightChartLinear = computed(() => {
const hide = computed(() => virtualHidden.value && suspendable.value) // Map every height and suspendable state
onMounted(() => { const chart = conversation.value.map(({ id }) => {
updateVirtualHeight() 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 // # Misc UI things
@ -466,17 +580,15 @@ export default {
toggleExpanded, toggleExpanded,
// # Virtual scrolling stuff // # Virtual scrolling stuff
hide,
onStatusSuspendStateChange, onStatusSuspendStateChange,
updateVirtualHeight, updateVirtualHeight,
virtualHidden,
hiddenStyle,
// # Misc UI things // # Misc UI things
getStatusClasses, getStatusClasses,
// # Linear style stuff // # Linear style stuff
isLinearView, isLinearView,
heightChartLinear,
// # Tree style stuff // # Tree style stuff
isTreeView, isTreeView,

View file

@ -1,10 +1,21 @@
<template> <template>
<div <div
v-if="!hide" ref="root"
ref="body"
class="Conversation" class="Conversation"
:class="{ '-expanded' : isExpanded, '-page': isPage, 'panel' : isExpanded }" :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 <div
v-if="isExpanded" v-if="isExpanded"
class="panel-heading conversation-heading -sticky" class="panel-heading conversation-heading -sticky"
@ -40,6 +51,7 @@
<div <div
v-if="isPage && !status" v-if="isPage && !status"
class="conversation-body" class="conversation-body"
ref="body"
:class="{ 'panel-body': isExpanded }" :class="{ 'panel-body': isExpanded }"
> >
<p v-if="!loadStatusError"> <p v-if="!loadStatusError">
@ -56,6 +68,7 @@
<div <div
v-else v-else
class="conversation-body" class="conversation-body"
ref="body"
:class="{ 'panel-body': isExpanded }" :class="{ 'panel-body': isExpanded }"
> >
<div <div
@ -155,17 +168,23 @@
class="thread-body" class="thread-body"
> >
<article <article
v-for="status in conversation" v-for="element in heightChartLinear"
class="panel-body" class="panel-body"
:key="element.id ?? element.ids"
> >
<div
v-if="element.type === 'spacer'"
class="virtual-spacer"
:style="{ height: element.height + 'px' }"
/>
<Status <Status
:key="status.id" v-if="element.type === 'status'"
class="conversation-status" class="conversation-status"
:class="getStatusClasses(status)" :class="getStatusClasses(status)"
:status-id="status.id" :status-id="element.status.id"
:replies="getReplies(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" @goto="setFocused"
@toggle-expanded="toggleExpanded" @toggle-expanded="toggleExpanded"
@ -176,11 +195,6 @@
</div> </div>
</div> </div>
</div> </div>
<div
v-else
class="Conversation -hidden"
:style="hiddenStyle"
/>
</template> </template>
<script src="./conversation.js"></script> <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() { data() {
return { return {
resizeObserver: new ResizeObserver(this.updateVirtualHeight),
replying: false, replying: false,
unmuted: false, unmuted: false,
mediaPlaying: new Set(), mediaPlaying: new Set(),
@ -562,47 +563,22 @@ const Status = {
// FIXME // FIXME
this.controlledToggleThreadDisplay() this.controlledToggleThreadDisplay()
}, },
scrollIfFocused(focused) { updateVirtualHeight(e) {
if (this.$el.getBoundingClientRect == null) return const [entry] = e
if (focused) { this.$emit('heightChange', {
const rect = this.$el.getBoundingClientRect() id: this.status.id,
if (rect.top < 100) { height: entry.contentRect.height,
// Post is above screen, match its top to screen top element: this.$el,
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')
}) })
}, },
}, },
mounted() {
this.resizeObserver.observe(this.$el)
},
unmounted() {
this.resizeObserver.disconnect()
},
watch: { 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) { 'mainStatus.repeat_num': function (num) {
// refetch repeats when repeat_num is changed in any way // refetch repeats when repeat_num is changed in any way
if (this.focused && this.repeatedBy.size !== num) { if (this.focused && this.repeatedBy.size !== num) {

View file

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