Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85712743
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
64 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index 7c160bc421..e8e52c7d14 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,319 +1,309 @@
import { get } from 'lodash-es'
import { storeToRefs } from 'pinia'
import {
computed,
nextTick,
provide,
ref,
toRefs,
useTemplateRef,
watch,
} from 'vue'
import { useRouter } from 'vue-router'
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from 'src/components/quick_view_settings/quick_view_settings.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import ThreadTree from 'src/components/thread_tree/thread_tree.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useStreamingStore } from 'src/stores/streaming.js'
import { useConversation } from 'src/composables/useConversation.js'
import { useTreeConversationTopology } from 'src/composables/useTreeConversationTopology.js'
import { useVirtualScrolling } from 'src/composables/useVirtualScrolling.js'
import { WSConnectionStatus } from 'src/api/websocket.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft,
faReply,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft,
faReply,
faTimes,
)
export default {
props: {
statusId: {
// Main thing
type: String,
required: true,
},
isPage: {
// Whether conversation is rendered as a standalone page
// as opposed to embedded into a timeline
type: Boolean,
default: false,
},
},
components: {
ThreadTree,
QuickFilterSettings,
QuickViewSettings,
ChatMessageList,
PostStatusForm,
RichContent,
},
setup(props) {
// # Helpers
const getStatusObject = (id) => useStatusesStore().allStatuses.get(id)
const getConversationId = (statusId) => {
const status = getStatusObject(statusId)
return get(
status,
'retweeted_status.statusnet_conversation_id',
get(status, 'statusnet_conversation_id'),
)
}
+ 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}]`)
+ await nextTick()
+ target.scrollIntoView({ behavior: 'smooth', block: 'center' })
+ }
+
const { statusId } = toRefs(props)
const router = useRouter()
// # Main Configuration / global state
const { mergedConfig } = storeToRefs(useMergedConfigStore())
const { mastoUserSocketStatus } = storeToRefs(useStreamingStore())
const displayStyle = computed(() => mergedConfig.value.conversationDisplay)
const streamingEnabled = computed(
() =>
mergedConfig.value.useStreamingApi &&
mastoUserSocketStatus === WSConnectionStatus.JOINED,
)
const { layoutType } = storeToRefs(useInterfaceStore())
const mobileLayout = computed(() => layoutType.value === 'mobile')
// # Conversation Expansion
const expanded = ref(false)
const { isPage } = toRefs(props)
const isExpanded = computed(() => !!(expanded.value || isPage.value))
const toggleExpanded = () => {
expanded.value = !expanded.value
}
provide('isExpanded', isExpanded)
provide('isPage', isPage)
+ provide('expandable', true)
// # Focus
const focusedId = ref(statusId.value)
const focused = computed(() => (isExpanded.value ? focusedId.value : null))
const setFocused = (id) => {
if (!id) return
focusedId.value = id
if (!streamingEnabled.value) {
useStatusesStore().fetchStatus(id)
}
useStatusesStore().fetchFavsAndRepeats(id)
useStatusesStore().fetchEmojiReactions(id)
}
provide('focused', focused)
// # Main things
const {
currentStatus,
conversation,
replies,
getReplies,
fetchConversation,
loadError,
} = useConversation(focusedId, isExpanded)
watch(expanded, (value) => {
+ tryScrollTo(currentStatus.value.id)
if (value) {
fetchConversation()
} else {
resetDisplayState()
}
- })
+ }, { flush: 'post' })
const resetDisplayState = () => {
setFocused(statusId.value)
resetThreadDisplay()
}
watch(statusId, (newVal, oldVal) => {
const newConversationId = getConversationId(newVal)
const oldConversationId = getConversationId(oldVal)
if (
newConversationId &&
oldConversationId &&
newConversationId === oldConversationId
) {
setFocused(newVal)
} else {
resetDisplayState()
fetchConversation()
}
})
// Component created
if (isPage.value) {
fetchConversation()
}
// # Misc UI things
const firstStatus = computed(() => conversation.value[0])
const lastStatus = computed(
() => conversation.value[conversation.value.legnth - 1],
)
const getStatusClasses = (status, active) => ({
'-first': status.id === firstStatus.value?.id,
'-last': status.id === lastStatus.value?.id,
})
// # Linear style stuff
const isLinearView = computed(() => displayStyle.value !== 'tree')
const linearElement = useTemplateRef('linear')
const {
heightChart: heightChartLinear,
changeSuspendState: changeSuspendStateLinear,
updateVirtualHeight: updateVirtualHeightLinear,
- } = useVirtualScrolling(conversation, linearElement)
+ } = useVirtualScrolling(conversation, linearElement, currentStatus)
// # Tree style stuff
const isTreeView = computed(() => displayStyle.value === 'tree')
const {
topLevel,
currentAncestors,
threadDisplay,
showThreadRecursively,
resetThreadDisplay,
} = useTreeConversationTopology(conversation, replies, focusedId)
provide('threadDisplay', threadDisplay)
const ancestorsElement = useTemplateRef('ancestors')
const {
heightChart: heightChartAncestors,
changeSuspendState: changeSuspendStateAncestors,
updateVirtualHeight: updateVirtualHeightAncestors,
} = useVirtualScrolling(currentAncestors, ancestorsElement)
const currentLevel = computed(() => [currentStatus.value].filter(Boolean))
const currentLevelElement = useTemplateRef('currentLevel')
const {
heightChart: heightChartCurrentLevel,
+ totalHeight: totalHeightCurrentLevel,
changeSuspendState: changeSuspendStateCurrentLevel,
updateVirtualHeight: updateVirtualHeightCurrentLevel,
- } = useVirtualScrolling(currentLevel, currentLevelElement)
+ } = useVirtualScrolling(currentLevel, currentLevelElement, currentStatus)
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,
)
const shouldFadeAncestors = computed(
() => mergedConfig.value.conversationTreeFadeAncestors,
)
// # Scrolling
- const tryScrollTo = (id) => {
- if (!id) {
- return
- }
- if (isPage.value) {
- router.push({ name: 'conversation', params: { statusId: id } })
- }
- // Because the conversation can be unmounted when out of sight
- // and mounted again when it comes into sight,
- // the `mounted` or `created` function in `status` should not
- // contain scrolling calls, as we do not want the page to jump
- // when we scroll with an expanded conversation.
- //
- // Now the method is to rely solely on the `focused` watcher
- // in `status` components.
- // In linear views, all statuses are rendered at all times, but
- // in tree views, it is possible that a change in active status
- // removes and adds status components (e.g. an originally child
- // status becomes an ancestor status, and thus they will be
- // different).
- // Here, let the components be rendered first, in order to trigger
- // the `focused` watcher.
- nextTick(() => {
- setFocused(id)
- })
- }
const diveIntoStatus = (id) => {
tryScrollTo(id)
}
const diveToTopLevel = () => {
tryScrollTo(currentAncestors.value[0].id)
}
return {
// # Misc
loadError,
mobileLayout,
+ // # Conversation Expansion
+ isPage,
+ isExpanded,
+ toggleExpanded,
+
// # Focus
focused,
setFocused,
// # Main things
conversation,
currentStatus,
getReplies,
- // # Conversation Expansion
- isPage,
- isExpanded,
- toggleExpanded,
-
// # Misc UI things
getStatusClasses,
// # Linear style stuff
isLinearView,
// ## Linear virtual scrolling
heightChartLinear,
changeSuspendStateLinear,
updateVirtualHeightLinear,
// # Tree style stuff
isTreeView,
// ## Tree virtual scrolling
heightChartAncestors,
changeSuspendStateAncestors,
updateVirtualHeightAncestors,
heightChartCurrentLevel,
changeSuspendStateCurrentLevel,
updateVirtualHeightCurrentLevel,
// ## Tree state
// ### Topology
topLevel,
currentAncestors,
// ### Thread Display
showThreadRecursively,
// ### Derived values and config
treeViewIsSimple,
shouldShowAllConversationButton,
shouldShowAncestors,
shouldFadeAncestors,
// # Scrolling
diveToTopLevel,
diveIntoStatus,
}
},
}
diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue
index 6c34210336..3a61decf55 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -1,185 +1,187 @@
<template>
<div
ref="root"
class="Conversation"
:class="{ '-expanded' : isExpanded, '-page': isPage, 'panel' : isExpanded }"
>
<div
v-if="isExpanded"
class="panel-heading conversation-heading -sticky"
>
<h1 class="title">
<RichContent
v-if="conversation[0]?.summary_raw_html"
:html="conversation[0].summary_raw_html"
:emoji="conversation[0].emojis"
/>
<template v-else>
{{ $t('timeline.conversation') }}
</template>
</h1>
<button
v-if="!isPage"
class="button-unstyled -link"
@click.prevent="toggleExpanded"
>
{{ $t('timeline.collapse') }}
</button>
<QuickFilterSettings
v-if="isPage && mobileLayout"
:conversation="true"
class="rightside-button"
/>
<QuickViewSettings
v-if="isPage"
:conversation="true"
class="rightside-button"
/>
</div>
<div
v-if="isPage && !currentStatus"
class="conversation-body"
ref="body"
:class="{ 'panel-body': isExpanded }"
>
<p v-if="!loadError">
<FAIcon
spin
icon="circle-notch"
/>
{{ $t('status.loading') }}
</p>
<p v-else>
{{ $t('status.load_error', { error: loadError }) }}
</p>
</div>
<div
v-else
class="conversation-body"
ref="body"
:class="{ 'panel-body': isExpanded }"
>
<div
v-if="isTreeView"
class="thread-body"
>
<div
v-if="shouldShowAllConversationButton"
class="conversation-dive-to-top-level-box"
>
<i18n-t
keypath="currentStatus.show_all_conversation_with_icon"
tag="button"
class="button-unstyled -link"
scope="global"
@click.prevent="diveToTopLevel"
>
<template #icon>
<FAIcon
icon="angle-double-left"
/>
</template>
<template #text>
<span>
{{ $t('status.show_all_conversation', { numStatus: topLevel.length - 1 }, topLevel.length - 1) }}
</span>
</template>
</i18n-t>
</div>
<div
v-if="shouldShowAncestors"
ref="ancestors"
class="thread-ancestors"
>
<article
v-for="element in heightChartAncestors"
class="thread-ancestor"
:class="{'thread-ancestor-has-other-replies': getReplies(element.id).size > 1, '-faded': shouldFadeAncestors}"
>
<Status
v-if="element.type === 'status'"
class="conversation-status panel-body"
:class="getStatusClasses(element.status)"
:status-id="element.status.id"
:replies="getReplies(element.status.id)"
:focused="focused === element.status.id"
conversation-rank="ancestor"
+ :data-status-id="element.id"
@goto="setFocused"
@dive="diveIntoStatus(element.status.id)"
@suspendable-state-change="changeSuspendStateAncestors"
@height-change="updateVirtualHeightAncestors"
/>
<div
v-if="element.type === 'spacer'"
class="virtual-spacer"
:style="{ height: element.height + 'px' }"
/>
</article>
</div>
<div
class="currentLevel"
ref="currentLevel"
>
<!-- Technically this will always have a single element but -->
<!-- it's more convenient for us to use a v-for here -->
<template v-for="element in heightChartCurrentLevel">
<ThreadTree
v-if="element.type === 'status'"
:status-id="currentStatus.id"
:depth="0"
@goto="setFocused"
@dive="diveIntoStatus"
@toggle-expanded="toggleExpanded"
@show-thread-recursively="showThreadRecursively"
@suspendable-state-change="changeSuspendStateCurrentLevel"
@height-change="updateVirtualHeightCurrentLevel"
/>
<div
v-if="element.type === 'spacer'"
class="virtual-spacer"
:style="{ height: element.height + 'px' }"
/>
</template>
</div>
</div>
<div
v-else-if="isLinearView"
ref="linear"
class="thread-body"
>
<article
v-for="element in heightChartLinear"
class="panel-body"
:key="element.id ?? element.ids"
>
<Status
v-if="element.type === 'status'"
class="conversation-status"
:class="getStatusClasses(element.status)"
:status-id="element.status.id"
:replies="getReplies(element.status.id)"
:focused="focused === element.id || focused === element.status.retweeted_status?.id"
+ :data-status-id="element.id"
@goto="setFocused"
@toggle-expanded="toggleExpanded"
@suspendable-state-change="changeSuspendStateLinear"
@height-change="updateVirtualHeightLinear"
/>
<div
v-if="element.type === 'spacer'"
class="virtual-spacer"
:style="{ height: element.height + 'px' }"
/>
</article>
</div>
</div>
</div>
</template>
<script src="./conversation.js"></script>
<style src="./conversation.scss" />
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 92c887386f..7b4649b6a0 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -1,626 +1,622 @@
import { defineAsyncComponent } from '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 { 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,
Timeago,
StatusPopover,
UserListPopover,
EmojiReactions,
StatusContent,
MentionLink,
MentionsLine,
UserPopover,
UserLink,
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,
threadDisplayState: String,
conversationRank: {
type: String,
default: 'linear',
},
},
emits: [
'goto',
'dive',
'toggleExpanded',
'suspendableStateChange',
'heightChange',
],
inject: {
profileUserId: {
- type: String,
default: null,
},
isPage: {
- type: Boolean,
default: false,
},
isExpanded: {
- type: Boolean,
default: false,
},
expandable: {
- type: Boolean,
default: false,
},
},
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: {
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)
},
simpleTree() {
return !this.mergedConfig.conversationTreeAdvanced
},
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))
},
userProfileLink() {
return this.generateUserProfileLink(this.user.id, 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
},
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
},
inThreadForest() {
return !!this.threadDisplayState
},
threadShowing() {
return this.threadDisplayState === 'showing'
},
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
},
},
methods: {
visibilityIcon(visibility) {
switch (visibility) {
case 'private':
return 'lock'
case 'unlisted':
return 'lock-open'
case 'direct':
return 'envelope'
case 'local':
return 'igloo'
default:
return 'globe'
}
},
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
},
gotoOriginal(id) {
if (this.inConversation) {
this.$emit('goto', id)
}
},
toggleExpanded() {
this.$emit('toggleExpanded')
},
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
},
toggleThreadDisplay() {
// FIXME
this.controlledToggleThreadDisplay()
},
updateVirtualHeight(e) {
const [entry] = e
this.$emit('heightChange', {
id: this.status.id,
height: entry.contentRect.height,
element: this.$el,
})
},
},
mounted() {
this.resizeObserver.observe(this.$el)
},
unmounted() {
this.resizeObserver.disconnect()
},
watch: {
'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 (suspend) {
this.$emit('suspendableStateChange', { id: this.status.id, suspend })
},
},
}
export default Status
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 2adbf5d416..c0a1b02388 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -1,576 +1,576 @@
<template>
<div
v-if="!hideStatus"
ref="root"
class="Status"
:class="rootClasses"
>
<div
v-if="error"
class="alert error"
>
{{ error }}
<button
class="fa-scale-110 fa-old-padding"
type="button"
@click="clearError"
>
<FAIcon icon="times" />
</button>
</div>
<template v-if="muted && !isPreview">
<div class="status-container muted">
<small class="status-username">
<FAIcon
v-if="muted && isRepeat"
class="fa-scale-110 fa-old-padding repeat-icon"
icon="retweet"
/>
<UserLink
:user="repeater"
:at="false"
/>
</small>
<small class="mute-reason">
{{ muteLocalized }}
</small>
<button
class="unmute button-unstyled"
@click.prevent="toggleMute"
>
<FAIcon
icon="eye-slash"
class="fa-scale-110 fa-old-padding"
/>
</button>
</div>
</template>
<template v-else>
<div
v-if="isRepeat && !noHeading && !inConversation"
:class="[repeaterClass, { highlighted: repeaterStyle }]"
:style="[repeaterStyle]"
class="status-container repeat-info"
>
<UserAvatar
class="left-side repeater-avatar"
:user-id="repeater.id"
/>
<div class="right-side faint">
<bdi
class="status-username repeater-name"
:title="repeaterName"
>
<router-link
v-if="repeaterHtml"
:to="repeaterProfileLink"
>
<RichContent
:html="repeaterHtml"
:emoji="repeater.emoji"
:allow-non-square-emoji="allowNonSquareEmoji"
:pause-mfm="pauseMfm"
:scale-mfm="scaleMfm"
:is-local="repeater.is_local"
/>
</router-link>
<router-link
v-else
:to="repeaterProfileLink"
>{{ repeaterName }}</router-link>
</bdi>
<div class="repeat-label">
<FAIcon
icon="retweet"
class="repeat-icon"
:title="$t('tool_tip.repeat')"
/>
{{ $t('timeline.repeated') }}
</div>
</div>
</div>
<div
v-if="!isDeleted"
:class="[userClass, { highlighted: userStyle, '-repeat': isRepeat && !inConversation }]"
:style="[ userStyle ]"
class="status-container"
:data-tags="tags"
>
<div
v-if="!noHeading"
class="left-side"
>
<a
v-if="user.name"
:href="$router.resolve(userProfileLink).href"
@click.prevent
>
<UserPopover
:user-id="user.id"
:overlay-centers="true"
>
<UserAvatar
class="post-avatar"
:compact="compact"
:user-id="user.id"
/>
</UserPopover>
</a>
<UserAvatar
v-else
:user-id="user.id"
class="post-avatar"
:compact="compact"
:title="$t('status.unknown_user_info')"
/>
</div>
<div class="right-side">
<div
v-if="!noHeading"
class="status-heading"
>
<div class="heading-name-row">
<div
v-if="user"
class="heading-left"
>
<h4
v-if="user.name_html"
class="status-username"
:title="user.name"
>
<RichContent
:html="user.name"
:emoji="user.emoji"
:allow-non-square-emoji="allowNonSquareEmoji"
:is-local="user.is_local"
/>
</h4>
<h4
v-else
class="status-username"
:title="user.name"
>
{{ user.name }}
</h4>
<UserLink
class="account-name"
:title="user.screen_name_ui"
:user="user"
:at="false"
/>
<img
v-if="!!(user && user.favicon)"
class="status-favicon"
:src="user.favicon"
>
</div>
<span class="heading-right">
<span
v-if="mainStatus.pinned"
class="pin"
>
<FAIcon
icon="thumbtack"
class="faint"
/>
<span class="faint">{{ $t('status.pinned') }}</span>
</span>
<router-link
class="timeago faint"
:to="{ name: 'conversation', params: { statusId: status.id } }"
>
<Timeago
:time="mainStatus.created_at"
:auto-update="60"
/>
</router-link>
<span
v-if="mainStatus.visibility"
class="visibility-icon"
:title="visibilityLocalized"
>
<FAIcon
fixed-width
class="fa-scale-110"
:icon="visibilityIcon(status.visibility)"
/>
</span>
<button
- v-if="!isExpanded && !isPreview"
+ v-if="expandable && !isExpanded && !isPreview"
class="button-unstyled"
:title="$t('status.expand')"
@click.prevent="toggleExpanded"
>
<FAIcon
fixed-width
class="fa-scale-110"
icon="plus-square"
/>
</button>
<button
v-if="unmuted"
class="button-unstyled"
@click.prevent="toggleMute"
>
<FAIcon
fixed-width
icon="eye-slash"
class="fa-scale-110"
/>
</button>
<button
v-if="inThreadForest && replies?.size && !simpleTree"
class="button-unstyled"
:title="threadShowing ? $t('status.thread_hide') : $t('status.thread_show')"
:aria-expanded="threadShowing ? 'true' : 'false'"
@click.prevent="toggleThreadDisplay"
>
<FAIcon
fixed-width
class="fa-scale-110"
:icon="threadShowing ? 'chevron-up' : 'chevron-down'"
/>
</button>
<button
v-if="isExpanded && !simpleTree"
class="button-unstyled"
:title="$t('status.show_only_conversation_under_this')"
@click.prevent="$emit('dive')"
>
<FAIcon
fixed-width
class="fa-scale-110"
:icon="'angle-double-right'"
/>
</button>
</span>
</div>
<div
v-if="scrobblePresent"
class="status-rich-presence"
>
<a
v-if="scrobble.externalLink"
:href="scrobble.externalLink"
target="_blank"
>
{{ scrobble.artist }} — {{ scrobble.title }}
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="play"
/>
<span class="status-rich-presence-time">
<Timeago
template-key="time.in_past"
:time="scrobble.created_at"
:auto-update="60"
/>
</span>
</a>
<span v-if="!scrobble.externalLink">
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="music"
/>
{{ scrobble.artist }} — {{ scrobble.title }}
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="play"
/>
<span class="status-rich-presence-time">
<Timeago
template-key="time.in_past"
:time="scrobble.created_at"
:auto-update="60"
/>
</span>
</span>
</div>
<div
v-if="isReply || hasMentionsLine"
class="heading-reply-row"
>
<span
v-if="isReply"
class="glued-label reply-glued-label"
>
<i18n-t
keypath="status.reply_to_with_arg"
scope="global"
>
<template #replyToWithIcon>
<StatusPopover
v-if="!isPreview"
:status-id="mainStatus.parent_visible && mainStatus.in_reply_to_status_id"
class="reply-to-popover"
style="min-width: 0;"
:class="{ '-strikethrough': !mainStatus.parent_visible }"
>
<button
class="button-unstyled reply-to"
:aria-label="$t('tool_tip.reply')"
@click.prevent="gotoOriginal(status.in_reply_to_status_id)"
>
<i18n-t
keypath="status.reply_to_with_icon"
scope="global"
>
<template #icon>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="reply"
flip="horizontal"
/>
</template>
<template #replyTo>
<span
class="reply-to-text"
>
{{ $t('status.reply_to') }}
</span>
</template>
</i18n-t>
</button>
</StatusPopover>
<span
v-else
class="reply-to-no-popover"
>
<span class="reply-to-text">{{ $t('status.reply_to') }}</span>
</span>
</template>
<template #user>
<MentionLink
:content="replyToName"
:url="replyProfileLink"
:user-id="status.in_reply_to_user_id"
:user-screen-name="status.in_reply_to_screen_name"
/>
</template>
</i18n-t>
</span>
<!-- This little wrapper is made for sole purpose of "gluing" -->
<!-- "Mentions" label to the first mention -->
<span
v-if="hasMentionsLine"
class="glued-label"
>
<span
class="mentions"
:aria-label="$t('tool_tip.mentions')"
@click.prevent="gotoOriginal(status.in_reply_to_status_id)"
>
<span
class="mentions-text"
>
{{ $t('status.mentions') }}
</span>
</span>
<MentionsLine
v-if="hasMentionsLine"
:mentions="mentionsLine.slice(0, 1)"
class="mentions-line-first"
/>
</span>
{{ ' ' }}
<MentionsLine
v-if="hasMentionsLine"
:mentions="mentionsLine.slice(1)"
class="mentions-line"
/>
</div>
<div
v-if="isEdited && editingAvailable && !isPreview"
class="heading-edited-row"
>
<i18n-t
scope="global"
keypath="status.edited_at"
tag="span"
>
<template #time>
<Timeago
template-key="time.in_past"
:time="mainStatus.edited_at"
:auto-update="60"
:long-format="true"
/>
</template>
</i18n-t>
</div>
</div>
<StatusContent
ref="content"
:status="mainStatus"
:focused="focused"
:in-conversation="inConversation"
@mediaplay="addMediaPlaying($event)"
@mediapause="removeMediaPlaying($event)"
@parse-ready="setHeadTailLinks"
/>
<Quote
:status-id="quoteId"
:status-url="quoteUrl"
:status-visible="quoteVisible"
:initially-expanded="quoteExpanded"
/>
<div
v-if="inConversation && !isPreview && replies?.size"
class="replies"
>
<button
v-if="showOtherRepliesInside && replies.size > 1"
class="button-unstyled -link"
:title="$t('status.ancestor_follow', { numReplies: replies.size - 1 }, replies.size - 1)"
@click.prevent="$emit('dive')"
>
{{ $t('status.replies_list_with_others', { numReplies: replies.size - 1 }, replies.size - 1) }}
</button>
<span
v-else
class="faint"
>
{{ $t('status.replies_list') }}
</span>
<StatusPopover
v-for="reply in replies.values()"
:key="reply.id"
:status-id="reply.id"
>
<button
class="button-unstyled -link reply-link"
@click.prevent="gotoOriginal(reply.id)"
>
{{ reply.name }}
</button>
</StatusPopover>
</div>
<Transition name="fade">
<div
v-if="shouldDisplayFavsAndRepeats"
class="favs-repeated-users"
>
<div class="stats">
<UserListPopover
v-if="repeatedBy.size > 0"
:user-ids="repeatedBy"
>
<div class="stat-count">
<a class="stat-title">{{ $t('status.repeats') }}</a>
<div class="stat-number">
{{ repeatedBy.size }}
</div>
</div>
</UserListPopover>
<UserListPopover
v-if="favoritedBy.size > 0"
:user-ids="favoritedBy"
>
<div
class="stat-count"
>
<a class="stat-title">{{ $t('status.favorites') }}</a>
<div class="stat-number">
{{ favoritedBy.size }}
</div>
</div>
</UserListPopover>
<router-link
v-if="mainStatus.quotes_count > 0"
:to="{ name: 'quotes', params: { id: status.id } }"
>
<div
class="stat-count"
>
<a class="stat-title">{{ $t('status.quotes') }}</a>
<div class="stat-number">
{{ mainStatus.quotes_count }}
</div>
</div>
</router-link>
<div class="avatar-row">
<AvatarList :user-ids="combinedFavsAndRepeatsUsers" />
</div>
</div>
</div>
</Transition>
<EmojiReactions
v-if="(mergedConfig.emojiReactionsOnTimeline || focused) && (!noHeading && !isPreview)"
:status="mainStatus"
/>
<StatusActionButtons
v-if="!noHeading && !isPreview"
class="status-action-buttons"
:status="mainStatus"
:replying="replying"
@toggle-replying="toggleReplyForm"
/>
</div>
</div>
<div
v-else
class="gravestone"
>
<div class="left-side">
<UserAvatar
class="post-avatar"
:compact="compact"
/>
</div>
<div class="right-side">
<div class="deleted-text">
{{ $t('status.status_deleted') }}
</div>
</div>
</div>
<div
v-if="replying"
class="status-container reply-form"
>
<PostStatusForm
ref="postStatusForm"
class="reply-body"
:closeable="true"
:replied-status="mainStatus"
@posted="closeReplyForm"
@draft-done="closeReplyForm"
@close-accepted="closeReplyForm"
/>
</div>
<i18n-t
v-if="inConversation && conversationRank === 'ancestor' && !isPreview && showOtherRepliesBelow && replies?.size > 1"
tag="button"
scope="global"
keypath="status.ancestor_follow_with_icon"
class="button-unstyled -link thread-tree-show-replies-button"
@click.prevent="$emit('dive')"
>
<template #icon>
<FAIcon
icon="angle-double-right"
/>
</template>
<template #text>
<span>
{{ $t('status.ancestor_follow', { numReplies: replies.size - 1 }) }}
</span>
</template>
</i18n-t>
</template>
</div>
</template>
<script src="./status.js"></script>
<style src="./status.scss" lang="scss"></style>
diff --git a/src/components/thread_tree/thread_tree.vue b/src/components/thread_tree/thread_tree.vue
index 3f004b4a3a..a13e3d6928 100644
--- a/src/components/thread_tree/thread_tree.vue
+++ b/src/components/thread_tree/thread_tree.vue
@@ -1,100 +1,101 @@
<template>
<article
ref="root"
class="thread-tree"
>
<Status
:key="statusId"
class="conversation-status conversation-status-treeview panel-body"
:status-id="statusId"
:replies="getReplies(statusId)"
:focused="focused === statusId"
+ :data-status-id="statusId"
:conversation-rank="depth === 0 ? 'current' : 'child'"
:thread-display-state="threadDisplay.get(statusId)"
@dive="$emit('dive', statusId)"
@goto="$emit('goto', statusId)"
@toggle-expanded="$emit('toggleExpanded', statusId)"
@suspendable-state-change="(e) => $emit('suspendableStateChange', e)"
/>
<div
v-if="currentReplies.length > 0 && threadShowing"
class="thread-tree-replies"
>
<ThreadTree
v-for="replyStatusId in currentReplies"
:key="replyStatusId"
:depth="depth + 1"
:status-id="replyStatusId"
@show-thread-recursively="(e) => $emit('showThreadRecursively', e)"
@goto="(e) => $emit('goto', e)"
@dive="(e) => $emit('dive', e)"
@suspendable-state-change="e => $emit('suspendableStateChange', e)"
@toggle-expanded="(e) => $emit('toggleExpanded', e)"
/>
</div>
<div
v-if="currentReplies.length && !threadShowing"
class="thread-tree-replies thread-tree-replies-hidden"
>
<i18n-t
v-if="simple"
scope="global"
tag="button"
keypath="status.thread_follow_with_icon"
class="button-unstyled -link thread-tree-show-replies-button"
@click.prevent="$emit('dive', statusId)"
>
<template #icon>
<FAIcon
icon="angle-double-right"
/>
</template>
<template #text>
<span>
{{ $t('status.thread_follow', { numStatus: totalReplyCount[status.id] }, totalReplyCount[status.id]) }}
</span>
</template>
</i18n-t>
<i18n-t
v-else
scope="global"
tag="button"
keypath="status.thread_show_full_with_icon"
class="button-unstyled -link thread-tree-show-replies-button"
@click.prevent="$emit('showThreadRecursively', statusId)"
>
<template #icon>
<FAIcon
icon="angle-double-down"
/>
</template>
<template #text>
<span>
{{ $t('status.thread_show_full', { numStatus: totalReplyCount[status.id], depth: totalReplyDepth[status.id] }, totalReplyCount[status.id]) }}
</span>
</template>
</i18n-t>
</div>
</article>
</template>
<script src="./thread_tree.js"></script>
<style lang="scss">
.thread-tree-replies {
margin-left: var(--status-margin);
border-left: 2px solid var(--border);
}
.thread-tree-replies-hidden {
padding: var(--status-margin);
/* Make the button stretch along the whole row */
display: flex;
align-items: stretch;
flex-direction: column;
}
</style>
diff --git a/src/composables/useVirtualScrolling.js b/src/composables/useVirtualScrolling.js
index 18cffe58b7..1bcd2f454e 100644
--- a/src/composables/useVirtualScrolling.js
+++ b/src/composables/useVirtualScrolling.js
@@ -1,169 +1,208 @@
import { storeToRefs } from 'pinia'
-import { computed, ref, watch } from 'vue'
+import { computed, ref, watch, nextTick } from 'vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useScrollPosition } from 'src/composables/useScrollPosition.js'
import { useWindowSize } from 'src/composables/useWindowSize.js'
-export function useVirtualScrolling(conversation, body) {
+export function useVirtualScrolling(conversation, body, anchorStatus) {
const getStatusObject = (id) => useStatusesStore().allStatuses.get(id)
const { mergedConfig } = storeToRefs(useMergedConfigStore())
+ const anchor = computed(() => anchorStatus?.value.id)
const unsuspendibleIds = ref(new Set())
const changeSuspendState = ({ id, suspend }) => {
if (!suspend) {
unsuspendibleIds.value.add(id)
} else {
unsuspendibleIds.value.delete(id)
}
}
// 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)
+ watch(fontSizeSetting, updateFontSize, { immediate: true })
// Placeholder heights.
const mutedStatusHeight = computed(() => {
return fontSize.value * 1.5
})
const normalStatusHeight = computed(() => {
return fontSize.value * 10
})
// Add buffer zone to boundary, equal to approx 3 statuses heights
const buffer = computed(() => normalStatusHeight.value * 3)
// Heights map.
const heights = ref(new Map())
const totalHeight = computed(() =>
conversation.value.reduce((acc, item) => {
if (heights.value.has(item.id)) {
return acc + heights.value.get(item.id)
} else if (item.muted) {
return acc + mutedStatusHeight.value
} else {
return acc + normalStatusHeight.value
}
}, 0),
)
const updateVirtualHeight = ({ id, height }) => {
heights.value.set(id, height)
}
// Scrolling
const { y: scrollY } = useScrollPosition()
const { height: windowHeight } = useWindowSize()
const topScrollBoundary = ref(0)
const bottomScrollBoundary = ref(0)
const updateBoundaries = () => {
if (!body.value) return // Not mounted yet
const { top } = body.value.getBoundingClientRect()
const distanceItemTopToWindowTop = 0 - top
const distanceItemTopToWindowBottom = windowHeight.value - top
topScrollBoundary.value = distanceItemTopToWindowTop
bottomScrollBoundary.value = distanceItemTopToWindowBottom
}
- watch(windowHeight, updateBoundaries)
- watch(scrollY, updateBoundaries)
- watch(totalHeight, updateBoundaries)
- watch(body, updateBoundaries)
+ const windowWatcher = watch(windowHeight, updateBoundaries)
+ const scrollWatcher = watch(scrollY, updateBoundaries)
+ const heightWatcher = watch(totalHeight, updateBoundaries)
+ const bodyWatcher = watch(body, updateBoundaries)
+ const pauseWatchers = () => {
+ windowWatcher.pause()
+ scrollWatcher.pause()
+ heightWatcher.pause()
+ bodyWatcher.pause()
+ }
+ const resumeWatchers = () => {
+ windowWatcher.resume()
+ scrollWatcher.resume()
+ heightWatcher.resume()
+ bodyWatcher.resume()
+ }
const heightChart = computed(() => {
// Map every height and suspendable state
const chart = conversation.value.map(({ id }) => {
const status = getStatusObject(id)
const height =
(() => {
if (heights.value.has(id)) {
return heights.value.get(id)
} else if (status?.muted) {
return mutedStatusHeight.value
} else {
return normalStatusHeight.value
}
})() + 1 //including border
const suspendable = !unsuspendibleIds.value.has(id)
return { id, height, suspendable, status }
})
// Walk over the list to set top offsets
chart.reduce((sum, item) => {
item.top = sum
return sum + item.height
}, 0)
+ return chart
+ })
+
+ watch(heightChart, async (newVal, oldVal) => {
+ pauseWatchers()
+ const getAnchoredEl = (list) => anchor.value
+ ? list.find(({ id }) => id === anchor.value)
+ : list[list.length - 1]
+ const oldElement = getAnchoredEl(oldVal)
+ const newElement = getAnchoredEl(newVal)
+ const oldOffset = oldElement?.top ?? 0
+ const newOffset = newElement?.top ?? 0
+
+ const diff = newOffset - oldOffset // Positive = down, Negative = up
+
+ console.log(diff, oldElement, newOffset)
+ topScrollBoundary.value += diff
+ bottomScrollBoundary.value += diff
+ await nextTick()
+ window.scrollBy(0, diff)
+
+ updateBoundaries()
+ resumeWatchers()
+ })
+
+ const heightChartGrouped = computed(() => {
// Determine visibility state
- chart.forEach((heightChartItem) => {
+ const chart = heightChart.value.map((heightChartItem) => {
const itemBottomBoundary = heightChartItem.top + heightChartItem.height
const itemTopBoundary = heightChartItem.top
const finalTopScrollBoundary = topScrollBoundary.value - buffer.value
const finalBottomScrollBoundary =
bottomScrollBoundary.value + buffer.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
- heightChartItem.visible = isBelowTopBoundary && isAboveBottomBoundary
+ return { ...heightChartItem, visible: isBelowTopBoundary && isAboveBottomBoundary }
})
// Group invisible statuses into spacers
return chart.reduce((acc, heightChartItem) => {
const { suspendable, visible, height, top, bottom, id, status } =
heightChartItem
const present = visible || !suspendable
if (present) {
return [...acc, { type: 'status', height, top, bottom, id, status }]
} else {
const previousItem = acc[acc.length - 1]
const spacer =
previousItem?.type === 'spacer'
? 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()
spacer.height += height
if (top < spacer.top) spacer.top = top
if (bottom < spacer.bottom) spacer.bottom = bottom
if (previousItem?.type === 'spacer') {
return acc
} else {
return [...acc, spacer]
}
}
}, [])
})
return {
- heightChart,
+ heightChart: heightChartGrouped,
changeSuspendState,
updateVirtualHeight,
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Sep 19, 12:14 PM (1 d, 23 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1767236
Default Alt Text
(64 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline
Log In to Comment