Page MenuHomePhorge

No OneTemporary

Size
22 KB
Referenced Files
None
Subscribers
None
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index d51b794c44..7eec35a5bb 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -1,268 +1,267 @@
import { debounce, throttle } from 'lodash-es'
import { storeToRefs } from 'pinia'
import {
computed,
inject,
onMounted,
onUnmounted,
ref,
toRefs,
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 } = 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 compensate = computed(() => !hasReachedTop.value)
const { heightChart, changeSuspendState, updateVirtualHeight } =
useVirtualScrolling({
name: 'Timeline',
enabled: ref(true),
list: filteredVisibleStatuses,
body,
offset,
scrollPositionInstance: scroller,
- scrollCompensation: compensate, // don't compensate when scrolled to the top
+ scrollCompensation: false,
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()
} else {
paused.value = true
}
})
const showNewStatuses = () => {
if (timeline.value.reloadNeeded) {
useTimelinesStore().clearTimeline(timelineRef.value.name)
fetchOlderStatuses()
} else {
blockClicksTemporarily()
useTimelinesStore().showNewStatuses(timelineRef.value.name)
}
paused.value = false
scroller.scrollTo({ top: 0 })
}
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)
})
// Scroll
watch(shouldLoadBottom, (value) => {
if (!value) return
fetchOlderStatuses()
})
// 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)
return {
timelineRef,
timeline,
filteredVisibleStatuses,
heightChart,
updateVirtualHeight,
changeSuspendState,
count,
showLoadButton,
showNewStatuses,
fetchOlderStatuses,
classes,
loadButtonString,
mobileLoadButtonString,
mobileLayout,
footerSlipgate,
embedded,
hideEmpty,
}
},
}
export default Timeline
diff --git a/src/composables/useVirtualScrolling.js b/src/composables/useVirtualScrolling.js
index 7a37e44829..85ad1cdcca 100644
--- a/src/composables/useVirtualScrolling.js
+++ b/src/composables/useVirtualScrolling.js
@@ -1,313 +1,377 @@
import { first, last } from 'lodash-es'
import { computed, nextTick, 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,
}) {
// # 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 = computed(() => {
// 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)
return chart
})
const updateVirtualHeight = ({ id, height }) => {
heights.value.set(id, height)
}
- const { top: scrollY, scrollBy, vHeight } = scrollPositionInstance
+ const {
+ top: scrollY,
+ scrollBy,
+ vHeight,
+ hasReachedTop,
+ } = 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)
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 3 items heights
const bufferZone = computed(
() => getPlaceholderHeight().value * (toValue(buffer) ?? 3),
)
const checkVisible = ({ 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 = computed(() =>
heightChart.value.map((heightChartItem) => ({
...heightChartItem,
visible: checkVisible(heightChartItem),
})),
)
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
if (present) {
return [...acc, { type: 'item', height, top, id }]
} 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 (!toValue(scrollCompensation)) return
if (newVal.length === 0 && oldVal.length === 0) return
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') {
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 = (() => {
const oldIds = new Set(oldVal.map(({ id }) => id))
const newIds = new Set(newVal.map(({ id }) => id))
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.
+ *
+ * So, basically - if amount of elements changes, or amount of REAL elements
+ * changes - compensate anyway even if compensation is disabled.
+ */
+ const compensateBecauseLengthChange =
+ newVal.length !== oldVal.length && hasReachedTop.value
+ const compensateBecauseReal =
+ newVal.filter(({ real }) => real).length !==
+ oldVal.filter(({ real }) => real).length
+
const expansion = (() => {
if (newVal.length < oldVal.length) return 0
const oldVisible = oldVal.filter(
(item) => checkVisible(item) && item.real,
)
const oldItem = first(oldVisible)
if (!oldItem) return 0 // probably out of bounds in timeline
const oldItemUpdated = newVal.find(({ id }) => id === oldItem.id)
- return (
- oldItemUpdated.top -
- oldItem.top -
- (oldItem.height - oldItemUpdated.height)
- )
+ if (
+ compensation ||
+ compensateBecauseReal ||
+ compensateBecauseLengthChange
+ ) {
+ return (
+ oldItemUpdated.top -
+ oldItem.top -
+ (oldItem.height - oldItemUpdated.height)
+ )
+ } else {
+ return 0
+ }
})()
const collapsing = (() => {
if (newVal.length >= oldVal.length) return 0
const newVisible = newVal
const newItem = first(newVisible)
if (!newItem) return 0 // probably out of bounds in timeline
const newItemBefore = oldVal.find(({ id }) => id === newItem.id)
- return (
- newItem.top -
- newItemBefore.top -
- (newItemBefore.height - newItem.height)
- )
+ if (
+ compensation ||
+ compensateBecauseReal ||
+ compensateBecauseLengthChange
+ ) {
+ return (
+ newItem.top -
+ newItemBefore.top -
+ (newItemBefore.height - newItem.height)
+ )
+ } else {
+ return 0
+ }
})()
return expansion + collapsing
}
})()
if (diff !== 0) {
// Scroll by amount offset changed to keep it in view
topScrollBoundary.value += diff
bottomScrollBoundary.value += diff
await scrollBy(0, diff)
await nextTick()
}
resumeWatchers()
},
{ flush: 'post' },
)
// Misc
const reset = () => {
unsuspendibleIds.value = new Set()
heights.value = new Map()
}
const scrollTo = (anchors) => {
const element = heightChart.value.find(({ id }) => anchors.has(id))
if (!element) {
console.error(`No element with id matching ${[...anchors].join()} found`)
return
}
pauseWatchers()
const elementMiddle = element.top + element.height / 2
const desiredTopBoundary = Math.min(
element.top,
elementMiddle - (vHeight.value - offset.value) / 2,
)
scrollBy(0, desiredTopBoundary - topScrollBoundary.value)
resumeWatchers()
}
return {
heightChart: heightChartGrouped,
changeSuspendState,
updateVirtualHeight,
pauseWatchers,
resumeWatchers,
updateBoundaries,
reset,
scrollTo,
}
}

File Metadata

Mime Type
text/x-diff
Expires
Thu, Oct 8, 9:34 PM (14 h, 7 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1784280
Default Alt Text
(22 KB)

Event Timeline