Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85805856
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
61 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
index e0f54da0a7..82bc919de8 100644
--- a/src/components/chat_message/chat_message.js
+++ b/src/components/chat_message/chat_message.js
@@ -1,218 +1,241 @@
import { mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import Attachment from 'src/components/attachment/attachment.vue'
import ChatMessageDate from 'src/components/chat_message_date/chat_message_date.vue'
import EmojiReactions from 'src/components/emoji_reactions/emoji_reactions.vue'
import Gallery from 'src/components/gallery/gallery.vue'
import LinkPreview from 'src/components/link-preview/link-preview.vue'
import MentionLink from 'src/components/mention_link/mention_link.vue'
import Popover from 'src/components/popover/popover.vue'
import StatusActionButtons from 'src/components/status_action_buttons/status_action_buttons.vue'
import StatusBody from 'src/components/status_body/status_body.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 UserPopover from 'src/components/user_popover/user_popover.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useUsersStore } from 'src/stores/users.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faCircleNotch,
faEllipsisH,
faReply,
faRetweet,
faStar,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(faTimes, faEllipsisH, faCircleNotch, faReply, faStar, faRetweet)
const ChatMessage = {
name: 'ChatMessage',
props: [
'edited',
'noHeading',
- 'previousItem',
'chatItem',
- 'previousItem',
'hoveredMessageChain',
'focused',
'repliedTo',
],
- emits: ['hover', 'replyRequested'],
+ emits: ['hover', 'replyRequested', 'heightChange'],
components: {
Popover,
Attachment,
StatusContent,
StatusBody,
StatusActionButtons,
UserAvatar,
Gallery,
LinkPreview,
ChatMessageDate,
EmojiReactions,
UserPopover,
StatusPopover,
MentionLink,
Quote: defineAsyncComponent(() => import('src/components/quote/quote.vue')),
Timeago,
},
inject: ['deleteChatMessage'],
computed: {
isMessage() {
return this.chatItem.type === 'message'
},
message() {
if (!this.isMessage) return null
return this.chatItem.data.retweeted_status ?? this.chatItem.data
},
+ previousItem() {
+ return this.chatItem.olderMessage
+ },
isStatus() {
// ChatMessage only has account_id while Status has full user data
return !!this.message.user
},
authorId() {
return this.isStatus ? this.message.user.id : this.message.account_id
},
author() {
return useUsersStore().findUser(this.authorId)
},
isCurrentUser() {
// mini-hack/optimizaiton:
// - current user would always be in memory so if user is missing it's obviously not us
// - if anon views page then "us" pretty much doesn't exist
if (!this.author || !this.currentUser) return false
return this.author.id === this.currentUser.id
},
// Reply stuff
isCustomReply() {
if (!this.previousItem) return false
if (!this.message.in_reply_to_status_id) return false
- return this.previousItem.data.id !== this.message.in_reply_to_status_id
+ return this.previousItem.id !== this.message.in_reply_to_status_id
},
isBrokenReply() {
if (!this.previousItem) return false
return !this.message.in_reply_to_status_id
},
customReplyTo() {
return useStatusesStore().allStatuses.get(
this.message.in_reply_to_status_id,
)
},
replyToName() {
if (this.message.in_reply_to_screen_name) {
return this.message.in_reply_to_screen_name
} else {
const user = useUsersStore().findUser(this.message.in_reply_to_user_id)
return user?.screen_name_ui
}
},
replyProfileLink() {
if (this.isCustomReply) {
const user = useUsersStore().findUser(this.message.in_reply_to_user_id)
return user.statusnet_profile_url
}
},
// Quote stuff
quoteId() {
return this.message.quote_id
},
quoteUrl() {
return this.message.quote_url
},
quoteVisible() {
return this.message.quote_visible
},
// Content
messageForStatusContent() {
return {
...this.message,
summary: '',
emojis: this.message.emojis,
raw_html: this.message.content || this.message.raw_html || '',
text: this.message.content || '',
}
},
hasAttachment() {
return this.message.attachments.length > 0
},
// Stylistic
classnames() {
return {
'-outgoing': this.isCurrentUser,
'-incoming': !this.isCurrentUser,
'-pending': this.message.pending,
'-focused': this.focused,
}
},
popoverMarginStyle() {
if (this.isCurrentUser) {
return {}
} else {
return { left: 50 }
}
},
// Global stuff
...mapState(useInterfaceStore, {
betterShadow: (store) => store.browserSupport.cssFilter,
}),
...mapState(useUsersStore, ['currentUser']),
...mapState(useInstanceStore, ['restrictedNicknames']),
...mapState(useMergedConfigStore, ['mergedConfig']),
},
data() {
return {
+ resizeObserver: new ResizeObserver(this.updateVirtualHeight),
hovered: false,
menuOpened: false,
}
},
+ mounted() {
+ if (this.$refs.root) {
+ this.resizeObserver.observe(this.$refs.root)
+ this.updateVirtualHeight([
+ {
+ contentRect: this.$refs.root.getBoundingClientRect(),
+ },
+ ])
+ }
+ },
+ unmounted() {
+ this.resizeObserver.disconnect()
+ },
methods: {
onHover(bool) {
this.$emit('hover', {
isHovered: bool,
messageChainId: this.chatItem.messageChainId,
})
},
+ updateVirtualHeight(e) {
+ const [entry] = e
+ this.$emit('heightChange', {
+ id: this.chatItem.id,
+ height: entry.contentRect.height + 1,
+ element: this.$el,
+ })
+ },
visibilityIcon(visibility) {
switch (visibility) {
case 'private':
return 'lock'
case 'unlisted':
return 'lock-open'
case 'direct':
return 'envelope'
case 'local':
return 'igloo'
default:
return 'globe'
}
},
visibilityLocalized() {
return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility)
},
async deleteMessage() {
const confirmed = window.confirm(this.$t('chats.delete_confirm'))
if (confirmed) {
await this.deleteChatMessage({
messageId: this.message.id,
chatId: this.message.chat_id,
})
}
this.hovered = false
this.menuOpened = false
},
},
}
export default ChatMessage
diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue
index a6700201d4..d69bc32003 100644
--- a/src/components/chat_message/chat_message.vue
+++ b/src/components/chat_message/chat_message.vue
@@ -1,252 +1,254 @@
<template>
<div
v-if="isMessage"
+ ref="root"
:id="`chatmessage-${message.id}`"
class="chat-message-wrapper"
:class="[classnames, { 'hovered-message-chain': hoveredMessageChain }]"
@mouseover="onHover(true)"
@mouseleave="onHover(false)"
>
<i18n-t
v-if="isStatus && (isCustomReply || isBrokenReply)"
keypath="status.reply_to_with_arg"
scope="global"
tag="small"
class="reply-to-header faint"
>
<template #replyToWithIcon>
<div class="avatar-spacer" />
<StatusPopover
v-if="!isBrokenReply"
:status-id="customReplyTo?.id"
class="reply-to-popover"
:class="{ '-strikethrough': !message.parent_visible }"
>
<i18n-t
keypath="status.reply_to_with_icon"
scope="global"
>
<template #icon>
<FAIcon
class="fa-scale-110"
icon="reply"
flip="horizontal"
/>
</template>
<template #replyTo>
<span class="reply-label">
{{ $t('status.reply_to') }}
</span>
</template>
</i18n-t>
</StatusPopover>
<span
v-else
class="reply-label"
>
{{ $t('status.broken_reply') }}
</span>
</template>
<template #user>
<MentionLink
class="reply-body"
:content="replyToName"
:url="replyProfileLink"
:user-id="message.in_reply_to_user_id"
:user-screen-name="message.in_reply_to_screen_name"
/>
<!-- v-if is there because status might not be loaded yet -->
<template v-if="customReplyTo?.text.trim().length > 0">
:
<StatusBody
class="reply-body faint"
:status="customReplyTo"
collapse
single-line
ignore-subject
/>
</template>
</template>
</i18n-t>
<div
class="chat-message"
:class="classnames"
>
<div
v-if="!isCurrentUser"
class="avatar-wrapper"
>
<UserPopover
v-if="chatItem.isHead"
:user-id="authorId"
>
<UserAvatar
v-if="author"
:compact="true"
:user-id="author.id"
/>
</UserPopover>
<div
v-else
class="avatar-spacer"
/>
</div>
<div class="chat-message-inner">
<div class="message-bubble-wrapper">
<div
class="status-body"
:style="{ 'min-width': message.attachment ? '80%' : '' }"
>
<div
class="media status"
:class="{ 'without-attachment': !hasAttachment, 'pending': chatItem.data.pending, 'error': chatItem.data.error }"
style="position: relative;"
@mouseenter="hovered = true"
@mouseleave="hovered = false"
>
<StatusActionButtons
v-if="isStatus"
class="chat-message-toolbar"
:class="{ '-visible': hovered || menuOpened }"
:status="message"
:pinned="new Set(['reply', 'emoji'])"
fixed-pinned
use-default-buttons
hide-labels
in-chat-view
@toggle-replying="$emit('replyRequested', message)"
/>
<div
v-else
class="chat-message-toolbar"
:class="{ '-visible': hovered || menuOpened }"
>
<Popover
trigger="click"
:trigger-attrs="{ 'class': 'button-default menu-icon simple-button', title: $t('chats.more') }"
placement="top"
:margin="popoverMarginStyle"
@show="menuOpened = true"
@close="menuOpened = false"
>
<template #content>
<div class="dropdown-menu">
<div class="menu-item dropdown-item -icon">
<button
class="main-button"
@click="deleteMessage"
>
<FAIcon icon="times" /> {{ $t("chats.delete") }}
</button>
</div>
</div>
</template>
<template #trigger>
<FAIcon icon="ellipsis-h" />
</template>
</Popover>
</div>
<StatusContent
class="message-content"
:class="{ faint: message.pending }"
:status="messageForStatusContent"
:full-content="true"
>
<template #footer>
<EmojiReactions
v-if="isStatus"
:status="message"
/>
<Quote
v-if="isStatus"
class="quoted-post"
:status-id="quoteId"
:status-url="quoteUrl"
:status-visible="quoteVisible"
initially-expanded
/>
<span
class="created-at"
>
<span
v-if="message.favorited"
>
<FAIcon
class="fa-scale-110"
icon="star"
fixed-width
/>
</span>
<span
v-if="message.repeated"
>
<FAIcon
class="fa-scale-110"
icon="retweet"
fixed-width
/>
</span>
<span
v-if="message.visibility"
class="visibility-icon"
:title="visibilityLocalized"
>
<FAIcon
class="fa-scale-110"
:icon="visibilityIcon(message.visibility)"
fixed-width
/>
</span>
<span
v-if="message.pending"
class="loading-spinner"
>
<FAIcon
class="fa-old-padding"
icon="circle-notch"
spin
/>
</span>
{{ ' ' }}
<router-link
class="timeago faint"
:to="{ name: 'conversation2', params: { statusId: message.id } }"
>
<Timeago
:time="message.created_at"
:auto-update="60"
/>
</router-link>
</span>
</template>
</StatusContent>
</div>
</div>
<div
v-if="isStatus && repliedTo"
class="reply-indicator"
>
<FAIcon
class="icon"
icon="reply"
/>
</div>
<div class="end-spacer" />
</div>
</div>
</div>
</div>
<div
v-else
class="chat-message-date-separator"
+ ref="root"
>
<ChatMessageDate
:date="chatItem.date"
:show-time="chatItem.isTime"
/>
</div>
</template>
<script src="./chat_message.js"></script>
<style src="./chat_message.scss" lang="scss" />
diff --git a/src/components/chat_message_list/chat_message_list.js b/src/components/chat_message_list/chat_message_list.js
index e5149784c1..50855441c8 100644
--- a/src/components/chat_message_list/chat_message_list.js
+++ b/src/components/chat_message_list/chat_message_list.js
@@ -1,133 +1,161 @@
-import { orderBy, uniqueId } from 'lodash-es'
+import { toRefs, ref, computed, useTemplateRef, inject } from 'vue'
+import { orderBy } from 'lodash-es'
+import { useVirtualScrolling } from 'src/composables/useVirtualScrolling.js'
+import { useInterfaceSizes } from 'src/composables/useInterfaceSizes.js'
import ChatMessage from 'src/components/chat_message/chat_message.vue'
const ChatMessageList = {
components: {
ChatMessage,
},
props: {
messages: Array,
pendingMessages: {
type: Array,
required: false,
default: [],
},
headerDate: Boolean,
focusedId: String,
repliedId: String,
},
- data() {
- return {
- hoveredMessageChainId: undefined,
- }
- },
- emits: ['messageDelete', 'replyRequested'],
- computed: {
- chatItems() {
- const messages = [
- ...orderBy(this.messages, ['pending', 'id'], ['asc', 'asc']),
- ...this.pendingMessages.map((m) => ({ ...m, pending: true })),
+ emits: ['replyRequested'],
+ setup(props, { emit }) {
+ const {
+ messages,
+ pendingMessages,
+ headerDate,
+ } = toRefs(props)
+ const hoveredMessageChainId = ref(null)
+ const chatItems = computed(() => {
+ const allMessages = [
+ ...orderBy(messages.value, ['pending', 'id'], ['asc', 'asc']),
+ ...pendingMessages.value.map((m) => ({ ...m, pending: true })),
]
- return messages
+ return allMessages
.reduceRight((acc, message, index) => {
const date = new Date(message.created_at)
- const olderMessage = messages[index - 1]
+ const olderMessage = messages.value[index - 1]
const newerItem = acc[acc.length - 1]
const diff = olderMessage
? message.created_at - olderMessage.created_at
: null
const MAX_DIFF = 1000 * 60 * 5 // 5 minutes
const dateDiffs = (() => {
if (olderMessage) {
const newerDate = new Date(message.created_at)
const olderDate = new Date(olderMessage.created_at)
newerDate.setHours(0, 0, 0, 0)
olderDate.setHours(0, 0, 0, 0)
return newerDate.toISOString() !== olderDate.toISOString()
} else {
return true
}
})()
const chatItem = {
type: 'message',
data: message,
date,
id: message.id,
isTail: true,
isHead: true,
+ olderMessage,
}
if (newerItem == null) {
- chatItem.messageChainId = uniqueId()
+ chatItem.messageChainId = message.id
} else if (newerItem.type === 'date') {
- chatItem.messageChainId = uniqueId()
+ chatItem.messageChainId = message.id
} else if (newerItem.type === 'message') {
const newerUser =
newerItem.data.account_id || newerItem.data.user.id
const olderUser = message.account_id || message.user.id
if (newerUser !== olderUser) {
- chatItem.messageChainId = uniqueId()
+ chatItem.messageChainId = message.id
} else {
chatItem.messageChainId = newerItem.messageChainId
chatItem.isTail = false
newerItem.isHead = false
}
}
- if (diff > MAX_DIFF || (!olderMessage && this.headerDate)) {
+ if (diff > MAX_DIFF || (!olderMessage && headerDate.value)) {
return [
...acc,
chatItem,
{
type: 'date',
date,
isDate: dateDiffs,
isTime: diff > MAX_DIFF && !dateDiffs,
id: date.getTime().toString(),
},
]
} else {
return [...acc, chatItem]
}
}, [])
.reverse()
- },
- },
- methods: {
- onMessageHover({ isHovered, messageChainId }) {
- this.hoveredMessageChainId = isHovered ? messageChainId : undefined
- },
- onMessageDelete({ messageId, chatId }) {
- this.$emit('messageDelete', { messageId, chatId })
- },
- onReplyRequested(message) {
- this.$emit('replyRequested', message)
- },
- getPreviousItem(index) {
- let result = null
+ })
+ const chatItemsIndex = computed(() =>
+ chatItems.value.reduce((map, value) => {
+ map.set(value.id, value)
+ return map
+ }, new Map())
+ )
- this.chatItems
- .slice(0, index)
- .reverse()
- .some((item) => {
- const isMessage = item.type === 'message'
- if (isMessage) {
- result = item
- }
- return isMessage
- })
+ const getCurrentItem = (id) => chatItemsIndex.value.get(id)
- return result
- },
+ const scroller = inject('bodyScrollPosition')
+ const body = useTemplateRef('body')
+ const offset = computed(() => 0)
+ const { fontSize } = useInterfaceSizes()
+ const normalStatusHeight = computed(() => fontSize.value * 5)
+ const getPlaceholderHeight = (id) => normalStatusHeight
+
+ const { heightChart, changeSuspendState, updateVirtualHeight, scrollTo } =
+ useVirtualScrolling({
+ name: 'ChatMessageList',
+ enabled: ref(true),
+ list: chatItems,
+ body,
+ scrollPositionInstance: scroller,
+ scrollCompensation: true,
+ getPlaceholderHeight,
+ invertDirection: true,
+ })
+
+ const {
+ focusedId,
+ repliedId,
+ } = toRefs(props)
+
+ const onMessageHover = ({ isHovered, messageChainId }) => {
+ hoveredMessageChainId.value = isHovered ? messageChainId : undefined
+ }
+ const onReplyRequested = (message) => emit('replyRequested', message)
+
+ return {
+ heightChart,
+ updateVirtualHeight,
+ getCurrentItem,
+ hoveredMessageChainId,
+
+ focusedId,
+ repliedId,
+
+ onReplyRequested,
+ onMessageHover,
+ }
},
}
export default ChatMessageList
diff --git a/src/components/chat_message_list/chat_message_list.vue b/src/components/chat_message_list/chat_message_list.vue
index 7833730e7c..092c4fda21 100644
--- a/src/components/chat_message_list/chat_message_list.vue
+++ b/src/components/chat_message_list/chat_message_list.vue
@@ -1,19 +1,28 @@
<template>
- <div class="ChatMessageList">
- <ChatMessage
- v-for="(chatItem, index) in chatItems"
- :key="chatItem.id"
- :chat-item="chatItem"
- :previous-item="getPreviousItem(index)"
- :hovered-message-chain="chatItem.messageChainId === hoveredMessageChainId"
- :focused="chatItem.id === focusedId"
- :replied-to="chatItem.id === repliedId"
- @hover="onMessageHover"
- @delete="onMessageDelete"
- @reply-requested="onReplyRequested"
- />
+ <div class="ChatMessageList" ref="body">
+ <template
+ v-for="element in heightChart"
+ :key="element.id"
+ >
+ <ChatMessage
+ v-if="element.type === 'item'"
+ :chat-item="getCurrentItem(element.id)"
+ :hovered-message-chain="getCurrentItem(element.id).messageChainId === hoveredMessageChainId"
+ :focused="element.id === focusedId"
+ :replied-to="element.id === repliedId"
+ @hover="onMessageHover"
+ @reply-requested="onReplyRequested"
+ @height-change="updateVirtualHeight"
+ />
+ <div
+ v-if="element.type === 'spacer'"
+ class="virtual-spacer"
+ aria-hidden="true"
+ :style="{ height: element.height + 'px' }"
+ />
+ </template>
</div>
</template>
<script src="./chat_message_list.js"></script>
<style src="./chat_message_list.scss" lang="scss" />
diff --git a/src/components/chat_view/chat_view.js b/src/components/chat_view/chat_view.js
index aff7279289..a002dfc9cb 100644
--- a/src/components/chat_view/chat_view.js
+++ b/src/components/chat_view/chat_view.js
@@ -1,244 +1,232 @@
import { storeToRefs } from 'pinia'
import {
computed,
inject,
nextTick,
provide,
ref,
toRefs,
useTemplateRef,
+ onUnmounted,
watch,
} from 'vue'
import { useRouter } from 'vue-router'
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
import ChatTitle from 'src/components/chat_title/chat_title.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useClientRectSize } from 'src/composables/useClientRectSize.js'
import { useConversation } from 'src/composables/useConversation.js'
-import { useDocumentFocus } from 'src/composables/useDocumentFocus.js'
import { usePleromaChat } from 'src/composables/usePleromaChat.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronDown, faChevronLeft } from '@fortawesome/free-solid-svg-icons'
library.add(faChevronDown, faChevronLeft)
const MARK_AS_READ_DELAY = 1500
const Chat = {
components: {
ChatMessageList,
ChatTitle,
PostStatusForm,
},
props: {
statusId: {
type: String,
default: null,
},
chatUserId: {
type: String,
default: null,
},
},
setup(props) {
const { statusId, chatUserId } = toRefs(props)
const isConversation = computed(() => !!statusId.value)
const isChat = computed(() => !!chatUserId.value)
const isValid = computed(() => !!isChat.value !== !!isConversation.value) // Logical XOR
watch(isValid, (value) => {
if (!value)
throw TypeError(
'Chat view must have either statusId or chatUserId, but not both',
)
})
// # Conversation stuff
const {
conversation: conversationMessages,
loadError: conversationError,
focusedId,
setFocused,
fetchConversation,
- } = useConversation(statusId, true)
+ } = useConversation(statusId, ref(true))
watch(isConversation, (value) => {
if (!value) return
fetchConversation()
- })
+ }, { immediate: true })
// # Chat stuff
const {
activate: chatActivate,
deactivate: chatDeactivate,
markAsRead: chatMarkRead,
fetchOlder: chatFetchOlder,
messages: chatMessages,
pendingMessages: chatPendingMessages,
newMessagesCount: chatNewMessagesCount,
fetchError: chatError,
+ ready: chatReady,
+ recipient: chatRecipient,
sendMessage,
deleteChatMessage,
} = usePleromaChat(chatUserId)
provide('deleteChatMessage', deleteChatMessage)
watch(chatUserId, (neu, old) => {
if (old) {
chatDeactivate()
}
if (neu) {
chatActivate()
}
- })
+ }, { immediate: true })
+ onUnmounted(() => { chatDeactivate() })
// # Forks
const messages = computed(() => {
if (isConversation.value) {
return conversationMessages.value
} else {
return chatMessages.value
}
})
const pendingMessages = computed(() => {
if (isConversation.value) {
return [] // Not implemented yet
} else {
return chatPendingMessages.value
}
})
const newMessagesCount = computed(() => {
if (isConversation.value) {
return 0 // Not implemented yet
} else {
return chatNewMessagesCount.value
}
})
const error = computed(() => {
if (isConversation.value) {
return conversationError.value
} else {
return chatError.value
}
})
const fetchOlder = () => {
if (isChat.value) {
chatFetchOlder()
}
// Unsupported in conversations
}
const markAsRead = () => {
if (isChat.value) {
chatMarkRead()
}
// Unsupported in conversations
}
// Reply-to
const explicitReply = ref(null)
const lastMessage = computed(() => messages.value?.at(-1))
const replyTo = computed(() => explicitReply.value ?? lastMessage.value)
const scroller = inject('bodyScrollPosition')
const rootElement = useTemplateRef('root')
- const postForm = useTemplateRef('postform')
+ const postForm = useTemplateRef('postStatusForm')
// Post form stuff
watch(replyTo, () => {
- if (isConversation.value) postForm.update()
+ if (isConversation.value) postForm.value.update()
})
// # Scroll stuff
- const { hasReachedBottom, cHeight, scrollTo } = scroller
-
- // ## Bottom-sticking stuff
- const stickToBottom = async (forceRead) => {
- // We don't want to scroll to the bottom on a new message when the user is viewing older messages.
- // Therefore we need to know whether the scroll position was at the bottom before the DOM update.
- if (!hasReachedBottom.value) return
- await nextTick()
- scrollTo({
- top: cHeight.value,
- })
- if (forceRead) {
- markAsRead()
- }
- }
+ const { hasReachedBottom } = scroller
const postFormElement = computed(() => postForm.value?.$el)
- const { focused } = useDocumentFocus()
const { height: postFormHeight } = useClientRectSize(postFormElement)
- const { vHeight: viewportHeight } = scroller
- watch(viewportHeight, stickToBottom)
- watch(postFormHeight, stickToBottom)
- watch(focused, async (value) => {
- if (!value) return
- stickToBottom(true)
- })
- watch(messages, (old, neu) => {
- if (old.length === neu.length) return
- stickToBottom(true)
+ const { scrollBy } = scroller
+
+ // ### Post form size compensation
+ watch(postFormHeight, (neu, old) => {
+ scrollBy(0, neu - old)
})
// ## Load / Read
const { shouldLoadTop } = scroller
watch(shouldLoadTop, (value) => {
if (value) fetchOlder()
})
watch(hasReachedBottom, (value) => {
if (!value) return
window.setTimeout(() => {
if (
newMessagesCount.value > 0 &&
rootElement.value &&
hasReachedBottom.value
) {
markAsRead()
}
}, MARK_AS_READ_DELAY)
})
// Misc UI stuff
const { layoutType } = storeToRefs(useInterfaceStore())
const mobileLayout = computed(() => layoutType.value === 'mobile')
const jumpToBottomButtonVisible = computed(() => !hasReachedBottom.value)
const router = useRouter()
const onPosted = (data) => {
explicitReply.value = null
if (isConversation.value) {
router.push({
name: 'conversation2',
params: { statusId: data.id },
})
}
}
return {
messages,
pendingMessages,
newMessagesCount,
error,
+ replyTo,
+ explicitReply,
// Conversation-exclusive
isConversation,
setFocused,
focusedId,
// Chats-exclusive
+ isChat,
sendMessage,
+ chatReady,
+ chatRecipient,
// Misc
onPosted,
mobileLayout,
jumpToBottomButtonVisible,
}
},
}
export default Chat
diff --git a/src/components/chat_view/chat_view.vue b/src/components/chat_view/chat_view.vue
index 1d565aa87d..2fa7b166b8 100644
--- a/src/components/chat_view/chat_view.vue
+++ b/src/components/chat_view/chat_view.vue
@@ -1,123 +1,122 @@
<template>
<div class="chat-view">
<div class="chat-view-inner">
<div
ref="inner"
class="panel-default panel chat-view-body"
>
<div
ref="header"
class="panel-heading -sticky chat-view-heading"
>
<button
class="button-unstyled go-back-button"
@click="goBack"
>
<FAIcon
size="lg"
icon="chevron-left"
/>
</button>
<div class="title">
<template v-if="isConversation">
<RichContent
v-if="messages[0]?.summary_raw_html"
:html="messages[0].summary_raw_html"
:emoji="messages[0].emojis"
/>
<template v-else>
{{ $t('timeline.conversation') }}
</template>
</template>
<ChatTitle
v-else
- :user="recipient"
+ :user="chatRecipient"
:with-avatar="true"
/>
</div>
</div>
<div class="chat-list-wrapper panel-body">
<div class="top-spacer" />
<ChatMessageList
ref="messageList"
header-date
:messages="messages"
:pending-messages="pendingMessages"
:replied-id="replyTo?.id"
:focused-id="focusedId"
@reply-requested="e => explicitReply = e"
/>
</div>
<div
ref="footer"
class="panel-footer -flexible-height footer"
>
<div
class="jump-to-bottom-button"
:class="{ 'visible': jumpToBottomButtonVisible }"
@click="scrollDown({ behavior: 'smooth' })"
>
<span>
<FAIcon icon="chevron-down" />
<div
- v-if="newMessageCount"
+ v-if="newMessagesCount"
class="badge -notification unread-chat-count unread-message-count"
>
- {{ newMessageCount }}
+ {{ newMessagesCount }}
</div>
</span>
</div>
<div
v-if="isConversation"
class="auto-reply-to-section"
>
<div class="reply-to-text">
- {{ explicitReplyStatus ? $t('status.reply_to_selected') : $t('status.reply_to_last') }}
+ {{ explicitReply ? $t('status.reply_to_selected') : $t('status.reply_to_last') }}
<button
- v-if="explicitReplyStatus"
+ v-if="explicitReply"
class="button-default"
- @click="explicitReplyStatus = null"
+ @click="explicitReply = null"
>
<FAIcon icon="times" />
{{ $t('general.cancel') }}
</button>
</div>
</div>
<PostStatusForm
ref="postStatusForm"
- :replied-status="replyStatus"
+ :replied-status="replyTo"
:mentions-line="isConversation"
mentions-line-read-only
disable-quotes
disable-notice
disable-lock-warning
- :disable-subject="!isConversation"
- :disable-scope-selector="!isConversation"
- :disable-polls="!isConversation"
- :disable-sensitivity-checkbox="!isConversation"
- :disable-preview="!isConversation"
- :disable-draft="!isConversation"
+ :disable-subject="isChat"
+ :disable-scope-selector="isChat"
+ :disable-polls="isChat"
+ :disable-sensitivity-checkbox="isChat"
+ :disable-preview="isChat"
+ :disable-draft="isChat"
- :disable-submit="isConversation ? !replyStatus : (errorLoadingChat || !chat)"
+ :disable-submit="isConversation ? !replyTo : (!!error || !chatReady)"
:optimistic-posting="!isConversation"
chat-view
preserve-focus
:auto-focus="!mobileLayout"
:placeholder="formPlaceholder"
:file-limit="isConversation ? null : 1"
:max-height="160"
emoji-picker-placement="top"
:post-handler="isConversation ? null : sendMessage"
- @resize="handleResize"
@posted="onPosted"
/>
</div>
</div>
</div>
</div>
</template>
<script src="./chat_view.js"></script>
<style src="./chat_view.scss" lang="scss" />
diff --git a/src/composables/usePleromaChat.js b/src/composables/usePleromaChat.js
index 6dab1197b2..44ec455fd3 100644
--- a/src/composables/usePleromaChat.js
+++ b/src/composables/usePleromaChat.js
@@ -1,342 +1,346 @@
import { maxBy, minBy } from 'lodash-es'
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { useChatsStore } from 'src/stores/chats.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useStreamingStore } from 'src/stores/streaming.js'
import { useUsersStore } from 'src/stores/users.js'
import {
deleteChatMessage as apiDeleteChatMessage,
chatMessages,
getOrCreateChat,
readChat,
sendChatMessage,
} from 'src/api/chats.js'
import { WSConnectionStatus } from 'src/api/websocket.js'
import { buildFakeMessage } from 'src/services/chat_utils/chat_utils.js'
import { promiseInterval } from 'src/services/promise_interval/promise_interval.js'
const MAX_RETRIES = 10
export function usePleromaChat(userId) {
const { mergedConfig } = storeToRefs(useMergedConfigStore())
const { mastoUserSocketStatus } = storeToRefs(useStreamingStore())
const streamingEnabled = computed(
() =>
mergedConfig.value.useStreamingApi &&
mastoUserSocketStatus === WSConnectionStatus.JOINED,
)
const chat = ref(null)
const socket = ref(null)
const error = ref(null)
const streaming = ref(false)
const fetcher = ref(null)
const fetching = ref(true)
const minId = ref(undefined)
const maxId = ref(undefined)
const idempotencyKeyIndex = ref(new Map())
const messages = ref([])
const messagesIndex = ref(new Map())
const pendingMessages = ref([])
const pendingMessagesIndex = ref(new Map())
const messageRetriers = ref(new Map())
const lastReadMessageId = ref(null)
const newMessagesCount = ref(0)
// # Posting & Optimism
const { currentUser } = storeToRefs(useUsersStore())
const sendMessage = async ({ status, media, idempotencyKey }) => {
const params = {
id: chat.value.id,
content: status,
idempotencyKey,
}
if (media[0]) {
params.mediaId = media[0].id
}
const fakeMessage = buildFakeMessage({
attachments: media,
chatId: chat.value.id,
content: status,
userId: currentUser.value.id,
idempotencyKey,
})
- pendingMessages.push(fakeMessage)
- pendingMessagesIndex.set(idempotencyKey, fakeMessage)
+ pendingMessages.value.push(fakeMessage)
+ pendingMessagesIndex.value.set(idempotencyKey, fakeMessage)
return doSendMessage({
params,
retriesLeft: MAX_RETRIES,
})
}
const doSendMessage = async ({ params, retriesLeft = MAX_RETRIES }) => {
if (retriesLeft <= 0) return
const handleMessageError = ({ idempotencyKey, isRetry }) => {
- const fakeMessage = pendingMessagesIndex.get(idempotencyKey)
+ const fakeMessage = pendingMessagesIndex.value.get(idempotencyKey)
if (fakeMessage) {
fakeMessage.error = true
fakeMessage.pending = false
}
}
try {
const { data } = await sendChatMessage({
...params,
credentials: useOAuthStore().token,
})
- addMessages({
- messages: [{ ...data }],
- })
+ addMessages([{ ...data }])
} catch (error) {
if (
error.name !== 'StatusCodeError' ||
error.message === 'Failed to fetch'
) {
throw error
}
console.error('Error sending message', error)
handleMessageError({
chatId: chat.value.id,
idempotencyKey: params.idempotencyKey,
isRetry: retriesLeft !== MAX_RETRIES,
})
const error5xx = error.statusCode >= 500 && error.statusCode < 600
if (error5xx || error.message === 'Failed to fetch') {
messageRetriers.set(
params.idempotencyKey,
setTimeout(
() => {
doSendMessage({
params,
retriesLeft: retriesLeft - 1,
})
},
1000 * 2 ** (MAX_RETRIES - retriesLeft),
),
)
}
}
}
// # Poll & Push
const startFetching = (reason, isFirstFetch) => {
console.debug('[Pleroma Chat] Started fetching', 'Reason:', reason)
+ fetchOlder()
fetcher.value = promiseInterval(() => fetchChat({ latest: true }), 5000)
clear()
fetching.value = true
}
const stopFetching = (reason) => {
console.debug('[Pleroma Chat] Stopped fetching', 'Reason:', reason)
fetcher.value.stop()
fetcher.value = null
fetching.value = false
}
const onStreamConnect = () => {
streaming.value = true
stopFetching('Socket connected')
}
const onStreamDisconnect = (closeEvent) => {
streaming.value = false
startFetching('Socket disconnected')
}
const onChatUpdate = ({ data: { chatUpdate } }) => {
const messages = [chatUpdate.lastMessage]
- addMessages({ messages })
+ addMessages(messages)
}
// # Actions
const markAsRead = async () => {
if (!maxId.value || document.hidden) {
return
}
const lastReadId = maxId.value
const isNewMessage = lastReadMessageId.value !== lastReadId
if (!isNewMessage) return
await readChat({
id: chat.value.id,
lastReadId,
credentials: useOAuthStore().token,
})
useChatsStore().readChat(chat.value.id)
lastReadMessageId.value = maxId.value
newMessagesCount.value = 0
}
const deleteChatMessage = async ({ chatId, messageId }) => {
await apiDeleteChatMessage({
chatId,
messageId,
credentials: useOAuthStore().token,
})
messages.value = messages.value.filter((m) => m.id !== messageId)
messagesIndex.delete(messageId)
if (maxId.value === messageId) {
const lastMessage = maxBy(messages.value, 'id')
maxId.value = lastMessage.id
}
if (minId.value === messageId) {
const firstMessage = minBy(messages.value, 'id')
minId.value = firstMessage.id
}
}
const fetchError = ref(null)
const fetchChat = async ({ older = false, latest = false }) => {
if (!older && streamingEnabled.value) {
return
}
try {
const { data: messages } = await chatMessages({
id: chat.value.id,
maxId: maxId.value,
sinceId: older ? null : maxId.value,
credentials: useOAuthStore().token,
})
- addMessages({ messages })
+ addMessages(messages)
fetchError.value = null
} catch (e) {
console.error('Error fetching chat', e)
fetchError.value = e
}
}
const fetchOlder = () => {
fetchChat({ older: true })
}
// # Message list forming
const addMessages = (newMessages) => {
for (let message of newMessages) {
// Clear any known pending messages
if (message.idempotency_key) {
if (pendingMessagesIndex.value.has(message.idempotency_key)) {
pendingMessagesIndex.value.delete(message.idempotency_key)
pendingMessages.value = pendingMessages.value.filter(
({ idempotency_key }) =>
idempotency_key !== message.idempotency_key,
)
}
}
if (!minId.value || (!message.pending && message.id < minId.value)) {
minId.value = message.id
}
if (!maxId.value || message.id > maxId.value) {
maxId.value = message.id
}
const isConfirmation = (message) => {
if (!message.idempotency_key) return
- return idempotencyKeyIndex.has(message.idempotency_key)
+ return idempotencyKeyIndex.value.has(message.idempotency_key)
}
- if (!messagesIndex.has(message.id) && !isConfirmation(message)) {
+ if (!messagesIndex.value.has(message.id) && !isConfirmation(message)) {
if (lastReadMessageId < message.id) {
newMessagesCount.value++
}
messagesIndex.value.set(message.id, message)
messages.value.push(messagesIndex.value.get(message.id))
- idempotencyKeyIndex.set(message.idempotency_key, true)
+ idempotencyKeyIndex.value.set(message.idempotency_key, true)
}
}
}
const clear = () => {
messages.value = messages.value.filter((m) => m.error)
messagesIndex.value = messages.value.reduce((acc, m) => {
acc.set(m.id, m)
return acc
}, new Map())
newMessagesCount.value = 0
lastReadMessageId.value = null
minId.value = undefined
maxId.value = undefined
}
const attachSocket = () => {
const et = new EventTarget()
const newSocket = {
name: 'chatview',
et,
}
et.addEventListener('pleroma:chat_update', onChatUpdate)
et.addEventListener('open', onStreamConnect)
et.addEventListener('close', onStreamDisconnect)
socket.value = newSocket
useStreamingStore().addSubscriber(socket.value)
}
const detachSocket = () => {
const { et } = socket.value
et.removeEventListener('pleroma:chat_update', onChatUpdate)
et.removeEventListener('open', onStreamConnect)
et.removeEventListener('close', onStreamDisconnect)
useStreamingStore().removeSubscriber(socket.value)
}
const activate = async () => {
try {
attachSocket()
const result = await getOrCreateChat({
- accountId: userId,
+ accountId: userId.value,
credentials: useOAuthStore().token,
})
const { data } = result
useUsersStore().addNewUsers({ ...result, data: data.account })
data.account = useUsersStore().findUser(data.account.id)
chat.value = data
maxId.value = chat.value.lastMessage?.id
} catch (e) {
console.error('Error creating or getting a chat', e)
error.value = e
}
startFetching('Chat activated', true)
}
const deactivate = () => {
if (fetching.value) {
stopFetching('Chat deactivated')
}
clear()
detachSocket()
}
+ const ready = computed(() => !!chat.value)
+ const recipient = computed(() => chat.value?.account)
+
return {
activate,
deactivate,
sendMessage,
newMessagesCount,
markAsRead,
deleteChatMessage,
messages,
- messagesIndex,
+ pendingMessages,
fetchError,
fetchOlder,
+ ready,
+ recipient,
}
}
diff --git a/src/composables/useVirtualScrolling.js b/src/composables/useVirtualScrolling.js
index 85ad1cdcca..9d1d567072 100644
--- a/src/composables/useVirtualScrolling.js
+++ b/src/composables/useVirtualScrolling.js
@@ -1,377 +1,399 @@
import { first, last } from 'lodash-es'
import { computed, nextTick, ref, toValue, watch } from 'vue'
export function useVirtualScrolling({
// For debugging
name = 'Generic',
// Master toggle
enabled,
// List of items
list,
// Container of items, used for measuring scroll position
body,
// vertical offset to account for fixed and sticky headers
offset,
// useScrollPosition composable, used to prevent dupicating instances
scrollPositionInstance,
// buffer zone, the amount of placeholder heights to include
buffer,
// whether to use scroll compensation when elements above anchor change
scrollCompensation,
// How to handle collapse/expansion (going from 0 elements to full and back)
// - false - don't do scroll compensation at all
// - 'height' - compensate scroll according to list's height
// - 'item' - same as height but uses anchor element's top offset
// instead of whole height
collapseMode,
// Placeholder height specification. Must be a function.
// function will be called either:
// - without arguments (for generic placeholder, i.e. buffer zone size)
// - with id (for specific item placeholders)
// function must return ref pointing to height
getPlaceholderHeight,
+ // Invert grow direction. Useful for chat views. Makes scroll compensation
+ // work in reverse - adding items to bottom scrolls down, adding items to
+ // top doesn't scroll up.
+ invertDirection = false,
}) {
// # Suspension
const unsuspendibleIds = ref(new Set())
const changeSuspendState = ({ id, suspendable }) => {
if (suspendable) {
unsuspendibleIds.value.delete(id)
} else {
unsuspendibleIds.value.add(id)
}
}
// # Heights mapping.
const heights = ref(new Map())
const heightChart = computed(() => {
// Map every height and suspendable state
const chart = list.value.map((item) => {
const { id } = item
const height = (() => {
if (heights.value.has(id)) {
return heights.value.get(id)
} else {
return getPlaceholderHeight(id).value
}
})()
const suspendable = !unsuspendibleIds.value.has(id)
const real = heights.value.has(id)
return { id, height, suspendable, real, visible: true }
})
// Walk over the list to set top offsets
chart.reduce((sum, item) => {
item.top = sum
return sum + item.height
}, 0)
return chart
})
const updateVirtualHeight = ({ id, height }) => {
heights.value.set(id, height)
}
const {
top: scrollY,
scrollBy,
vHeight,
hasReachedTop,
+ hasReachedBottom,
} = scrollPositionInstance
// Real scroll boundary, relative to body's bounds
const topScrollBoundary = ref(0)
const bottomScrollBoundary = ref(0)
const updateBoundaries = () => {
if (!toValue(enabled)) return
if (!body.value) return // Not mounted yet
const { top } = body.value.getBoundingClientRect()
const distanceItemTopToWindowTop = 0 - top + (toValue(offset) ?? 0)
const distanceItemTopToWindowBottom = vHeight.value - top
// Technically, bottom scroll boundary should be distance
// from element's top border to window's bottom border,
// but it just so happens that it is equal to this.
// You can verify it by drawing the boxes and measuring
// distances yourself, I know I did. Geometry, man...
topScrollBoundary.value = distanceItemTopToWindowTop
bottomScrollBoundary.value = distanceItemTopToWindowBottom
}
const windowWatcher = watch(vHeight, updateBoundaries)
const scrollWatcher = watch(scrollY, updateBoundaries)
const heightWatcher = watch(heightChart, updateBoundaries)
const bodyWatcher = watch(body, updateBoundaries)
+ watch(vHeight, (neu, old) => {
+ if (invertDirection) {
+ // Only compensate (stick to bottom) when shrinking
+ scrollBy(0, Math.max(old - neu, 0))
+ // compensating in other direction causes overscrolling
+ }
+ })
+
const pauseWatchers = () => {
windowWatcher.pause()
scrollWatcher.pause()
heightWatcher.pause()
bodyWatcher.pause()
}
const resumeWatchers = (skipUpdate = false) => {
windowWatcher.resume()
scrollWatcher.resume()
heightWatcher.resume()
bodyWatcher.resume()
if (skipUpdate) return
updateBoundaries()
}
watch(enabled, (val) => {
if (val) {
resumeWatchers()
} else {
pauseWatchers()
}
})
// # Visiblity
// Add buffer zone to boundary, equal to approx 3 items heights
const bufferZone = computed(
() => getPlaceholderHeight().value * (toValue(buffer) ?? 3),
)
const checkVisible = ({ top, height }) => {
const itemTopBoundary = top
const itemBottomBoundary = top + height
// Include buffer zone
const finalTopScrollBoundary = topScrollBoundary.value - bufferZone.value
const finalBottomScrollBoundary =
bottomScrollBoundary.value + bufferZone.value
// To be visible, item's bottom boundary shoud be below top scroll boundary)
const isBelowTopBoundary = itemBottomBoundary > finalTopScrollBoundary
// To be visible, item's top boundary shoud be above bottom scroll boundary)
const isAboveBottomBoundary = itemTopBoundary < finalBottomScrollBoundary
// This accounts for the case where item's boundaries exceed scroll boundary
return isBelowTopBoundary && isAboveBottomBoundary
}
const heightChartVisibility = computed(() =>
heightChart.value.map((heightChartItem) => ({
...heightChartItem,
visible: checkVisible(heightChartItem),
})),
)
const heightChartGrouped = computed(() => {
const chart = enabled.value ? heightChartVisibility : heightChart
// Group invisible items into spacers
return chart.value.reduce((acc, heightChartItem, index) => {
const { suspendable, visible, height, top, id } = heightChartItem
const present = visible || !suspendable
if (present) {
return [...acc, { type: 'item', height, top, id }]
} else {
// Reusing previous item if possible
const previousItem = acc[acc.length - 1]
const usingPreviousItem = previousItem?.type === 'spacer'
// We only really care for height and id of spacer, everything else
// is just for debugging
const spacer = usingPreviousItem
? previousItem
: {
type: 'spacer',
top: Number.POSITIVE_INFINITY,
height: 0,
id: `i${index}`, // used for v-for key attribute
ids: new Set(),
}
spacer.ids.add(id)
spacer.height += height
if (top < spacer.top) spacer.top = top
// If we used previous item there is no need to push it to array
if (usingPreviousItem) {
return acc
} else {
return [...acc, spacer]
}
}
}, [])
})
// ## Scroll compensation
watch(
heightChart,
async (newVal, oldVal) => {
if (newVal.length === 0 && oldVal.length === 0) return
const explosion = oldVal.length === 0 && newVal.length !== 0
const implosion = oldVal.length !== 0 && newVal.length === 0
const compensation = toValue(scrollCompensation)
const diff = (() => {
if (explosion) {
if (!compensation) return 0
- if (toValue(collapseMode) === 'height') {
+ if (toValue(collapseMode) === 'height' || invertDirection) {
const newBottomElement = last(newVal)
return newBottomElement.top + newBottomElement.height
} else {
return 0
}
} else if (implosion) {
if (!compensation) return 0
const oldBottomElement = last(oldVal)
return 0 - oldBottomElement.top - oldBottomElement.height
} else {
const contextChange = (() => {
const oldIds = new Set(oldVal.map(({ id }) => id))
const newIds = new Set(newVal.map(({ id }) => id))
if (oldVal.length <= newVal.length) {
return [...oldIds].some((id) => !newIds.has(id))
} else {
return [...newIds].some((id) => !oldIds.has(id))
}
})()
if (contextChange) return 0
/* Ok, here's a thing. Both Timeline and Conversation have virtual scrolling.
* But since Conversation can be inside Timeline (in fact it's chock-full of
* them) it creates a problem. Virtual scrolling involves a lot of scroll
* compensation. Elements appearing, disappearing, changing sizes etc. What
* happens if a single conversation gets expanded? Conversation sees influx
* of new posts and compensates for them appearing above to keep old items
* in screen. Next, timeline sees that Conversation changed height and ALSO
* compensates for that changed height. We get double the compensation and
* result ends up being all wrong, we scrolled way past the conversation.
*
* Easy, just don't compensate in Timeline, right? Wrong. We still have two
* cases where we do want compensation in Timeline - all the cases that
* aren't handled by Conversation. Namely:
* - Change in amount of Conversations
* - Change in height of freshly-rendered Conversations.
*
* First one is simple. We scrolled down and made a new post - it appeared
* at the top but we don't want screen to scroll.
*
* Second one is tricky. Here's a real situation:
* 1. We scroll down in Timeline.
* 2. Open a thread (navigate to it, not expand!)
* 3. Press "back".
* Now we have a situation: vue-router restored our scroll position but
* since Timeline was removed and re-created its heightChart is full of
* fake placeholders. This naturally means our actual scroll position is
* all wrong. TODO: Store heightchart in timelines store?
* But it gets worse - when you start scrolling up elements begin getting
* their real heights back, and since there's no compensation timeline gets
* all jumpy! We can somewhat live with wrong scroll position but timeline
* being jumpy is unacceptable.
*
* So, basically - if amount of elements changes, or amount of REAL elements
* changes - compensate anyway even if compensation is disabled.
*/
- const compensateBecauseLengthChange =
- newVal.length !== oldVal.length && hasReachedTop.value
+ const isSticking = invertDirection ? hasReachedBottom.value : hasReachedTop.value
+ const compensateBecauseLengthChange = isSticking &&
+ newVal.length !== oldVal.length
const compensateBecauseReal =
newVal.filter(({ real }) => real).length !==
oldVal.filter(({ real }) => real).length
const expansion = (() => {
if (newVal.length < oldVal.length) return 0
const oldVisible = oldVal.filter(
- (item) => checkVisible(item) && item.real,
+ (item) => checkVisible(item),
)
- const oldItem = first(oldVisible)
+ const oldItem = invertDirection ? last(oldVisible) : first(oldVisible)
if (!oldItem) return 0 // probably out of bounds in timeline
const oldItemUpdated = newVal.find(({ id }) => id === oldItem.id)
if (
compensation ||
compensateBecauseReal ||
compensateBecauseLengthChange
) {
- return (
- oldItemUpdated.top -
- oldItem.top -
- (oldItem.height - oldItemUpdated.height)
- )
+ if (invertDirection) {
+ const oldItemUpdatedBottom = oldItemUpdated.top + oldItemUpdated.height
+ const oldItemBottom = oldItem.top + oldItem.height
+ return oldItemUpdatedBottom - oldItemBottom
+ } else {
+ const oldItemUpdatedTop = oldItemUpdated.top
+ const oldItemTop = oldItem.top
+ return oldItemUpdatedTop - oldItemTop
+ }
} else {
return 0
}
})()
const collapsing = (() => {
if (newVal.length >= oldVal.length) return 0
const newVisible = newVal
- const newItem = first(newVisible)
+ const newItem = invertDirection ? last(newVisible) : first(newVisible)
if (!newItem) return 0 // probably out of bounds in timeline
const newItemBefore = oldVal.find(({ id }) => id === newItem.id)
if (
compensation ||
compensateBecauseReal ||
compensateBecauseLengthChange
) {
- return (
- newItem.top -
- newItemBefore.top -
- (newItemBefore.height - newItem.height)
- )
+ if (invertDirection) {
+ const newItemTop = newItem.top
+ const newItemBeforeTop = newItemBefore.top
+ return newItemTop - newItemBeforeTop
+ } else {
+ const newItemBottom = newItem.top + newItem.height
+ const newItemBeforeBottom = newItemBefore.top + newItemBefore.height
+ return newItemBottom - newItemBeforeBottom
+ }
} else {
return 0
}
})()
return expansion + collapsing
}
})()
if (diff !== 0) {
// Scroll by amount offset changed to keep it in view
topScrollBoundary.value += diff
bottomScrollBoundary.value += diff
await scrollBy(0, diff)
await nextTick()
}
resumeWatchers()
},
{ flush: 'post' },
)
// Misc
const reset = () => {
unsuspendibleIds.value = new Set()
heights.value = new Map()
}
const scrollTo = (anchors) => {
const element = heightChart.value.find(({ id }) => anchors.has(id))
if (!element) {
console.error(`No element with id matching ${[...anchors].join()} found`)
return
}
pauseWatchers()
const elementMiddle = element.top + element.height / 2
const desiredTopBoundary = Math.min(
element.top,
elementMiddle - (vHeight.value - offset.value) / 2,
)
scrollBy(0, desiredTopBoundary - topScrollBoundary.value)
resumeWatchers()
}
return {
heightChart: heightChartGrouped,
changeSuspendState,
updateVirtualHeight,
pauseWatchers,
resumeWatchers,
updateBoundaries,
reset,
scrollTo,
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Mon, Oct 12, 6:31 AM (1 d, 19 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1786071
Default Alt Text
(61 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline
Log In to Comment