Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
42 KB
Referenced Files
None
Subscribers
None
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index d0e5460dfc..3d8504f48f 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,379 +1,382 @@
import { last } from 'lodash-es'
import { storeToRefs } from 'pinia'
import {
computed,
inject,
nextTick,
provide,
ref,
toRefs,
useTemplateRef,
watch,
} from '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 RichContent from 'src/components/rich_content/rich_content.jsx'
import ThreadTree from 'src/components/thread_tree/thread_tree.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useClientRectSize } from 'src/composables/useClientRectSize.js'
import { useConversation } from 'src/composables/useConversation.js'
import { useInterfaceSizes } from 'src/composables/useInterfaceSizes.js'
import { useTreeConversationTopology } from 'src/composables/useTreeConversationTopology.js'
import { useVirtualScrolling } from 'src/composables/useVirtualScrolling.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft,
faReply,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft,
faReply,
faTimes,
)
export default {
props: {
statusId: {
// Main thing
type: String,
required: true,
},
- isPage: {
- // Whether conversation is rendered as a standalone page
- // as opposed to embedded into a timeline
+ // Whether conversation is rendered as a standalone page
+ // as opposed to embedded into a timeline
+ isPage: Boolean,
+ // Whether to engage virtual scrolling scroll compensation
+ scrollCompensation: {
type: Boolean,
- default: false,
+ default: true,
},
},
components: {
ThreadTree,
QuickFilterSettings,
QuickViewSettings,
RichContent,
},
emits: ['heightChange', 'suspendableStateChange', 'expanded', 'collapsed'],
setup(props, { emit }) {
const scroller = inject('bodyScrollPosition')
const { statusId } = toRefs(props)
// # Main Configuration / global state
const { mergedConfig } = storeToRefs(useMergedConfigStore())
const displayStyle = computed(() => mergedConfig.value.conversationDisplay)
const { layoutType } = storeToRefs(useInterfaceStore())
const mobileLayout = computed(() => layoutType.value === 'mobile')
// # Conversation Expansion
const expanded = ref(false)
const { isPage } = toRefs(props)
const isExpanded = computed(() => !!(expanded.value || isPage.value))
const toggleExpanded = async () => {
const newVal = !expanded.value
if (newVal) {
virtualScrollingEnabled.value = newVal
await nextTick()
expanded.value = newVal
} else {
expanded.value = newVal
await nextTick()
virtualScrollingEnabled.value = newVal
}
}
provide('isExpanded', isExpanded)
provide('isPage', isPage)
provide('expandable', true)
watch(expanded, (val) => (val ? emit('expanded') : emit('collapsed')), {
flush: 'post',
})
// # Main things
const {
focusedId,
conversationId,
setFocused,
currentStatus,
conversation,
replies,
getReplies,
fetchConversation,
loadError,
} = useConversation(statusId, isExpanded)
const conversationLite = computed(() =>
conversation.value.map(({ id }) => ({ id })),
)
provide('focusedId', focusedId)
provide('conversation', conversation)
provide('replies', replies)
// Component created
if (isPage.value) {
fetchConversation()
}
// # Misc UI things
const firstStatus = computed(() => conversation.value[0])
const lastStatus = computed(() => last(conversation.value))
const getStatusClasses = (statusId, ancestor) => {
const result = {
'-first': statusId === firstStatus.value?.id,
'-last': statusId === lastStatus.value?.id,
}
if (ancestor) {
result['-ancestor'] = true
result['-fade'] = mergedConfig.value.conversationTreeFadeAncestors
}
return result
}
// # External virtual scrolling
const unsuspendableIds = ref(new Set())
const suspendable = computed(
() => !isExpanded.value && unsuspendableIds.value.size === 0,
)
const rootElement = useTemplateRef('root')
const rootElementMargin = ref(null)
const { height } = useClientRectSize(rootElement)
watch(height, (value) => {
const rootCss = window.getComputedStyle(rootElement.value)
const rootMarginString = rootCss.getPropertyValue('margin-top')
const rootMargin = Number.parseInt(rootMarginString.slice(0, -2), 10)
rootElementMargin.value = rootMargin
const border = isExpanded.value ? 2 : 1
emit('heightChange', {
id: statusId.value,
height: value + border,
element: rootElement,
})
})
watch(suspendable, (value) =>
emit('suspendableStateChange', {
suspendable: value,
id: statusId.value,
}),
)
// # Internal virtual scrolling
const virtualScrollingEnabled = ref(isExpanded.value)
// Placeholder heights.
const { fontSize, navbarSize, panelHeaderSize } = useInterfaceSizes()
const normalStatusHeight = computed(() => fontSize.value * 10)
const getPlaceholderHeight = (id) => normalStatusHeight
const offset = computed(() => {
// The fontsize after navbar is the little gap between navbar and content
if (isPage.value) {
return navbarSize.value + fontSize.value + panelHeaderSize.value
} else if (expanded.value) {
return (
navbarSize.value +
fontSize.value +
panelHeaderSize.value * 2 +
rootElementMargin.value
)
} else {
return navbarSize.value + fontSize.value
}
})
+ const { scrollCompensation } = toRefs(props)
// # Linear style stuff
const isLinearView = computed(() => displayStyle.value !== 'tree')
const linearElement = useTemplateRef('linear')
const linearScrollCompensation = computed(
- () => virtualScrollingEnabled.value && isLinearView.value,
+ () => virtualScrollingEnabled.value && isLinearView.value && scrollCompensation.value,
)
const {
heightChart: heightChartLinear,
changeSuspendState: changeSuspendStateLinear,
updateVirtualHeight: updateVirtualHeightLinear,
reset: resetLinearScrollVirtualization,
scrollTo: linearScrollTo,
} = useVirtualScrolling({
name: 'Linear',
enabled: linearScrollCompensation,
list: conversationLite,
body: linearElement,
offset,
scrollPositionInstance: scroller,
scrollCompensation: linearScrollCompensation,
getPlaceholderHeight,
})
const changeSuspendStateLinearLocal = (e) => {
changeSuspendStateLinear(e)
const { id, suspendable } = e
if (suspendable) {
unsuspendableIds.value.delete(id)
} else {
unsuspendableIds.value.add(id)
}
}
// # Tree style stuff
const isTreeView = computed(() => displayStyle.value === 'tree')
const {
topLevel,
currentAncestors,
threadDisplay,
setThreadDisplayRecursively,
showThreadRecursively,
resetThreadDisplay,
totalReplyCount,
totalReplyDepth,
} = useTreeConversationTopology(conversation, replies, focusedId)
provide('threadDisplay', threadDisplay)
provide('totalReplyCount', totalReplyCount)
provide('totalReplyDepth', totalReplyDepth)
watch(
isExpanded,
(value) => {
if (!value) resetThreadDisplay()
},
{ flush: 'post' },
)
const toggleThreadDisplay = (id) => {
const current = threadDisplay.value.get(id)
const next = current === 'hidden' ? 'showing' : 'hidden'
setThreadDisplayRecursively(id, next)
}
const currentAncestorsLite = computed(() =>
currentAncestors.value.map(({ id }) => ({ id })),
)
const ancestorsElement = useTemplateRef('ancestors')
const treeScrollCompensation = computed(
- () => virtualScrollingEnabled.value && isTreeView.value,
+ () => virtualScrollingEnabled.value && isTreeView.value && scrollCompensation.value,
)
const {
heightChart: heightChartAncestors,
changeSuspendState: changeSuspendStateAncestors,
updateVirtualHeight: updateVirtualHeightAncestors,
reset: resetTreeScrollVirtualization,
} = useVirtualScrolling({
name: 'Ancestors',
enabled: treeScrollCompensation,
list: currentAncestorsLite,
body: ancestorsElement,
offset,
scrollPositionInstance: scroller,
scrollCompensation: treeScrollCompensation,
collapseMode: 'height',
getPlaceholderHeight,
})
const changeSuspendStateAncestorsLocal = (e) => {
changeSuspendStateAncestors(e)
const { id, suspendable } = e
if (suspendable) {
unsuspendableIds.value.delete(id)
} else {
unsuspendableIds.value.add(id)
}
}
const changeSuspendStateCurrentLevelLocal = (e) => {
const { id, suspendable } = e
if (suspendable) {
unsuspendableIds.value.delete(id)
} else {
unsuspendableIds.value.add(id)
}
}
watch(conversationId, (neu, old) => {
resetLinearScrollVirtualization()
resetTreeScrollVirtualization()
})
const treeViewIsSimple = computed(
() => !mergedConfig.value.conversationTreeAdvanced,
)
const shouldShowAllConversationButton = computed(
() => currentAncestors.value.length > 0 && topLevel.value.length > 1,
)
const shouldShowAncestors = computed(
() => isExpanded.value && heightChartAncestors.value.length > 0,
)
// # Scrolling
const scrollTo = (ids) => {
if (isLinearView.value) {
return linearScrollTo(ids)
}
}
const focusTopLevel = () => setFocused(currentAncestors.value[0].id)
watch(focusedId, async (neu, old) => {
// Ignoring initial update (null -> id) since that is handled by scroll compensation
if (old && neu) scrollTo(new Set([neu]))
})
return {
// # Misc
loadError,
mobileLayout,
// # Conversation Expansion
isPage,
isExpanded,
toggleExpanded,
// # Focus
focusedId,
setFocused,
// # Main things
conversation,
currentStatus,
getReplies,
// # Misc UI things
getStatusClasses,
// # Linear style stuff
isLinearView,
// ## Linear virtual scrolling
heightChartLinear,
changeSuspendStateLinearLocal,
updateVirtualHeightLinear,
// # Tree style stuff
isTreeView,
// ## Tree virtual scrolling
heightChartAncestors,
changeSuspendStateAncestorsLocal,
updateVirtualHeightAncestors,
changeSuspendStateCurrentLevelLocal,
// ## Tree state
// ### Topology
topLevel,
currentAncestors,
// ### Thread Display
showThreadRecursively,
toggleThreadDisplay,
// ### Derived values and config
treeViewIsSimple,
shouldShowAllConversationButton,
shouldShowAncestors,
// # Scrolling
focusTopLevel,
unsuspendableIds,
}
},
}
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index 18387b22c4..8251420c0b 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -1,279 +1,279 @@
import { debounce, throttle } from 'lodash-es'
import { storeToRefs } from 'pinia'
import {
computed,
inject,
nextTick,
onMounted,
onUnmounted,
ref,
toRefs,
useId,
useTemplateRef,
watch,
} from 'vue'
import { useI18n } from 'vue-i18n'
import Conversation from 'src/components/conversation/conversation.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 ScrollTopButton from 'src/components/scroll_top_button/scroll_top_button.vue'
import TimelineMenu from 'src/components/timeline_menu/timeline_menu.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useTimelinesStore } from 'src/stores/timelines.js'
import { useDocumentFocus } from 'src/composables/useDocumentFocus.js'
import { useInterfaceSizes } from 'src/composables/useInterfaceSizes.js'
import { useVirtualScrolling } from 'src/composables/useVirtualScrolling.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faArrowUp,
faCheck,
faCircleNotch,
faCirclePlus,
faCog,
faMinus,
} from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch, faCog, faMinus, faArrowUp, faCirclePlus, faCheck)
const Timeline = {
props: {
timelineRef: Object,
footerSlipgate: Object, // reference to an element where we should put our footer
embedded: Boolean,
skipPinned: Boolean,
hideEmpty: Boolean,
},
components: {
ScrollTopButton,
Conversation,
TimelineMenu,
QuickFilterSettings,
QuickViewSettings,
},
setup(props, ctx) {
const { t } = useI18n()
const { focused } = useDocumentFocus()
const unfocused = computed(() => !focused.value)
// Timeline
const { timelineRef } = toRefs(props)
const timeline = computed(() => useTimelinesStore()[timelineRef.value.name])
const { skipPinned } = toRefs(props)
const filteredVisibleStatuses = computed(() => {
return timeline.value.order
.filter((id) => timeline.value.visibleStatusIds.has(id))
.map((id) => useStatusesStore().allStatuses.get(id))
.filter(({ pinned }) => (skipPinned.value ? !pinned : true))
})
// Scroll position
const scroller = inject('bodyScrollPosition')
const { hasReachedTop, shouldLoadBottom, top: scrollY } = scroller
// Virtual scrolling
const { fontSize, navbarSize } = 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 offset = computed(() => {
if (embedded.value) {
// The fontsize after navbar is the little gap between navbar and content
return navbarSize.value + fontSize.value
} else {
return 0
}
})
const { heightChart, changeSuspendState, updateVirtualHeight } =
useVirtualScrolling({
name: 'Timeline',
enabled: ref(true),
list: filteredVisibleStatuses,
body,
offset,
scrollPositionInstance: scroller,
- scrollCompensation: false,
+ scrollCompensation: true,
getPlaceholderHeight,
})
// Counter
const count = computed(() => timeline.value.order.length)
const newStatusCount = computed(() => timeline.value.newStatusCount)
const showLoadButton = computed(
() => timeline.value.newStatusCount > 0 || timeline.value.reloadNeeded,
)
// Showing new
const paused = ref(false)
const { mergedConfig } = storeToRefs(useMergedConfigStore())
const unfocusedPause = computed(
() => unfocused.value && mergedConfig.value.pauseOnUnfocused,
)
const showNewAutomatically = computed(
() => useMergedConfigStore().mergedConfig.streaming,
)
watch(newStatusCount, (count) => {
if (!showNewAutomatically.value) return
if (count <= 0) return
// only 'stream' them when you're scrolled to the top
if (hasReachedTop.value && !paused.value && !unfocusedPause.value) {
showNewStatuses(true)
} else {
paused.value = true
}
})
const showNewStatuses = async (fast = false) => {
if (timeline.value.reloadNeeded) {
useTimelinesStore().clearTimeline(timelineRef.value.name)
fetchOlderStatuses()
} else {
blockClicksTemporarily()
useTimelinesStore().showNewStatuses(timelineRef.value.name)
}
paused.value = false
await nextTick()
scroller.scrollToPriority({
top: 0,
behavior: fast ? 'instant' : 'smooth',
})
}
const fetchOlderStatuses = throttle(() => {
timeline.value.fetcher.fetchOlder()
}, 1000)
// Timeline change
const timelineChange = (newTimeline, oldTimeline) => {
const sameName = newTimeline?.name === oldTimeline?.name
const sameArgument = newTimeline?.argument === oldTimeline?.argument
if (sameName && sameArgument) return
if (oldTimeline) {
useTimelinesStore().deactivate(oldTimeline.name)
}
if (newTimeline) {
useTimelinesStore().activate(newTimeline.name, newTimeline.argument)
}
}
watch(timelineRef, timelineChange, { immediate: true })
onUnmounted(() => {
timelineChange(null, timelineRef.value)
})
// Misclick prevention
const blockingClicks = ref(false)
const stopBlockingClicks = debounce(() => {
blockingClicks.value = false
}, 1000)
const blockClicksTemporarily = () => {
if (!blockingClicks.value) {
blockingClicks.value = true
}
stopBlockingClicks()
}
// Shortcuts
const handleShortKey = (e) => {
// Ignore when input fields are focused
if (['textarea', 'input'].includes(e.target.tagName.toLowerCase())) return
if (e.key === '.') showNewStatuses()
}
onMounted(() => {
window.addEventListener('keydown', handleShortKey)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleShortKey)
})
const infiniteLoadWatcher = debounce((value) => {
if (!value) return
fetchOlderStatuses()
}, 100)
// Scroll
watch([shouldLoadBottom, scrollY], infiniteLoadWatcher)
onUnmounted(() => {
infiniteLoadWatcher.cancel()
})
// Misc UI things
const classes = computed(() => {
let rootClasses = !embedded.value
? ['panel', 'panel-default']
: ['-embedded']
if (blockingClicks.value)
rootClasses = rootClasses.concat(['-blocked', '_misclick-prevention'])
return {
root: rootClasses,
header: ['timeline-heading'].concat(
!embedded.value ? ['panel-heading', '-sticky'] : ['panel-body'],
),
body: ['timeline-body'].concat(
!embedded.value ? ['panel-body'] : ['panel-body'],
),
footer: ['timeline-footer'].concat(
!embedded.value ? ['panel-footer'] : ['panel-body'],
),
}
})
const loadButtonString = computed(() => {
if (timeline.value.reloadNeeded) {
return t('timeline.reload')
} else {
return `${t('timeline.show_new')} (${newStatusCount.value})`
}
})
const mobileLoadButtonString = computed(() => {
if (timeline.value.reloadNeeded) {
return '+'
} else {
return newStatusCount.value > 99 ? '∞' : newStatusCount.value
}
})
const { layoutType } = storeToRefs(useInterfaceStore())
const mobileLayout = computed(() => layoutType.value === 'mobile')
const { footerSlipgate, embedded, hideEmpty } = toRefs(props)
const uniqueId = useId()
return {
timelineRef,
timeline,
filteredVisibleStatuses,
heightChart,
updateVirtualHeight,
changeSuspendState,
count,
showLoadButton,
showNewStatuses,
fetchOlderStatuses,
classes,
loadButtonString,
mobileLoadButtonString,
mobileLayout,
footerSlipgate,
embedded,
hideEmpty,
uniqueId,
}
},
}
export default Timeline
diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue
index f8774fbc7f..de844e5a01 100644
--- a/src/components/timeline/timeline.vue
+++ b/src/components/timeline/timeline.vue
@@ -1,172 +1,174 @@
<template>
<!-- there is a brief moment during logout when old timeline gets forcibly deactivated -->
<div
v-if="timeline.fetcher"
:class="['Timeline', classes.root]"
>
<div
v-if="!embedded"
:class="classes.header"
>
<TimelineMenu
v-if="!embedded"
:timeline-name="timelineRef.name"
:id="'timeline-header-' + uniqueId"
/>
<div
v-if="timeline.fetcher.loadingNewer && !showLoadButton"
class="loadingIndicator"
>
<FAIcon
fixed-width
icon="circle-notch"
spin
/>
</div>
<ScrollTopButton />
<template v-if="mobileLayout">
<div
v-if="showLoadButton"
class="rightside-button"
>
<button
class="button-unstyled loadmore-button"
:title="loadButtonString"
@click.prevent="showNewStatuses(false)"
>
<FAIcon
fixed-width
icon="circle-plus"
/>
<div class="badge -counter">
{{ mobileLoadButtonString }}
</div>
</button>
</div>
<div
v-else-if="!timeline.fetcher.loadingNewer"
class="loadmore-text faint veryfaint rightside-icon"
:title="$t('timeline.up_to_date')"
:aria-disabled="true"
@click.prevent
>
<FAIcon
fixed-width
icon="check"
/>
</div>
</template>
<template v-else>
<button
v-if="showLoadButton"
class="button-default loadmore-button"
@click.prevent="showNewStatuses(false)"
>
{{ loadButtonString }}
</button>
<div
v-else
class="loadmore-text faint"
@click.prevent
>
{{ $t('timeline.up_to_date') }}
</div>
</template>
<QuickFilterSettings
v-if="!mobileLayout"
class="rightside-button"
/>
<QuickViewSettings
class="rightside-button"
/>
</div>
<div :class="classes.body">
<div
class="timeline"
ref="timeline"
role="feed"
:aria-labelledby="embedded ? undefined : ('timeline-header-'+ uniqueId)"
>
<template
v-for="element in heightChart"
:key="element.id"
>
<Conversation
v-if="element.type === 'item'"
class="timeline-conversation"
:status-id="element.id"
+ :scroll-compensation="element.visible"
:data-vs-height="element.height"
:data-vs-top="element.top"
:data-vs-id="element.id"
+ :data-vs-visible="element.visible"
@suspendable-state-change="changeSuspendState"
@height-change="updateVirtualHeight"
/>
<div
v-if="element.type === 'spacer'"
class="virtual-spacer"
aria-hidden="true"
:style="{ height: element.height + 'px' }"
/>
</template>
</div>
<template v-if="!hideEmpty && count === 0">
<div
v-if="timeline.fetcher.loadingNewer || timeline.fetcher.loadingOlder"
class="timeline-placeholder"
>
<FAIcon
icon="circle-notch"
spin
size="4x"
/>
</div>
<div
v-else
class="timeline-placeholder faint"
>
{{ $t('timeline.no_statuses') }}
</div>
</template>
</div>
<div v-if="!embedded || footerSlipgate" :class="classes.footer">
<teleport
:to="footerSlipgate"
:disabled="!embedded || !footerSlipgate"
>
<div
v-if="timeline.fetcher.bottomedOut"
class="new-status-notification text-center faint"
>
{{ $t('timeline.no_more_statuses') }}
</div>
<div
v-else-if="timeline.fetcher.loadingOlder"
class="new-status-notification text-center"
>
<FAIcon
icon="circle-notch"
spin
size="lg"
/>
</div>
<button
v-else-if="timeline.minId !== ''"
class="button-unstyled -link"
@click.prevent="fetchOlderStatuses()"
>
<div class="new-status-notification text-center">
{{ $t('timeline.load_older') }}
</div>
</button>
</teleport>
<!-- spacer to avoid having empty shrug -->
<span v-if="embedded && footerSlipgate" />
</div>
</div>
</template>
<script src="./timeline.js"></script>
<style src="./timeline.scss" lang="scss"> </style>
diff --git a/src/composables/useVirtualScrolling.js b/src/composables/useVirtualScrolling.js
index 232c44f448..3dfb077f75 100644
--- a/src/composables/useVirtualScrolling.js
+++ b/src/composables/useVirtualScrolling.js
@@ -1,434 +1,398 @@
import { debounce, first, last, throttle } from 'lodash-es'
import { computed, nextTick, onUnmounted, ref, toValue, watch } from 'vue'
export function useVirtualScrolling({
// For debugging
name = 'Generic',
// Master toggle
enabled,
// List of items
list,
// Container of items, used for measuring scroll position
body,
// vertical offset to account for fixed and sticky headers
offset,
// useScrollPosition composable, used to prevent dupicating instances
scrollPositionInstance,
// buffer zone, the amount of placeholder heights to include
buffer,
// whether to use scroll compensation when elements above anchor change
scrollCompensation,
// How to handle collapse/expansion (going from 0 elements to full and back)
// - false - don't do scroll compensation at all
// - 'height' - compensate scroll according to list's height
// - 'item' - same as height but uses anchor element's top offset
// instead of whole height
collapseMode,
// Placeholder height specification. Must be a function.
// function will be called either:
// - without arguments (for generic placeholder, i.e. buffer zone size)
// - with id (for specific item placeholders)
// function must return ref pointing to height
getPlaceholderHeight,
// Invert grow direction. Useful for chat views. Makes scroll compensation
// work in reverse - adding items to bottom scrolls down, adding items to
// top doesn't scroll up.
invertDirection = false,
}) {
// # Suspension
const unsuspendibleIds = ref(new Set())
const changeSuspendState = ({ id, suspendable }) => {
if (suspendable) {
unsuspendibleIds.value.delete(id)
} else {
unsuspendibleIds.value.add(id)
}
}
// # Heights mapping.
const heights = ref(new Map())
const heightChart = ref([])
- const updateHeightChart = debounce(() => {
+ const updateHeightChart = () => {
// Map every height and suspendable state
const chart = list.value.map((item) => {
const { id } = item
const height = (() => {
if (heights.value.has(id)) {
return heights.value.get(id)
} else {
return getPlaceholderHeight(id).value
}
})()
const suspendable = !unsuspendibleIds.value.has(id)
const real = heights.value.has(id)
return { id, height, suspendable, real, visible: true }
})
// Walk over the list to set top offsets
chart.reduce((sum, item) => {
item.top = sum
return sum + item.height
}, 0)
heightChart.value = chart
- }, 32) // 32ms = ~30fps
+ }
watch([list, heights, unsuspendibleIds], updateHeightChart, {
immediate: true,
deep: true,
})
- onUnmounted(() => {
- updateHeightChart.cancel()
- })
const updateVirtualHeight = ({ id, height }) => {
heights.value.set(id, height)
}
const {
top: scrollY,
scrollBy,
vHeight,
hasReachedTop,
hasReachedBottom,
} = scrollPositionInstance
// Real scroll boundary, relative to body's bounds
const topScrollBoundary = ref(0)
const bottomScrollBoundary = ref(0)
const updateBoundaries = () => {
if (!toValue(enabled)) return
if (!body.value) return // Not mounted yet
const { top } = body.value.getBoundingClientRect()
const distanceItemTopToWindowTop = 0 - top + (toValue(offset) ?? 0)
const distanceItemTopToWindowBottom = vHeight.value - top
// Technically, bottom scroll boundary should be distance
// from element's top border to window's bottom border,
// but it just so happens that it is equal to this.
// You can verify it by drawing the boxes and measuring
// distances yourself, I know I did. Geometry, man...
topScrollBoundary.value = distanceItemTopToWindowTop
bottomScrollBoundary.value = distanceItemTopToWindowBottom
}
const windowWatcher = watch(vHeight, updateBoundaries)
const scrollWatcher = watch(scrollY, updateBoundaries)
const heightWatcher = watch(heightChart, updateBoundaries)
const bodyWatcher = watch(body, updateBoundaries)
watch(vHeight, (neu, old) => {
if (toValue(invertDirection)) {
// Only compensate (stick to bottom) when shrinking
scrollBy(0, Math.max(old - neu, 0))
// compensating in other direction causes overscrolling
}
})
const pauseWatchers = () => {
windowWatcher.pause()
scrollWatcher.pause()
heightWatcher.pause()
bodyWatcher.pause()
}
const resumeWatchers = (skipUpdate = false) => {
windowWatcher.resume()
scrollWatcher.resume()
heightWatcher.resume()
bodyWatcher.resume()
if (skipUpdate) return
updateBoundaries()
}
watch(enabled, (val) => {
if (val) {
resumeWatchers()
} else {
pauseWatchers()
}
})
// # Visiblity
// Add buffer zone to boundary, equal to approx 10 items heights
const bufferZone = computed(
() => getPlaceholderHeight().value * (toValue(buffer) ?? 5),
)
+ // Stuff that's on screen
const checkVisible = ({ top, height }) => {
const itemTopBoundary = top
const itemBottomBoundary = top + height
+ // Include buffer zone
+ const finalTopScrollBoundary = topScrollBoundary.value
+ const finalBottomScrollBoundary = bottomScrollBoundary.value
+
+ // To be visible, item's bottom boundary shoud be below top scroll boundary)
+ const isBelowTopBoundary = itemBottomBoundary > finalTopScrollBoundary
+ // To be visible, item's top boundary shoud be above bottom scroll boundary)
+ const isAboveBottomBoundary = itemTopBoundary < finalBottomScrollBoundary
+ // This accounts for the case where item's boundaries exceed scroll boundary
+
+ return isBelowTopBoundary && isAboveBottomBoundary
+ }
+
+ // Same thing but including buffer zone
+ const checkRendered = ({ top, height }) => {
+ const itemTopBoundary = top
+ const itemBottomBoundary = top + height
+
// Include buffer zone
const finalTopScrollBoundary = topScrollBoundary.value - bufferZone.value
const finalBottomScrollBoundary =
bottomScrollBoundary.value + bufferZone.value
// To be visible, item's bottom boundary shoud be below top scroll boundary)
const isBelowTopBoundary = itemBottomBoundary > finalTopScrollBoundary
// To be visible, item's top boundary shoud be above bottom scroll boundary)
const isAboveBottomBoundary = itemTopBoundary < finalBottomScrollBoundary
// This accounts for the case where item's boundaries exceed scroll boundary
return isBelowTopBoundary && isAboveBottomBoundary
}
const heightChartVisibility = ref(null)
const recalculateChartVisibility = throttle(
(newVal, oldVal) => {
heightChartVisibility.value = heightChart.value.map(
(heightChartItem) => ({
...heightChartItem,
visible: checkVisible(heightChartItem),
+ rendered: checkRendered(heightChartItem),
}),
)
},
32, // 32ms = ~30fps
{ leading: true, trailing: true },
)
watch(
[heightChart, topScrollBoundary, bottomScrollBoundary, bufferZone],
recalculateChartVisibility,
{ immediate: true },
)
const heightChartGrouped = computed(() => {
const chart = enabled.value ? heightChartVisibility : heightChart
// Group invisible items into spacers
return chart.value.reduce((acc, heightChartItem, index) => {
- const { suspendable, visible, height, top, id } = heightChartItem
- const present = visible || !suspendable
+ const { suspendable, rendered, visible, height, top, id } = heightChartItem
+ const present = rendered || !suspendable
if (present) {
- return [...acc, { type: 'item', height, top, id }]
+ return [...acc, { type: 'item', height, top, id, visible }]
} else {
// Reusing previous item if possible
const previousItem = acc[acc.length - 1]
const usingPreviousItem = previousItem?.type === 'spacer'
// We only really care for height and id of spacer, everything else
// is just for debugging
const spacer = usingPreviousItem
? previousItem
: {
type: 'spacer',
top: Number.POSITIVE_INFINITY,
height: 0,
id: `i${index}`, // used for v-for key attribute
ids: new Set(),
}
spacer.ids.add(id)
spacer.height += height
if (top < spacer.top) spacer.top = top
// If we used previous item there is no need to push it to array
if (usingPreviousItem) {
return acc
} else {
return [...acc, spacer]
}
}
}, [])
})
// ## Scroll compensation
watch(
heightChart,
async (newVal, oldVal) => {
if (newVal.length === 0 && oldVal.length === 0) return 0
const explosion = oldVal.length === 0 && newVal.length !== 0
const implosion = oldVal.length !== 0 && newVal.length === 0
const compensation = toValue(scrollCompensation)
const diff = (() => {
if (explosion) {
if (!compensation) return 0
if (toValue(collapseMode) === 'height' || toValue(invertDirection)) {
const newBottomElement = last(newVal)
return newBottomElement.top + newBottomElement.height
} else {
return 0
}
} else if (implosion) {
if (!compensation) return 0
const oldBottomElement = last(oldVal)
return 0 - oldBottomElement.top - oldBottomElement.height
} else {
const contextChange = (() => {
// HACK ignore date separators and fake messages in chat view
const oldIds = new Set(
oldVal
.map(({ id }) => id)
.filter((id) => !id.startsWith('fake-')),
)
const newIds = new Set(
newVal
.map(({ id }) => id)
.filter((id) => !id.startsWith('fake-')),
)
if (oldVal.length <= newVal.length) {
return [...oldIds].some((id) => !newIds.has(id))
} else {
return [...newIds].some((id) => !oldIds.has(id))
}
})()
if (contextChange) {
return 0
}
- /* Ok, here's a thing. Both Timeline and Conversation have virtual scrolling.
- * But since Conversation can be inside Timeline (in fact it's chock-full of
- * them) it creates a problem. Virtual scrolling involves a lot of scroll
- * compensation. Elements appearing, disappearing, changing sizes etc. What
- * happens if a single conversation gets expanded? Conversation sees influx
- * of new posts and compensates for them appearing above to keep old items
- * in screen. Next, timeline sees that Conversation changed height and ALSO
- * compensates for that changed height. We get double the compensation and
- * result ends up being all wrong, we scrolled way past the conversation.
- *
- * Easy, just don't compensate in Timeline, right? Wrong. We still have two
- * cases where we do want compensation in Timeline - all the cases that
- * aren't handled by Conversation. Namely:
- * - Change in amount of Conversations
- * - Change in height of freshly-rendered Conversations.
- *
- * First one is simple. We scrolled down and made a new post - it appeared
- * at the top but we don't want screen to scroll.
- *
- * Second one is tricky. Here's a real situation:
- * 1. We scroll down in Timeline.
- * 2. Open a thread (navigate to it, not expand!)
- * 3. Press "back".
- * Now we have a situation: vue-router restored our scroll position but
- * since Timeline was removed and re-created its heightChart is full of
- * fake placeholders. This naturally means our actual scroll position is
- * all wrong. TODO: Store heightchart in timelines store?
- * But it gets worse - when you start scrolling up elements begin getting
- * their real heights back, and since there's no compensation timeline gets
- * all jumpy! We can somewhat live with wrong scroll position but timeline
- * being jumpy is unacceptable.
- *
- * TODO: Probably a good way to avoid it all is just to:
- * - Don't do scroll compensation when nested
- * - For expanded conversations use animated priority scrollTo
- * something to do for Virtual Scrolling 2.2
- *
- * So, basically - if amount of elements changes, or amount of REAL elements
- * changes - compensate anyway even if compensation is disabled.
- */
const isSticking = toValue(invertDirection)
? hasReachedBottom.value
: hasReachedTop.value
- const compensateBecauseLengthChange =
- !isSticking && newVal.length !== oldVal.length
- const newReal = newVal.filter(({ real }) => real).length
- const oldReal = oldVal.filter(({ real }) => real).length
- const compensateBecauseReal =
- oldReal > 0 && newReal > 0 && oldReal !== newReal
-
- if (
- !(
- compensation ||
- compensateBecauseReal ||
- compensateBecauseLengthChange
- )
- ) {
+ if (!compensation) {
return 0
}
// Section is used for debugging only
const getShift = (section, a, b, find, getter) => {
const aItem = find(a)
if (!aItem) return 0
const bItem = b.find(({ id }) => id === aItem.id)
if (!bItem) return 0
return getter(bItem) - getter(aItem)
}
const expansion = (() => {
if (newVal.length < oldVal.length) return 0
return getShift(
'expansion',
- oldVal.filter((item) => checkVisible(item)),
+ oldVal.filter((item) => !item.visible && item.rendered),
newVal,
(list) =>
list.find(
({ top, height }) => top + height < topScrollBoundary.value,
) ?? first(list),
({ top, height }) => top + height,
)
})()
const inverseCompensation = (() => {
if (!toValue(invertDirection)) return 0
if (newVal.length < oldVal.length) return 0
const shift = getShift(
'inverse',
- oldVal.filter((item) => checkVisible(item)),
+ oldVal.filter((item) => !item.visible && item.rendered),
newVal,
(list) =>
list.findLast(({ top }) => top > bottomScrollBoundary.value) ??
last(list),
({ top }) => top,
)
if (isSticking) {
// Don't do 99999 here, it breaks virtual scrolling
const { height } = body.value.getBoundingClientRect()
return height // keep sticking
} else {
return shift
}
})()
const collapsing = (() => {
if (newVal.length >= oldVal.length) return 0
return -getShift(
'collapse',
newVal,
oldVal,
(list) => first(list),
({ top, height }) => top + height,
)
})()
return (
(toValue(invertDirection) ? inverseCompensation : expansion) +
collapsing
)
}
})()
if (diff !== 0) {
// Scroll by amount offset changed to keep it in view
topScrollBoundary.value += diff
bottomScrollBoundary.value += diff
await nextTick()
await scrollBy(0, diff)
}
resumeWatchers()
},
{ flush: 'post' },
)
// Misc
const reset = () => {
unsuspendibleIds.value = new Set()
heights.value = new Map()
}
return {
heightChart: heightChartGrouped,
changeSuspendState,
updateVirtualHeight,
pauseWatchers,
resumeWatchers,
updateBoundaries,
reset,
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sat, Oct 31, 11:42 PM (19 h, 19 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1801909
Default Alt Text
(42 KB)

Event Timeline