Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85828525
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Flag For Later
Award Token
Authored By
Unknown
Size
50 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index 3b8bd6b4a7..ee166a578c 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,351 +1,389 @@
import { storeToRefs } from 'pinia'
import {
computed,
onUnmounted,
provide,
ref,
toRefs,
useTemplateRef,
watch,
+ nextTick,
} 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 { 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 { 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'],
+ emits: ['heightChange', 'suspendableStateChange', 'expanded', 'collapsed'],
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' })
- }
+ 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 = () => {
- expanded.value = !expanded.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 { statusId } = toRefs(props)
const {
focusedId,
+ conversationId,
setFocused,
currentStatus,
mainStatus,
conversation,
replies,
getReplies,
fetchConversation,
loadError,
} = useConversation(statusId, isExpanded)
const conversationLite = computed(() =>
conversation.value.map(({ id }) => ({ id })),
)
+ const mainStatusId = computed(() => mainStatus.value.id)
watch(
expanded,
- async (value) => {
+ (value) => {
if (value) {
- await fetchConversation()
+ 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 = (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 updateVirtualHeight = (e) => {
const [entry] = e
+
+ const rootCss = window.getComputedStyle(rootElement.value)
+ const rootMarginString = rootCss.getPropertyValue('margin-top')
+ const rootMargin = Number.parseInt(rootMarginString.slice(0, -2), 10)
+ rootElementMargin.value = rootMargin
+
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())
+ // Internal virtual scrolling
+ const virtualScrollingEnabled = ref(isExpanded.value)
// Placeholder heights.
- const { fontSize } = useInterfaceSizes()
+ const { fontSize, navbarSize, panelHeaderSize } = 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 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 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(
- () => isExpanded.value && isLinearView.value,
+ () => virtualScrollingEnabled.value && isLinearView.value,
)
const {
heightChart: heightChartLinear,
changeSuspendState: changeSuspendStateLinear,
updateVirtualHeight: updateVirtualHeightLinear,
reset: resetLinearScrollVirtualization,
+ scrollTo: linearScrollTo,
} = useVirtualScrolling({
- context: statusId,
+ name: 'Linear',
+ enabled: linearScrollCompensation,
list: conversationLite,
body: linearElement,
+ offset,
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(
- () => isExpanded.value && isTreeView.value,
+ () => virtualScrollingEnabled.value && isTreeView.value,
)
const {
heightChart: heightChartAncestors,
changeSuspendState: changeSuspendStateAncestors,
updateVirtualHeight: updateVirtualHeightAncestors,
reset: resetTreeScrollVirtualization,
+ scrollTo: treeScrollTo,
} = useVirtualScrolling({
- context: statusId,
+ name: 'Ancestors',
+ enabled: treeScrollCompensation,
list: currentAncestorsLite,
body: ancestorsElement,
+ offset,
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) => {
+ 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 diveIntoStatus = (id) => tryScrollTo(id)
- const diveToTopLevel = () => tryScrollTo(currentAncestors.value[0].id)
+ const scrollTo = (ids) => {
+ if (isTreeView.value) {
+ return treeScrollTo(ids)
+ } else {
+ return linearScrollTo(ids)
+ }
+ }
+ const diveIntoStatus = (id) => scrollTo(new Set([id]))
+ const diveToTopLevel = () => scrollTo(new Set([currentAncestors.value[0].id]))
+ watch(focusedId, async (neu) => {
+ if (!isPage.value) return
+ if (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,
// ### 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 7344b8bb72..50f20a5b9e 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -1,176 +1,177 @@
<template>
<div
ref="root"
class="Conversation"
:class="{ '-expanded' : isExpanded, '-page': isPage, 'panel' : isExpanded }"
>
<div
v-if="isExpanded"
+ ref="panelHeader"
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.id, true)"
:status-id="element.id"
:replies="getReplies(element.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.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.id)"
:status-id="element.id"
:replies="getReplies(element.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/components/timeline/timeline.js b/src/components/timeline/timeline.js
index 38376dc81e..77a160672b 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -1,289 +1,305 @@
import { debounce, throttle } from 'lodash-es'
import { storeToRefs } from 'pinia'
import {
computed,
onMounted,
onUnmounted,
ref,
toRefs,
useTemplateRef,
watch,
+ nextTick,
} 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 { useInterfaceSizes } from 'src/composables/useInterfaceSizes.js'
import { useScrollPosition } from 'src/composables/useScrollPosition.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 unfocused = ref(false)
// 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))
})
// Virtual scrolling
- const { fontSize } = useInterfaceSizes()
+ const { fontSize, navbarSize, panelHeaderSize } = 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 { heightChart, changeSuspendState, updateVirtualHeight } =
+ 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: useScrollPosition(),
- scrollCompensation: false,
+ scrollCompensation: ref(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)
watch(newStatusCount, (count) => {
if (!useMergedConfigStore().mergedConfig.streaming) {
return
}
if (count <= 0) return
// only 'stream' them when you're scrolled to the top
const doc = document.documentElement
const top = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0)
if (
top < 15 &&
!paused.value &&
!(
unfocused.value &&
useMergedConfigStore().mergedConfig.pauseOnUnfocused
)
) {
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
}
window.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
const scrollLoad = () => {
// TODO simplify this logic
const bodyBRect = document.body.getBoundingClientRect()
const height = Math.max(bodyBRect.height, -bodyBRect.y)
if (
!timeline.value.fetcher.loadingOlder &&
window.innerHeight + window.pageYOffset >= height - 750
) {
fetchOlderStatuses()
}
}
const handleScroll = throttle((e) => {
scrollLoad(e)
}, 200)
onMounted(() => {
window.addEventListener('scroll', handleScroll)
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
})
// Focused state
const handleVisibilityChange = () => {
unfocused.value = document.hidden
}
onMounted(() => {
if (document.hidden === undefined) return
document.addEventListener(
'visibilitychange',
handleVisibilityChange,
false,
)
unfocused.value = document.hidden
})
onUnmounted(() => {
if (document.hidden === undefined) return
document.removeEventListener(
'visibilitychange',
handleVisibilityChange,
false,
)
})
// 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/useConversation.js b/src/composables/useConversation.js
index f780754885..b4c76e0264 100644
--- a/src/composables/useConversation.js
+++ b/src/composables/useConversation.js
@@ -1,171 +1,180 @@
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'
import { WSConnectionStatus } from 'src/api/websocket.js'
export function useConversation(statusId, expanded) {
const loadError = ref(null)
const { status: currentStatus, mainStatus } = useMainStatus(statusId)
+ const mainStatusId = computed(() => mainStatus.value?.id)
// # 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 focused = ref(null)
+ const { mainStatus: focusedStatus } = useMainStatus(focused)
const setFocused = (id) => {
- focusedId.value = id
+ focused.value = id
}
+ watch(mainStatusId, (val) => setFocused(val))
+ const focusedId = computed(() => expanded.value ? focusedStatus.value?.id : null)
provide('focusedId', focusedId)
- watch(mainStatus, (newStatus, oldStatus) => {
- if (newStatus) setFocused(newStatus.id)
- if (newStatus.id !== oldStatus.id) {
+ watch(statusId, (neu, old) => {
+ console.log('focused', neu)
+ if (neu) setFocused(neu)
+ console.log('focusedid', focusedId.value)
+ }, { immediate: true })
+
+ watch(statusId, (neu, old) => {
+ if (neu !== old) {
fetchConversation()
}
})
watch(
expanded,
(value) => {
- setFocused(value ? statusId.value : null)
+ setFocused(value ? mainStatusId.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,
+ () => 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,
+ conversationId,
setFocused,
currentStatus,
mainStatus,
conversation,
replies,
getReplies,
fetchConversation,
loadError,
}
}
diff --git a/src/composables/useInterfaceSizes.js b/src/composables/useInterfaceSizes.js
index 7404d6cd56..baf5679137 100644
--- a/src/composables/useInterfaceSizes.js
+++ b/src/composables/useInterfaceSizes.js
@@ -1,45 +1,43 @@
import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
export function useInterfaceSizes() {
const { mergedConfig } = storeToRefs(useMergedConfigStore())
// Getting the actual font size in pixels since UI might have
// a different scale
const fontSizeSetting = computed(() => mergedConfig.value.textSize)
const fontSize = ref(0)
const updateFontSize = () => {
const string = window
.getComputedStyle(document.body)
.getPropertyValue('font-size')
fontSize.value = Number.parseInt(string.slice(0, -2), 10) // remove the 'px'
}
// Update font size if user changed UI scale
watch(fontSizeSetting, updateFontSize, { immediate: true })
const navbarSize = computed(() => {
const string =
- fontSize.value *
window.getComputedStyle(document.body).getPropertyValue('--navbarSize')
return fontSize.value * Number.parseInt(string.slice(0, -3), 10) // remove the 'rem'
})
const panelHeaderSize = computed(() => {
const string =
- fontSize.value *
window
.getComputedStyle(document.body)
.getPropertyValue('--panelHeaderSize')
return fontSize.value * Number.parseInt(string.slice(0, -3), 10) // remove the 'rem'
})
return {
fontSize,
navbarSize,
panelHeaderSize,
}
}
diff --git a/src/composables/useMainStatus.js b/src/composables/useMainStatus.js
index be9e582251..d778d3df7c 100644
--- a/src/composables/useMainStatus.js
+++ b/src/composables/useMainStatus.js
@@ -1,24 +1,26 @@
import { computed, toValue } from 'vue'
+import { storeToRefs } from 'pinia'
import { useStatusesStore } from 'src/stores/statuses.js'
export function useMainStatus(statusId) {
- const getStatusObject = (id) => useStatusesStore().allStatuses.get(id)
+ const statusesStore = storeToRefs(useStatusesStore())
+ const getStatusObject = (id) => statusesStore.allStatuses.value.get(id)
- const status = computed(() => getStatusObject(toValue(statusId)))
+ const status = computed(() => getStatusObject(statusId.value))
const mainStatus = computed(() => {
- if (!status.value) return
+ if (!status.value) return null
const retweetedStatusId = status.value.retweeted_status?.id
if (retweetedStatusId) {
return getStatusObject(retweetedStatusId)
} else {
return status.value
}
})
return {
status,
mainStatus,
}
}
diff --git a/src/composables/useScrollPosition.js b/src/composables/useScrollPosition.js
index d32f6a261e..e0613d9fb8 100644
--- a/src/composables/useScrollPosition.js
+++ b/src/composables/useScrollPosition.js
@@ -1,49 +1,50 @@
import { onMounted, onUnmounted, ref } from 'vue'
import { useWindowSize } from 'src/composables/useWindowSize.js'
export function useScrollPosition() {
const x = ref(0)
const y = ref(0)
const inProgress = ref(false)
const update = (e) => {
x.value = window.scrollX
y.value = window.scrollY
}
onMounted(() => {
window.addEventListener('scroll', update)
update()
})
onUnmounted(() => {
window.removeEventListener('scroll', update)
})
- const scrollBy = async (x1, y1, options) => {
+ const scrollBy = async (...args) => {
inProgress.value = true
- await window.scrollBy(x1, y1, options)
+ await window.scrollBy(...args)
inProgress.value = false
}
const scrollIntoView = async (element, options) => {
if (element == null) throw new TypeError(`Element is ${element}!`)
inProgress.value = true
if (!element.scrollIntoViewIfNeeded) {
const { height: windowHeight } = useWindowSize()
const { top, height } = element.getBoundingClientRect()
const bottom = top + height
const biggerThanScreen = height > windowHeight
const aboveTop = top < 0
const belowBottom = bottom > windowHeight.value
if (aboveTop || belowBottom || biggerThanScreen) {
await element.scrollIntoView(options)
}
+ } else {
+ await element.scrollIntoViewIfNeeded(options)
}
- await element.scrollIntoViewIfNeeded(options)
inProgress.value = false
}
return { x, y, scrollBy, scrollIntoView, inProgress }
}
diff --git a/src/composables/useVirtualScrolling.js b/src/composables/useVirtualScrolling.js
index af80b274ea..16b03efa9a 100644
--- a/src/composables/useVirtualScrolling.js
+++ b/src/composables/useVirtualScrolling.js
@@ -1,258 +1,316 @@
import { first, last } from 'lodash-es'
import { computed, nextTick, ref, toValue, watch } from 'vue'
import { useWindowSize } from 'src/composables/useWindowSize.js'
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
- // 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)
const real = heights.value.has(id)
- return { id, height, suspendable, real }
+ 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 { y: scrollY, scrollBy } = scrollPositionInstance
const { height: windowHeight } = useWindowSize()
// 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
+ const distanceItemTopToWindowTop = 0 - top + offset.value
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()
}
+ 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) => {
+ 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 }]
+ } 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]
+ }
+ }
+ }, [])
+ })
// ## Scroll compensation
watch(
heightChart,
async (newVal, oldVal) => {
if (!toValue(scrollCompensation)) return
if (newVal.length === 0 && oldVal.length === 0) return
+ const expansion = oldVal.length === 0 && newVal.length !== 0
+ const collapse = oldVal.length !== 0 && newVal.length === 0
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 {
- const oldVisible = oldVal.filter((item) => checkVisible(item))
- const oldItem = first(oldVisible)
- if (!oldItem) return 0 // probably out of bounds in timeline
- const oldItemUpdated = newVal.find(({ id }) => id === oldItem.id)
- if (!oldItemUpdated) return 0 // context change?
- return (
- oldItemUpdated.top -
- oldItem.top -
- (oldItem.height - oldItemUpdated.height)
- )
+ 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
+
+ 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)
+ )
+ })()
+
+ 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)
+ )
+ })()
+
+ 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' },
)
- const heightChartGrouped = computed(() =>
- // Group invisible items into spacers
- 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 }]
- } 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(),
- }
+ // Misc
+ const reset = async () => {
+ unsuspendibleIds.value = new Set()
+ heights.value = new Map()
+ }
- spacer.ids.add(id)
- spacer.id = [...spacer.ids].join() // used for v-for key attribute
- spacer.height += height
+ const scrollTo = (anchors) => {
+ pauseWatchers()
- if (top < spacer.top) spacer.top = top
- if (bottom < spacer.bottom) spacer.bottom = bottom
+ const element = heightChart.value.find(({ id }) => anchors.has(id))
+ const elementMiddle = element.top + element.height / 2
+ const desiredTopBoundary = Math.min(element.top, elementMiddle - (windowHeight.value - offset.value) / 2)
- // If we used previous item there is no need to push it to array
- if (usingPreviousItem) {
- return acc
- } else {
- return [...acc, spacer]
- }
- }
- }, []),
- )
+ console.log(element, desiredTopBoundary, topScrollBoundary.value)
+ scrollBy(0, desiredTopBoundary - topScrollBoundary.value)
- const reset = async () => {
- unsuspendibleIds.value = new Set()
- heights.value = new Map()
+ resumeWatchers()
}
return {
heightChart: heightChartGrouped,
changeSuspendState,
updateVirtualHeight,
pauseWatchers,
resumeWatchers,
updateBoundaries,
reset,
+ scrollTo,
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Oct 31, 8:01 AM (17 h, 57 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1801174
Default Alt Text
(50 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline