Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85827120
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Flag For Later
Award Token
Authored By
Unknown
Size
17 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index 1fcce80075..3a4239cb2f 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -1,334 +1,367 @@
import { debounce, throttle } from 'lodash-es'
import { storeToRefs } from 'pinia'
import {
computed,
inject,
nextTick,
onMounted,
onUnmounted,
ref,
toRefs,
useId,
useTemplateRef,
watch,
} from 'vue'
import { useI18n } from 'vue-i18n'
import Conversation from 'src/components/conversation/conversation.vue'
import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from 'src/components/quick_view_settings/quick_view_settings.vue'
import ScrollTopButton from 'src/components/scroll_top_button/scroll_top_button.vue'
import TimelineMenu from 'src/components/timeline_menu/timeline_menu.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useTimelinesStore } from 'src/stores/timelines.js'
import { useDocumentFocus } from 'src/composables/useDocumentFocus.js'
import { useInterfaceSizes } from 'src/composables/useInterfaceSizes.js'
import { useVirtualScrolling } from 'src/composables/useVirtualScrolling.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faArrowUp,
faCheck,
faCircleNotch,
faCirclePlus,
faCog,
faMinus,
} from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch, faCog, faMinus, faArrowUp, faCirclePlus, faCheck)
const Timeline = {
props: {
timelineRef: Object,
footerSlipgate: Object, // reference to an element where we should put our footer
embedded: Boolean,
skipPinned: Boolean,
hideEmpty: Boolean,
},
components: {
ScrollTopButton,
Conversation,
TimelineMenu,
QuickFilterSettings,
QuickViewSettings,
},
setup(props, ctx) {
const { t } = useI18n()
const { mergedConfig } = storeToRefs(useMergedConfigStore())
const { focused } = useDocumentFocus()
const unfocused = computed(() => !focused.value)
// Timeline
const { timelineRef } = toRefs(props)
const timeline = computed(() => useTimelinesStore()[timelineRef.value.name])
const { skipPinned } = toRefs(props)
const filteredVisibleStatuses = computed(() => {
return timeline.value.order
.filter((id) => timeline.value.visibleStatusIds.has(id))
.map((id) => useStatusesStore().allStatuses.get(id))
.filter(({ pinned }) => (skipPinned.value ? !pinned : true))
})
// Scroll position
const scroller = inject('bodyScrollPosition')
const {
hasReachedTop,
shouldLoadBottom,
top: scrollY,
scrollToPriority,
} = scroller
// Pagination
const paginationEnabled = computed(
() => mergedConfig.value.timelinePagination,
)
const page = ref(0)
+ const pageLoading = ref(false)
const PAGE_SIZE = 20
+
const pageStart = computed(() => page.value * PAGE_SIZE)
const pageEnd = computed(() => pageStart.value + PAGE_SIZE)
+
const currentSlice = computed(() => {
if (paginationEnabled.value) {
return filteredVisibleStatuses.value.slice(
pageStart.value,
pageEnd.value,
)
} else {
return filteredVisibleStatuses.value
}
})
+ const nextSlice = computed(() => {
+ if (paginationEnabled.value) {
+ return filteredVisibleStatuses.value.slice(
+ pageStart.value + PAGE_SIZE,
+ pageEnd.value + PAGE_SIZE,
+ )
+ } else {
+ return filteredVisibleStatuses.value
+ }
+ })
+ const nextSliceReady = computed(() => {
+ if (nextSlice.value.length >= PAGE_SIZE) {
+ return true
+ } else {
+ return nextSlice.value.find(
+ ({ id }) => timeline.value.minId,
+ )
+ }
+ })
+
const isFirstPage = computed(() => page.value <= 0)
const isLastPage = computed(() => {
const bottomedOut = timeline.value.fetcher.bottomedOut
const lastId = timeline.value.minId
const lastIdInPage = currentSlice.value.find(
({ id }) => timeline.value.minId,
)
return bottomedOut && lastId && lastIdInPage
})
- const nextPage = () => {
+ const nextPage = async () => {
if (isLastPage.value) return
- fetchOlderStatuses()
+ if (!nextSliceReady.value) {
+ pageLoading.value = true
+ await fetchOlderStatuses()
+ pageLoading.value = false
+ }
page.value++
scrollToPriority({ top: 0, behavior: 'smooth' })
}
const previousPage = () => {
if (isFirstPage.value) return
page.value--
scrollToPriority({ top: 0, behavior: 'smooth' })
}
+ watch(timeline, () => {
+ page.value = 0
+ })
// Virtual scrolling
const { fontSize, navbarSize } = useInterfaceSizes()
// Placeholder heights.
const mutedStatusHeight = computed(() => fontSize.value * 1.5)
const normalStatusHeight = computed(() => fontSize.value * 10)
const getPlaceholderHeight = (id) =>
currentSlice.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: currentSlice,
body,
offset,
scrollPositionInstance: scroller,
scrollCompensation: true,
getPlaceholderHeight,
})
// Counter
const count = computed(() => timeline.value.order.length)
const newStatusCount = computed(() => timeline.value.newStatusCount)
const showLoadButton = computed(
() => timeline.value.newStatusCount > 0 || timeline.value.reloadNeeded,
)
// Showing new
const paused = ref(false)
const unfocusedPause = computed(
() => unfocused.value && mergedConfig.value.pauseOnUnfocused,
)
const showNewAutomatically = computed(() => mergedConfig.value.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 &&
page.value === 0
) {
showNewStatuses(true)
} else {
paused.value = true
}
})
const showNewStatuses = async (fast = false) => {
if (timeline.value.reloadNeeded) {
useTimelinesStore().clearTimeline(timelineRef.value.name)
fetchOlderStatuses()
} else {
blockClicksTemporarily()
useTimelinesStore().showNewStatuses(timelineRef.value.name)
}
page.value = 0
paused.value = false
await nextTick()
scroller.scrollToPriority({
top: 0,
behavior: fast ? 'instant' : 'smooth',
})
}
- const fetchOlderStatuses = throttle(() => {
- timeline.value.fetcher.fetchOlder()
+ const fetchOlderStatuses = throttle(async () => {
+ await timeline.value.fetcher.fetchOlder()
}, 1000)
// Timeline change
const timelineChange = (newTimeline, oldTimeline) => {
const sameName = newTimeline?.name === oldTimeline?.name
const sameArgument = newTimeline?.argument === oldTimeline?.argument
if (sameName && sameArgument) return
if (oldTimeline) {
useTimelinesStore().deactivate(oldTimeline.name)
}
if (newTimeline) {
useTimelinesStore().activate(newTimeline.name, newTimeline.argument)
}
}
watch(timelineRef, timelineChange, { immediate: true })
onUnmounted(() => {
timelineChange(null, timelineRef.value)
})
// Misclick prevention
const blockingClicks = ref(false)
const stopBlockingClicks = debounce(() => {
blockingClicks.value = false
}, 1000)
const blockClicksTemporarily = () => {
if (!blockingClicks.value) {
blockingClicks.value = true
}
stopBlockingClicks()
}
// Shortcuts
const handleShortKey = (e) => {
// Ignore when input fields are focused
if (['textarea', 'input'].includes(e.target.tagName.toLowerCase())) return
if (e.key === '.') showNewStatuses()
}
onMounted(() => {
window.addEventListener('keydown', handleShortKey)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleShortKey)
})
const infiniteLoadWatcher = debounce(([value]) => {
if (!value) return
if (paginationEnabled.value) return
fetchOlderStatuses()
}, 100)
// Scroll
watch([shouldLoadBottom, scrollY], infiniteLoadWatcher)
onUnmounted(() => {
infiniteLoadWatcher.cancel()
})
// Misc UI things
const classes = computed(() => {
let rootClasses = !embedded.value
? ['panel', 'panel-default']
: ['-embedded']
if (blockingClicks.value)
rootClasses = rootClasses.concat(['-blocked', '_misclick-prevention'])
return {
root: rootClasses,
header: ['timeline-heading'].concat(
!embedded.value ? ['panel-heading', '-sticky'] : ['panel-body'],
),
body: ['timeline-body'].concat(
!embedded.value ? ['panel-body'] : ['panel-body'],
),
footer: ['timeline-footer'].concat(
!embedded.value ? ['panel-footer'] : ['panel-body'],
),
}
})
const loadButtonString = computed(() => {
if (timeline.value.reloadNeeded) {
return t('timeline.reload')
} else {
return `${t('timeline.show_new')} (${newStatusCount.value})`
}
})
const mobileLoadButtonString = computed(() => {
if (timeline.value.reloadNeeded) {
return '+'
} else {
return newStatusCount.value > 99 ? '∞' : newStatusCount.value
}
})
const { layoutType } = storeToRefs(useInterfaceStore())
const mobileLayout = computed(() => layoutType.value === 'mobile')
const { footerSlipgate, embedded, hideEmpty } = toRefs(props)
const uniqueId = useId()
return {
timelineRef,
timeline,
paginationEnabled,
page,
+ pageLoading,
nextPage,
previousPage,
+ isLastPage,
+ isFirstPage,
heightChart,
updateVirtualHeight,
changeSuspendState,
count,
showLoadButton,
showNewStatuses,
fetchOlderStatuses,
classes,
loadButtonString,
mobileLoadButtonString,
mobileLayout,
footerSlipgate,
embedded,
hideEmpty,
uniqueId,
}
},
}
export default Timeline
diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue
index 73ee229dd3..bb539dedf5 100644
--- a/src/components/timeline/timeline.vue
+++ b/src/components/timeline/timeline.vue
@@ -1,213 +1,223 @@
<template>
<!-- there is a brief moment during logout when old timeline gets forcibly deactivated -->
<div
v-if="timeline.fetcher"
:class="['Timeline', classes.root]"
>
<div
v-if="!embedded"
:class="classes.header"
>
<TimelineMenu
v-if="!embedded"
:timeline-name="timelineRef.name"
:id="'timeline-header-' + uniqueId"
/>
<div
v-if="timeline.fetcher.loadingNewer && !showLoadButton"
class="loadingIndicator"
>
<FAIcon
fixed-width
icon="circle-notch"
spin
/>
</div>
<ScrollTopButton />
<template v-if="mobileLayout">
<div
v-if="showLoadButton"
class="rightside-button"
>
<button
class="button-unstyled loadmore-button"
:title="loadButtonString"
@click.prevent="showNewStatuses(false)"
>
<FAIcon
fixed-width
icon="circle-plus"
/>
<div class="badge -counter">
{{ mobileLoadButtonString }}
</div>
</button>
</div>
<div
v-else-if="!timeline.fetcher.loadingNewer"
class="loadmore-text faint veryfaint rightside-icon"
:title="$t('timeline.up_to_date')"
:aria-disabled="true"
@click.prevent
>
<FAIcon
fixed-width
icon="check"
/>
</div>
</template>
<template v-else>
<button
v-if="showLoadButton"
class="button-default loadmore-button"
@click.prevent="showNewStatuses(false)"
>
{{ loadButtonString }}
</button>
<div
v-else
class="loadmore-text faint"
@click.prevent
>
{{ $t('timeline.up_to_date') }}
</div>
</template>
<QuickFilterSettings
v-if="!mobileLayout"
class="rightside-button"
/>
<QuickViewSettings
class="rightside-button"
/>
</div>
<div
v-if="paginationEnabled && page > 0"
class="pagination panel-subheading"
>
<button
class="panel-body button-unstyled"
@click.left.prevent="previousPage"
>
<div class="new-status-notification text-center">
{{ $t('timeline.previous_page') }}
</div>
</button>
</div>
<div :class="classes.body">
<div
class="timeline"
ref="timeline"
role="feed"
:aria-labelledby="embedded ? undefined : ('timeline-header-'+ uniqueId)"
>
<template
v-for="element in heightChart"
:key="element.id"
>
<Conversation
v-if="element.type === 'item'"
class="timeline-conversation"
:status-id="element.id"
:scroll-compensation="element.visible"
:top="element.top"
:data-vs-height="element.height"
:data-vs-top="element.top"
:data-vs-id="element.id"
:data-vs-visible="element.visible"
@suspendable-state-change="changeSuspendState"
@height-change="updateVirtualHeight"
/>
<div
v-if="element.type === 'spacer'"
class="virtual-spacer"
aria-hidden="true"
:style="{ height: element.height + 'px' }"
/>
</template>
</div>
<template v-if="!hideEmpty && count === 0">
<div
v-if="timeline.fetcher.loadingNewer || timeline.fetcher.loadingOlder"
class="timeline-placeholder"
>
<FAIcon
icon="circle-notch"
spin
size="4x"
/>
</div>
<div
v-else
class="timeline-placeholder faint"
>
{{ $t('timeline.no_statuses') }}
</div>
</template>
</div>
<div v-if="!embedded || footerSlipgate" :class="classes.footer">
<teleport
:to="footerSlipgate"
:disabled="!embedded || !footerSlipgate"
>
<div
v-if="paginationEnabled"
class="pagination"
>
+ <div
+ v-if="pageLoading"
+ class="new-status-notification text-center"
+ >
+ <FAIcon
+ icon="circle-notch"
+ spin
+ size="lg"
+ />
+ </div>
<button
- v-if="!isLastPage"
+ v-else-if="!isLastPage"
class="button-unstyled"
@click.left.prevent="nextPage"
>
<div class="new-status-notification text-center">
{{ $t('timeline.next_page') }}
</div>
</button>
<div
v-else
class="new-status-notification text-center faint"
>
{{ $t('timeline.no_more_statuses') }}
</div>
</div>
<div
v-else
class="infinite-scroll"
>
<div
v-if="timeline.fetcher.bottomedOut"
class="new-status-notification text-center faint"
>
{{ $t('timeline.no_more_statuses') }}
</div>
<div
v-else-if="timeline.fetcher.loadingOlder"
class="new-status-notification text-center"
>
<FAIcon
icon="circle-notch"
spin
size="lg"
/>
</div>
<button
v-else-if="timeline.minId !== ''"
class="button-unstyled"
@click.prevent="fetchOlderStatuses()"
>
<div class="new-status-notification text-center">
{{ $t('timeline.load_older') }}
</div>
</button>
</div>
</teleport>
<!-- spacer to avoid having empty shrug -->
<span v-if="embedded && footerSlipgate" />
</div>
</div>
</template>
<script src="./timeline.js"></script>
<style src="./timeline.scss" lang="scss"> </style>
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Fri, Oct 30, 3:06 PM (18 h, 29 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1797362
Default Alt Text
(17 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline