Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85832000
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Flag For Later
Award Token
Authored By
Unknown
Size
35 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/components/chat_message_list/chat_message_list.js b/src/components/chat_message_list/chat_message_list.js
index 4e88cb69da..7a3258dbb5 100644
--- a/src/components/chat_message_list/chat_message_list.js
+++ b/src/components/chat_message_list/chat_message_list.js
@@ -1,160 +1,153 @@
-import { toRefs, ref, computed, useTemplateRef, inject } from 'vue'
-import { useVirtualScrolling } from 'src/composables/useVirtualScrolling.js'
-import { useInterfaceSizes } from 'src/composables/useInterfaceSizes.js'
+import { computed, inject, ref, toRefs, useTemplateRef } from 'vue'
import ChatMessage from 'src/components/chat_message/chat_message.vue'
+import { useInterfaceSizes } from 'src/composables/useInterfaceSizes.js'
+import { useVirtualScrolling } from 'src/composables/useVirtualScrolling.js'
+
const ChatMessageList = {
components: {
ChatMessage,
},
props: {
messages: Array,
pendingMessages: {
type: Array,
required: false,
default: [],
},
headerDate: Boolean,
focusedId: String,
repliedId: String,
},
emits: ['replyRequested'],
setup(props, { emit }) {
- const {
- messages,
- pendingMessages,
- headerDate,
- } = toRefs(props)
+ const { messages, pendingMessages, headerDate } = toRefs(props)
const hoveredMessageChainId = ref(null)
const chatItems = computed(() => {
const allMessages = [
...messages.value,
...pendingMessages.value.map((m) => ({ ...m, pending: true })),
]
return allMessages
.reduceRight((acc, message, index) => {
const date = new Date(message.created_at)
const olderMessage = allMessages[index - 1]
const newerItem = acc.at(-1)
const diff = olderMessage
? message.created_at - olderMessage.created_at
: null
const MAX_DIFF = 1000 * 60 * 5 // 5 minutes
const dateDiffs = (() => {
if (olderMessage) {
const newerDate = new Date(message.created_at)
const olderDate = new Date(olderMessage.created_at)
newerDate.setHours(0, 0, 0, 0)
olderDate.setHours(0, 0, 0, 0)
return newerDate.toISOString() !== olderDate.toISOString()
} else {
return true
}
})()
const chatItem = {
type: 'message',
data: message,
date,
id: message.id,
isTail: true,
isHead: true,
olderMessage,
}
if (newerItem == null) {
chatItem.messageChainId = message.id
} else if (newerItem.type === 'date') {
chatItem.messageChainId = message.id
} else if (newerItem.type === 'message') {
const newerUser =
newerItem.data.account_id || newerItem.data.user.id
const olderUser = message.account_id || message.user.id
if (newerUser !== olderUser) {
chatItem.messageChainId = message.id
} else {
chatItem.messageChainId = newerItem.messageChainId
chatItem.isTail = false
newerItem.isHead = false
}
}
if (diff > MAX_DIFF || (!olderMessage && headerDate.value)) {
return [
...acc,
chatItem,
{
type: 'date',
date,
isDate: dateDiffs,
isTime: diff > MAX_DIFF && !dateDiffs,
id: date.getTime().toString(),
},
]
} else {
return [...acc, chatItem]
}
}, [])
.reverse()
})
const chatItemsIndex = computed(() =>
chatItems.value.reduce((map, value) => {
map.set(value.id, value)
return map
- }, new Map())
+ }, new Map()),
)
const getCurrentItem = (id) => chatItemsIndex.value.get(id)
const scroller = inject('bodyScrollPosition')
const body = useTemplateRef('body')
- const offset = computed(() => 0)
const { fontSize } = useInterfaceSizes()
const normalStatusHeight = computed(() => fontSize.value * 5)
const getPlaceholderHeight = (id) => normalStatusHeight
- const { heightChart, changeSuspendState, updateVirtualHeight, scrollTo } =
+ const { heightChart, changeSuspendState, updateVirtualHeight } =
useVirtualScrolling({
name: 'ChatMessageList',
enabled: ref(true),
list: chatItems,
body,
scrollPositionInstance: scroller,
scrollCompensation: true,
getPlaceholderHeight,
invertDirection: true,
})
- const {
- focusedId,
- repliedId,
- } = toRefs(props)
+ const { focusedId, repliedId } = toRefs(props)
const onMessageHover = ({ isHovered, messageChainId }) => {
hoveredMessageChainId.value = isHovered ? messageChainId : undefined
}
const onReplyRequested = (message) => emit('replyRequested', message)
return {
heightChart,
updateVirtualHeight,
getCurrentItem,
hoveredMessageChainId,
focusedId,
repliedId,
onReplyRequested,
onMessageHover,
}
},
}
export default ChatMessageList
diff --git a/src/components/chat_view/chat_view.js b/src/components/chat_view/chat_view.js
index 2a0682bc73..9ac079b571 100644
--- a/src/components/chat_view/chat_view.js
+++ b/src/components/chat_view/chat_view.js
@@ -1,244 +1,253 @@
import { storeToRefs } from 'pinia'
import {
computed,
inject,
- nextTick,
+ onUnmounted,
provide,
ref,
toRefs,
useTemplateRef,
- onUnmounted,
watch,
} from 'vue'
-import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
+import { useRouter } from 'vue-router'
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
import ChatTitle from 'src/components/chat_title/chat_title.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useClientRectSize } from 'src/composables/useClientRectSize.js'
import { useConversation } from 'src/composables/useConversation.js'
import { usePleromaChat } from 'src/composables/usePleromaChat.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronDown, faChevronLeft } from '@fortawesome/free-solid-svg-icons'
library.add(faChevronDown, faChevronLeft)
const MARK_AS_READ_DELAY = 1500
const Chat = {
components: {
ChatMessageList,
ChatTitle,
PostStatusForm,
},
props: {
statusId: {
type: String,
default: null,
},
chatUserId: {
type: String,
default: null,
},
},
setup(props) {
const { statusId, chatUserId } = toRefs(props)
const isConversation = computed(() => !!statusId.value)
const isChat = computed(() => !!chatUserId.value)
const isValid = computed(() => !!isChat.value !== !!isConversation.value) // Logical XOR
watch(isValid, (value) => {
if (!value)
throw TypeError(
'Chat view must have either statusId or chatUserId, but not both',
)
})
// # Conversation stuff
const {
conversation: conversationMessages,
loadError: conversationError,
focusedId,
setFocused,
fetchConversation,
} = useConversation(statusId, ref(true))
- watch(isConversation, (value) => {
- if (!value) return
- fetchConversation()
- }, { immediate: true })
+ watch(
+ isConversation,
+ (value) => {
+ if (!value) return
+ fetchConversation()
+ },
+ { immediate: true },
+ )
// # Chat stuff
const {
activate: chatActivate,
deactivate: chatDeactivate,
markAsRead: chatMarkRead,
fetchOlder: chatFetchOlder,
messages: chatMessages,
pendingMessages: chatPendingMessages,
newMessagesCount: chatNewMessagesCount,
fetchError: chatError,
ready: chatReady,
recipient: chatRecipient,
sendMessage,
deleteChatMessage,
} = usePleromaChat(chatUserId)
provide('deleteChatMessage', deleteChatMessage)
- watch(chatUserId, (neu, old) => {
- if (old) {
- chatDeactivate()
- }
- if (neu) {
- chatActivate()
- }
- }, { immediate: true })
- onUnmounted(() => { chatDeactivate() })
+ watch(
+ chatUserId,
+ (neu, old) => {
+ if (old) {
+ chatDeactivate()
+ }
+ if (neu) {
+ chatActivate()
+ }
+ },
+ { immediate: true },
+ )
+ onUnmounted(() => {
+ chatDeactivate()
+ })
// # Forks
const messages = computed(() => {
if (isConversation.value) {
return conversationMessages.value
} else {
return chatMessages.value
}
})
const pendingMessages = computed(() => {
if (isConversation.value) {
return [] // Not implemented yet
} else {
return chatPendingMessages.value
}
})
const newMessagesCount = computed(() => {
if (isConversation.value) {
return 0 // Not implemented yet
} else {
return chatNewMessagesCount.value
}
})
const error = computed(() => {
if (isConversation.value) {
return conversationError.value
} else {
return chatError.value
}
})
const fetchOlder = () => {
if (isChat.value) {
chatFetchOlder()
}
// Unsupported in conversations
}
const markAsRead = () => {
if (isChat.value) {
chatMarkRead()
}
// Unsupported in conversations
}
// Reply-to
const explicitReply = ref(null)
const lastMessage = computed(() => messages.value?.at(-1))
const replyTo = computed(() => explicitReply.value ?? lastMessage.value)
const scroller = inject('bodyScrollPosition')
const rootElement = useTemplateRef('root')
const postForm = useTemplateRef('postStatusForm')
// Post form stuff
watch(replyTo, () => {
if (isConversation.value) postForm.value.update()
})
// # Scroll stuff
const { hasReachedBottom } = scroller
const postFormElement = computed(() => postForm.value?.$el)
const { height: postFormHeight } = useClientRectSize(postFormElement)
const { scrollBy } = scroller
// ### Post form size compensation
watch(postFormHeight, (neu, old) => {
scrollBy(0, neu - old)
})
// ## Load / Read
const { shouldLoadTop } = scroller
watch(shouldLoadTop, (value) => {
if (value) fetchOlder()
})
watch(hasReachedBottom, (value) => {
if (!value) return
window.setTimeout(() => {
if (
newMessagesCount.value > 0 &&
rootElement.value &&
hasReachedBottom.value
) {
markAsRead()
}
}, MARK_AS_READ_DELAY)
})
// Misc UI stuff
const { layoutType } = storeToRefs(useInterfaceStore())
const mobileLayout = computed(() => layoutType.value === 'mobile')
const jumpToBottomButtonVisible = computed(() => !hasReachedBottom.value)
const router = useRouter()
const { t } = useI18n()
const formPlaceholder = computed(() => {
if (chatRecipient) {
return t('chats.message_user', {
nickname: chatRecipient.screen_name_ui,
})
} else {
return ''
}
})
const onPosted = (data) => {
explicitReply.value = null
if (isConversation.value) {
router.push({
name: 'conversation2',
params: { statusId: data.id },
})
}
}
return {
messages,
pendingMessages,
newMessagesCount,
error,
replyTo,
explicitReply,
// Conversation-exclusive
isConversation,
setFocused,
focusedId,
// Chats-exclusive
isChat,
sendMessage,
chatReady,
chatRecipient,
// Misc
onPosted,
mobileLayout,
jumpToBottomButtonVisible,
formPlaceholder,
}
},
}
export default Chat
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index d843aaac92..0b3d16b4a3 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -1,268 +1,269 @@
import { debounce, throttle } from 'lodash-es'
import { storeToRefs } from 'pinia'
import {
computed,
inject,
+ nextTick,
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 { heightChart, changeSuspendState, updateVirtualHeight } =
useVirtualScrolling({
name: 'Timeline',
enabled: ref(true),
list: filteredVisibleStatuses,
body,
offset,
scrollPositionInstance: scroller,
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 = async () => {
if (timeline.value.reloadNeeded) {
useTimelinesStore().clearTimeline(timelineRef.value.name)
fetchOlderStatuses()
} else {
blockClicksTemporarily()
useTimelinesStore().showNewStatuses(timelineRef.value.name)
}
paused.value = false
await nextTick()
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 16f595a51c..baf58a312f 100644
--- a/src/composables/useVirtualScrolling.js
+++ b/src/composables/useVirtualScrolling.js
@@ -1,397 +1,404 @@
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,
// 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 = 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,
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 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 (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' || 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 = (() => {
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 isSticking = toValue(invertDirection) ? hasReachedBottom.value : hasReachedTop.value
- const compensateBecauseLengthChange = !isSticking &&
- newVal.length !== oldVal.length
+ const isSticking = toValue(invertDirection)
+ ? hasReachedBottom.value
+ : hasReachedTop.value
+ const compensateBecauseLengthChange =
+ !isSticking && newVal.length !== oldVal.length
const oldReal = newVal.filter(({ real }) => real).length
const newReal = oldVal.filter(({ real }) => real).length
- const compensateBecauseReal = oldReal > 0 && newReal > 0 && oldReal !== newReal
+ const compensateBecauseReal =
+ oldReal > 0 && newReal > 0 && oldReal !== newReal
const expansion = (() => {
if (newVal.length < oldVal.length) return 0
- const oldVisible = oldVal.filter(
- (item) => checkVisible(item),
- )
- const oldItem = toValue(invertDirection) ? last(oldVisible) : first(oldVisible)
+ const oldVisible = oldVal.filter((item) => checkVisible(item))
+ const oldItem = toValue(invertDirection)
+ ? last(oldVisible)
+ : first(oldVisible)
if (!oldItem) return 0 // probably out of bounds in timeline
const oldItemUpdated = newVal.find(({ id }) => id === oldItem.id)
if (
compensation ||
compensateBecauseReal ||
compensateBecauseLengthChange
) {
- const oldItemUpdatedBottom = oldItemUpdated.top + oldItemUpdated.height
+ const oldItemUpdatedBottom =
+ oldItemUpdated.top + oldItemUpdated.height
const oldItemBottom = oldItem.top + oldItem.height
if (toValue(invertDirection)) {
return oldItemBottom - oldItemUpdatedBottom
} else {
return oldItemUpdatedBottom - oldItemBottom
}
} else {
return 0
}
})()
const collapsing = (() => {
if (newVal.length >= oldVal.length) return 0
const newVisible = newVal
- const newItem = toValue(invertDirection) ? last(newVisible) : first(newVisible)
+ const newItem = toValue(invertDirection)
+ ? last(newVisible)
+ : first(newVisible)
if (!newItem) return 0 // probably out of bounds in timeline
const newItemBefore = oldVal.find(({ id }) => id === newItem.id)
if (
compensation ||
compensateBecauseReal ||
compensateBecauseLengthChange
) {
const newItemBottom = newItem.top + newItem.height
- const newItemBeforeBottom = newItemBefore.top + newItemBefore.height
+ const newItemBeforeBottom =
+ newItemBefore.top + newItemBefore.height
if (toValue(invertDirection)) {
return newItemBottom - newItemBeforeBottom
} else {
return newItemBeforeBottom - newItemBottom
}
} 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
Details
Attached
Mime Type
text/x-diff
Expires
Mon, Nov 2, 2:03 AM (12 h, 49 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1803499
Default Alt Text
(35 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline