Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85830930
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Flag For Later
Award Token
Authored By
Unknown
Size
33 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index 30fba1b6b7..1c94fad3a3 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,335 +1,347 @@
import { get } from 'lodash-es'
import { storeToRefs } from 'pinia'
import {
computed,
onUnmounted,
provide,
ref,
toRefs,
useTemplateRef,
watch,
} from 'vue'
import { useRouter } from 'vue-router'
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.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 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 { useStatusesStore } from 'src/stores/statuses.js'
import { useConversation } from 'src/composables/useConversation.js'
import { useInterfaceSizes } from 'src/composables/useInterfaceSizes.js'
import { useScrollPosition } from 'src/composables/useScrollPosition.js'
import { useTreeConversationTopology } from 'src/composables/useTreeConversationTopology.js'
import { useVirtualScrolling } from 'src/composables/useVirtualScrolling.js'
import { WSConnectionStatus } from 'src/api/websocket.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
type: Boolean,
default: false,
},
},
components: {
ThreadTree,
QuickFilterSettings,
QuickViewSettings,
ChatMessageList,
PostStatusForm,
RichContent,
},
emits: ['heightChange', 'suspendableStateChange'],
setup(props, { emit }) {
const router = useRouter()
const scroller = useScrollPosition()
const tryScrollTo = async (id) => {
if (!id) {
return
}
if (isPage.value) {
router.push({ name: 'conversation', params: { statusId: id } })
}
setFocused(id)
const target = document.querySelector(`.Status[data-status-id="${id}"]`)
return await scroller.scrollIntoView(target, { block: 'nearest' })
}
// # 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 = () => {
expanded.value = !expanded.value
}
provide('isExpanded', isExpanded)
provide('isPage', isPage)
provide('expandable', true)
// # Main things
const { statusId } = toRefs(props)
const {
focusedId,
setFocused,
currentStatus,
mainStatus,
conversation,
replies,
getReplies,
fetchConversation,
loadError,
} = useConversation(statusId, isExpanded)
+ const conversationLite = computed(() => conversation.value.map(({ id }) => ({ id })))
watch(
expanded,
async (value) => {
if (value) {
await fetchConversation()
}
},
{ flush: 'post' },
)
// Component created
if (isPage.value) {
fetchConversation()
}
// # Misc UI things
const firstStatus = computed(() => conversation.value[0])
const lastStatus = computed(
() => conversation.value[conversation.value.legnth - 1],
)
- const getStatusClasses = (status, ancestor) => {
+ const getStatusClasses = (statusId, ancestor) => {
const result = {
- '-first': status.id === firstStatus.value?.id,
- '-last': status.id === lastStatus.value?.id,
+ '-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 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.
const { fontSize } = useInterfaceSizes()
const mutedStatusHeight = computed(() => fontSize.value * 1.5)
const normalStatusHeight = computed(() => fontSize.value * 10)
const getPlaceholderHeight = (id) =>
conversation.value.find((item) => item.id === id)?.muted
? mutedStatusHeight
: normalStatusHeight
const anchorIds = computed(
() => new Set([mainStatus.value?.id, currentStatus.value?.id]),
)
// # Linear style stuff
const isLinearView = computed(() => displayStyle.value !== 'tree')
const linearElement = useTemplateRef('linear')
const linearScrollCompensation = computed(() => isLinearView.value)
const {
heightChart: heightChartLinear,
changeSuspendState: changeSuspendStateLinear,
updateVirtualHeight: updateVirtualHeightLinear,
+ reset: resetLinearScrollVirtualization,
} = useVirtualScrolling({
- list: conversation,
+ context: statusId,
+ list: conversationLite,
body: linearElement,
scrollPositionInstance: scroller,
scrollCompensation: linearScrollCompensation,
anchorIds,
+ collapseMode: 'item',
getPlaceholderHeight,
})
const changeSuspendStateLinearLocal = (e) => {
changeSuspendStateLinear(e)
const { id, suspend } = e
if (suspend) {
unsuspendableIds.value.add(id)
} else {
unsuspendableIds.value.delete(id)
}
}
// # Tree style stuff
const isTreeView = computed(() => displayStyle.value === 'tree')
const {
topLevel,
currentAncestors,
threadDisplay,
showThreadRecursively,
resetThreadDisplay,
} = useTreeConversationTopology(conversation, replies, focusedId)
provide('threadDisplay', threadDisplay)
watch(
isExpanded,
(value) => {
if (!value) resetThreadDisplay()
},
{ flush: 'post' },
)
+ const currentAncestorsLite = computed(() => currentAncestors.value.map(({ id }) => ({ id })))
const ancestorsElement = useTemplateRef('ancestors')
const treeScrollCompensation = computed(() => isTreeView.value)
const {
heightChart: heightChartAncestors,
changeSuspendState: changeSuspendStateAncestors,
updateVirtualHeight: updateVirtualHeightAncestors,
+ reset: resetTreeScrollVirtualization,
} = useVirtualScrolling({
- list: currentAncestors,
+ context: statusId,
+ list: currentAncestorsLite,
body: ancestorsElement,
scrollPositionInstance: scroller,
scrollCompensation: treeScrollCompensation,
collapseMode: 'height',
getPlaceholderHeight,
})
const changeSuspendStateAncestorsLocal = (e) => {
changeSuspendStateAncestors(e)
const { id, suspend } = e
if (suspend) {
unsuspendableIds.value.add(id)
} else {
unsuspendableIds.value.delete(id)
}
}
const changeSuspendStateCurrentLevelLocal = (e) => {
changeSuspendStateLinear(e)
const { id, suspend } = e
if (suspend) {
unsuspendableIds.value.add(id)
} else {
unsuspendableIds.value.delete(id)
}
}
+ watch(statusId, (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 diveIntoStatus = (id) => tryScrollTo(id)
const diveToTopLevel = () => tryScrollTo(currentAncestors.value[0].id)
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,
// ### Derived values and config
treeViewIsSimple,
shouldShowAllConversationButton,
shouldShowAncestors,
// # Scrolling
diveToTopLevel,
diveIntoStatus,
}
},
}
diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue
index 3b5a967064..7344b8bb72 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -1,176 +1,176 @@
<template>
<div
ref="root"
class="Conversation"
:class="{ '-expanded' : isExpanded, '-page': isPage, 'panel' : isExpanded }"
>
<div
v-if="isExpanded"
class="panel-heading conversation-heading -sticky"
>
<h1 class="title">
<RichContent
v-if="conversation[0]?.summary_raw_html"
:html="conversation[0].summary_raw_html"
:emoji="conversation[0].emojis"
/>
<template v-else>
{{ $t('timeline.conversation') }}
</template>
</h1>
<button
v-if="!isPage"
class="button-unstyled -link"
@click.prevent="toggleExpanded"
>
{{ $t('timeline.collapse') }}
</button>
<QuickFilterSettings
v-if="isPage && mobileLayout"
:conversation="true"
class="rightside-button"
/>
<QuickViewSettings
v-if="isPage"
:conversation="true"
class="rightside-button"
/>
</div>
<div
v-if="isPage && !currentStatus"
class="conversation-body"
ref="body"
:class="{ 'panel-body': isExpanded }"
>
<p v-if="!loadError">
<FAIcon
spin
icon="circle-notch"
/>
{{ $t('status.loading') }}
</p>
<p v-else>
{{ $t('status.load_error', { error: loadError }) }}
</p>
</div>
<div
v-else
class="conversation-body"
ref="body"
:class="{ 'panel-body': isExpanded }"
>
<div
v-if="isTreeView"
class="thread-body"
>
<div
v-if="shouldShowAllConversationButton"
class="conversation-dive-to-top-level-box"
>
<i18n-t
keypath="currentStatus.show_all_conversation_with_icon"
tag="button"
class="button-unstyled -link"
scope="global"
@click.prevent="diveToTopLevel"
>
<template #icon>
<FAIcon
icon="angle-double-left"
/>
</template>
<template #text>
<span>
{{ $t('status.show_all_conversation', { numStatus: topLevel.length - 1 }, topLevel.length - 1) }}
</span>
</template>
</i18n-t>
</div>
<div
v-if="shouldShowAncestors"
ref="ancestors"
class="thread-ancestors"
>
<template
v-for="element in heightChartAncestors"
:key="element.id"
>
<Status
v-if="element.type === 'item'"
class="conversation-status panel-body"
- :class="getStatusClasses(element.item, true)"
+ :class="getStatusClasses(element.id, true)"
- :status-id="element.item.id"
- :replies="getReplies(element.item.id)"
+ :status-id="element.id"
+ :replies="getReplies(element.id)"
- :focused="focusedId === element.item.id"
+ :focused="focusedId === element.id"
conversation-rank="ancestor"
:data-status-id="element.id"
:data-vs-height="element.height"
:data-vs-top="element.top"
@goto="setFocused"
- @dive="diveIntoStatus(element.item.id)"
+ @dive="diveIntoStatus(element.id)"
@suspendable-state-change="changeSuspendStateAncestorsLocal"
@height-change="updateVirtualHeightAncestors"
/>
<div
v-if="element.type === 'spacer'"
class="virtual-spacer"
:style="{ height: element.height + 'px' }"
aria-hidden="true"
/>
</template>
</div>
<ThreadTree
:status-id="currentStatus.id"
:depth="0"
@goto="setFocused"
@dive="diveIntoStatus"
@toggle-expanded="toggleExpanded"
@show-thread-recursively="showThreadRecursively"
@suspendable-state-change="changeSuspendStateCurrentLevelLocal"
/>
</div>
<div
v-else-if="isLinearView"
ref="linear"
class="thread-body"
>
<template
v-for="element in heightChartLinear"
:key="element.id"
>
<Status
v-if="element.type === 'item'"
class="panel-body conversation-status"
- :class="getStatusClasses(element.item)"
- :status-id="element.item.id"
- :replies="getReplies(element.item.id)"
+ :class="getStatusClasses(element.id)"
+ :status-id="element.id"
+ :replies="getReplies(element.id)"
- :focused="focusedId === element.item.id || focusedId === element.item.retweeted_status?.id"
+ :focused="focusedId === element.id"
:data-status-id="element.id"
:data-vs-height="element.height"
:data-vs-top="element.top"
@goto="setFocused"
@toggle-expanded="toggleExpanded"
@suspendable-state-change="changeSuspendStateLinearLocal"
@height-change="updateVirtualHeightLinear"
/>
<div
v-if="element.type === 'spacer'"
aria-hidden="true"
class="panel-body virtual-spacer"
:style="{ height: element.height + 'px' }"
/>
</template>
</div>
</div>
</div>
</template>
<script src="./conversation.js"></script>
<style src="./conversation.scss" />
diff --git a/src/composables/useConversation.js b/src/composables/useConversation.js
index d449e0798a..1251cb0424 100644
--- a/src/composables/useConversation.js
+++ b/src/composables/useConversation.js
@@ -1,175 +1,165 @@
import { get } from 'lodash-es'
import { storeToRefs } from 'pinia'
import { computed, provide, ref, watch } from 'vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useStreamingStore } from 'src/stores/streaming.js'
import { useMainStatus } from 'src/composables/useMainStatus.js'
import {
fetchConversation as apiFetchConversation,
fetchStatus as apiFetchStatus,
} from 'src/api/public.js'
export function useConversation(statusId, expanded) {
const loadError = ref(null)
const { status: currentStatus, mainStatus } = useMainStatus(statusId)
// # Config
const { mergedConfig } = storeToRefs(useMergedConfigStore())
const { mastoUserSocketStatus } = storeToRefs(useStreamingStore())
const streamingEnabled = computed(
() =>
mergedConfig.value.useStreamingApi &&
mastoUserSocketStatus === WSConnectionStatus.JOINED,
)
// # Focus
const focusedId = ref(null)
const { mainStatus: focusedStatus } = useMainStatus(focusedId)
const setFocused = (id) => {
focusedId.value = id
- console.log('SF', id)
}
provide('focusedId', focusedId)
watch(mainStatus, (newStatus, oldStatus) => {
- setFocused(newStatus.id)
- const newConversationId = newStatus?.statusnet_conversation_id
- const oldConversationId = oldStatus?.statusnet_conversation_id
- if (
- newConversationId &&
- oldConversationId &&
- newConversationId === oldConversationId
- ) {
- } else {
- // resetDisplayState()
- // fetchConversation()
- }
+ if (newStatus) setFocused(newStatus.id)
+ fetchConversation()
})
+
watch(expanded, (value) => {
setFocused(value ? statusId.value : null)
}, { immediate: true })
watch(
focusedStatus,
(newVal, oldVal) => {
if (!newVal) return
if (newVal?.id === oldVal?.id) return // prevents infinite loop
if (!streamingEnabled.value) {
useStatusesStore().fetchStatus(newVal.id)
}
useStatusesStore().fetchFavsAndRepeats(newVal.id)
useStatusesStore().fetchEmojiReactions(newVal.id)
},
{ immediate: true },
)
const sortById = (a, b) => {
const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
const idB = b.type === 'retweet' ? b.retweeted_status.id : b.id
const seqA = Number(idA)
const seqB = Number(idB)
const isSeqA = !Number.isNaN(seqA)
const isSeqB = !Number.isNaN(seqB)
if (isSeqA && isSeqB) {
return seqA < seqB ? -1 : 1
} else if (isSeqA && !isSeqB) {
return -1
} else if (!isSeqA && isSeqB) {
return 1
} else {
return idA < idB ? -1 : 1
}
}
const conversationId = computed(
() => mainStatus.value.statusnet_conversation_id,
)
const conversation = computed(() => {
if (!currentStatus.value) {
return []
}
if (!expanded.value) {
return [currentStatus.value]
}
const conversation = useStatusesStore().conversations.get(
conversationId.value,
)
return [...conversation.keys()]
.map((k) => useStatusesStore().allStatuses.get(k))
.filter((status) => status.type != 'repeat') // Old backend behavior?
.toSorted(sortById)
})
const replies = computed(() =>
conversation.value.reduce(
(result, { id, in_reply_to_status_id: irid }, index) => {
if (irid) {
if (!result.has(irid)) {
result.set(irid, new Set())
}
result.get(irid).add({
name: `#${index}`,
id,
})
}
return result
},
new Map(),
),
)
const getReplies = (id) => replies.value.get(id) ?? new Set()
provide('conversation', conversation)
provide('replies', replies)
const fetchConversation = async () => {
if (currentStatus.value) {
const {
data: { ancestors, descendants },
timestamp,
} = await apiFetchConversation({
id: statusId.value,
credentials: useOAuthStore().token,
})
useStatusesStore().addNewStatuses({ statuses: ancestors, timestamp })
useStatusesStore().addNewStatuses({
statuses: descendants,
timestamp,
})
} else {
try {
loadError.value = null
const { data: status } = await apiFetchStatus({
id: statusId.value,
credentials: useOAuthStore().token,
})
useStatusesStore().addNewStatuses({ statuses: [status] })
fetchConversation()
} catch (error) {
console.error(error)
loadError.value = error
}
}
}
return {
focusedId,
setFocused,
currentStatus,
mainStatus,
conversation,
replies,
getReplies,
fetchConversation,
loadError,
}
}
diff --git a/src/composables/useVirtualScrolling.js b/src/composables/useVirtualScrolling.js
index d466fc059e..864b0cf7b0 100644
--- a/src/composables/useVirtualScrolling.js
+++ b/src/composables/useVirtualScrolling.js
@@ -1,245 +1,256 @@
-import { last } from 'lodash-es'
+import { last, first } from 'lodash-es'
import { computed, nextTick, ref, toValue, watch } from 'vue'
import { useWindowSize } from 'src/composables/useWindowSize.js'
export function useVirtualScrolling({
// List of items
list,
// Container of items, used for measuring scroll position
body,
// 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
// set to 'positive' to only compensate for positive increase (useful when
// combined with infinite scroll)
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,
// Anchor. Set of IDs of element relative to which do scroll compensation
anchorIds,
// 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, suspend }) => {
if (!suspend) {
unsuspendibleIds.value.add(id)
} else {
unsuspendibleIds.value.delete(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)
- return { id, height, suspendable, item }
+ const real = heights.value.has(id)
+ return { id, height, suspendable, real }
})
// 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)
}
- // ## Scroll compensation
const { y: scrollY, scrollBy } = scrollPositionInstance
- watch(heightChart, async (newVal, oldVal) => {
- if (!toValue(scrollCompensation)) return
- if (newVal.length === 0 && oldVal.length === 0) return
- pauseWatchers()
-
- const expansion = oldVal.length === 0 && newVal.length !== 0
- const collapse = oldVal.length !== 0 && newVal.length === 0
-
- const diff = (() => {
- if (expansion || collapse) {
- if (toValue(collapseMode) === 'height') {
- const oldBottomElement = last(oldVal)
- const newBottomElement = last(newVal)
-
- if (expansion) {
- return newBottomElement.top + newBottomElement.height
- } else if (collapse) {
- return 0 - oldBottomElement.top - oldBottomElement.height
- }
- }
- return 0
- } else if (toValue(anchorIds) != null) {
- const anchorOld = oldVal.find(({ id }) => toValue(anchorIds).has(id))
- const anchorNew = newVal.find(({ id }) => toValue(anchorIds).has(id))
- if (anchorOld == null) {
- throw new Error('Anchor not found!')
- }
-
- const disappeared = anchorOld != null && anchorNew == null
- if (disappeared) {
- throw new Error('Anchor disappeared!')
- }
-
- return anchorNew.top - anchorOld.top
- } else {
- return 0
- }
- })()
-
- console.log(diff)
- 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()
- })
-
const { height: windowHeight } = useWindowSize()
// Real scroll boundary, relative to body's bounds
const topScrollBoundary = ref(0)
const bottomScrollBoundary = 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
// 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(windowHeight, 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()
}
// # Visiblity
// Add buffer zone to boundary, equal to approx 3 items heights
const bufferZone = computed(
() => getPlaceholderHeight().value * (toValue(buffer) ?? 3),
)
- const heightChartGrouped = computed(() => {
- // Determine visibility state
- const chart = heightChart.value.map((heightChartItem) => {
- const itemTopBoundary = heightChartItem.top
- const itemBottomBoundary = heightChartItem.top + heightChartItem.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 {
- ...heightChartItem,
- visible: isBelowTopBoundary && isAboveBottomBoundary,
+ 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),
+ }))
+ )
+
+ // ## Scroll compensation
+ watch(
+ heightChart,
+ async (newVal, oldVal) => {
+ if (!toValue(scrollCompensation)) return
+ if (newVal.length === 0 && oldVal.length === 0) return
+
+ const diff = (() => {
+ const expansion = oldVal.length === 0 && newVal.length !== 0
+ const collapse = oldVal.length !== 0 && newVal.length === 0
+
+ if (expansion) {
+ if (toValue(collapseMode) === 'height') {
+ const newBottomElement = last(newVal)
+
+ return newBottomElement.top + newBottomElement.height
+ } else if (toValue(collapseMode) === 'item') {
+ const element = newVal.find(({ id }) => toValue(anchorIds).has(id))
+
+ return element.top
+ } else {
+ return 0
+ }
+ } else if (collapse) {
+ const oldBottomElement = last(oldVal)
+
+ return 0 - oldBottomElement.top - oldBottomElement.height
+ } else {
+ console.log('COMPENSATE', oldVal, newVal, topScrollBoundary.value)
+ const oldVisible = oldVal.filter((item) => checkVisible(item))
+ const oldItem = first(oldVisible)
+ const oldItemUpdated = newVal.find(({ id }) => id === oldItem.id)
+ console.log('OLD', oldItem, oldItemUpdated)
+ if (!oldItemUpdated) return 0 // context change?
+ return oldItemUpdated.top - oldItem.top - (oldItem.height - oldItemUpdated.height)
+ }
+ })()
+
+ if (diff !== 0) {
+ console.log('DIFF', diff)
+ // 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' }
+ )
+
+ const heightChartGrouped = computed(() =>
// Group invisible items into spacers
- return chart.reduce((acc, heightChartItem) => {
- const { suspendable, visible, height, top, bottom, id, item } =
+ heightChartVisibility.value.reduce((acc, heightChartItem) => {
+ const { suspendable, visible, height, top, bottom, id } =
heightChartItem
// Bottom value isn't really used otherwise for debugging
const present = visible || !suspendable
if (present) {
- return [...acc, { type: 'item', height, top, bottom, id, item }]
+ return [...acc, { type: 'item', height, top, bottom, 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,
bottom: Number.POSITIVE_INFINITY,
height: 0,
ids: new Set(),
}
spacer.ids.add(id)
spacer.id = [...spacer.ids].join() // used for v-for key attribute
spacer.height += height
if (top < spacer.top) spacer.top = top
if (bottom < spacer.bottom) spacer.bottom = bottom
// If we used previous item there is no need to push it to array
if (usingPreviousItem) {
return acc
} else {
return [...acc, spacer]
}
}
}, [])
- })
+ )
+
+ const reset = async () => {
+ unsuspendibleIds.value = new Set()
+ heights.value = new Map()
+ }
return {
heightChart: heightChartGrouped,
changeSuspendState,
updateVirtualHeight,
pauseWatchers,
resumeWatchers,
updateBoundaries,
+ reset,
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sun, Nov 1, 7:42 AM (4 h, 25 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1802817
Default Alt Text
(33 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline