Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
73 KB
Referenced Files
None
Subscribers
None
diff --git a/changelog.d/vs21a.skip b/changelog.d/vs21a.skip
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index eb8133d733..7869e5df47 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,378 +1,388 @@
import { last } from 'lodash-es'
import { storeToRefs } from 'pinia'
import {
computed,
inject,
nextTick,
provide,
ref,
toRefs,
useTemplateRef,
watch,
} from '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 { useClientRectSize } from 'src/composables/useClientRectSize.js'
import { useConversation } from 'src/composables/useConversation.js'
import { useInterfaceSizes } from 'src/composables/useInterfaceSizes.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
+ // Whether conversation is rendered as a standalone page
+ // as opposed to embedded into a timeline
+ isPage: Boolean,
+ // Whether to engage virtual scrolling scroll compensation
+ scrollCompensation: {
type: Boolean,
- default: false,
+ default: true,
},
},
components: {
ThreadTree,
QuickFilterSettings,
QuickViewSettings,
RichContent,
},
emits: ['heightChange', 'suspendableStateChange', 'expanded', 'collapsed'],
setup(props, { emit }) {
const scroller = inject('bodyScrollPosition')
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 = 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 {
focusedId,
conversationId,
setFocused,
currentStatus,
conversation,
replies,
getReplies,
fetchConversation,
loadError,
} = useConversation(statusId, isExpanded)
const conversationLite = computed(() =>
conversation.value.map(({ id }) => ({ id })),
)
provide('focusedId', focusedId)
provide('conversation', conversation)
provide('replies', replies)
// Component created
if (isPage.value) {
fetchConversation()
}
// # Misc UI things
const firstStatus = computed(() => conversation.value[0])
const lastStatus = computed(() => last(conversation.value))
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 { height } = useClientRectSize(rootElement)
watch(height, (value) => {
const rootCss = window.getComputedStyle(rootElement.value)
const rootMarginString = rootCss.getPropertyValue('margin-top')
const rootMargin = Number.parseInt(rootMarginString.slice(0, -2), 10)
rootElementMargin.value = rootMargin
+ const border = isExpanded.value ? 2 : 1
emit('heightChange', {
id: statusId.value,
- height: value,
+ height: value + border,
element: rootElement,
})
})
watch(suspendable, (value) =>
emit('suspendableStateChange', {
suspendable: value,
id: statusId.value,
}),
)
// # Internal virtual scrolling
const virtualScrollingEnabled = ref(isExpanded.value)
// Placeholder heights.
const { fontSize, navbarSize, panelHeaderSize } = useInterfaceSizes()
const normalStatusHeight = computed(() => fontSize.value * 10)
const getPlaceholderHeight = (id) => 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 { scrollCompensation } = toRefs(props)
// # Linear style stuff
const isLinearView = computed(() => displayStyle.value !== 'tree')
const linearElement = useTemplateRef('linear')
const linearScrollCompensation = computed(
- () => virtualScrollingEnabled.value && isLinearView.value,
+ () =>
+ virtualScrollingEnabled.value &&
+ isLinearView.value &&
+ scrollCompensation.value,
)
const {
heightChart: heightChartLinear,
changeSuspendState: changeSuspendStateLinear,
updateVirtualHeight: updateVirtualHeightLinear,
reset: resetLinearScrollVirtualization,
scrollTo: linearScrollTo,
} = useVirtualScrolling({
name: 'Linear',
enabled: linearScrollCompensation,
list: conversationLite,
body: linearElement,
offset,
scrollPositionInstance: scroller,
scrollCompensation: linearScrollCompensation,
getPlaceholderHeight,
})
const changeSuspendStateLinearLocal = (e) => {
changeSuspendStateLinear(e)
const { id, suspendable } = e
if (suspendable) {
unsuspendableIds.value.delete(id)
} else {
unsuspendableIds.value.add(id)
}
}
// # Tree style stuff
const isTreeView = computed(() => displayStyle.value === 'tree')
const {
topLevel,
currentAncestors,
threadDisplay,
setThreadDisplayRecursively,
showThreadRecursively,
resetThreadDisplay,
totalReplyCount,
totalReplyDepth,
} = useTreeConversationTopology(conversation, replies, focusedId)
provide('threadDisplay', threadDisplay)
provide('totalReplyCount', totalReplyCount)
provide('totalReplyDepth', totalReplyDepth)
watch(
isExpanded,
(value) => {
if (!value) resetThreadDisplay()
},
{ flush: 'post' },
)
const toggleThreadDisplay = (id) => {
const current = threadDisplay.value.get(id)
const next = current === 'hidden' ? 'showing' : 'hidden'
setThreadDisplayRecursively(id, next)
}
const currentAncestorsLite = computed(() =>
currentAncestors.value.map(({ id }) => ({ id })),
)
const ancestorsElement = useTemplateRef('ancestors')
const treeScrollCompensation = computed(
- () => virtualScrollingEnabled.value && isTreeView.value,
+ () =>
+ virtualScrollingEnabled.value &&
+ isTreeView.value &&
+ scrollCompensation.value,
)
const {
heightChart: heightChartAncestors,
changeSuspendState: changeSuspendStateAncestors,
updateVirtualHeight: updateVirtualHeightAncestors,
reset: resetTreeScrollVirtualization,
} = useVirtualScrolling({
name: 'Ancestors',
enabled: treeScrollCompensation,
list: currentAncestorsLite,
body: ancestorsElement,
offset,
scrollPositionInstance: scroller,
scrollCompensation: treeScrollCompensation,
collapseMode: 'height',
getPlaceholderHeight,
})
const changeSuspendStateAncestorsLocal = (e) => {
changeSuspendStateAncestors(e)
const { id, suspendable } = e
if (suspendable) {
unsuspendableIds.value.delete(id)
} else {
unsuspendableIds.value.add(id)
}
}
const changeSuspendStateCurrentLevelLocal = (e) => {
const { id, suspendable } = e
if (suspendable) {
unsuspendableIds.value.delete(id)
} else {
unsuspendableIds.value.add(id)
}
}
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 scrollTo = (ids) => {
if (isLinearView.value) {
return linearScrollTo(ids)
}
}
const focusTopLevel = () => setFocused(currentAncestors.value[0].id)
watch(focusedId, async (neu, old) => {
// Ignoring initial update (null -> id) since that is handled by scroll compensation
if (old && 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,
toggleThreadDisplay,
// ### Derived values and config
treeViewIsSimple,
shouldShowAllConversationButton,
shouldShowAncestors,
// # Scrolling
focusTopLevel,
unsuspendableIds,
}
},
}
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 0059bab3df..e94d51f297 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -1,630 +1,630 @@
import { mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import ActivityHeader from 'src/components/activity_header/activity_header.vue'
import AvatarList from 'src/components/avatar_list/avatar_list.vue'
import EmojiReactions from 'src/components/emoji_reactions/emoji_reactions.vue'
import MentionLink from 'src/components/mention_link/mention_link.vue'
import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import StatusActionButtons from 'src/components/status_action_buttons/status_action_buttons.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import StatusPopover from 'src/components/status_popover/status_popover.vue'
import Timeago from 'src/components/timeago/timeago.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserLink from 'src/components/user_link/user_link.vue'
import UserListPopover from 'src/components/user_list_popover/user_list_popover.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
import { muteFilterHits } from '../../services/status_parser/status_parser.js'
import {
highlightClass,
highlightStyle,
} from '../../services/user_highlighter/user_highlighter.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useScrobblesStore } from 'src/stores/scrobbles.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import { useUsersStore } from 'src/stores/users.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleRight,
faChevronDown,
faChevronUp,
faEllipsisH,
faEnvelope,
faEye,
faEyeSlash,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faPlay,
faPlusSquare,
faReply,
faRetweet,
faSmileBeam,
faStar,
faThumbtack,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faEnvelope,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faTimes,
faRetweet,
faReply,
faPlusSquare,
faStar,
faSmileBeam,
faEllipsisH,
faEyeSlash,
faEye,
faThumbtack,
faChevronUp,
faChevronDown,
faAngleDoubleRight,
faPlay,
)
const Status = {
name: 'Status',
components: {
PostStatusForm,
UserAvatar,
AvatarList,
StatusPopover,
UserListPopover,
EmojiReactions,
StatusContent,
ActivityHeader,
MentionLink,
MentionsLine,
UserPopover,
UserLink,
Timeago,
Quote: defineAsyncComponent(() => import('src/components/quote/quote.vue')),
StatusActionButtons,
},
props: {
statusId: String,
statusoid: Object,
replies: Set,
focused: Boolean,
compact: Boolean,
isPreview: Boolean,
noHeading: Boolean,
inQuote: Boolean,
ignoreMute: Boolean,
widthEfficient: Boolean,
threadDisplayState: String,
conversationRank: {
type: String,
default: 'linear',
},
},
emits: [
'requestFocus',
'toggleExpanded',
'toggleThreadDisplay',
'suspendableStateChange',
'heightChange',
],
inject: {
profileUserId: {
default: null,
},
isPage: {
default: false,
},
isExpanded: {
default: false,
},
expandable: {
default: false,
},
},
provide: {
expandable: false, // for quotes
},
data() {
return {
resizeObserver: new ResizeObserver(this.updateVirtualHeight),
replying: false,
unmuted: false,
mediaPlaying: new Set(),
error: null,
headTailLinks: null,
}
},
created() {
useScrobblesStore().getLatestScrobble(this.status.user.id)
},
computed: {
shouldBeWidthEfficient() {
return (
this.widthEfficient ||
this.layoutType === 'mobile' ||
this.mergedConfig.widthEfficientPostLayout
)
},
headerLevel() {
return [this.isPage ? 0 : 1, this.isExpanded ? 1 : 0].reduce(
(acc, item) => acc + item,
1,
)
},
rootClasses() {
return [
{
'-focused': this.focused,
'-conversation': !this.isPage && this.isExpanded,
},
`-conversation-rank-${this.conversationRank}`,
]
},
// Whatever we're given to work with
status() {
return this.statusoid ?? useStatusesStore().allStatuses.get(this.statusId)
},
inConversation() {
return this.isExpanded
},
inProfile() {
return this.profileUserId != null
},
// Status repeated
repeatedStatus() {
if (this.status.retweeted_status === undefined) return undefined
return useStatusesStore().allStatuses.get(this.status.retweeted_status.id)
},
// THE repeat
repeatStatus() {
if (this.isRepeat) {
return this.status
} else {
return null
}
},
mainStatus() {
if (this.isRepeat) {
return this.repeatedStatus
} else {
return this.status
}
},
repeater() {
return useUsersStore().findUser(this.status.user.id)
},
user() {
return useUsersStore().findUser(this.mainStatus.user.id)
},
isTreeView() {
return this.mergedConfig.conversationDisplay === 'tree'
},
showOtherRepliesInside() {
return this.mergedConfig.conversationOtherRepliesButton === 'inside'
},
showOtherRepliesBelow() {
return this.mergedConfig.conversationOtherRepliesButton === 'below'
},
showReasonMutedThread() {
return (
(this.mainStatus.thread_muted || this.repeatStatus?.thread_muted) &&
!this.inConversation
)
},
allowNonSquareEmoji() {
return this.mergedConfig.nonSquareEmoji
},
pauseMfm() {
return this.mergedConfig.pauseMfm
},
scaleMfm() {
return this.mergedConfig.scaleMfm
},
repeaterClass() {
return highlightClass(this.repeater)
},
userClass() {
return highlightClass(this.user)
},
isDeleted() {
return this.status.deleted
},
repeaterStyle() {
return highlightStyle(
useUserHighlightStore().get(this.repeater.screen_name),
)
},
favoritedBy() {
return useStatusesStore().favs.get(this.mainStatus.id) ?? new Set()
},
repeatedBy() {
return useStatusesStore().repeats.get(this.mainStatus.id) ?? new Set()
},
userStyle() {
if (this.noHeading) return
return highlightStyle(useUserHighlightStore().get(this.user.screen_name))
},
replyProfileLink() {
if (this.isReply) {
const user = useUsersStore().findUser(
this.mainStatus.in_reply_to_user_id,
)
// User referenced in post might not be yet present in store
// since their data is not included in status data, just the id
return user?.statusnet_profile_url
}
},
isRepeat() {
return !!this.repeatedStatus
},
repeaterName() {
return this.status.user.name || this.status.user.screen_name_ui
},
repeaterHtml() {
return this.status.user.name
},
repeaterProfileLink() {
return this.generateUserProfileLink(
this.repeater.id,
this.repeater.screen_name,
)
},
loggedIn() {
return !!this.currentUser
},
muteFilterHits() {
return muteFilterHits(
Object.values(
useSyncConfigStore().prefsStorage.simple.muteFilters || {},
),
this.status,
)
},
botStatus() {
return this.status.user.actor_type === 'Service'
},
sensitiveStatus() {
return this.status.nsfw
},
mentionsLine() {
if (!this.headTailLinks) return []
const writtenSet = new Set(
this.headTailLinks.writtenMentions.map((_) => _.url),
)
return this.mainStatus.attentions
.filter((attn) => {
// no reply user
return (
attn.id !== this.mainStatus.in_reply_to_user_id &&
// no self-replies
attn.statusnet_profile_url !==
this.mainStatus.user.statusnet_profile_url &&
// don't include if mentions is written
!writtenSet.has(attn.statusnet_profile_url)
)
})
.map((attn) => ({
url: attn.statusnet_profile_url,
content: attn.screen_name,
userId: attn.id,
}))
},
hasMentionsLine() {
return this.mentionsLine.length > 0
},
// TODO move muting logic into some store
muteReasons() {
return [
this.userIsMuted ? 'user' : null,
this.mainStatus.thread_muted ? 'thread' : null,
this.muteFilterHits.length > 0 ? 'filtered' : null,
this.muteBotStatuses && this.botStatus ? 'bot' : null,
this.muteSensitiveStatuses && this.sensitiveStatus ? 'nsfw' : null,
].filter(Boolean)
},
muteLocalized() {
if (this.muteReasons.length === 0) return null
const mainReason = () => {
switch (this.muteReasons[0]) {
case 'user':
return this.$t('status.muted_user')
case 'thread':
return this.$t('status.thread_muted')
case 'filtered':
return this.$t(
'status.muted_filters',
{
name: this.muteFilterHits[0].name,
filterMore: this.muteFilterHits.length - 1,
},
this.muteFilterHits.length,
)
case 'bot':
return this.$t('status.bot_muted')
case 'nsfw':
return this.$t('status.sensitive_muted')
}
}
if (this.muteReasons.length > 1) {
return this.$t(
'status.multi_reason_mute',
{
main: mainReason(),
numReasonsMore: this.muteReasons.length - 1,
},
this.muteReasons.length - 1,
)
} else {
return mainReason()
}
},
muted() {
if (this.ignoreMute) return false
if (this.status.user.id === this.currentUser?.id) return false
return !this.unmuted && !this.shouldNotMute && this.muteReasons.length > 0
},
userIsMuted() {
if (!this.currentUser) return false
if (this.user === this.currentUser) return false
if (this.repeater === this.currentUser) return false
const relationship = useUsersStore().relationship(this.user.id)
const relationshipRepeat = useUsersStore().relationship(this.repeater?.id)
return (
(this.status.muted && !this.status.thread_muted) ||
// Reprööt of a muted post according to BE
(this.repeatedStatus?.muted && !this.repeatedStatus.thread_muted) ||
// Muted user
relationship.muting ||
// Muted user of a reprööt
relationshipRepeat?.muting
)
},
shouldNotMute() {
if (this.ignoreMute) return true
if (this.focused) return true
const { reblog } = this.mainStatus
return (
((this.inProfile &&
// Don't mute user's posts on user timeline (except reblogs)
((!reblog && this.mainStatus.user.id === this.profileUserId) ||
// Same as above but also allow self-reblogs
reblog?.user.id === this.profileUserId)) ||
// Don't mute statuses in muted conversation when said conversation is opened
(this.inConversation && this.mainStatus.thread_muted)) &&
// No excuses if post has muted words
!this.muteFilterHits.length > 0
)
},
hideMutedUsers() {
return this.mergedConfig.hideMutedPosts
},
hideMutedThreads() {
return this.mergedConfig.hideMutedThreads
},
hideFilteredStatuses() {
return this.mergedConfig.hideFilteredStatuses
},
hideWordFilteredPosts() {
return this.mergedConfig.hideWordFilteredPosts
},
hideStatus() {
return (
!this.shouldNotMute &&
((this.muted && this.hideFilteredStatuses) ||
(this.userIsMuted && this.hideMutedUsers) ||
(this.status.thread_muted && this.hideMutedThreads) ||
(this.muteFilterHits.length > 0 && this.hideWordFilteredPosts) ||
this.muteFilterHits.some((x) => x.hide))
)
},
isReply() {
return !!(
this.mainStatus.in_reply_to_status_id &&
this.mainStatus.in_reply_to_user_id
)
},
replyToName() {
if (this.mainStatus.in_reply_to_screen_name) {
return this.mainStatus.in_reply_to_screen_name
} else {
const user = useUsersStore().findUser(
this.mainStatus.in_reply_to_user_id,
)
return user?.screen_name_ui
}
},
combinedFavsAndRepeatsUsers() {
return new Set([...this.favoritedBy, ...this.repeatedBy])
},
tags() {
return [...this.status.tags]
.filter((tagObj) => Object.hasOwn(tagObj, 'name'))
.map((tagObj) => tagObj.name)
.join(' ')
},
hidePostStats() {
return this.mergedConfig.hidePostStats
},
shouldDisplayFavsAndRepeats() {
return (
!this.hidePostStats &&
this.focused &&
(this.combinedFavsAndRepeatsUsers.size > 0 ||
this.mainStatus.quotes_count)
)
},
muteBotStatuses() {
return this.mergedConfig.muteBotStatuses
},
muteSensitiveStatuses() {
return this.mergedConfig.muteSensitiveStatuses
},
hideBotIndication() {
return this.mergedConfig.hideBotIndication
},
currentUser() {
return useUsersStore().currentUser
},
mergedConfig() {
return useMergedConfigStore().mergedConfig
},
isSuspendable() {
return !this.replying && this.mediaPlaying.size === 0
},
visibilityLocalized() {
return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility)
},
isEdited() {
return this.mainStatus.edited_at !== null
},
editingAvailable() {
return useInstanceCapabilitiesStore().editingAvailable
},
quoteId() {
return this.mainStatus.quote_id
},
quoteUrl() {
return this.mainStatus.quote_url
},
quoteVisible() {
return this.mainStatus.quote_visible
},
quoteExpanded() {
return !this.inQuote
},
scrobblePresent() {
if (this.mergedConfig.hideScrobbles) return false
if (!this.status.user?.latestScrobble) return false
const value = this.mergedConfig.hideScrobblesAfter.match(/\d+/gs)[0]
const unit = this.mergedConfig.hideScrobblesAfter.match(/\D+/gs)[0]
let multiplier = 60 * 1000 // minutes is smallest unit
switch (unit) {
case 'm':
break
case 'h':
multiplier *= 60 // hour
break
case 'd':
multiplier *= 60 // hour
multiplier *= 24 // day
break
}
const maxAge = Number(value) * multiplier
const createdAt = Date.parse(this.status.user.latestScrobble.created_at)
const age = Date.now() - createdAt
if (age > maxAge) return false
return this.status.user.latestScrobble.artist
},
scrobble() {
return this.status.user?.latestScrobble
},
...mapState(useInterfaceStore, ['layoutType']),
},
methods: {
showError(error) {
this.error = error
},
clearError() {
this.error = undefined
},
toggleReplyForm() {
if (this.replying) {
// This emits 'close-accepted' if successful
// which in turn callse closeReply()
this.$refs.postStatusForm.requestClose()
} else {
this.replying = true
}
},
closeReplyForm() {
this.replying = false
},
toggleMute() {
this.unmuted = !this.unmuted
},
generateUserProfileLink(id, name) {
return generateProfileLink(
id,
name,
useInstanceStore().restrictedNicknames,
)
},
addMediaPlaying(id) {
this.mediaPlaying.add(id)
},
removeMediaPlaying(id) {
this.mediaPlaying.delete(id)
},
setHeadTailLinks(headTailLinks) {
this.headTailLinks = headTailLinks
},
updateVirtualHeight(e) {
const [entry] = e
this.$emit('heightChange', {
id: this.status.id,
height: entry.contentRect.height + 1,
- element: this.$el,
+ element: this.$refs.root,
})
},
},
mounted() {
if (this.$refs.root) {
this.resizeObserver.observe(this.$refs.root)
this.updateVirtualHeight([
{
contentRect: this.$refs.root.getBoundingClientRect(),
},
])
}
},
unmounted() {
this.resizeObserver.disconnect()
},
watch: {
hideStatus: {
handler() {
if (this.$refs.root) {
this.resizeObserver.observe(this.$refs.root)
} else {
this.resizeObserver.disconnect()
}
},
flush: 'post',
},
'mainStatus.repeat_num': function (num) {
// refetch repeats when repeat_num is changed in any way
if (this.focused && this.repeatedBy.size !== num) {
useStatusesStore().fetchRepeats(this.mainStatus.id)
}
},
'mainStatus.fave_num': function (num) {
// refetch favs when fave_num is changed in any way
if (this.focused && this.favoritedBy.size !== num) {
useStatusesStore().fetchFavs(this.mainStatus.id)
}
},
isSuspendable: function (suspendable) {
this.$emit('suspendableStateChange', { id: this.status.id, suspendable })
},
},
}
export default Status
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index 18387b22c4..4b9d42d88c 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -1,279 +1,279 @@
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 { 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 } = 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,
+ 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 { 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(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)
}
paused.value = false
await nextTick()
scroller.scrollToPriority({
top: 0,
behavior: fast ? 'instant' : 'smooth',
})
}
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)
})
- const infiniteLoadWatcher = debounce((value) => {
+ const infiniteLoadWatcher = debounce(([value]) => {
if (!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,
filteredVisibleStatuses,
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 f8774fbc7f..de844e5a01 100644
--- a/src/components/timeline/timeline.vue
+++ b/src/components/timeline/timeline.vue
@@ -1,172 +1,174 @@
<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 :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"
: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="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 -link"
@click.prevent="fetchOlderStatuses()"
>
<div class="new-status-notification text-center">
{{ $t('timeline.load_older') }}
</div>
</button>
</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>
diff --git a/src/composables/useVirtualScrolling.js b/src/composables/useVirtualScrolling.js
index e56c3d0170..8db08dae57 100644
--- a/src/composables/useVirtualScrolling.js
+++ b/src/composables/useVirtualScrolling.js
@@ -1,434 +1,397 @@
-import { debounce, first, last, throttle } from 'lodash-es'
-import { computed, nextTick, onUnmounted, ref, toValue, watch } from 'vue'
+import { first, last, throttle } 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 = ref([])
- const updateHeightChart = debounce(() => {
+ 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)
- heightChart.value = chart
- }, 32) // 32ms = ~30fps
- watch([list, heights, unsuspendibleIds], updateHeightChart, {
- immediate: true,
- deep: true,
- })
- onUnmounted(() => {
- updateHeightChart.cancel()
+ 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 10 items heights
const bufferZone = computed(
- () => getPlaceholderHeight().value * (toValue(buffer) ?? 10),
+ () => getPlaceholderHeight().value * (toValue(buffer) ?? 5),
)
+ // Stuff that's on screen
const checkVisible = ({ top, height }) => {
const itemTopBoundary = top
const itemBottomBoundary = top + height
+ // Include buffer zone
+ const finalTopScrollBoundary = topScrollBoundary.value
+ const finalBottomScrollBoundary = bottomScrollBoundary.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
+ }
+
+ // Same thing but including buffer zone
+ const checkRendered = ({ 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 = ref(null)
const recalculateChartVisibility = throttle(
(newVal, oldVal) => {
heightChartVisibility.value = heightChart.value.map(
(heightChartItem) => ({
...heightChartItem,
visible: checkVisible(heightChartItem),
+ rendered: checkRendered(heightChartItem),
}),
)
},
32, // 32ms = ~30fps
{ leading: true, trailing: true },
)
watch(
[heightChart, topScrollBoundary, bottomScrollBoundary, bufferZone],
recalculateChartVisibility,
{ immediate: true },
)
const heightChartGrouped = computed(() => {
- const chart = enabled.value ? heightChartVisibility : heightChart
+ const chart = heightChartVisibility
// Group invisible items into spacers
return chart.value.reduce((acc, heightChartItem, index) => {
- const { suspendable, visible, height, top, id } = heightChartItem
- const present = visible || !suspendable
+ const { suspendable, rendered, visible, height, top, id } =
+ heightChartItem
+ const present = rendered || !suspendable || !enabled.value
if (present) {
- return [...acc, { type: 'item', height, top, id }]
+ return [
+ ...acc,
+ { type: 'item', height, top, id, visible, rendered, present },
+ ]
} 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,
+ heightChartVisibility,
async (newVal, oldVal) => {
if (newVal.length === 0 && oldVal.length === 0) return 0
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 = (() => {
// HACK ignore date separators and fake messages in chat view
const oldIds = new Set(
oldVal
.map(({ id }) => id)
.filter((id) => !id.startsWith('fake-')),
)
const newIds = new Set(
newVal
.map(({ id }) => id)
.filter((id) => !id.startsWith('fake-')),
)
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.
- *
- * TODO: Probably a good way to avoid it all is just to:
- * - Don't do scroll compensation when nested
- * - For expanded conversations use animated priority scrollTo
- * something to do for Virtual Scrolling 2.2
- *
- * 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 newReal = newVal.filter(({ real }) => real).length
- const oldReal = oldVal.filter(({ real }) => real).length
- const compensateBecauseReal =
- oldReal > 0 && newReal > 0 && oldReal !== newReal
-
- if (
- !(
- compensation ||
- compensateBecauseReal ||
- compensateBecauseLengthChange
- )
- ) {
+ if (!compensation) {
return 0
}
// Section is used for debugging only
const getShift = (section, a, b, find, getter) => {
const aItem = find(a)
if (!aItem) return 0
const bItem = b.find(({ id }) => id === aItem.id)
if (!bItem) return 0
return getter(bItem) - getter(aItem)
}
const expansion = (() => {
if (newVal.length < oldVal.length) return 0
return getShift(
'expansion',
- oldVal.filter((item) => checkVisible(item)),
+ oldVal,
newVal,
(list) =>
list.find(
({ top, height }) => top + height < topScrollBoundary.value,
) ?? first(list),
({ top, height }) => top + height,
)
})()
const inverseCompensation = (() => {
if (!toValue(invertDirection)) return 0
if (newVal.length < oldVal.length) return 0
const shift = getShift(
'inverse',
- oldVal.filter((item) => checkVisible(item)),
+ oldVal,
newVal,
(list) =>
list.findLast(({ top }) => top > bottomScrollBoundary.value) ??
last(list),
({ top }) => top,
)
if (isSticking) {
// Don't do 99999 here, it breaks virtual scrolling
const { height } = body.value.getBoundingClientRect()
return height // keep sticking
} else {
return shift
}
})()
const collapsing = (() => {
if (newVal.length >= oldVal.length) return 0
return -getShift(
'collapse',
newVal,
oldVal,
(list) => first(list),
({ top, height }) => top + height,
)
})()
return (
(toValue(invertDirection) ? inverseCompensation : expansion) +
collapsing
)
}
})()
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()
},
{ flush: 'post' },
)
// Misc
const reset = () => {
unsuspendibleIds.value = new Set()
heights.value = new Map()
}
return {
heightChart: heightChartGrouped,
changeSuspendState,
updateVirtualHeight,
pauseWatchers,
resumeWatchers,
updateBoundaries,
reset,
}
}
diff --git a/src/composables/useWindowScroll.js b/src/composables/useWindowScroll.js
index 1746596b02..c3b287b2a0 100644
--- a/src/composables/useWindowScroll.js
+++ b/src/composables/useWindowScroll.js
@@ -1,24 +1,26 @@
import { onMounted, onUnmounted, ref } from 'vue'
export function useWindowScroll() {
const x = ref(0)
const y = ref(0)
const update = (e) => {
- x.value = window.scrollX
- y.value = window.scrollY
+ requestAnimationFrame(() => {
+ x.value = window.scrollX
+ y.value = window.scrollY
+ })
}
onMounted(() => {
- window.addEventListener('scroll', update)
+ window.addEventListener('scroll', update, { capture: false, passive: true })
update()
})
onUnmounted(() => {
window.removeEventListener('scroll', update)
})
const scrollBy = (...args) => window.scrollBy(...args)
const scrollTo = (...args) => window.scrollTo(...args)
return { x, y, scrollBy, scrollTo }
}
diff --git a/test/unit/specs/composables/useVirtualScrolling.spec.js b/test/unit/specs/composables/useVirtualScrolling.spec.js
index e37f91a881..3fbf1004c4 100644
--- a/test/unit/specs/composables/useVirtualScrolling.spec.js
+++ b/test/unit/specs/composables/useVirtualScrolling.spec.js
@@ -1,385 +1,437 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { ref } from 'vue'
import { useVirtualScrolling } from 'src/composables/useVirtualScrolling.js'
const scrollPositionInstance = {
x: ref(0),
y: ref(0),
vHeight: ref(768),
vWidth: ref(1024),
cHeight: ref(768),
cWidth: ref(1024),
scrollBy: vi.fn(),
hasReachedTop: ref(false),
hasReachedBottom: ref(false),
}
describe('useVirtualScrolling', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.resetAllMocks()
scrollPositionInstance.x.value = 0
scrollPositionInstance.y.value = 0
})
it('should init boundaries and compute a height chart', async () => {
const body = ref({
getBoundingClientRect: () => ({
top: 0,
}),
})
const list = [...new Array(20)].map((i, index) => ({
id: `${index}i`,
}))
const result = useVirtualScrolling({
name: 'Test',
enabled: ref(true),
list: ref(list),
body: ref(body),
buffer: 3,
offset: 0,
getPlaceholderHeight: () => ref(100),
scrollPositionInstance,
})
result.updateBoundaries()
const items = [...new Array(11)].map((i, index) => ({
id: `${index}i`,
top: index * 100,
height: 100,
type: 'item',
+ present: true,
+ rendered: true,
+ visible: true,
}))
+ items[8].visible = false
+ items[9].visible = false
+ items[10].visible = false
const spacer = {
type: 'spacer',
top: 1100,
height: 900,
ids: expect.any(Set),
id: 'i11',
}
await vi.advanceTimersByTime(32)
expect(result.heightChart.value).toEqual([...items, spacer])
})
it('should update a height chart on list update', async () => {
const body = ref({
getBoundingClientRect: () => ({
top: 0,
}),
})
const list = ref(
[...new Array(40)].map((i, index) => ({
id: `${index}i`,
})),
)
const result = useVirtualScrolling({
name: 'Test',
enabled: ref(true),
list,
body: ref(body),
offset: 0,
+ buffer: 10,
getPlaceholderHeight: () => ref(100),
scrollPositionInstance,
})
result.updateBoundaries()
const items = [...new Array(18)].map((i, index) => ({
id: `${index}i`,
top: index * 100,
height: 100,
type: 'item',
+ present: true,
+ rendered: true,
+ visible: true,
}))
+ for (let i = 8; i < 18; i++) {
+ items[i].visible = false
+ }
const spacerOld = {
type: 'spacer',
top: 1800,
height: 2200,
ids: expect.any(Set),
id: 'i18',
}
const spacerNew = {
type: 'spacer',
top: 1800,
height: 2300,
ids: expect.any(Set),
id: 'i18',
}
await vi.advanceTimersByTime(32)
expect(result.heightChart.value).toEqual([...items, spacerOld])
list.value.push({ id: 'new' })
await vi.advanceTimersByTime(32)
await vi.advanceTimersByTime(32)
expect(result.heightChart.value).toEqual([...items, spacerNew])
})
it('should update boundaries and compute a height chart on scroll', async () => {
let topPosition = 0
const body = ref({
getBoundingClientRect: () => ({
top: topPosition,
}),
})
const list = [...new Array(20)].map((i, index) => ({
id: `${index}i`,
}))
const result = useVirtualScrolling({
name: 'Test',
enabled: ref(true),
list: ref(list),
body: ref(body),
buffer: 3,
offset: 0,
getPlaceholderHeight: () => ref(100),
scrollPositionInstance,
})
result.updateBoundaries()
const amount = 500
topPosition -= amount
result.updateBoundaries()
const spacer1 = {
type: 'spacer',
top: 0,
height: 200,
ids: expect.any(Set),
id: 'i0',
}
const items = [...new Array(14)].map((i, index) => ({
id: `${index + 2}i`,
top: (index + 2) * 100,
height: 100,
type: 'item',
+ present: true,
+ rendered: true,
+ visible: true,
}))
+ for (let i = 0; i < 3; i++) {
+ items[i].visible = false
+ }
+ for (let i = 11; i < 14; i++) {
+ items[i].visible = false
+ }
const spacer2 = {
type: 'spacer',
top: 1600,
height: 400,
ids: expect.any(Set),
id: 'i16',
}
await vi.advanceTimersByTime(32)
expect(result.heightChart.value).toEqual([spacer1, ...items, spacer2])
})
it('should compensate for scroll when new items added on top', async () => {
let topPosition = 0
const body = ref({
getBoundingClientRect: () => ({
top: topPosition,
}),
})
const listOld = [...new Array(10)].map((i, index) => ({
id: `${index}ia`,
}))
const listNew = [...new Array(10)].map((i, index) => ({
id: `${index}ib`,
}))
const list = ref(listOld)
const result = useVirtualScrolling({
name: 'Test',
enabled: ref(true),
list,
body: ref(body),
offset: 0,
buffer: 3,
getPlaceholderHeight: () => ref(100),
scrollCompensation: true,
scrollPositionInstance,
})
listOld.forEach((i) => {
result.updateVirtualHeight({ id: i.id, height: 200 })
})
result.updateBoundaries()
await vi.advanceTimersToNextTimerAsync()
list.value = [...listNew, ...listOld]
result.updateBoundaries()
await vi.advanceTimersToNextTimerAsync()
const amount = 1000
expect(scrollPositionInstance.scrollBy).to.have.been.calledWith(0, amount)
topPosition -= amount
result.updateBoundaries()
const spacer1 = {
type: 'spacer',
top: 0,
height: 700,
ids: expect.any(Set),
id: 'i0',
}
const itemsNew = [...new Array(3)].map((i, index) => ({
id: `${index + 7}ib`,
top: (index + 7) * 100,
height: 100,
type: 'item',
+ present: true,
+ rendered: true,
+ visible: true,
}))
+ for (let i = 0; i < 3; i++) {
+ itemsNew[i].visible = false
+ }
const itemsOld = [...new Array(6)].map((i, index) => ({
id: `${index}ia`,
top: index * 200 + 1000,
height: 200,
type: 'item',
+ present: true,
+ rendered: true,
+ visible: true,
}))
+ for (let i = 4; i < 6; i++) {
+ itemsOld[i].visible = false
+ }
const spacer2 = {
type: 'spacer',
top: 2200,
height: 800,
ids: expect.any(Set),
id: 'i16',
}
await vi.advanceTimersByTime(32)
expect(result.heightChart.value).toEqual([
spacer1,
...itemsNew,
...itemsOld,
spacer2,
])
})
it('should compensate for scroll when items are removed from top', async () => {
let topPosition = 0
const body = ref({
getBoundingClientRect: () => ({
top: topPosition,
}),
})
const listA = [...new Array(10)].map((i, index) => ({
id: `${index}ia`,
}))
const listB = [...new Array(10)].map((i, index) => ({
id: `${index}ib`,
}))
const listC = [...new Array(10)].map((i, index) => ({
id: `${index}ic`,
}))
const list = ref([...listA, ...listB, ...listC])
const result = useVirtualScrolling({
name: 'Test',
enabled: ref(true),
list,
body: ref(body),
offset: 0,
buffer: 3,
getPlaceholderHeight: () => ref(100),
scrollCompensation: true,
scrollPositionInstance,
})
list.value.forEach((i) => {
result.updateVirtualHeight({ id: i.id, height: 200 })
})
result.updateBoundaries()
await vi.advanceTimersToNextTimerAsync()
list.value = listB
result.updateBoundaries()
await vi.advanceTimersToNextTimerAsync()
const amount = -2000
expect(scrollPositionInstance.scrollBy).to.have.been.calledWith(0, amount)
// topPosition -= amount // not doing this because it would overscroll
result.updateBoundaries()
await vi.advanceTimersToNextTimerAsync()
const items = [...new Array(6)].map((i, index) => ({
id: `${index}ib`,
top: index * 200,
height: 200,
type: 'item',
+ present: true,
+ rendered: true,
+ visible: true,
}))
+ for (let i = 4; i < 6; i++) {
+ items[i].visible = false
+ }
const spacer = {
type: 'spacer',
top: 1200,
height: 800,
ids: expect.any(Set),
id: 'i6',
}
await vi.advanceTimersByTime(32)
expect(result.heightChart.value).toEqual([...items, spacer])
})
it('should exclude unsuspendable items from virtualization', async () => {
let topPosition = 0
const body = ref({
getBoundingClientRect: () => ({
top: topPosition,
}),
})
const list = ref(
[...new Array(20)].map((i, index) => ({
id: `${index}i`,
})),
)
const result = useVirtualScrolling({
name: 'Test',
enabled: ref(true),
list,
body: ref(body),
offset: 0,
buffer: 3,
getPlaceholderHeight: () => ref(100),
scrollPositionInstance,
})
list.value.forEach((i) => {
result.updateVirtualHeight({ id: i.id, height: 200 })
})
result.changeSuspendState({ id: '2i', suspendable: false })
result.changeSuspendState({ id: '3i', suspendable: true })
result.updateBoundaries()
await vi.advanceTimersToNextTimerAsync()
result.updateBoundaries()
await vi.advanceTimersToNextTimerAsync()
const amount = 2000
topPosition -= amount
result.updateBoundaries()
await vi.advanceTimersToNextTimerAsync()
const spacer1 = {
type: 'spacer',
top: 0,
height: 400,
ids: expect.any(Set),
id: 'i0',
}
const items1 = [
{
id: `2i`,
top: 400,
height: 200,
type: 'item',
+ present: true,
+ rendered: false,
+ visible: false,
},
]
const spacer2 = {
type: 'spacer',
top: 600,
height: 1000,
ids: expect.any(Set),
id: 'i3',
}
const items2 = [...new Array(8)].map((i, index) => ({
id: `${index + 8}i`,
top: (index + 8) * 200,
height: 200,
type: 'item',
+ present: true,
+ rendered: true,
+ visible: true,
}))
+ for (let i = 0; i < 2; i++) {
+ items2[i].visible = false
+ }
+ for (let i = 6; i < 8; i++) {
+ items2[i].visible = false
+ }
const spacer3 = {
type: 'spacer',
top: 3200,
height: 800,
ids: expect.any(Set),
id: 'i16',
}
await vi.advanceTimersByTime(32)
expect(result.heightChart.value).toEqual([
spacer1,
...items1,
spacer2,
...items2,
spacer3,
])
})
})

File Metadata

Mime Type
text/x-diff
Expires
Fri, Oct 30, 12:10 AM (1 d, 13 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1798942
Default Alt Text
(73 KB)

Event Timeline