Page MenuHomePhorge

No OneTemporary

Size
61 KB
Referenced Files
None
Subscribers
None
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

Mime Type
text/x-diff
Expires
Mon, Oct 12, 6:31 AM (1 d, 21 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1786071
Default Alt Text
(61 KB)

Event Timeline