Page MenuHomePhorge

No OneTemporary

Size
182 KB
Referenced Files
None
Subscribers
None
diff --git a/changelog.d/chat-view-fixes.fix b/changelog.d/chat-view-fixes.fix
new file mode 100644
index 0000000000..0dcfdf7da9
--- /dev/null
+++ b/changelog.d/chat-view-fixes.fix
@@ -0,0 +1 @@
+Various Chat View fixes
diff --git a/src/App.js b/src/App.js
index ae38204b6b..d3cd88b7c6 100644
--- a/src/App.js
+++ b/src/App.js
@@ -1,318 +1,329 @@
import { throttle } from 'lodash-es'
import { mapState } from 'pinia'
-import { defineAsyncComponent } from 'vue'
+import { defineAsyncComponent, provide } from 'vue'
import DesktopNav from 'src/components/desktop_nav/desktop_nav.vue'
import FeaturesPanel from 'src/components/features_panel/features_panel.vue'
import GlobalError from 'src/components/global_error/global_error.vue'
import GlobalNoticeList from 'src/components/global_notice_list/global_notice_list.vue'
import InstanceSpecificPanel from 'src/components/instance_specific_panel/instance_specific_panel.vue'
import MobileNav from 'src/components/mobile_nav/mobile_nav.vue'
import MobilePostStatusButton from 'src/components/mobile_post_status_button/mobile_post_status_button.vue'
import NavPanel from 'src/components/nav_panel/nav_panel.vue'
import UserPanel from 'src/components/user_panel/user_panel.vue'
import { getOrCreateServiceWorker } from './services/sw/sw'
import { windowHeight, windowWidth } from './services/window_utils/window_utils'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useI18nStore } from 'src/stores/i18n.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useShoutStore } from 'src/stores/shout.js'
import { useUsersStore } from 'src/stores/users.js'
+import { useBodyScroller } from 'src/composables/useBodyScroller.js'
+import { useScrollPosition } from 'src/composables/useScrollPosition.js'
+import { useWindowScroll } from 'src/composables/useWindowScroll.js'
+import { useWindowSize } from 'src/composables/useWindowSize.js'
+
// Helper to unwrap reactive proxies
window.toValue = (x) => JSON.parse(JSON.stringify(x))
export default {
name: 'app',
components: {
UserPanel,
NavPanel,
Notifications: defineAsyncComponent(
() => import('src/components/notifications/notifications.vue'),
),
InstanceSpecificPanel,
FeaturesPanel,
WhoToFollowPanel: defineAsyncComponent(
() =>
import('src/components/who_to_follow_panel/who_to_follow_panel.vue'),
),
ShoutPanel: defineAsyncComponent(
() => import('src/components/shout_panel/shout_panel.vue'),
),
MediaModal: defineAsyncComponent(
() => import('src/components/media_modal/media_modal.vue'),
),
MobilePostStatusButton,
MobileNav,
DesktopNav,
FollowRequestConfirm: defineAsyncComponent(
() =>
import(
'src/components/follow_request_confirm/follow_request_confirm.vue'
),
),
SettingsModal: defineAsyncComponent(
() => import('src/components/settings_modal/settings_modal.vue'),
),
UpdateNotification: defineAsyncComponent(
() =>
import('src/components/update_notification/update_notification.vue'),
),
PostStatusModal: defineAsyncComponent(
() => import('src/components/post_status_modal/post_status_modal.vue'),
),
UserReportingModal: defineAsyncComponent(
() =>
import('src/components/user_reporting_modal/user_reporting_modal.vue'),
),
EditStatusModal: defineAsyncComponent(
() => import('src/components/edit_status_modal/edit_status_modal.vue'),
),
StatusHistoryModal: defineAsyncComponent(
() =>
import('src/components/status_history_modal/status_history_modal.vue'),
),
GlobalError,
GlobalNoticeList,
},
+ setup() {
+ provide(
+ 'bodyScrollPosition',
+ useScrollPosition(useBodyScroller(useWindowScroll(), useWindowSize())),
+ )
+ },
data: () => ({
mobileActivePanel: 'timeline',
updateMobileState: null,
updateScrollState: null,
}),
provide() {
return {
allowNonSquareEmoji: useMergedConfigStore().mergedConfig.nonSquareEmoji,
}
},
watch: {
themeApplied() {
this.removeSplash()
},
currentTheme() {
this.setThemeBodyClass()
},
layoutType() {
document.getElementById('modal').classList = ['-' + this.layoutType]
},
},
created() {
// Load the locale from the storage
const value = useMergedConfigStore().mergedConfig.interfaceLanguage
useI18nStore().setLanguage(value)
useEmojiStore().loadUnicodeEmojiData(value)
document.getElementById('modal').classList = ['-' + this.layoutType]
// Create bound handlers
this.updateScrollState = throttle(this.scrollHandler, 200)
this.updateMobileState = throttle(this.resizeHandler, 200)
},
mounted() {
window.addEventListener('resize', this.updateMobileState)
this.scrollParent.addEventListener('scroll', this.updateScrollState)
if (this.themeApplied) {
this.setThemeBodyClass()
this.removeSplash()
}
getOrCreateServiceWorker()
},
unmounted() {
window.removeEventListener('resize', this.updateMobileState)
this.scrollParent.removeEventListener('scroll', this.updateScrollState)
},
computed: {
currentTheme() {
if (this.styleDataUsed) {
const styleMeta = this.styleDataUsed.find(
(x) => x.component === '@meta',
)
if (styleMeta !== undefined) {
return styleMeta.directives.name.replaceAll(' ', '-').toLowerCase()
}
}
return 'stock'
},
layoutModalClass() {
return '-' + this.layoutType
},
classes() {
return [
{
'-reverse': this.reverseLayout,
'-no-sticky-headers': this.noSticky,
'-has-new-post-button': this.newPostButtonShown,
},
'-' + this.layoutType,
]
},
navClasses() {
const { navbarColumnStretch } = useMergedConfigStore().mergedConfig
return [
'-' + this.layoutType,
...(navbarColumnStretch ? ['-column-stretch'] : []),
]
},
userBackground() {
return this.currentUser?.background_image
},
foreignProfileBackground() {
return (
useMergedConfigStore().mergedConfig.allowForeignUserBackground &&
useInterfaceStore().foreignProfileBackground
)
},
instanceBackground() {
return useMergedConfigStore().mergedConfig.hideInstanceWallpaper
? null
: this.instanceBackgroundUrl
},
background() {
return (
this.foreignProfileBackground ||
this.userBackground ||
this.instanceBackground
)
},
bgStyle() {
if (this.background) {
return {
'--body-background-image': `url(${this.background})`,
}
}
},
shoutJoined() {
return useShoutStore().joined
},
isChats() {
return (
this.$route.name === 'chat' ||
this.$route.name === 'chats' ||
this.$route.name === 'conversation2'
)
},
isListEdit() {
return this.$route.name === 'lists-edit'
},
newPostButtonShown() {
if (this.isChats) return false
if (this.isListEdit) return false
return (
useMergedConfigStore().mergedConfig.alwaysShowNewPostButton ||
this.layoutType === 'mobile'
)
},
shoutboxPosition() {
return (
useMergedConfigStore().mergedConfig.alwaysShowNewPostButton || false
)
},
hideShoutbox() {
return this.isChats || useMergedConfigStore().mergedConfig.hideShoutbox
},
thirdColumnMode() {
return this.mergedConfig.thirdColumnMode
},
reverseSetting() {
return this.mergedConfig.sidebarRight
},
reverseLayout() {
if (this.layoutType !== 'wide') {
return this.reverseSetting
} else {
return this.thirdColumnMode === 'notifications'
? this.reverseSetting
: !this.reverseSetting
}
},
noSticky() {
return this.mergedConfig.disableStickyHeaders
},
showScrollbars() {
return this.mergedConfig.showScrollbars
},
scrollParent() {
return window /* this.$refs.appContentRef */
},
showInstanceSpecificPanel() {
return this.instanceSpecificPanelPresent && !this.mergedConfig.hideISP
},
...mapState(useMergedConfigStore, ['mergedConfig']),
...mapState(useInterfaceStore, [
'themeApplied',
'styleDataUsed',
'layoutType',
]),
...mapState(useUsersStore, ['currentUser']),
...mapState(useInstanceStore, ['styleDataUsed']),
...mapState(useInstanceCapabilitiesStore, [
'suggestionsEnabled',
'editingAvailable',
]),
...mapState(useInstanceStore, {
instanceBackgroundUrl: (store) => store.instanceIdentity.background,
showFeaturesPanel: (store) => store.instanceIdentity.showFeaturesPanel,
instanceSpecificPanelPresent: (store) =>
store.instanceIdentity.showInstanceSpecificPanel &&
store.instanceIdentity.instanceSpecificPanelContent,
}),
},
methods: {
resizeHandler() {
useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight())
},
scrollHandler() {
const scrollPosition =
this.scrollParent === window
? window.scrollY
: this.scrollParent.scrollTop
if (scrollPosition != 0) {
this.$refs.appContentRef.classList.add(['-scrolled'])
} else {
this.$refs.appContentRef.classList.remove(['-scrolled'])
}
},
setThemeBodyClass() {
const themeName = this.currentTheme
const classList = Array.from(document.body.classList)
const oldTheme = classList.filter((c) => c.startsWith('theme-'))
if (themeName !== null && themeName !== '') {
const newTheme = `theme-${themeName.toLowerCase()}`
// remove old theme reference if there are any
if (oldTheme.length) {
document.body.classList.replace(oldTheme[0], newTheme)
} else {
document.body.classList.add(newTheme)
}
} else {
// remove theme reference if non-V3 theme is used
document.body.classList.remove(...oldTheme)
}
},
removeSplash() {
document.querySelector('#status').textContent = this.$t(
'splash.fun_' + Math.ceil(Math.random() * 4),
)
const splashscreenRoot = document.querySelector('#splash')
splashscreenRoot.addEventListener('transitionend', () => {
splashscreenRoot.remove()
})
setTimeout(() => {
splashscreenRoot.remove() // forcibly remove it, should fix my plasma browser widget t. HJ
}, 600)
splashscreenRoot.classList.add('hidden')
document.querySelector('#app').classList.remove('hidden')
},
},
}
diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
index bf8eabdf2f..47effda73e 100644
--- a/src/components/chat_message/chat_message.js
+++ b/src/components/chat_message/chat_message.js
@@ -1,217 +1,259 @@
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', 'suspendableStateChange'],
+ data() {
+ return {
+ resizeObserver: new ResizeObserver(this.updateVirtualHeight),
+ mediaPlaying: new Set(),
+ hovered: false,
+ menuOpened: false,
+ }
+ },
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'],
+ mounted() {
+ if (this.$refs.root) {
+ this.resizeObserver.observe(this.$refs.root)
+ this.updateVirtualHeight([
+ {
+ contentRect: this.$refs.root.getBoundingClientRect(),
+ },
+ ])
+ }
+ },
+ unmounted() {
+ this.resizeObserver.disconnect()
+ },
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 }
}
},
+ isSuspendable() {
+ return this.mediaPlaying.size === 0
+ },
// Global stuff
...mapState(useInterfaceStore, {
betterShadow: (store) => store.browserSupport.cssFilter,
}),
...mapState(useUsersStore, ['currentUser']),
...mapState(useInstanceStore, ['restrictedNicknames']),
...mapState(useMergedConfigStore, ['mergedConfig']),
},
- data() {
- return {
- hovered: false,
- menuOpened: false,
- }
- },
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.$emit('delete', {
+ await this.deleteChatMessage({
messageId: this.message.id,
chatId: this.message.chat_id,
})
}
this.hovered = false
this.menuOpened = false
},
+ addMediaPlaying(id) {
+ this.mediaPlaying.add(id)
+ },
+ removeMediaPlaying(id) {
+ this.mediaPlaying.delete(id)
+ },
+ },
+ watch: {
+ isSuspendable: function (suspendable) {
+ this.$emit('suspendableStateChange', {
+ id: this.chatItem.id,
+ suspendable,
+ })
+ },
},
}
export default ChatMessage
diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss
index e23ca3bd04..7a688e54f8 100644
--- a/src/components/chat_message/chat_message.scss
+++ b/src/components/chat_message/chat_message.scss
@@ -1,218 +1,219 @@
.chat-message-wrapper {
&.hovered-message-chain {
.animated.Avatar {
canvas {
display: none;
}
img {
visibility: visible;
}
}
}
.attachments {
min-width: 10em;
}
.quoted-post {
margin-bottom: 1.5em;
}
.chat-message-toolbar {
transition: opacity 0.1s;
opacity: 0;
position: absolute;
top: -0.8em;
right: 0.4rem;
z-index: 1;
.quick-action-buttons {
justify-items: end;
grid-template-columns: auto auto auto;
}
.simple-button {
padding-top: 0.2em;
padding-bottom: 0.2em;
}
&.-visible {
opacity: 1;
}
}
.menu-icon {
cursor: pointer;
}
.reply-to-header {
display: flex;
gap: 0.5rem;
align-items: center;
padding: 0.125em 0;
width: 100%;
}
.avatar-spacer {
flex: 0 0 2.2rem;
width: 2.2rem;
}
.popover {
width: 12em;
}
.chat-message {
display: flex;
+ padding-top: 0.5em;
.status-body:hover {
--_still-image-img-visibility: visible;
--_still-image-canvas-visibility: hidden;
--_still-image-label-visibility: hidden;
}
}
.avatar-wrapper {
margin-right: 0.5em;
}
.link-preview,
.attachments {
margin-bottom: 1em;
}
.status {
background-color: var(--background);
color: var(--text);
border-radius: var(--roundness);
display: flex;
padding: 0.75em;
border: 1px solid var(--border);
}
.created-at {
position: relative;
float: right;
font-size: 0.8em;
margin: -1em 0 -0.5em;
font-style: italic;
}
.without-attachment {
.message-content {
// TODO figure out how to do it properly
.RichContent::after {
margin-right: 5.4em;
content: " ";
display: inline-block;
}
}
}
.pending {
.status-content.media-body,
.created-at {
color: var(--faint);
}
}
.error {
.status-content.media-body,
.created-at {
color: var(--badgeNotification);
}
}
.message-bubble-wrapper {
display: flex;
}
.chat-message-inner {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.end-spacer {
flex: 1 1 0;
min-width: calc(2.2rem + 0.5rem + 2rem);
}
.reply-indicator {
display: flex;
place-items: center;
place-content: center;
padding: 0.25em;
width: 1em;
margin: var(--roundness) 0;
background: var(--border);
border-radius: var(--roundness);
border: 1px solid var(--border);
+ .end-spacer {
// Compensate for reply indicator
min-width: calc(2.2rem + 0.5rem + 2rem - (1rem + (1px + 0.25rem) * 2));
}
}
&.-incoming {
.reply-indicator {
border-bottom-left-radius: 0;
border-top-left-radius: 0;
}
}
.reply-to-popover {
white-space: nowrap;
}
.reply-label {
white-space: nowrap;
}
&.-outgoing {
&,
.message-bubble-wrapper,
.chat-message{
flex-direction: row-reverse;
}
.reply-to-header {
justify-content: end;
}
.reply-indicator {
border-bottom-right-radius: 0;
border-top-right-radius: 0;
.icon {
transform: scaleX(-1);
}
}
.chat-message-inner {
align-items: flex-end;
}
}
.chat-message-inner.with-media {
width: 100%;
.status {
width: 100%;
}
}
.visible {
opacity: 1;
}
}
.chat-message-date-separator {
text-align: center;
font-size: 0.9em;
line-height: 2;
user-select: none;
color: var(--textFaint);
}
diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue
index a6700201d4..a60cd8ad5e 100644
--- a/src/components/chat_message/chat_message.vue
+++ b/src/components/chat_message/chat_message.vue
@@ -1,252 +1,256 @@
<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"
+ @mediaplay="addMediaPlaying($event)"
+ @mediapause="removeMediaPlaying($event)"
>
<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..fe984020ea 100644
--- a/src/components/chat_message_list/chat_message_list.js
+++ b/src/components/chat_message_list/chat_message_list.js
@@ -1,133 +1,154 @@
-import { orderBy, uniqueId } from 'lodash-es'
+import { computed, inject, ref, toRefs, useTemplateRef } from 'vue'
import ChatMessage from 'src/components/chat_message/chat_message.vue'
+import { useInterfaceSizes } from 'src/composables/useInterfaceSizes.js'
+import { useVirtualScrolling } from 'src/composables/useVirtualScrolling.js'
+
const ChatMessageList = {
components: {
ChatMessage,
},
props: {
+ pending: Boolean,
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 })),
- ]
- return messages
+ emits: ['replyRequested'],
+ setup(props, { emit }) {
+ const { messages, headerDate } = toRefs(props)
+ const hoveredMessageChainId = ref(null)
+ const chatItems = computed(() => {
+ return messages.value
.reduceRight((acc, message, index) => {
const date = new Date(message.created_at)
- const olderMessage = messages[index - 1]
- const newerItem = acc[acc.length - 1]
+ const olderMessage = messages.value[index - 1]
+ const newerItem = acc.at(-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(),
+ id: 'fake-date-' + 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
-
- this.chatItems
- .slice(0, index)
- .reverse()
- .some((item) => {
- const isMessage = item.type === 'message'
- if (isMessage) {
- result = item
- }
- return isMessage
- })
+ })
+ const chatItemsIndex = computed(() =>
+ chatItems.value.reduce((map, value) => {
+ map.set(value.id, value)
+ return map
+ }, new Map()),
+ )
+
+ const getCurrentItem = (id) => chatItemsIndex.value.get(id)
+
+ const scroller = inject('bodyScrollPosition')
+ const body = useTemplateRef('body')
+ const { fontSize } = useInterfaceSizes()
+ const normalHeight = computed(() => fontSize.value * 3)
+ const dateHeight = computed(() => fontSize.value * 1.5)
+ const getPlaceholderHeight = (id) =>
+ id?.startsWith('fake-date-') ? dateHeight : normalHeight
- return result
- },
+ const { pending } = toRefs(props)
+ const { heightChart, changeSuspendState, updateVirtualHeight } =
+ useVirtualScrolling({
+ name: pending.value
+ ? 'ChatMessageList<Pending>'
+ : 'ChatMessageList<Real>',
+ 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,
+ changeSuspendState,
+
+ focusedId,
+ repliedId,
+
+ onReplyRequested,
+ onMessageHover,
+ _test: {
+ chatItems,
+ },
+ }
},
}
export default ChatMessageList
diff --git a/src/components/chat_message_list/chat_message_list.scss b/src/components/chat_message_list/chat_message_list.scss
index 2bc604da7e..ffc44690f6 100644
--- a/src/components/chat_message_list/chat_message_list.scss
+++ b/src/components/chat_message_list/chat_message_list.scss
@@ -1,7 +1,6 @@
.ChatMessageList {
padding: 0.5em;
display: flex;
- gap: 0.5em;
flex-direction: column;
justify-content: end;
}
diff --git a/src/components/chat_message_list/chat_message_list.vue b/src/components/chat_message_list/chat_message_list.vue
index 7833730e7c..1d99387428 100644
--- a/src/components/chat_message_list/chat_message_list.vue
+++ b/src/components/chat_message_list/chat_message_list.vue
@@ -1,19 +1,34 @@
<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"
+
+ :data-vs-height="element.height"
+ :data-vs-top="element.top"
+ :data-vs-id="element.id"
+
+ @hover="onMessageHover"
+ @reply-requested="onReplyRequested"
+ @height-change="updateVirtualHeight"
+ @suspendable-state-change="changeSuspendState"
+ />
+ <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_layout_utils.js b/src/components/chat_view/chat_layout_utils.js
deleted file mode 100644
index 10d0a5e45f..0000000000
--- a/src/components/chat_view/chat_layout_utils.js
+++ /dev/null
@@ -1,27 +0,0 @@
-// Captures a scroll position
-export const getScrollPosition = () => {
- return {
- scrollTop: window.scrollY,
- scrollHeight: document.documentElement.scrollHeight,
- offsetHeight: window.innerHeight,
- }
-}
-
-// A helper function that is used to keep the scroll position fixed as the new elements are added to the top
-// Takes two scroll positions, before and after the update.
-export const getNewTopPosition = (previousPosition, newPosition) => {
- return (
- previousPosition.scrollTop +
- (newPosition.scrollHeight - previousPosition.scrollHeight)
- )
-}
-
-export const isBottomedOut = (offset = 0) => {
- const scrollHeight = window.scrollY + offset
- const totalHeight = document.documentElement.scrollHeight - window.innerHeight
- return totalHeight <= scrollHeight
-}
-// Returns whether or not the scrollbar is visible.
-export const isScrollable = () => {
- return document.documentElement.scrollHeight > window.innerHeight
-}
diff --git a/src/components/chat_view/chat_view.js b/src/components/chat_view/chat_view.js
index 7d80459bb5..cbc54157eb 100644
--- a/src/components/chat_view/chat_view.js
+++ b/src/components/chat_view/chat_view.js
@@ -1,725 +1,256 @@
-import { get, maxBy, minBy, sortBy, throttle } from 'lodash-es'
-import { mapState } from 'pinia'
-import { nextTick } from 'vue'
+import { storeToRefs } from 'pinia'
+import {
+ computed,
+ inject,
+ onUnmounted,
+ provide,
+ ref,
+ toRefs,
+ useTemplateRef,
+ watch,
+ watchEffect,
+} from 'vue'
+import { useI18n } from 'vue-i18n'
+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 { buildFakeMessage } from '../../services/chat_utils/chat_utils.js'
-import { promiseInterval } from '../../services/promise_interval/promise_interval.js'
-import {
- getNewTopPosition,
- getScrollPosition,
- isBottomedOut,
- isScrollable,
-} from './chat_layout_utils.js'
-import { useChatsStore } from 'src/stores/chats.js'
import { useInterfaceStore } from 'src/stores/interface.js'
-import { useMergedConfigStore } from 'src/stores/merged_config.js'
-import { useOAuthStore } from 'src/stores/oauth.js'
-import { useStatusesStore } from 'src/stores/statuses.js'
-import { useStreamingStore } from 'src/stores/streaming.js'
-import { useUsersStore } from 'src/stores/users.js'
-import {
- chatMessages,
- deleteChatMessage,
- getOrCreateChat,
- readChat,
- sendChatMessage,
-} from 'src/api/chats.js'
-import { fetchConversation, fetchStatus } from 'src/api/public.js'
-import { WSConnectionStatus } from 'src/api/websocket.js'
+import { useClientRectSize } from 'src/composables/useClientRectSize.js'
+import { useConversation } from 'src/composables/useConversation.js'
+import { usePleromaChat } from 'src/composables/usePleromaChat.js'
import { library } from '@fortawesome/fontawesome-svg-core'
-import { faChevronDown, faChevronLeft } from '@fortawesome/free-solid-svg-icons'
+import { faChevronDown } from '@fortawesome/free-solid-svg-icons'
-library.add(faChevronDown, faChevronLeft)
+library.add(faChevronDown)
-const BOTTOMED_OUT_OFFSET = 10
-const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 10
-const SAFE_RESIZE_TIME_OFFSET = 100
const MARK_AS_READ_DELAY = 1500
-const MAX_RETRIES = 10
-
-const isConfirmation = (storage, message) => {
- if (!message.idempotency_key) return
- return storage.idempotencyKeyIndex[message.idempotency_key]
-}
const Chat = {
components: {
ChatMessageList,
ChatTitle,
PostStatusForm,
},
props: {
statusId: {
type: String,
default: null,
},
chatUserId: {
type: String,
default: null,
},
- testMode: Boolean,
- },
- data() {
- return {
- // Main info
- chat: null,
- messages: [],
- messagesIndex: {},
- pendingMessages: [],
- pendingMessagesIndex: {},
- minId: undefined,
- maxId: undefined,
-
- // Conversation stuff
- explicitReplyStatus: null,
-
- // Unread stuff
- newMessageCount: 0,
- lastReadMessageId: null,
- lastScrollPosition: {},
- jumpToBottomButtonVisible: false,
-
- // Internal network stuff
- fetcher: null,
- socket: null,
- streaming: false,
- fetching: true,
- errorLoadingChat: false,
- messageRetriers: {},
- idempotencyKeyIndex: {},
- }
- },
- async created() {
- if (this.testMode) return
- await this.activate()
- this.attachSocket()
- },
- mounted() {
- window.addEventListener('resize', this.handleResize)
- window.addEventListener('scroll', this.handleScroll)
- if (document.hidden !== undefined) {
- document.addEventListener(
- 'visibilitychange',
- this.handleVisibilityChange,
- false,
- )
- }
-
- this.$nextTick(() => {
- this.handleResize()
- })
- },
- unmounted() {
- window.removeEventListener('scroll', this.handleScroll)
- window.removeEventListener('resize', this.handleResize)
- if (document.hidden !== undefined)
- document.removeEventListener(
- 'visibilitychange',
- this.handleVisibilityChange,
- false,
- )
-
- if (this.testMode) return
- this.deactivate()
- this.detachSocket()
- },
- computed: {
- conversationId() {
- const status = useStatusesStore().allStatuses.get(this.statusId)
- return get(
- status,
- 'retweeted_status.statusnet_conversation_id',
- get(status, 'statusnet_conversation_id'),
- )
- },
- isConversation() {
- return this.statusId !== null
- },
- recipient() {
- return this.chat?.account
- },
- formPlaceholder() {
- if (this.recipient) {
- return this.$t('chats.message_user', {
- nickname: this.recipient.screen_name_ui,
- })
- } else {
- return ''
- }
- },
-
- // Conversation stuff
- lastStatus() {
- return this.messages[this.messages.length - 1]
- },
- replyStatus() {
- return this.explicitReplyStatus ?? this.lastStatus
- },
-
- // Global Stuff
- streamingEnabled() {
- if (this.isConversation) return false // Unsupported
- return (
- this.mergedConfig.useStreamingApi &&
- useStreamingStore().state === WSConnectionStatus.JOINED
- )
- },
- ...mapState(useInterfaceStore, {
- mobileLayout: (store) => store.layoutType === 'mobile',
- }),
- ...mapState(useMergedConfigStore, ['mergedConfig']),
- ...mapState(useUsersStore, ['currentUser']),
},
- watch: {
- messages(old, neu) {
- if (old.length === neu.length) return
- // 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.
- const bottomedOutBeforeUpdate = isBottomedOut(BOTTOMED_OUT_OFFSET)
- this.$nextTick(() => {
- if (bottomedOutBeforeUpdate) {
- this.scrollDown()
- }
- })
- },
- async replyStatus(newVal) {
- await nextTick() // wait for changes to propagate to postStatusForm
- if (this.testMode) return
- this.$refs.postStatusForm.update()
- },
- $route: async function (newVal) {
- if (this.messagesIndex[newVal.params.statusId]) {
- const focused = document.getElementById(
- `chatmessage-${this.$route.params.statusId}`,
+ 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',
)
- if (focused?.getBoundingClientRect == null) return
- const bottomBoundary =
- window.innerHeight - this.$refs.footer.clientHeight
- const topBoundary =
- this.$refs.header.clientHeight +
- document.getElementById('nav').clientHeight
- const margin = Number(
- window
- .getComputedStyle(this.$refs.messageList.$el)
- .gap.replace('px', ''),
- )
-
- const rect = focused.getBoundingClientRect()
- const scrollAmount = (() => {
- if (rect.top < topBoundary) {
- // Post is above screen, match its top to screen top
- return rect.top - topBoundary - margin
- } else if (rect.height >= bottomBoundary) {
- // Post we want to see is taller than screen so match its top to screen top
- return rect.top - topBoundary - margin
- } else if (rect.bottom > bottomBoundary) {
- // Post is below screen, match its bottom to screen bottom
- return rect.bottom - bottomBoundary + margin
- } else {
- return 0
- }
- })()
-
- if (scrollAmount !== 0) {
- window.scrollBy(0, scrollAmount)
- }
-
- return
- }
+ })
- this.deactivate()
- this.activate()
- },
- },
- methods: {
- async activate() {
- if (!this.isConversation) {
- try {
- const result = await getOrCreateChat({
- accountId: this.chatUserId,
- credentials: useOAuthStore().token,
- })
- const { data } = result
- useUsersStore().addNewUsers({ ...result, data: data.account })
- data.account = useUsersStore().findUser(data.account.id)
- this.chat = data
- this.maxId = this.chat.lastMessage?.id
- } catch (e) {
- console.error('Error creating or getting a chat', e)
- this.errorLoadingChat = true
+ // # Conversation stuff
+ const {
+ conversation: conversationMessages,
+ loadError: conversationError,
+
+ focusedId,
+ setFocused,
+ fetchConversation,
+ } = 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 (this.isConversation || this.chat) {
- this.startFetching('Chat activated', true)
- this.$nextTick(() => {
- this.scrollDown({ forceRead: true })
- })
- }
- },
- deactivate() {
- this.clear()
- if (this.fetching) {
- this.stopFetching('Chat deactivated')
- }
- },
- attachSocket() {
- const et = new EventTarget()
- const socket = {
- name: 'chatview',
- et,
- }
-
- et.addEventListener('update', this.onStreamMessage)
- et.addEventListener('pleroma:chat_update', this.onChatUpdate)
- et.addEventListener('open', this.onStreamConnect)
- et.addEventListener('close', this.onStreamDisconnect)
-
- this.socket = socket
- useStreamingStore().addSubscriber(this.socket)
- },
- detachSocket() {
- const { et } = this.socket
-
- et.removeEventListener('update', this.onStreamMessage)
- et.removeEventListener('pleroma:chat_update', this.onChatUpdate)
- et.removeEventListener('open', this.onStreamConnect)
- et.removeEventListener('close', this.onStreamDisconnect)
-
- useStreamingStore().removeSubscriber(this.socket)
- },
-
- // Poll & Push
- onStreamConnect() {
- this.streaming = true
- this.stopFetching('Socket connected')
- },
- onStreamDisconnect(closeEvent) {
- this.streaming = false
- this.startFetching('Socket disconnected')
- },
- startFetching(reason, isFirstFetch) {
- console.debug('[Chat View] Started fetching', 'Reason:', reason)
- this.fetcher = promiseInterval(
- () => this.fetchChat({ fetchLatest: true }),
- 5000,
- )
- this.fetchChat({ isFirstFetch })
- this.fetching = true
- },
- stopFetching(reason) {
- console.debug('[Chat View] Stopped fetching', 'Reason:', reason)
- this.fetcher.stop()
- this.fetcher = null
- this.fetching = false
- },
-
- // Actions
- async readChat() {
- if (this.conversationId) return // Unsupported
- if (!this.maxId || document.hidden) {
- return
- }
- const lastReadId = this.maxId
- const isNewMessage = this.lastReadMessageId !== lastReadId
-
- if (!isNewMessage) return
-
- if (!this.testMode) {
- await readChat({
- id: this.chat.id,
- lastReadId,
- credentials: useOAuthStore().token,
- })
- }
-
- useChatsStore().readChat(this.chat.id)
- this.lastReadMessageId = this.maxId
- this.newMessageCount = 0
- },
-
- // Clears
- cullOlder() {
- const maxIndex = this.messages.length
- const minIndex = maxIndex - 50
- if (maxIndex <= 50) return
-
- this.messages = sortBy(this.messages, ['id'])
- this.minId = this.messages[minIndex].id
-
- for (const message of this.messages) {
- if (message.id < this.minId) {
- delete this.messagesIndex[message.id]
- delete this.idempotencyKeyIndex[message.idempotency_key]
+ if (neu) {
+ chatActivate()
}
+ },
+ { immediate: true },
+ )
+ onUnmounted(() => {
+ if (chatUserId.value) {
+ chatDeactivate()
}
+ })
- this.messages = this.messages.slice(minIndex, maxIndex)
- },
- clear() {
- this.messages = this.messages.filter((m) => m.error)
- this.messagesIndex = this.messages.reduce(
- (acc, m) => ({
- ...acc,
- [m.id]: m,
- }),
- {},
- )
- this.newMessageCount = 0
- this.lastReadMessageId = null
- this.minId = undefined
- this.maxId = undefined
- },
- async fetchChat({ isFirstFetch = false, fetchLatest = false, maxId }) {
- if (fetchLatest && this.streamingEnabled) {
- return
- }
-
- let messages
- if (this.isConversation) {
- const [
- { data: status },
- {
- data: { ancestors, descendants },
- },
- ] = await Promise.all([
- fetchStatus({
- id: this.statusId,
- credentials: useOAuthStore().token,
- }),
- fetchConversation({
- id: this.statusId,
- credentials: useOAuthStore().token,
- }),
- ])
- messages = [...ancestors, status, ...descendants]
+ // # Forks
+ const messages = computed(() => {
+ if (isConversation.value) {
+ return conversationMessages.value
} else {
- const { data } = await chatMessages({
- id: this.chat.id,
- maxId,
- sinceId: fetchLatest ? this.maxId : null,
- credentials: useOAuthStore().token,
- })
- messages = data
- }
-
- // Clear the current chat in case we're recovering from a ws connection loss.
- if (isFirstFetch) {
- this.clear()
- }
-
- const positionBeforeUpdate = getScrollPosition()
- this.addMessages({ messages })
-
- await nextTick()
- if (isFirstFetch) {
- this.scrollDown()
+ return chatMessages.value
}
-
- const fetchOlderMessages = !!maxId
- if (fetchOlderMessages) {
- this.handleScrollUp(positionBeforeUpdate)
+ })
+ const pendingMessages = computed(() => {
+ if (isConversation.value) {
+ return [] // Not implemented yet
+ } else {
+ return chatPendingMessages.value
}
-
- // In vertical screens, the first batch of fetched messages may not always take the
- // full height of the scrollable container.
- // If this is the case, we want to fetch the messages until the scrollable container
- // is fully populated so that the user has the ability to scroll up and load the history.
- //
- // Conversation fetching doesn't support pagination and spews out everything at once
- // so we both can't and don't need to fetch previous posts
- if (!this.isConversation && !isScrollable() && messages.length > 0) {
- this.fetchChat({
- maxId: this.minId,
- })
+ })
+ const newMessagesCount = computed(() => {
+ if (isConversation.value) {
+ return 0 // Not implemented yet
+ } else {
+ return chatNewMessagesCount.value
}
- },
- onStreamMessage({ data }) {
- const messages = data.filter(
- ({ statusnet_conversation_id }) =>
- statusnet_conversation_id === this.conversationId,
- )
- this.addMessages({ messages })
- },
- onChatUpdate({ data: { chatUpdate } }) {
- const messages = [chatUpdate.lastMessage]
- this.addMessages({ messages })
- },
- addMessages({ messages: newMessages }) {
- for (let i = 0; i < newMessages.length; i++) {
- const message = newMessages[i]
-
- // Sanity check
- if (!this.isConversation && message.chat_id !== this.chat.id) {
- // This is spammy, we get chat updates from a global chat update
- // handler, which naturally receives updates for ALL chats.
- // There is no way to subscribe to specific chat updates and listen
- // to that in the API.
- /*
- console.warn(
- `Chat message doesn't belong to current chat (id: ${this.chat.id})!!`,
- message,
- )
- */
- return
- }
-
- // Clear any known pending messages
- if (message.idempotency_key) {
- if (this.pendingMessagesIndex[message.idempotency_key]) {
- delete this.pendingMessagesIndex[message.idempotency_key]
- this.pendingMessages = this.pendingMessages.filter(
- ({ idempotency_key }) =>
- idempotency_key !== message.idempotency_key,
- )
- }
- }
-
- if (!this.minId || (!message.pending && message.id < this.minId)) {
- this.minId = message.id
- }
-
- if (!this.maxId || message.id > this.maxId) {
- this.maxId = message.id
- }
-
- if (!this.messagesIndex[message.id] && !isConfirmation(this, message)) {
- if (this.lastReadMessageId < message.id) {
- this.newMessageCount++
- }
- this.messagesIndex[message.id] = message
- this.messages.push(this.messagesIndex[message.id])
- this.idempotencyKeyIndex[message.idempotency_key] = true
- }
+ })
+ const error = computed(() => {
+ if (isConversation.value) {
+ return conversationError.value
+ } else {
+ return chatError.value
}
- },
+ })
- // Optimistic posting (chats only)
- async sendMessage({ status, media, idempotencyKey }) {
- const params = {
- id: this.chat.id,
- content: status,
- idempotencyKey,
+ const fetchOlder = () => {
+ if (isChat.value) {
+ chatFetchOlder()
}
-
- if (media[0]) {
- params.mediaId = media[0].id
+ // Unsupported in conversations
+ }
+ const markAsRead = () => {
+ if (isChat.value) {
+ chatMarkRead()
}
+ // Unsupported in conversations
+ }
- const fakeMessage = buildFakeMessage({
- attachments: media,
- chatId: this.chat.id,
- content: status,
- userId: this.currentUser.id,
- idempotencyKey,
- })
+ // Reply-to
+ const explicitReply = ref(null)
+ const lastMessage = computed(() => messages.value?.at(-1))
+ const replyTo = computed(() => explicitReply.value ?? lastMessage.value)
- this.pendingMessages.push(fakeMessage)
- this.pendingMessagesIndex[idempotencyKey] = fakeMessage
+ const scroller = inject('bodyScrollPosition')
+ const rootElement = useTemplateRef('root')
+ const postForm = useTemplateRef('postStatusForm')
- this.handleAttachmentPosting()
+ // Post form stuff
+ watch(replyTo, () => {
+ if (isConversation.value) postForm.value.update()
+ })
- return this.doSendMessage({
- params,
- retriesLeft: MAX_RETRIES,
- })
- },
- async doSendMessage({ params, retriesLeft = MAX_RETRIES }) {
- if (retriesLeft <= 0) return
+ // # Scroll stuff
+ const { hasReachedBottom } = scroller
- try {
- const { data } = await sendChatMessage({
- ...params,
- credentials: useOAuthStore().token,
- })
+ const postFormElement = computed(() => postForm.value?.$el)
+ const { height: postFormHeight } = useClientRectSize(postFormElement)
+ const { scrollBy } = scroller
- this.addMessages({
- messages: [{ ...data }],
- })
- } catch (error) {
- if (
- error.name !== 'StatusCodeError' ||
- error.message === 'Failed to fetch'
- )
- throw error
- console.error('Error sending message', error)
-
- this.handleMessageError({
- chatId: this.chat.id,
- idempotencyKey: params.idempotencyKey,
- isRetry: retriesLeft !== MAX_RETRIES,
- })
+ // ### Post form size compensation
+ watch(postFormHeight, (neu, old) => {
+ scrollBy(0, neu - old)
+ })
+ // ## Load / Read
+ const { shouldLoadTop } = scroller
+ watchEffect(() => {
+ if (shouldLoadTop.value && messages.value.length > 0) fetchOlder()
+ })
+ watch(hasReachedBottom, (value) => {
+ if (!value) return
+ window.setTimeout(() => {
if (
- (error.statusCode >= 500 && error.statusCode < 600) ||
- error.message === 'Failed to fetch'
+ newMessagesCount.value > 0 &&
+ rootElement.value &&
+ hasReachedBottom.value
) {
- this.messageRetriers[params.idempotencyKey] = setTimeout(
- () => {
- this.doSendMessage({
- params,
- retriesLeft: retriesLeft - 1,
- })
- },
- 1000 * 2 ** (MAX_RETRIES - retriesLeft),
- )
- }
- }
- },
- handleMessageError(idempotencyKey, isRetry) {
- const fakeMessage = this.pendingMessagesIndex[idempotencyKey]
-
- if (fakeMessage) {
- fakeMessage.error = true
- fakeMessage.pending = false
- }
- },
-
- // Checks
- hasReachedTop() {
- return window.scrollY <= 0
- },
- cullOlderCheck() {
- if (this.conversationId) return
- window.setTimeout(() => {
- if (isBottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
- this.cullOlder()
+ markAsRead()
}
- }, 5000)
- },
+ }, MARK_AS_READ_DELAY)
+ })
- // Event handlers
- onPosted(data) {
- // only conversation poster has the returned data
- if (this.isConversation) {
- this.explicitReplyStatus = null
- this.$router.push({
- name: 'conversation2',
- params: { statusId: data.id },
+ // Misc UI stuff
+ const { layoutType } = storeToRefs(useInterfaceStore())
+ const mobileLayout = computed(() => layoutType.value === 'mobile')
+ const jumpToBottomButtonVisible = computed(() => !hasReachedBottom.value)
+ const router = useRouter()
+ const { t } = useI18n()
+ const formPlaceholder = computed(() => {
+ if (chatRecipient.value) {
+ return t('chats.message_user', {
+ nickname: chatRecipient.value?.screen_name_ui,
})
- }
- },
- handleVisibilityChange() {
- this.$nextTick(() => {
- if (!document.hidden && isBottomedOut(BOTTOMED_OUT_OFFSET)) {
- this.scrollDown({ forceRead: true })
- }
- })
- },
- onFilesDropped() {
- this.$nextTick(() => {
- this.handleResize()
- })
- },
- handleResize(opts = {}) {
- // "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport
- const { delayed = false } = opts
-
- if (delayed) {
- setTimeout(() => {
- this.handleResize({ ...opts, delayed: false })
- }, SAFE_RESIZE_TIME_OFFSET)
- return
- }
-
- this.$nextTick(() => {
- const { offsetHeight = undefined } = getScrollPosition()
- const diff = offsetHeight - this.lastScrollPosition.offsetHeight
- if (diff !== 0 && !isBottomedOut()) {
- this.$nextTick(() => {
- window.scrollBy({ top: -Math.trunc(diff) })
- })
- }
- this.lastScrollPosition = getScrollPosition()
- })
- },
- handleScroll: throttle(function () {
- if (!this.chat) {
- return
- }
- this.lastScrollPosition = getScrollPosition()
-
- if (this.hasReachedTop()) {
- this.fetchChat({ maxId: this.minId })
- } else if (isBottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
- this.jumpToBottomButtonVisible = false
- this.cullOlderCheck()
- if (this.newMessageCount > 0) {
- // Use a delay before marking as read to prevent situation where new messages
- // arrive just as you're leaving the view and messages that you didn't actually
- // get to see get marked as read.
- window.setTimeout(() => {
- // Don't mark as read if the element doesn't exist, user has left chat view
- if (this.$el) this.readChat()
- }, MARK_AS_READ_DELAY)
- }
} else {
- this.jumpToBottomButtonVisible = true
- }
- }, 200),
- handleScrollUp(positionBeforeLoading) {
- const positionAfterLoading = getScrollPosition()
-
- window.scrollTo({
- top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
- })
- },
- handleAttachmentPosting() {
- this.$nextTick(() => {
- this.handleResize()
- // When the posting form size changes because of a media attachment, we need an extra resize
- // to account for the potential delay in the DOM update.
- this.scrollDown({ forceRead: true })
- })
- },
-
- // Misc
- scrollDown(options = {}) {
- const { behavior = 'auto', forceRead = false } = options
- this.$nextTick(() => {
- window.scrollTo({
- top: document.documentElement.scrollHeight,
- behavior,
- })
- })
- if (forceRead) {
- this.readChat()
+ return ''
}
- },
- goBack() {
- this.$router.back()
- },
+ })
+ const onPosted = (data) => {
+ explicitReply.value = null
- // Ugly
- // TODO move to ChatMessage
- async deleteChatMessage({ chatId, messageId }) {
- if (!this.testMode)
- await deleteChatMessage({
- chatId,
- messageId,
- credentials: useOAuthStore().token,
+ if (isConversation.value) {
+ router.push({
+ name: 'conversation2',
+ params: { statusId: data.id },
})
-
- this.messages = this.messages.filter((m) => m.id !== messageId)
- delete this.messagesIndex[messageId]
-
- if (this.maxId === messageId) {
- const lastMessage = maxBy(this.messages, 'id')
- this.maxId = lastMessage.id
}
+ }
- if (this.minId === messageId) {
- const firstMessage = minBy(this.messages, 'id')
- this.minId = firstMessage.id
- }
- },
+ return {
+ messages,
+ pendingMessages,
+ newMessagesCount,
+ error,
+ replyTo,
+ explicitReply,
+
+ // Conversation-exclusive
+ isConversation,
+ setFocused,
+ focusedId,
+
+ // Chats-exclusive
+ isChat,
+ sendMessage,
+ chatReady,
+ chatRecipient,
+
+ // Misc
+ onPosted,
+ mobileLayout,
+ jumpToBottomButtonVisible,
+ formPlaceholder,
+ }
},
}
export default Chat
diff --git a/src/components/chat_view/chat_view.scss b/src/components/chat_view/chat_view.scss
index 87028c7c47..6cae78ecf4 100644
--- a/src/components/chat_view/chat_view.scss
+++ b/src/components/chat_view/chat_view.scss
@@ -1,119 +1,120 @@
.chat-view {
display: flex;
.chat-list-wrapper {
display: flex;
+ justify-content: end;
flex-direction: column;
height: 100%;
}
.top-spacer {
flex: 1 1 0;
min-height: 0;
}
.chat-view-inner {
height: auto;
width: 100%;
overflow: visible;
display: flex;
}
.chat-view-body {
box-sizing: border-box;
display: flex;
flex-direction: column;
width: 100%;
overflow: visible;
min-height: calc(100vh - var(--navbar-height));
margin: 0;
border-radius: var(--roundness);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
&::after {
border-radius: 0;
}
}
.message-list {
padding: 0 0.8em;
height: 100%;
display: flex;
flex-direction: column;
justify-content: end;
}
.footer {
position: sticky;
display: flex;
align-items: stretch;
flex-direction: column;
padding: 0;
bottom: 0;
z-index: 1;
}
.chat-view-heading {
grid-template-columns: auto minmax(50%, 1fr);
}
.go-back-button {
text-align: center;
line-height: 1;
height: 100%;
align-self: start;
width: var(--__panel-heading-height-inner);
}
.jump-to-bottom-button {
width: 2.5em;
height: 2.5em;
border-radius: 100%;
position: absolute;
right: 1.3em;
top: -3.2em;
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0 1px 1px rgb(0 0 0 / 30%), 0 2px 4px rgb(0 0 0 / 30%);
z-index: 10;
transition: 0.35s all;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
opacity: 0;
visibility: hidden;
cursor: pointer;
&.visible {
opacity: 1;
visibility: visible;
}
.unread-message-count {
font-size: 0.8em;
left: 50%;
margin-top: -1rem;
padding: 0.1em;
border-radius: 50px;
position: absolute;
}
.chat-loading-error {
width: 100%;
display: flex;
align-items: flex-end;
height: 100%;
.error {
width: 100%;
}
}
}
.reply-to-text {
text-align: center;
line-height: 1.2;
padding-top: 0.5em;
margin-bottom: -0.5em;
}
}
diff --git a/src/components/chat_view/chat_view.vue b/src/components/chat_view/chat_view.vue
index 06862ab3c1..77e9a07854 100644
--- a/src/components/chat_view/chat_view.vue
+++ b/src/components/chat_view/chat_view.vue
@@ -1,124 +1,117 @@
<template>
- <div class="chat-view">
+<div
+ class="chat-view"
+ ref="root"
+>
<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="replyStatus?.id"
- :focused-id="statusId"
- @message-delete="deleteChatMessage"
- @reply-requested="e => explicitReplyStatus = e"
+ :replied-id="replyTo?.id"
+ :focused-id="focusedId"
+ @reply-requested="e => explicitReply = e"
+ />
+ <ChatMessageList
+ pending
+ :messages="pendingMessages"
/>
</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/components/conversation/conversation.js b/src/components/conversation/conversation.js
index 24432b3a6d..18a061ee2b 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,383 +1,380 @@
import { last } from 'lodash-es'
import { storeToRefs } from 'pinia'
import {
computed,
+ inject,
nextTick,
- onUnmounted,
provide,
ref,
toRefs,
useTemplateRef,
watch,
} from 'vue'
import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from 'src/components/quick_view_settings/quick_view_settings.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import ThreadTree from 'src/components/thread_tree/thread_tree.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useClientRectSize } from 'src/composables/useClientRectSize.js'
import { useConversation } from 'src/composables/useConversation.js'
import { useInterfaceSizes } from 'src/composables/useInterfaceSizes.js'
-import { useScrollPosition } from 'src/composables/useScrollPosition.js'
import { useTreeConversationTopology } from 'src/composables/useTreeConversationTopology.js'
import { useVirtualScrolling } from 'src/composables/useVirtualScrolling.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft,
faReply,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft,
faReply,
faTimes,
)
export default {
props: {
statusId: {
// Main thing
type: String,
required: true,
},
isPage: {
// Whether conversation is rendered as a standalone page
// as opposed to embedded into a timeline
type: Boolean,
default: false,
},
},
components: {
ThreadTree,
QuickFilterSettings,
QuickViewSettings,
RichContent,
},
emits: ['heightChange', 'suspendableStateChange', 'expanded', 'collapsed'],
setup(props, { emit }) {
- const scroller = useScrollPosition()
+ const scroller = inject('bodyScrollPosition')
const { statusId } = toRefs(props)
// # Main Configuration / global state
const { mergedConfig } = storeToRefs(useMergedConfigStore())
const displayStyle = computed(() => mergedConfig.value.conversationDisplay)
const { layoutType } = storeToRefs(useInterfaceStore())
const mobileLayout = computed(() => layoutType.value === 'mobile')
// # Conversation Expansion
const expanded = ref(false)
const { isPage } = toRefs(props)
const isExpanded = computed(() => !!(expanded.value || isPage.value))
const toggleExpanded = async () => {
const newVal = !expanded.value
if (newVal) {
virtualScrollingEnabled.value = newVal
await nextTick()
expanded.value = newVal
} else {
expanded.value = newVal
await nextTick()
virtualScrollingEnabled.value = newVal
}
}
provide('isExpanded', isExpanded)
provide('isPage', isPage)
provide('expandable', true)
watch(expanded, (val) => (val ? emit('expanded') : emit('collapsed')), {
flush: 'post',
})
// # Main things
const {
focusedId,
conversationId,
setFocused,
currentStatus,
conversation,
replies,
getReplies,
fetchConversation,
loadError,
} = useConversation(statusId, isExpanded)
const conversationLite = computed(() =>
conversation.value.map(({ id }) => ({ id })),
)
provide('focusedId', focusedId)
provide('conversation', conversation)
provide('replies', replies)
// Component created
if (isPage.value) {
fetchConversation()
}
// # Misc UI things
const firstStatus = computed(() => conversation.value[0])
const lastStatus = computed(() => last(conversation.value))
const getStatusClasses = (statusId, ancestor) => {
const result = {
'-first': statusId === firstStatus.value?.id,
'-last': statusId === lastStatus.value?.id,
}
if (ancestor) {
result['-ancestor'] = true
result['-fade'] = mergedConfig.value.conversationTreeFadeAncestors
}
return result
}
// # External virtual scrolling
const unsuspendableIds = ref(new Set())
const suspendable = computed(
() => !isExpanded.value && unsuspendableIds.value.size === 0,
)
const rootElement = useTemplateRef('root')
const rootElementMargin = ref(null)
- const updateVirtualHeight = (e) => {
- const [entry] = e
-
+ const { height } = useClientRectSize(rootElement)
+ watch(height, (value) => {
const rootCss = window.getComputedStyle(rootElement.value)
const rootMarginString = rootCss.getPropertyValue('margin-top')
const rootMargin = Number.parseInt(rootMarginString.slice(0, -2), 10)
rootElementMargin.value = rootMargin
emit('heightChange', {
id: statusId.value,
- height: entry.contentRect.height,
+ height: value,
element: rootElement,
})
- }
- const resizeObserver = ref(new ResizeObserver(updateVirtualHeight))
- watch(rootElement, () => resizeObserver.value.observe(rootElement.value))
+ })
+
watch(suspendable, (value) =>
emit('suspendableStateChange', {
suspendable: value,
id: statusId.value,
}),
)
- onUnmounted(() => resizeObserver.value.disconnect())
// # Internal virtual scrolling
const virtualScrollingEnabled = ref(isExpanded.value)
// Placeholder heights.
const { fontSize, navbarSize, panelHeaderSize } = useInterfaceSizes()
const normalStatusHeight = computed(() => fontSize.value * 10)
const getPlaceholderHeight = (id) => normalStatusHeight
const offset = computed(() => {
// The fontsize after navbar is the little gap between navbar and content
if (isPage.value) {
return navbarSize.value + fontSize.value + panelHeaderSize.value
} else if (expanded.value) {
return (
navbarSize.value +
fontSize.value +
panelHeaderSize.value * 2 +
rootElementMargin.value
)
} else {
return navbarSize.value + fontSize.value
}
})
// # Linear style stuff
const isLinearView = computed(() => displayStyle.value !== 'tree')
const linearElement = useTemplateRef('linear')
const linearScrollCompensation = computed(
() => virtualScrollingEnabled.value && isLinearView.value,
)
const {
heightChart: heightChartLinear,
changeSuspendState: changeSuspendStateLinear,
updateVirtualHeight: updateVirtualHeightLinear,
reset: resetLinearScrollVirtualization,
scrollTo: linearScrollTo,
} = useVirtualScrolling({
name: 'Linear',
enabled: linearScrollCompensation,
list: conversationLite,
body: linearElement,
offset,
scrollPositionInstance: scroller,
scrollCompensation: linearScrollCompensation,
getPlaceholderHeight,
})
const changeSuspendStateLinearLocal = (e) => {
changeSuspendStateLinear(e)
const { id, suspendable } = e
if (suspendable) {
unsuspendableIds.value.delete(id)
} else {
unsuspendableIds.value.add(id)
}
}
// # Tree style stuff
const isTreeView = computed(() => displayStyle.value === 'tree')
const {
topLevel,
currentAncestors,
threadDisplay,
setThreadDisplayRecursively,
showThreadRecursively,
resetThreadDisplay,
totalReplyCount,
totalReplyDepth,
} = useTreeConversationTopology(conversation, replies, focusedId)
provide('threadDisplay', threadDisplay)
provide('totalReplyCount', totalReplyCount)
provide('totalReplyDepth', totalReplyDepth)
watch(
isExpanded,
(value) => {
if (!value) resetThreadDisplay()
},
{ flush: 'post' },
)
const toggleThreadDisplay = (id) => {
const current = threadDisplay.value.get(id)
const next = current === 'hidden' ? 'showing' : 'hidden'
setThreadDisplayRecursively(id, next)
}
const currentAncestorsLite = computed(() =>
currentAncestors.value.map(({ id }) => ({ id })),
)
const ancestorsElement = useTemplateRef('ancestors')
const treeScrollCompensation = computed(
() => virtualScrollingEnabled.value && isTreeView.value,
)
const {
heightChart: heightChartAncestors,
changeSuspendState: changeSuspendStateAncestors,
updateVirtualHeight: updateVirtualHeightAncestors,
reset: resetTreeScrollVirtualization,
} = useVirtualScrolling({
name: 'Ancestors',
enabled: treeScrollCompensation,
list: currentAncestorsLite,
body: ancestorsElement,
offset,
scrollPositionInstance: scroller,
scrollCompensation: treeScrollCompensation,
collapseMode: 'height',
getPlaceholderHeight,
})
const changeSuspendStateAncestorsLocal = (e) => {
changeSuspendStateAncestors(e)
const { id, suspendable } = e
if (suspendable) {
unsuspendableIds.value.delete(id)
} else {
unsuspendableIds.value.add(id)
}
}
const changeSuspendStateCurrentLevelLocal = (e) => {
const { id, suspendable } = e
if (suspendable) {
unsuspendableIds.value.delete(id)
} else {
unsuspendableIds.value.add(id)
}
}
watch(conversationId, (neu, old) => {
resetLinearScrollVirtualization()
resetTreeScrollVirtualization()
})
const treeViewIsSimple = computed(
() => !mergedConfig.value.conversationTreeAdvanced,
)
const shouldShowAllConversationButton = computed(
() => currentAncestors.value.length > 0 && topLevel.value.length > 1,
)
const shouldShowAncestors = computed(
() => isExpanded.value && heightChartAncestors.value.length > 0,
)
// # Scrolling
const scrollTo = (ids) => {
if (isLinearView.value) {
return linearScrollTo(ids)
}
}
const diveIntoStatus = (id) => setFocused(id)
const diveToTopLevel = () => setFocused(currentAncestors.value[0].id)
watch(focusedId, async (neu, old) => {
// Ignoring initial update (null -> id) since that is handled by scroll compensation
if (old && neu) scrollTo(new Set([neu]))
})
return {
// # Misc
loadError,
mobileLayout,
// # Conversation Expansion
isPage,
isExpanded,
toggleExpanded,
// # Focus
focusedId,
setFocused,
// # Main things
conversation,
currentStatus,
getReplies,
// # Misc UI things
getStatusClasses,
// # Linear style stuff
isLinearView,
// ## Linear virtual scrolling
heightChartLinear,
changeSuspendStateLinearLocal,
updateVirtualHeightLinear,
// # Tree style stuff
isTreeView,
// ## Tree virtual scrolling
heightChartAncestors,
changeSuspendStateAncestorsLocal,
updateVirtualHeightAncestors,
changeSuspendStateCurrentLevelLocal,
// ## Tree state
// ### Topology
topLevel,
currentAncestors,
// ### Thread Display
showThreadRecursively,
toggleThreadDisplay,
// ### Derived values and config
treeViewIsSimple,
shouldShowAllConversationButton,
shouldShowAncestors,
// # Scrolling
diveToTopLevel,
diveIntoStatus,
unsuspendableIds,
}
},
}
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index 5c2e073c26..d98a284b82 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -1,454 +1,457 @@
<template>
<div
v-if="initialized"
ref="form"
class="post-status-form"
>
<form
autocomplete="off"
@submit.prevent
@dragover.prevent="fileDrag"
>
<div class="form-group">
<div
v-if="!currentUser.locked && newStatus.visibility == 'private' && !disableLockWarning"
class="visibility-notice notice-dismissible"
>
<i18n-t
keypath="post_status.account_not_locked_warning"
tag="p"
class=""
scope="global"
>
<button
class="button-unstyled -link"
@click="openProfileTab"
>
{{ $t('post_status.account_not_locked_warning_link') }}
</button>
</i18n-t>
</div>
<p
v-if="!hideScopeNotice && newStatus.visibility === 'public'"
class="visibility-notice notice-dismissible"
>
<span>{{ $t('post_status.scope_notice.public') }}</span>
<a
class="fa-scale-110 fa-old-padding dismiss"
:title="$t('post_status.scope_notice_dismiss')"
role="button"
tabindex="0"
@click.prevent="dismissScopeNotice()"
>
<FAIcon icon="times" />
</a>
</p>
<p
v-else-if="!hideScopeNotice && newStatus.visibility === 'unlisted'"
class="visibility-notice notice-dismissible"
>
<span>{{ $t('post_status.scope_notice.unlisted') }}</span>
<a
class="fa-scale-110 fa-old-padding dismiss"
:title="$t('post_status.scope_notice_dismiss')"
role="button"
tabindex="0"
@click.prevent="dismissScopeNotice()"
>
<FAIcon icon="times" />
</a>
</p>
<p
v-else-if="!hideScopeNotice && newStatus.visibility === 'private' && currentUser.locked"
class="visibility-notice notice-dismissible"
>
<span>{{ $t('post_status.scope_notice.private') }}</span>
<a
class="fa-scale-110 fa-old-padding dismiss"
:title="$t('post_status.scope_notice_dismiss')"
role="button"
tabindex="0"
@click.prevent="dismissScopeNotice()"
>
<FAIcon icon="times" />
</a>
</p>
<p
v-else-if="!hideScopeNotice && newStatus.visibility === 'direct'"
class="visibility-notice notice-dismissible"
>
<span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span>
<span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
</p>
<div
v-if="isEdit"
class="visibility-notice edit-warning"
>
<p>{{ $t('post_status.edit_remote_warning') }}</p>
<p>{{ $t('post_status.edit_unsupported_warning') }}</p>
</div>
<div
v-if="!disablePreview"
class="preview-heading"
>
<a
class="preview-toggle faint"
@click.stop.prevent="togglePreview"
>
{{ $t('post_status.preview') }}
<FAIcon :icon="showPreview ? 'chevron-left' : 'chevron-right'" />
</a>
<div
v-show="previewLoading"
class="preview-spinner"
>
<FAIcon
class="fa-old-padding"
spin
icon="circle-notch"
/>
</div>
<div
v-if="quotable"
role="radiogroup"
class="reply-or-quote-selector"
>
<Checkbox
v-model="quoteThreadToggled"
:radio="true"
:disabled="quoteFormVisible"
>
{{ $t('post_status.quote_option') }}
</Checkbox>
<Checkbox
role="radio"
:radio="true"
:model-value="!quoteThreadToggled"
:disabled="quoteFormVisible"
@update:model-value="e => quoteThreadToggled = !e"
>
{{ $t('post_status.reply_option') }}
</Checkbox>
</div>
</div>
<div
v-if="showPreview"
class="preview-container"
>
<div
v-if="!preview"
class="preview-status"
>
{{ $t('general.loading') }}
</div>
<div
v-else-if="preview.error"
class="preview-status preview-error"
>
{{ preview.error }}
</div>
<StatusContent
v-else
:status="preview"
class="preview-status"
/>
</div>
<div class="input inputs-wrapper">
<EmojiInput
v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)"
v-model="newStatus.spoilerText"
enable-emoji-picker
:suggest="emojiSuggestor"
class="input form-control subject-input unstyled"
>
<template #default="inputProps">
<input
v-model="newStatus.spoilerText"
type="text"
:placeholder="$t('post_status.content_warning')"
:disabled="posting && !optimisticPosting"
v-bind="propsToNative(inputProps)"
size="1"
class="input form-post-subject unstyled"
>
</template>
</EmojiInput>
<input
v-if="mentionsLine"
:value="mentionsLineReadOnly ? mentionsString : newStatus.mentionsLine"
type="text"
:placeholder="$t('post_status.mentions_line')"
:disabled="mentionsLineReadOnly || (posting && !optimisticPosting)"
size="1"
class="input mentions-input form-post-mentions unstyled"
@change="onMentionsLineUpdate"
>
<EmojiInput
ref="emoji-input"
v-model="newStatus.status"
:suggest="emojiUserSuggestor"
:placement="emojiPickerPlacement"
class="input form-control main-input unstyled"
enable-sticker-picker
enable-emoji-picker
:newline-on-ctrl-enter="submitOnEnter"
@input="onEmojiInputInput"
@sticker-uploaded="addMediaFile"
@sticker-upload-failed="uploadFailed"
@shown="handleEmojiInputShow"
>
<template #default="inputProps">
<textarea
ref="textarea"
v-model="newStatus.status"
:placeholder="placeholder || $t('post_status.default')"
rows="1"
cols="1"
:disabled="posting && !optimisticPosting"
class="input form-post-body"
:class="{ 'scrollable-form': !!maxHeight }"
v-bind="propsToNative(inputProps)"
@keydown.exact.enter="submitOnEnter && postStatus($event)"
@keydown.meta.enter="postStatus($event, newStatus)"
@keydown.ctrl.enter="!submitOnEnter && postStatus($event)"
@input="resize"
@compositionupdate="resize"
@paste="paste"
/>
<p
v-if="hasStatusLengthLimit"
class="character-counter faint"
:class="{ error: isOverLengthLimit }"
>
{{ charactersLeft }}
</p>
</template>
</EmojiInput>
</div>
<div
v-if="!disableScopeSelector"
class="visibility-tray"
>
<scope-selector
v-if="!disableVisibilitySelector"
ref="scopeSelector"
:show-all="showAllScopes"
:user-default="userDefaultScope"
:original-scope="repliedStatus?.visibility"
:initial-scope="newStatus.visibility"
@change="changeVis"
/>
<div
v-if="postFormats.length > 1"
class="text-format"
>
<Select
v-model="newStatus.contentType"
class="input form-control unstyled"
:attrs="{ 'aria-label': $t('post_status.content_type_selection') }"
unstyled="true"
>
<option
v-for="postFormat in postFormats"
:key="postFormat"
:value="postFormat"
>
{{ $t(`post_status.content_type["${postFormat}"]`) }}
</option>
</Select>
</div>
<div
v-if="postFormats.length === 1 && postFormats[0] !== 'text/plain'"
class="text-format"
>
<span class="only-format">
{{ $t(`post_status.content_type["${postFormats[0]}"]`) }}
</span>
</div>
</div>
</div>
<PollForm
v-if="pollsAvailable"
ref="pollForm"
v-model="newStatus.poll"
:visible="pollFormVisible"
/>
<QuoteForm
v-if="quotingAvailable"
:id="newStatus.quote?.id"
ref="quoteForm"
:visible="quoteFormVisible"
:url="newStatus.quote?.url"
@update:url="url => newStatus.quote.url = url"
@update:id="id => newStatus.quote.id = id"
/>
<span
v-if="!disableDraft && shouldAutoSaveDraft"
class="auto-save-status"
>
{{ autoSaveState }}
</span>
<div
ref="bottom"
class="form-bottom"
>
<div class="form-bottom-left">
<media-upload
ref="mediaUpload"
class="bottom-left-button media-upload-icon"
:drop-files="dropFiles"
:disabled="uploadFileLimitReached"
@uploading="startedUploadingFiles"
@uploaded="addMediaFile"
@upload-failed="uploadFailed"
@all-uploaded="finishedUploadingFiles"
/>
<button
v-if="pollsAvailable"
class="bottom-left-button poll-icon button-unstyled"
:class="{ toggled: pollFormVisible }"
:title="$t('polls.add_poll')"
@click="togglePollForm"
>
<FAIcon icon="poll-h" />
</button>
<button
v-if="quotingAvailable"
class="bottom-left-button quote-icon button-unstyled"
:disabled="quoteThreadToggled"
:class="{ toggled: quoteFormVisible }"
:title="$t('tool_tip.add_quote')"
@click="toggleQuoteForm"
>
<FAIcon icon="quote-right" />
</button>
</div>
<div class="btn-group post-button-group">
<button
class="btn button-default post-button"
:disabled="isOverLengthLimit || posting || uploadingFiles || disableSubmit"
@click.stop.prevent="postStatus($event)"
>
<template v-if="posting">
{{ $t('post_status.posting') }}
</template>
<template v-else>
{{ $t('post_status.post') }}
</template>
</button>
<Popover
v-if="!hideExtraActions"
class="more-post-actions"
:normal-button="true"
trigger="click"
placement="bottom"
:offset="{ y: 5 }"
:trigger-attrs="{ 'aria-label': $t('post_status.more_post_actions') }"
>
<template #trigger>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="chevron-down"
/>
</template>
<template #content="{close}">
<div
class="dropdown-menu"
role="menu"
>
<div
class="menu-item dropdown-item"
:class="{ disabled: !safeToSaveDraft }"
>
<button
v-if="!hideDraft || !disableDraft"
class="main-button"
role="menu"
:disabled="!safeToSaveDraft"
@click.prevent="saveDraft"
@click="close"
>
<template v-if="closeable">
{{ $t('post_status.save_to_drafts_and_close_button') }}
</template>
<template v-else>
{{ $t('post_status.save_to_drafts_button') }}
</template>
</button>
</div>
</div>
</template>
</Popover>
</div>
</div>
- <small class="keyboard-enter-hint faint">
+ <small
+ v-if="chatView"
+ class="keyboard-enter-hint faint"
+ >
<i v-if="submitOnEnter">
{{ $t('post_status.enter_submits') }}
</i>
<i v-else>
{{ $t('post_status.enter_newline') }}
</i>
</small>
<div
v-show="showDropIcon !== 'hide'"
:style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }"
class="drop-indicator"
@dragleave="fileDragStop"
@drop.stop="fileDrop"
>
<FAIcon :icon="uploadFileLimitReached ? 'ban' : 'upload'" />
</div>
<div
v-if="error"
class="alert error -dismissible"
>
<span>
{{ error }}
</span>
<button
class="button-unstyled"
@click="clearError"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
/>
</button>
</div>
<Gallery
v-if="newStatus.files && newStatus.files.length > 0"
class="attachments"
:grid="true"
:nsfw="false"
:attachments="newStatus.files"
:descriptions="newStatus.mediaDescriptions"
:set-media="() => setMedia()"
:editable="true"
:edit-attachment="editAttachment"
:remove-attachment="removeMediaFile"
:shift-up-attachment="newStatus.files.length > 1 && shiftUpMediaFile"
:shift-dn-attachment="newStatus.files.length > 1 && shiftDnMediaFile"
@play="$emit('mediaplay', 'newStatus')"
@pause="$emit('mediapause', 'newStatus')"
/>
<div
v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox"
class="upload_settings"
>
<Checkbox v-model="newStatus.nsfw">
{{ $t('post_status.attachments_sensitive') }}
</Checkbox>
</div>
</form>
<DraftCloser
ref="draftCloser"
@save="saveAndCloseDraft"
@discard="discardAndCloseDraft"
/>
</div>
</template>
<script src="./post_status_form.js"></script>
<style src="./post_status_form.scss" lang="scss"></style>
diff --git a/src/components/scroll_top_button/scroll_top_button.js b/src/components/scroll_top_button/scroll_top_button.js
index e4b908f0b8..45e6923f9f 100644
--- a/src/components/scroll_top_button/scroll_top_button.js
+++ b/src/components/scroll_top_button/scroll_top_button.js
@@ -1,18 +1,19 @@
const ScrollTopButton = {
props: {
fast: {
type: Boolean,
required: false,
default: false,
},
},
+ inject: ['bodyScrollPosition'],
methods: {
scrollToTop() {
const speed = this.fast ? 'instant' : 'smooth'
- window.scrollTo({ top: 0, behavior: speed })
+ this.bodyScrollPosition.scrollToPriority({ top: 0, behavior: speed })
},
},
}
export default ScrollTopButton
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index 69ffbd8b94..2f9b01aef7 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -1,302 +1,276 @@
import { debounce, throttle } from 'lodash-es'
import { storeToRefs } from 'pinia'
import {
computed,
+ inject,
+ nextTick,
onMounted,
onUnmounted,
ref,
toRefs,
useTemplateRef,
watch,
} from 'vue'
import { useI18n } from 'vue-i18n'
import Conversation from 'src/components/conversation/conversation.vue'
import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from 'src/components/quick_view_settings/quick_view_settings.vue'
import ScrollTopButton from 'src/components/scroll_top_button/scroll_top_button.vue'
import TimelineMenu from 'src/components/timeline_menu/timeline_menu.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useTimelinesStore } from 'src/stores/timelines.js'
+import { useDocumentFocus } from 'src/composables/useDocumentFocus.js'
import { useInterfaceSizes } from 'src/composables/useInterfaceSizes.js'
-import { useScrollPosition } from 'src/composables/useScrollPosition.js'
import { useVirtualScrolling } from 'src/composables/useVirtualScrolling.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faArrowUp,
faCheck,
faCircleNotch,
faCirclePlus,
faCog,
faMinus,
} from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch, faCog, faMinus, faArrowUp, faCirclePlus, faCheck)
const Timeline = {
props: {
timelineRef: Object,
footerSlipgate: Object, // reference to an element where we should put our footer
embedded: Boolean,
skipPinned: Boolean,
hideEmpty: Boolean,
},
components: {
ScrollTopButton,
Conversation,
TimelineMenu,
QuickFilterSettings,
QuickViewSettings,
},
setup(props, ctx) {
const { t } = useI18n()
- const unfocused = ref(false)
+
+ const { focused } = useDocumentFocus()
+ const unfocused = computed(() => !focused.value)
// Timeline
const { timelineRef } = toRefs(props)
const timeline = computed(() => useTimelinesStore()[timelineRef.value.name])
const { skipPinned } = toRefs(props)
const filteredVisibleStatuses = computed(() => {
return timeline.value.order
.filter((id) => timeline.value.visibleStatusIds.has(id))
.map((id) => useStatusesStore().allStatuses.get(id))
.filter(({ pinned }) => (skipPinned.value ? !pinned : true))
})
- // Scroll position // FIXME unify scroll position logic in timelines
- const scroller = useScrollPosition()
+ // Scroll position
+ const scroller = inject('bodyScrollPosition')
+ const { hasReachedTop, shouldLoadBottom, top: scrollY } = scroller
// Virtual scrolling
const { fontSize, navbarSize } = useInterfaceSizes()
// Placeholder heights.
const mutedStatusHeight = computed(() => fontSize.value * 1.5)
const normalStatusHeight = computed(() => fontSize.value * 10)
const getPlaceholderHeight = (id) =>
filteredVisibleStatuses.value.find((item) => item.id === id)?.muted
? mutedStatusHeight
: normalStatusHeight
const body = useTemplateRef('timeline')
const offset = computed(() => {
if (embedded.value) {
// The fontsize after navbar is the little gap between navbar and content
return navbarSize.value + fontSize.value
} else {
return 0
}
})
const { heightChart, changeSuspendState, updateVirtualHeight } =
useVirtualScrolling({
name: 'Timeline',
enabled: ref(true),
list: filteredVisibleStatuses,
body,
offset,
scrollPositionInstance: scroller,
- scrollCompensation: ref(false),
+ scrollCompensation: false,
getPlaceholderHeight,
})
// Counter
const count = computed(() => timeline.value.order.length)
const newStatusCount = computed(() => timeline.value.newStatusCount)
const showLoadButton = computed(
() => timeline.value.newStatusCount > 0 || timeline.value.reloadNeeded,
)
// Showing new
const paused = ref(false)
+ const { mergedConfig } = storeToRefs(useMergedConfigStore())
+ const unfocusedPause = computed(
+ () => unfocused.value && mergedConfig.value.pauseOnUnfocused,
+ )
+ const showNewAutomatically = computed(
+ () => useMergedConfigStore().mergedConfig.streaming,
+ )
+
watch(newStatusCount, (count) => {
- if (!useMergedConfigStore().mergedConfig.streaming) {
- return
- }
+ if (!showNewAutomatically.value) return
if (count <= 0) return
+
// only 'stream' them when you're scrolled to the top
- const doc = document.documentElement
- const top = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0)
- if (
- top < 15 &&
- !paused.value &&
- !(
- unfocused.value &&
- useMergedConfigStore().mergedConfig.pauseOnUnfocused
- )
- ) {
- showNewStatuses()
+ if (hasReachedTop.value && !paused.value && !unfocusedPause.value) {
+ showNewStatuses(true)
} else {
paused.value = true
}
})
- const showNewStatuses = () => {
+
+ const showNewStatuses = async (fast = false) => {
if (timeline.value.reloadNeeded) {
useTimelinesStore().clearTimeline(timelineRef.value.name)
fetchOlderStatuses()
} else {
blockClicksTemporarily()
useTimelinesStore().showNewStatuses(timelineRef.value.name)
- paused.value = false
}
- window.scrollTo({ top: 0 })
+ paused.value = false
+ await nextTick()
+ scroller.scrollToPriority({
+ top: 0,
+ behavior: fast ? 'instant' : 'smooth',
+ })
}
const fetchOlderStatuses = throttle(() => {
timeline.value.fetcher.fetchOlder()
}, 1000)
// Timeline change
const timelineChange = (newTimeline, oldTimeline) => {
const sameName = newTimeline?.name === oldTimeline?.name
const sameArgument = newTimeline?.argument === oldTimeline?.argument
if (sameName && sameArgument) return
if (oldTimeline) {
useTimelinesStore().deactivate(oldTimeline.name)
}
if (newTimeline) {
useTimelinesStore().activate(newTimeline.name, newTimeline.argument)
}
}
watch(timelineRef, timelineChange, { immediate: true })
onUnmounted(() => {
timelineChange(null, timelineRef.value)
})
// Misclick prevention
const blockingClicks = ref(false)
const stopBlockingClicks = debounce(() => {
blockingClicks.value = false
}, 1000)
const blockClicksTemporarily = () => {
if (!blockingClicks.value) {
blockingClicks.value = true
}
stopBlockingClicks()
}
// Shortcuts
const handleShortKey = (e) => {
// Ignore when input fields are focused
if (['textarea', 'input'].includes(e.target.tagName.toLowerCase())) return
if (e.key === '.') showNewStatuses()
}
onMounted(() => {
window.addEventListener('keydown', handleShortKey)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleShortKey)
})
+ const infiniteLoadWatcher = debounce((value) => {
+ if (!value) return
+ fetchOlderStatuses()
+ }, 100)
// Scroll
- const scrollLoad = () => {
- // TODO simplify this logic
- const bodyBRect = document.body.getBoundingClientRect()
- const height = Math.max(bodyBRect.height, -bodyBRect.y)
- if (
- !timeline.value.fetcher.loadingOlder &&
- window.innerHeight + window.pageYOffset >= height - 750
- ) {
- fetchOlderStatuses()
- }
- }
- const handleScroll = throttle((e) => {
- scrollLoad(e)
- }, 200)
- onMounted(() => {
- window.addEventListener('scroll', handleScroll)
- })
- onUnmounted(() => {
- window.removeEventListener('scroll', handleScroll)
- })
-
- // Focused state
- const handleVisibilityChange = () => {
- unfocused.value = document.hidden
- }
- onMounted(() => {
- if (document.hidden === undefined) return
- document.addEventListener(
- 'visibilitychange',
- handleVisibilityChange,
- false,
- )
- unfocused.value = document.hidden
- })
+ watch([shouldLoadBottom, scrollY], infiniteLoadWatcher)
onUnmounted(() => {
- if (document.hidden === undefined) return
- document.removeEventListener(
- 'visibilitychange',
- handleVisibilityChange,
- false,
- )
+ infiniteLoadWatcher.cancel()
})
// Misc UI things
const classes = computed(() => {
let rootClasses = !embedded.value
? ['panel', 'panel-default']
: ['-embedded']
if (blockingClicks.value)
rootClasses = rootClasses.concat(['-blocked', '_misclick-prevention'])
return {
root: rootClasses,
header: ['timeline-heading'].concat(
!embedded.value ? ['panel-heading', '-sticky'] : ['panel-body'],
),
body: ['timeline-body'].concat(
!embedded.value ? ['panel-body'] : ['panel-body'],
),
footer: ['timeline-footer'].concat(
!embedded.value ? ['panel-footer'] : ['panel-body'],
),
}
})
const loadButtonString = computed(() => {
if (timeline.value.reloadNeeded) {
return t('timeline.reload')
} else {
return `${t('timeline.show_new')} (${newStatusCount.value})`
}
})
const mobileLoadButtonString = computed(() => {
if (timeline.value.reloadNeeded) {
return '+'
} else {
return newStatusCount.value > 99 ? '∞' : newStatusCount.value
}
})
const { layoutType } = storeToRefs(useInterfaceStore())
const mobileLayout = computed(() => layoutType.value === 'mobile')
const { footerSlipgate, embedded, hideEmpty } = toRefs(props)
return {
timelineRef,
timeline,
filteredVisibleStatuses,
heightChart,
updateVirtualHeight,
changeSuspendState,
count,
showLoadButton,
showNewStatuses,
fetchOlderStatuses,
classes,
loadButtonString,
mobileLoadButtonString,
mobileLayout,
footerSlipgate,
embedded,
hideEmpty,
}
},
}
export default Timeline
diff --git a/src/composables/useBodyScroller.js b/src/composables/useBodyScroller.js
new file mode 100644
index 0000000000..d3b4a5bd6a
--- /dev/null
+++ b/src/composables/useBodyScroller.js
@@ -0,0 +1,12 @@
+import { computed } from 'vue'
+
+import { useClientRectSize } from 'src/composables/useClientRectSize.js'
+
+export function useBodyScroller(windowScroller, windowSize) {
+ const element = computed(() => window.document.body)
+ const { x, y, scrollBy, scrollTo } = windowScroller
+ const { width: vWidth, height: vHeight } = windowSize
+ const { width: cWidth, height: cHeight } = useClientRectSize(element)
+
+ return { x, y, vWidth, vHeight, cWidth, cHeight, scrollBy, scrollTo }
+}
diff --git a/src/composables/useClientRectSize.js b/src/composables/useClientRectSize.js
new file mode 100644
index 0000000000..1a4ed25fa0
--- /dev/null
+++ b/src/composables/useClientRectSize.js
@@ -0,0 +1,37 @@
+import { onMounted, onUnmounted, ref, watch } from 'vue'
+
+export function useClientRectSize(element) {
+ const width = ref(0)
+ const height = ref(0)
+
+ const update = (entry) => {
+ const contentRect = entry
+ ? entry[0].contentRect
+ : element.value.getBoundingClientRect()
+
+ height.value = contentRect.height
+ width.value = contentRect.width
+ }
+
+ const resizeObserver = new ResizeObserver(update)
+
+ watch(element, (value) => {
+ if (value) {
+ resizeObserver.observe(element.value)
+ update()
+ }
+ })
+
+ onMounted(() => {
+ if (element.value) {
+ resizeObserver.observe(element.value)
+ update()
+ }
+ })
+ onUnmounted(() => resizeObserver.disconnect())
+
+ return {
+ width,
+ height,
+ }
+}
diff --git a/src/composables/useConversation.js b/src/composables/useConversation.js
index 1ca2b57ec2..b88960acdb 100644
--- a/src/composables/useConversation.js
+++ b/src/composables/useConversation.js
@@ -1,212 +1,212 @@
import { storeToRefs } from 'pinia'
import { computed, nextTick, ref, toValue, watch } from 'vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useStreamingStore } from 'src/stores/streaming.js'
import { useMainStatus } from 'src/composables/useMainStatus.js'
import {
fetchConversation as apiFetchConversation,
fetchStatus as apiFetchStatus,
} from 'src/api/public.js'
import { WSConnectionStatus } from 'src/api/websocket.js'
export function useConversation(statusId, expanded) {
const loadError = ref(null)
const { status: currentStatus, mainStatus } = useMainStatus(statusId)
// # Config
const { mergedConfig } = storeToRefs(useMergedConfigStore())
- const { mastoUserSocketStatus } = storeToRefs(useStreamingStore())
+ const { state: mastoUserSocketStatus } = storeToRefs(useStreamingStore())
const streamingEnabled = computed(
() =>
mergedConfig.value.useStreamingApi &&
mastoUserSocketStatus.value === WSConnectionStatus.JOINED,
)
watch(statusId, (neu, old) => {
if (neu !== old) {
fetchConversation()
}
})
const sortById = (a, b) => {
const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
const idB = b.type === 'retweet' ? b.retweeted_status.id : b.id
const seqA = Number(idA)
const seqB = Number(idB)
const isSeqA = !Number.isNaN(seqA)
const isSeqB = !Number.isNaN(seqB)
if (isSeqA && isSeqB) {
return seqA < seqB ? -1 : 1
} else if (isSeqA && !isSeqB) {
return -1
} else if (!isSeqA && isSeqB) {
return 1
} else {
return idA < idB ? -1 : 1
}
}
const fullyLoaded = ref(false)
const conversationId = computed(
() => mainStatus.value?.statusnet_conversation_id ?? null,
)
watch(conversationId, (neu, old) => {
if (neu !== old) fullyLoaded.value = false
})
const conversation = computed(() => {
if (!currentStatus.value) {
return []
}
if (!toValue(expanded) || !fullyLoaded.value) {
return [currentStatus.value]
}
/* It took me a week or so to figure this out.
*
* Virtual scrolling can compensate for posts being prepended to content,
* and prepended posts changing height. The problem is that it has to happen
* in prepended (i.e. either above visible post and/or above screen boundary)
*
* Here's the problem: showing conversation from store can be broken. UI might
* already know that some posts belong to a conversation, because you were
* mentioned in it, but doesn't know the rest of it. It ends up displaying this
* "partial" conversation, and then the rest loads in.
*
* Problem is, this partial conversation can be very fragmented, with missing
* pieces appearing in-between posts. These pieces don't have proper heights
* assigned to them yet but their neigbours do and neither me nor virtual
* scrolling knows how to compensate for it, it ends up either not compensating
* or compensating wrong.
*
* Using "fullyLoaded" ref helps with this, to ensure that we have stable
* conversation expansion process of focused post -> entire convo
*
* With fullyLoaded:
* **id:3** -> id:1 id:2 id:3 id:4 id:5 id:6
*
* Without fullyLoaded:
* id:1 **id:3** id:5 -> id:1 id:2 **id:3** id:4 id:5 id:6
* (focused post is **id:3**)
*
* After initial load, fullyLoaded remains true, allowing newer updates to
* appear in conversation.
*/
const fullConversation =
useStatusesStore().conversations.get(conversationId.value) ?? new Map() // guard if this somehow fails (user wipe)
return [...fullConversation.keys()]
.map((k) => useStatusesStore().allStatuses.get(k))
.filter((status) => status.type !== 'repeat') // Old backend behavior?
.toSorted(sortById)
})
const replies = computed(() =>
conversation.value.reduce(
(result, { id, in_reply_to_status_id: irid }, index) => {
if (irid) {
if (!result.has(irid)) {
result.set(irid, new Set())
}
result.get(irid).add({
name: `#${index}`,
id,
})
}
return result
},
new Map(),
),
)
const getReplies = (id) => replies.value.get(id) ?? new Set()
const fetchConversation = async () => {
try {
loadError.value = null
if (currentStatus.value) {
const {
data: { ancestors, descendants },
timestamp,
} = await apiFetchConversation({
id: toValue(statusId),
credentials: useOAuthStore().token,
})
useStatusesStore().addNewStatuses({ statuses: ancestors, timestamp })
useStatusesStore().addNewStatuses({
statuses: descendants,
timestamp,
})
await nextTick()
fullyLoaded.value = true
} else {
const { data: status } = await apiFetchStatus({
id: toValue(statusId),
credentials: useOAuthStore().token,
})
useStatusesStore().addNewStatuses({ statuses: [status] })
fetchConversation()
}
} catch (error) {
console.error(error)
loadError.value = error
}
}
watch(
expanded,
(value) => {
if (value) {
fetchConversation()
}
},
{ flush: 'post' },
)
// # Focus
const focused = ref(null)
const { mainStatus: focusedStatus } = useMainStatus(focused)
const setFocused = (id) => {
focused.value = id
}
watch(statusId, (val) => setFocused(val), { immediate: true })
const focusedId = computed(() =>
toValue(expanded) && fullyLoaded.value ? focusedStatus.value?.id : null,
)
watch(
focusedId,
(newVal, oldVal) => {
if (!newVal) return
if (newVal === oldVal) return // prevents infinite loop
if (!streamingEnabled.value) {
useStatusesStore().fetchStatus(newVal)
}
useStatusesStore().fetchFavsAndRepeats(newVal)
useStatusesStore().fetchEmojiReactions(newVal)
},
{ immediate: true },
)
return {
focusedId,
setFocused,
conversationId,
currentStatus,
mainStatus,
conversation,
replies,
getReplies,
fetchConversation,
loadError,
}
}
diff --git a/src/composables/useDocumentFocus.js b/src/composables/useDocumentFocus.js
new file mode 100644
index 0000000000..ae09327b06
--- /dev/null
+++ b/src/composables/useDocumentFocus.js
@@ -0,0 +1,35 @@
+import { nextTick, onMounted, onUnmounted, ref } from 'vue'
+
+export function useDocumentFocus() {
+ const focused = ref(!document.hidden)
+
+ const handleVisibilityChange = async () => {
+ focused.value = !document.hidden
+ }
+
+ onMounted(async () => {
+ if (document.hidden !== undefined) {
+ document.addEventListener(
+ 'visibilitychange',
+ handleVisibilityChange,
+ false,
+ )
+ }
+
+ await nextTick()
+ })
+
+ onUnmounted(() => {
+ if (document.hidden !== undefined) {
+ document.removeEventListener(
+ 'visibilitychange',
+ handleVisibilityChange,
+ false,
+ )
+ }
+ })
+
+ return {
+ focused,
+ }
+}
diff --git a/src/composables/usePleromaChat.js b/src/composables/usePleromaChat.js
new file mode 100644
index 0000000000..22d0bcb353
--- /dev/null
+++ b/src/composables/usePleromaChat.js
@@ -0,0 +1,360 @@
+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 { state: mastoUserSocketStatus } = storeToRefs(useStreamingStore())
+
+ const streamingEnabled = computed(
+ () =>
+ mergedConfig.value.useStreamingApi &&
+ mastoUserSocketStatus.value === WSConnectionStatus.JOINED,
+ )
+
+ const chat = ref(null)
+ const socket = ref(null)
+
+ const streaming = ref(false)
+ const fetcher = ref(null)
+ const fetching = ref(false)
+
+ 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.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 }) => {
+ const fakeMessage = pendingMessagesIndex.value.get(idempotencyKey)
+
+ if (fakeMessage) {
+ fakeMessage.error = true
+ fakeMessage.pending = false
+ }
+ }
+
+ try {
+ const { data } = await sendChatMessage({
+ ...params,
+ credentials: useOAuthStore().token,
+ })
+
+ 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,
+ })
+
+ const error5xx = error.statusCode >= 500 && error.statusCode < 600
+ if (error5xx || error.message === 'Failed to fetch') {
+ messageRetriers.value.set(
+ params.idempotencyKey,
+ setTimeout(
+ () => {
+ doSendMessage({
+ params,
+ retriesLeft: retriesLeft - 1,
+ })
+ },
+ 1000 * 2 ** (MAX_RETRIES - retriesLeft),
+ ),
+ )
+ }
+ }
+ }
+
+ // # Poll & Push
+ const startFetching = (reason) => {
+ console.debug('[Pleroma Chat] Started fetching', 'Reason:', reason)
+ fetchOlder()
+ fetcher.value = promiseInterval(() => fetchChat({ latest: true }), 5000)
+ fetching.value = true
+ }
+ const stopFetching = (reason) => {
+ console.debug('[Pleroma Chat] Stopped fetching', 'Reason:', reason)
+ if (!fetching.value) return
+ 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)
+ }
+
+ // # 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.value.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: older ? minId.value : null,
+ sinceId: older ? null : maxId.value,
+ credentials: useOAuthStore().token,
+ })
+
+ addMessages(messages, true)
+ 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, prepend = false) => {
+ for (let message of newMessages) {
+ // Chats we're getting from streaming might belong to someone else instead
+ if (message.chat_id !== chat.value.id) {
+ continue
+ }
+
+ // 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.value.has(message.idempotency_key)
+ }
+
+ if (!messagesIndex.value.has(message.id) && !isConfirmation(message)) {
+ if (lastReadMessageId.value < message.id) {
+ newMessagesCount.value++
+ }
+ messagesIndex.value.set(message.id, message)
+ if (prepend) {
+ messages.value.unshift(messagesIndex.value.get(message.id))
+ } else {
+ messages.value.push(messagesIndex.value.get(message.id))
+ }
+ 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
+ messageRetriers.value.values().forEach((timeout) => clearTimeout(timeout))
+ }
+
+ 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.value,
+ credentials: useOAuthStore().token,
+ })
+ const { data } = result
+
+ newMessagesCount.value = data.unread
+ useUsersStore().addNewUsers({ ...result, data: data.account })
+ data.account = useUsersStore().findUser(data.account.id)
+
+ chat.value = data
+ startFetching('Chat activated', true)
+ } catch (e) {
+ console.error('Error creating or getting a chat', e)
+ fetchError.value = e
+ }
+ }
+ 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,
+ pendingMessages,
+ fetchError,
+ fetchOlder,
+ ready,
+ recipient,
+ _test: {
+ chat,
+ addMessages,
+ minId,
+ maxId,
+ lastReadMessageId,
+ },
+ }
+}
diff --git a/src/composables/useScrollPosition.js b/src/composables/useScrollPosition.js
index 243c08c8b2..3201d0d24e 100644
--- a/src/composables/useScrollPosition.js
+++ b/src/composables/useScrollPosition.js
@@ -1,28 +1,105 @@
-import { onMounted, onUnmounted, ref } from 'vue'
+import { computed, ref } from 'vue'
-export function useScrollPosition() {
- const x = ref(0)
- const y = ref(0)
- const inProgress = ref(false)
+import { useInterfaceSizes } from 'src/composables/useInterfaceSizes.js'
- const update = (e) => {
- x.value = window.scrollX
- y.value = window.scrollY
- }
+export function useScrollPosition(scroller) {
+ const {
+ x,
+ y,
+ cWidth,
+ cHeight,
+ vWidth,
+ vHeight,
+ scrollBy: realScrollBy,
+ scrollTo: realScrollTo,
+ } = scroller
+ const priorityInProgress = ref(false)
+
+ const top = computed(() => y.value)
+ const bottom = computed(() => y.value + vHeight.value)
+ const left = computed(() => x.value)
+ const right = computed(() => x.value + vWidth.value)
+
+ const distanceToTop = top
+ const distanceToLeft = left
+ const distanceToBottom = computed(() => cHeight.value - bottom.value)
+ const distanceToRight = computed(() => cWidth.value - right.value)
+
+ const { fontSize } = useInterfaceSizes()
- onMounted(() => {
- window.addEventListener('scroll', update)
- update()
- })
- onUnmounted(() => {
- window.removeEventListener('scroll', update)
- })
+ const reachMargin = computed(() => fontSize.value)
+ const hasReachedTop = computed(() => distanceToTop.value < reachMargin.value)
+ const hasReachedBottom = computed(
+ () => distanceToBottom.value < reachMargin.value,
+ )
+ const hasReachedLeft = computed(
+ () => distanceToLeft.value < reachMargin.value,
+ )
+ const hasReachedRight = computed(
+ () => distanceToRight.value < reachMargin.value,
+ )
+ const loadMargin = computed(() => fontSize.value * 25)
+ const shouldLoadTop = computed(() => distanceToTop.value < loadMargin.value)
+ const shouldLoadBottom = computed(
+ () => distanceToBottom.value < loadMargin.value,
+ )
+ const shouldLoadLeft = computed(() => distanceToLeft.value < loadMargin.value)
+ const shouldLoadRight = computed(
+ () => distanceToRight.value < loadMargin.value,
+ )
+
+ const scrollTo = async (...args) => {
+ if (priorityInProgress.value) return true
+ return await realScrollTo(...args)
+ }
const scrollBy = async (...args) => {
- inProgress.value = true
- await window.scrollBy(...args)
- inProgress.value = false
+ if (priorityInProgress.value) return true
+ return await realScrollBy(...args)
}
- return { x, y, scrollBy, inProgress }
+ const scrollToPriority = async (...args) => {
+ priorityInProgress.value = true
+ const result = await realScrollTo(...args)
+ priorityInProgress.value = false
+ return result
+ }
+ const scrollByPriority = async (...args) => {
+ priorityInProgress.value = true
+ const result = await realScrollBy(...args)
+ priorityInProgress.value = false
+ return result
+ }
+
+ return {
+ top,
+ left,
+ right,
+ bottom,
+
+ distanceToTop,
+ distanceToLeft,
+ distanceToRight,
+ distanceToBottom,
+
+ cWidth,
+ cHeight,
+ vWidth,
+ vHeight,
+
+ hasReachedLeft,
+ hasReachedRight,
+ hasReachedTop,
+ hasReachedBottom,
+
+ shouldLoadLeft,
+ shouldLoadRight,
+ shouldLoadTop,
+ shouldLoadBottom,
+
+ scrollBy,
+ scrollTo,
+ scrollByPriority,
+ scrollToPriority,
+ }
}
diff --git a/src/composables/useVirtualScrolling.js b/src/composables/useVirtualScrolling.js
index 4bb50d531c..fa6a3de030 100644
--- a/src/composables/useVirtualScrolling.js
+++ b/src/composables/useVirtualScrolling.js
@@ -1,316 +1,411 @@
import { first, last } from 'lodash-es'
import { computed, nextTick, ref, toValue, watch } from 'vue'
-import { useWindowSize } from 'src/composables/useWindowSize.js'
-
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 { y: scrollY, scrollBy } = scrollPositionInstance
- const { height: windowHeight } = useWindowSize()
+ 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 = windowHeight.value - top
+ 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(windowHeight, updateBoundaries)
+ const windowWatcher = watch(vHeight, updateBoundaries)
const scrollWatcher = watch(scrollY, updateBoundaries)
const heightWatcher = watch(heightChart, updateBoundaries)
const bodyWatcher = watch(body, updateBoundaries)
+ watch(vHeight, (neu, old) => {
+ if (toValue(invertDirection)) {
+ // Only compensate (stick to bottom) when shrinking
+ scrollBy(0, Math.max(old - neu, 0))
+ // compensating in other direction causes overscrolling
+ }
+ })
+
const pauseWatchers = () => {
windowWatcher.pause()
scrollWatcher.pause()
heightWatcher.pause()
bodyWatcher.pause()
}
const resumeWatchers = (skipUpdate = false) => {
windowWatcher.resume()
scrollWatcher.resume()
heightWatcher.resume()
bodyWatcher.resume()
if (skipUpdate) return
updateBoundaries()
}
watch(enabled, (val) => {
if (val) {
resumeWatchers()
} else {
pauseWatchers()
}
})
// # Visiblity
// Add buffer zone to boundary, equal to approx 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 (!toValue(scrollCompensation)) return
- if (newVal.length === 0 && oldVal.length === 0) return
+ if (newVal.length === 0 && oldVal.length === 0) return 0
const explosion = oldVal.length === 0 && newVal.length !== 0
const implosion = oldVal.length !== 0 && newVal.length === 0
+ const compensation = toValue(scrollCompensation)
const diff = (() => {
if (explosion) {
- if (toValue(collapseMode) === 'height') {
+ if (!compensation) return 0
+ if (toValue(collapseMode) === 'height' || toValue(invertDirection)) {
const newBottomElement = last(newVal)
return newBottomElement.top + newBottomElement.height
} else {
return 0
}
} else if (implosion) {
+ if (!compensation) return 0
const oldBottomElement = last(oldVal)
return 0 - oldBottomElement.top - oldBottomElement.height
} else {
const contextChange = (() => {
- const oldIds = new Set(oldVal.map(({ id }) => id))
- const newIds = new Set(newVal.map(({ id }) => id))
+ // HACK ignore date separators and fake messages in chat view
+ const oldIds = new Set(
+ oldVal
+ .map(({ id }) => id)
+ .filter((id) => !id.startsWith('fake-')),
+ )
+ const newIds = new Set(
+ newVal
+ .map(({ id }) => id)
+ .filter((id) => !id.startsWith('fake-')),
+ )
if (oldVal.length <= newVal.length) {
return [...oldIds].some((id) => !newIds.has(id))
} else {
return [...newIds].some((id) => !oldIds.has(id))
}
})()
- if (contextChange) return 0
+ if (contextChange) {
+ return 0
+ }
+
+ /* Ok, here's a thing. Both Timeline and Conversation have virtual scrolling.
+ * But since Conversation can be inside Timeline (in fact it's chock-full of
+ * them) it creates a problem. Virtual scrolling involves a lot of scroll
+ * compensation. Elements appearing, disappearing, changing sizes etc. What
+ * happens if a single conversation gets expanded? Conversation sees influx
+ * of new posts and compensates for them appearing above to keep old items
+ * in screen. Next, timeline sees that Conversation changed height and ALSO
+ * compensates for that changed height. We get double the compensation and
+ * result ends up being all wrong, we scrolled way past the conversation.
+ *
+ * Easy, just don't compensate in Timeline, right? Wrong. We still have two
+ * cases where we do want compensation in Timeline - all the cases that
+ * aren't handled by Conversation. Namely:
+ * - Change in amount of Conversations
+ * - Change in height of freshly-rendered Conversations.
+ *
+ * First one is simple. We scrolled down and made a new post - it appeared
+ * at the top but we don't want screen to scroll.
+ *
+ * Second one is tricky. Here's a real situation:
+ * 1. We scroll down in Timeline.
+ * 2. Open a thread (navigate to it, not expand!)
+ * 3. Press "back".
+ * Now we have a situation: vue-router restored our scroll position but
+ * since Timeline was removed and re-created its heightChart is full of
+ * fake placeholders. This naturally means our actual scroll position is
+ * all wrong. TODO: Store heightchart in timelines store?
+ * But it gets worse - when you start scrolling up elements begin getting
+ * their real heights back, and since there's no compensation timeline gets
+ * all jumpy! We can somewhat live with wrong scroll position but timeline
+ * being jumpy is unacceptable.
+ *
+ * TODO: Probably a good way to avoid it all is just to:
+ * - Don't do scroll compensation when nested
+ * - For expanded conversations use animated priority scrollTo
+ * something to do for Virtual Scrolling 2.2
+ *
+ * So, basically - if amount of elements changes, or amount of REAL elements
+ * changes - compensate anyway even if compensation is disabled.
+ */
+ const isSticking = toValue(invertDirection)
+ ? hasReachedBottom.value
+ : hasReachedTop.value
+ const compensateBecauseLengthChange =
+ !isSticking && newVal.length !== oldVal.length
+ const newReal = newVal.filter(({ real }) => real).length
+ const oldReal = oldVal.filter(({ real }) => real).length
+ const compensateBecauseReal =
+ oldReal > 0 && newReal > 0 && oldReal !== newReal
+
+ if (
+ !(
+ compensation ||
+ compensateBecauseReal ||
+ compensateBecauseLengthChange
+ )
+ ) {
+ return 0
+ }
+
+ // Section is used for debugging only
+ const getShift = (section, a, b, find, getter) => {
+ const aItem = find(a)
+ if (!aItem) return 0
+ const bItem = b.find(({ id }) => id === aItem.id)
+ if (!bItem) return 0
+ return getter(bItem) - getter(aItem)
+ }
const expansion = (() => {
if (newVal.length < oldVal.length) return 0
- const oldVisible = oldVal.filter(
- (item) => checkVisible(item) && item.real,
+ return getShift(
+ 'expansion',
+ oldVal.filter((item) => checkVisible(item)),
+ newVal,
+ (list) =>
+ list.find(
+ ({ top, height }) => top + height < topScrollBoundary.value,
+ ) ?? first(list),
+ ({ top, height }) => top + height,
)
- const oldItem = first(oldVisible)
- if (!oldItem) return 0 // probably out of bounds in timeline
- const oldItemUpdated = newVal.find(({ id }) => id === oldItem.id)
-
- return (
- oldItemUpdated.top -
- oldItem.top -
- (oldItem.height - oldItemUpdated.height)
+ })()
+
+ const inverseCompensation = (() => {
+ if (!toValue(invertDirection)) return 0
+ if (newVal.length < oldVal.length) return 0
+ const shift = getShift(
+ 'inverse',
+ oldVal.filter((item) => checkVisible(item)),
+ newVal,
+ (list) =>
+ list.findLast(({ top }) => top > bottomScrollBoundary.value) ??
+ last(list),
+ ({ top }) => top,
)
+ if (isSticking) {
+ // Don't do 99999 here, it breaks virtual scrolling
+ const { height } = body.value.getBoundingClientRect()
+ return height // keep sticking
+ } else {
+ return shift
+ }
})()
const collapsing = (() => {
if (newVal.length >= oldVal.length) return 0
- const newVisible = newVal
- const newItem = first(newVisible)
- if (!newItem) return 0 // probably out of bounds in timeline
- const newItemBefore = oldVal.find(({ id }) => id === newItem.id)
-
- return (
- newItem.top -
- newItemBefore.top -
- (newItemBefore.height - newItem.height)
+ return -getShift(
+ 'collapse',
+ newVal,
+ oldVal,
+ (list) => first(list),
+ ({ top, height }) => top + height,
)
})()
- return expansion + collapsing
+ return (
+ (toValue(invertDirection) ? inverseCompensation : expansion) +
+ collapsing
+ )
}
})()
if (diff !== 0) {
// Scroll by amount offset changed to keep it in view
topScrollBoundary.value += diff
bottomScrollBoundary.value += diff
- await scrollBy(0, diff)
await nextTick()
+ await scrollBy(0, diff)
}
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 - (windowHeight.value - offset.value) / 2,
- )
-
- scrollBy(0, desiredTopBoundary - topScrollBoundary.value)
-
- resumeWatchers()
- }
-
return {
heightChart: heightChartGrouped,
changeSuspendState,
updateVirtualHeight,
pauseWatchers,
resumeWatchers,
updateBoundaries,
reset,
- scrollTo,
}
}
diff --git a/src/composables/useScrollPosition.js b/src/composables/useWindowScroll.js
similarity index 58%
copy from src/composables/useScrollPosition.js
copy to src/composables/useWindowScroll.js
index 243c08c8b2..1746596b02 100644
--- a/src/composables/useScrollPosition.js
+++ b/src/composables/useWindowScroll.js
@@ -1,28 +1,24 @@
import { onMounted, onUnmounted, ref } from 'vue'
-export function useScrollPosition() {
+export function useWindowScroll() {
const x = ref(0)
const y = ref(0)
- const inProgress = ref(false)
const update = (e) => {
x.value = window.scrollX
y.value = window.scrollY
}
onMounted(() => {
window.addEventListener('scroll', update)
update()
})
onUnmounted(() => {
window.removeEventListener('scroll', update)
})
- const scrollBy = async (...args) => {
- inProgress.value = true
- await window.scrollBy(...args)
- inProgress.value = false
- }
+ const scrollBy = (...args) => window.scrollBy(...args)
+ const scrollTo = (...args) => window.scrollTo(...args)
- return { x, y, scrollBy, inProgress }
+ return { x, y, scrollBy, scrollTo }
}
diff --git a/src/services/chat_utils/chat_utils.js b/src/services/chat_utils/chat_utils.js
index 978c8c2744..f03b690cd5 100644
--- a/src/services/chat_utils/chat_utils.js
+++ b/src/services/chat_utils/chat_utils.js
@@ -1,53 +1,53 @@
import { showDesktopNotification } from '../desktop_notification_utils/desktop_notification_utils.js'
import { useUsersStore } from 'src/stores/users.js'
export const maybeShowChatNotification = (chat) => {
// No messages
if (!chat.lastMessage) return
// No unreads to display
if (chat.unread === 0) return
// Don't notify on outgoing message (shouldn't happen with condition above)
if (useUsersStore().currentUser.id === chat.lastMessage.account_id) return
const opts = {
tag: chat.lastMessage.id,
title: chat.account.name,
icon: chat.account.profile_image_url,
body: chat.lastMessage.content,
type: 'chatMention',
}
if (chat.lastMessage.attachment?.type === 'image') {
opts.image = chat.lastMessage.attachment.preview_url
}
showDesktopNotification(opts)
}
export const buildFakeMessage = ({
content,
chatId,
attachments,
userId,
idempotencyKey,
}) => {
const fakeMessage = {
content,
chat_id: chatId,
created_at: new Date(),
- id: Date.now().toString(),
+ id: 'fake-' + Date.now().toString(),
attachments,
account_id: userId,
idempotency_key: idempotencyKey,
emojis: [],
pending: true,
isNormalized: true,
}
if (attachments[0]) {
fakeMessage.attachment = attachments[0]
}
return fakeMessage
}
diff --git a/test/unit/specs/components/chat_message_list.spec.js b/test/unit/specs/components/chat_message_list.spec.js
index 126332b958..63d2c66b2e 100644
--- a/test/unit/specs/components/chat_message_list.spec.js
+++ b/test/unit/specs/components/chat_message_list.spec.js
@@ -1,244 +1,208 @@
+import { createTestingPinia } from '@pinia/testing'
import { shallowMount } from '@vue/test-utils'
+import { setActivePinia } from 'pinia'
+import { ref } from 'vue'
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
+const scrollPositionInstance = {
+ x: ref(0),
+ y: ref(0),
+ vHeight: ref(768),
+ vWidth: ref(1024),
+ cHeight: ref(768),
+ cWidth: ref(1024),
+ scrollBy: vi.fn(),
+ hasReachedTop: ref(false),
+ hasReachedBottom: ref(false),
+}
+const global = {
+ provide: {
+ bodyScrollPosition: scrollPositionInstance,
+ },
+}
+
describe('ChatMessageList', () => {
- describe('computed.chatItems', () => {
+ beforeEach(() => {
+ setActivePinia(createTestingPinia({ stubActions: false }))
+ })
+
+ describe('computed.chatItems.value', () => {
it('Inserts date separators', () => {
const component = shallowMount(ChatMessageList, {
+ global,
props: {
messages: [
{
id: '0',
account_id: 'Alice',
created_at: new Date('2020-06-22T20:00:00.000Z'),
},
{
id: '1',
account_id: 'Alice',
created_at: new Date('2020-06-22T20:01:00.000Z'),
},
{
id: '2',
account_id: 'Alice',
created_at: new Date('2020-06-23T20:00:00.000Z'),
},
],
},
})
- expect(component.vm.chatItems.map((i) => i.type)).to.eql([
+ expect(component.vm._test.chatItems.value.map((i) => i.type)).to.eql([
'message',
'message',
'date',
'message',
])
})
it('Inserts date header if needed', () => {
const component = shallowMount(ChatMessageList, {
+ global,
props: {
headerDate: true,
messages: [
{
id: '0',
account_id: 'Alice',
created_at: new Date('2020-06-23T20:00:00.000Z'),
},
],
},
})
- expect(component.vm.chatItems.map((i) => i.type)).to.eql([
+ expect(component.vm._test.chatItems.value.map((i) => i.type)).to.eql([
'date',
'message',
])
})
it('Inserts time separators if messages were sent with considerable delay (5 minutes)', () => {
const component = shallowMount(ChatMessageList, {
+ global,
props: {
messages: [
{
id: '0',
account_id: 'Alice',
created_at: new Date('2020-06-22T20:00:00.000Z'),
},
{
id: '1',
account_id: 'Alice',
created_at: new Date('2020-06-22T20:06:00.000Z'),
},
{
id: '2',
account_id: 'Alice',
created_at: new Date('2020-06-23T20:00:00.000Z'),
},
],
},
})
- expect(component.vm.chatItems.map((i) => i.type)).to.eql([
+ expect(component.vm._test.chatItems.value.map((i) => i.type)).to.eql([
'message',
'date',
'message',
'date',
'message',
])
- expect(component.vm.chatItems.map((i) => i.isTime)).to.eql([
+ expect(component.vm._test.chatItems.value.map((i) => i.isTime)).to.eql([
undefined,
true,
undefined,
false,
undefined,
])
})
it('Groups message chains by time and author', () => {
const component = shallowMount(ChatMessageList, {
+ global,
props: {
messages: [
{
id: '0',
account_id: 'Alice',
created_at: new Date('2020-06-22T20:00:00.000Z'),
},
{
id: '1',
account_id: 'Alice',
created_at: new Date('2020-06-22T20:06:00.000Z'),
},
{
id: '2',
account_id: 'Alice',
created_at: new Date('2020-06-23T20:00:00.000Z'),
},
{
id: '3',
account_id: 'Bob',
created_at: new Date('2020-06-23T20:01:00.000Z'),
},
{
id: '4',
account_id: 'Bob',
created_at: new Date('2020-06-23T20:02:00.000Z'),
},
{
id: '5',
account_id: 'Bob',
created_at: new Date('2020-06-23T20:03:00.000Z'),
},
{
id: '6',
account_id: 'Eve',
created_at: new Date('2020-06-23T20:04:00.000Z'),
},
],
},
})
// Type check
- expect(component.vm.chatItems.map((i) => i.type)).to.eql([
+ expect(component.vm._test.chatItems.value.map((i) => i.type)).to.eql([
'message',
'date',
'message',
'date',
'message',
'message',
'message',
'message',
'message',
])
// Chain head/Tail checks
- expect(component.vm.chatItems.map((i) => [i.isHead, i.isTail])).to.eql([
+ expect(
+ component.vm._test.chatItems.value.map((i) => [i.isHead, i.isTail]),
+ ).to.eql([
[true, true],
[undefined, undefined],
[true, true],
[undefined, undefined],
[true, true],
[true, false],
[false, false],
[false, true],
[true, true],
])
// Unique ID is randomly generated so we have to compare data against itself
// Two messages from Bob next to each other
- expect(component.vm.chatItems[5].messageChainId).to.eql(
- component.vm.chatItems[6].messageChainId,
+ expect(component.vm._test.chatItems.value[5].messageChainId).to.eql(
+ component.vm._test.chatItems.value[6].messageChainId,
)
// Message from Even right after Bob
- expect(component.vm.chatItems[7].messageChainId).to.not.eql(
- component.vm.chatItems[8].messageChainId,
+ expect(component.vm._test.chatItems.value[7].messageChainId).to.not.eql(
+ component.vm._test.chatItems.value[8].messageChainId,
)
})
})
- describe('methods.getPreviousItem', () => {
- describe('Finds correct previous meaningful (non-separator) message in the chatlist', () => {
- let component
-
- beforeEach(() => {
- component = shallowMount(ChatMessageList, {
- props: {
- messages: [
- {
- id: '0',
- account_id: 'Alice',
- created_at: new Date('2020-06-22T20:00:00.000Z'),
- },
- {
- // Separator
- id: '1', // 2
- account_id: 'Alice',
- created_at: new Date('2020-06-22T20:06:00.000Z'),
- },
- {
- // Separator
- id: '2', // 4
- account_id: 'Alice',
- created_at: new Date('2020-06-23T20:00:00.000Z'),
- },
- {
- id: '3', // 5
- account_id: 'Bob',
- created_at: new Date('2020-06-23T20:01:00.000Z'),
- },
- {
- id: '4', // 6
- account_id: 'Bob',
- created_at: new Date('2020-06-23T20:02:00.000Z'),
- },
- {
- id: '5', // 7
- account_id: 'Bob',
- created_at: new Date('2020-06-23T20:03:00.000Z'),
- },
- {
- id: '6', // 8
- account_id: 'Eve',
- created_at: new Date('2020-06-23T20:04:00.000Z'),
- },
- ],
- },
- })
- })
-
- it('Directly next to each other', () => {
- const correct = component.vm.chatItems[6]
- expect(component.vm.getPreviousItem(7)).to.eql(correct)
- })
-
- it('Across separator', () => {
- const correct = component.vm.chatItems[2]
- expect(component.vm.getPreviousItem(4)).to.eql(correct)
- })
-
- it('Returns null if no previous item exist', () => {
- const correct = null
- expect(component.vm.getPreviousItem(0)).to.eql(correct)
- })
- })
- })
})
diff --git a/test/unit/specs/components/chat_view.spec.js b/test/unit/specs/components/chat_view.spec.js
deleted file mode 100644
index dd95b1fd3d..0000000000
--- a/test/unit/specs/components/chat_view.spec.js
+++ /dev/null
@@ -1,128 +0,0 @@
-import { createTestingPinia } from '@pinia/testing'
-import { shallowMount } from '@vue/test-utils'
-import { setActivePinia } from 'pinia'
-
-import ChatView from 'src/components/chat_view/chat_view.vue'
-
-const message1 = {
- id: '1',
- chat_id: 2,
- idempotency_key: '1',
- created_at: new Date('2020-06-22T18:45:53.000Z'),
-}
-
-const message2 = {
- id: '2',
- chat_id: 2,
- idempotency_key: '2',
- account_id: '9vmRb29zLQReckr5ay',
- created_at: new Date('2020-06-22T18:45:56.000Z'),
-}
-
-const message3 = {
- id: '3',
- chat_id: 2,
- idempotency_key: '3',
- account_id: '9vmRb29zLQReckr5ay',
- created_at: new Date('2020-07-22T18:45:59.000Z'),
-}
-
-const global = {
- mocks: {
- $route: {
- params: {
- recipient_id: 2,
- },
- },
- $router: {
- push: () => {
- /* noop */
- },
- },
- },
- stubs: {
- FAIcon: true,
- },
-}
-
-describe('ChatView methods', () => {
- let component
- beforeEach(() => {
- setActivePinia(createTestingPinia())
- component = shallowMount(ChatView, { global, props: { testMode: true } })
- component.vm.chat = { id: 2 }
- })
-
- describe('addMessages', () => {
- it("Doesn't add duplicates", () => {
- component.vm.addMessages({ messages: [message1] })
- component.vm.addMessages({ messages: [message1] })
- expect(component.vm.messages).to.have.length(1)
-
- component.vm.addMessages({ messages: [message2] })
- expect(component.vm.messages).to.have.length(2)
- })
-
- it('Updates minId and lastMessage and newMessageCount', async () => {
- component.vm.addMessages({ messages: [message1] })
- expect(component.vm.maxId).to.eql(message1.id)
- expect(component.vm.minId).to.eql(message1.id)
- expect(component.vm.newMessageCount).to.eql(1)
-
- component.vm.addMessages({ messages: [message2] })
- expect(component.vm.maxId).to.eql(message2.id)
- expect(component.vm.minId).to.eql(message1.id)
- expect(component.vm.newMessageCount).to.eql(2)
-
- await component.vm.readChat()
- expect(component.vm.newMessageCount).to.eql(0)
- expect(component.vm.lastReadMessageId).to.eql(message2.id)
-
- // Add message with higher id
- component.vm.addMessages({ messages: [message3] })
- expect(component.vm.newMessageCount).to.eql(1)
- })
- })
-
- describe('deleteChatMessage', () => {
- it('Updates minId and lastMessage', () => {
- component.vm.addMessages({ messages: [message1] })
- component.vm.addMessages({ messages: [message2] })
- component.vm.addMessages({ messages: [message3] })
-
- expect(component.vm.maxId).to.eql(message3.id)
- expect(component.vm.minId).to.eql(message1.id)
-
- component.vm.deleteChatMessage({ messageId: message3.id })
- expect(component.vm.maxId).to.eql(message2.id)
- expect(component.vm.minId).to.eql(message1.id)
-
- component.vm.deleteChatMessage({ messageId: message1.id })
- expect(component.vm.maxId).to.eql(message2.id)
- expect(component.vm.minId).to.eql(message2.id)
- })
- })
-
- describe('cullOlder', () => {
- it('keeps 50 newest messages and messagesIndex matches', () => {
- for (let i = 100; i > 0; i--) {
- // Use decimal values with toFixed to hack together constant length predictable strings
- component.vm.addMessages({
- messages: [
- {
- ...message1,
- id: 'a' + (i / 1000).toFixed(3),
- idempotency_key: i,
- },
- ],
- })
- }
- component.vm.cullOlder()
- expect(component.vm.messages).to.have.length(50)
- expect(component.vm.messages[0].id).to.eql('a0.051')
- expect(component.vm.minId).to.eql('a0.051')
- expect(component.vm.messages[49].id).to.eql('a0.100')
- expect(Object.keys(component.vm.messagesIndex)).to.have.length(50)
- })
- })
-})
diff --git a/test/unit/specs/composables/usePleromaChat.spec.js b/test/unit/specs/composables/usePleromaChat.spec.js
new file mode 100644
index 0000000000..bb00ef7dbb
--- /dev/null
+++ b/test/unit/specs/composables/usePleromaChat.spec.js
@@ -0,0 +1,105 @@
+import { createTestingPinia } from '@pinia/testing'
+import { setActivePinia } from 'pinia'
+
+import { usePleromaChat } from 'src/composables/usePleromaChat.js'
+
+const message1 = {
+ id: '1',
+ chat_id: 2,
+ idempotency_key: '1',
+ created_at: new Date('2020-06-22T18:45:53.000Z'),
+}
+
+const message2 = {
+ id: '2',
+ chat_id: 2,
+ idempotency_key: '2',
+ account_id: '9vmRb29zLQReckr5ay',
+ created_at: new Date('2020-06-22T18:45:56.000Z'),
+}
+
+const message3 = {
+ id: '3',
+ chat_id: 2,
+ idempotency_key: '3',
+ account_id: '9vmRb29zLQReckr5ay',
+ created_at: new Date('2020-07-22T18:45:59.000Z'),
+}
+
+describe('usePleromaChat', () => {
+ let composable
+ beforeEach(() => {
+ setActivePinia(createTestingPinia())
+ composable = usePleromaChat('1')
+ composable._test.chat.value = { id: 2 }
+ })
+
+ describe('addMessages', () => {
+ it("Doesn't add duplicates", () => {
+ composable._test.addMessages([message1])
+ composable._test.addMessages([message1])
+ expect(composable.messages.value).to.have.length(1)
+
+ composable._test.addMessages([message2])
+ expect(composable.messages.value).to.have.length(2)
+ })
+
+ it('Updates minId and lastMessage and newMessageCount', async () => {
+ composable._test.addMessages([message1])
+ expect(composable._test.maxId.value).to.eql(message1.id)
+ expect(composable._test.minId.value).to.eql(message1.id)
+ expect(composable.newMessagesCount.value).to.eql(1)
+
+ composable._test.addMessages([message2])
+ expect(composable._test.maxId.value).to.eql(message2.id)
+ expect(composable._test.minId.value).to.eql(message1.id)
+ expect(composable.newMessagesCount.value).to.eql(2)
+
+ const mockFetch = vi.fn()
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify({}), {
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+ vi.stubGlobal('fetch', mockFetch)
+ await composable.markAsRead()
+ expect(composable.newMessagesCount.value).to.eql(0)
+ expect(composable._test.lastReadMessageId.value).to.eql(message2.id)
+
+ // Add message with higher id
+ composable._test.addMessages([message3])
+ expect(composable.newMessagesCount.value).to.eql(1)
+ })
+ })
+
+ describe('deleteChatMessage', () => {
+ it('Updates minId and lastMessage', async () => {
+ composable._test.addMessages([message1])
+ composable._test.addMessages([message2])
+ composable._test.addMessages([message3])
+
+ expect(composable._test.maxId.value).to.eql(message3.id)
+ expect(composable._test.minId.value).to.eql(message1.id)
+
+ const mockFetch = vi.fn()
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify({}), {
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify({}), {
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+ vi.stubGlobal('fetch', mockFetch)
+ await composable.deleteChatMessage({ messageId: message3.id })
+ expect(composable._test.maxId.value).to.eql(message2.id)
+ expect(composable._test.minId.value).to.eql(message1.id)
+
+ await composable.deleteChatMessage({ messageId: message1.id })
+ expect(composable._test.maxId.value).to.eql(message2.id)
+ expect(composable._test.minId.value).to.eql(message2.id)
+ })
+ })
+})
diff --git a/test/unit/specs/composables/useVirtualScrolling.spec.js b/test/unit/specs/composables/useVirtualScrolling.spec.js
index 2a176dff1d..5d71f4198c 100644
--- a/test/unit/specs/composables/useVirtualScrolling.spec.js
+++ b/test/unit/specs/composables/useVirtualScrolling.spec.js
@@ -1,336 +1,325 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { ref } from 'vue'
import { useVirtualScrolling } from 'src/composables/useVirtualScrolling.js'
-const windowSize = {
- width: ref(1024),
- height: ref(768),
-}
-
const scrollPositionInstance = {
x: ref(0),
y: ref(0),
+ vHeight: ref(768),
+ vWidth: ref(1024),
+ cHeight: ref(768),
+ cWidth: ref(1024),
scrollBy: vi.fn(),
+ hasReachedTop: ref(false),
+ hasReachedBottom: ref(false),
}
-vi.mock(import('src/composables/useWindowSize.js'), () => ({
- useWindowSize: () => windowSize,
-}))
-
describe('useVirtualScrolling', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.resetAllMocks()
scrollPositionInstance.x.value = 0
scrollPositionInstance.y.value = 0
- windowSize.width.value = 1024
- windowSize.height.value = 768
})
it('should init boundaries and compute a height chart', () => {
const body = ref({
getBoundingClientRect: () => ({
top: 0,
}),
})
const list = [...new Array(20)].map((i, index) => ({
id: `${index}i`,
}))
const result = useVirtualScrolling({
name: 'Test',
enabled: ref(true),
list: ref(list),
body: ref(body),
offset: 0,
getPlaceholderHeight: () => ref(100),
scrollPositionInstance,
})
result.updateBoundaries()
const items = [...new Array(11)].map((i, index) => ({
id: `${index}i`,
top: index * 100,
height: 100,
type: 'item',
}))
const spacer = {
type: 'spacer',
top: 1100,
height: 900,
ids: expect.any(Set),
id: 'i11',
}
expect(result.heightChart.value).toEqual([...items, spacer])
})
it('should update boundaries and compute a height chart on scroll', () => {
let topPosition = 0
const body = ref({
getBoundingClientRect: () => ({
top: topPosition,
}),
})
const list = [...new Array(20)].map((i, index) => ({
id: `${index}i`,
}))
const result = useVirtualScrolling({
name: 'Test',
enabled: ref(true),
list: ref(list),
body: ref(body),
offset: 0,
getPlaceholderHeight: () => ref(100),
scrollPositionInstance,
})
result.updateBoundaries()
const amount = 500
topPosition -= amount
result.updateBoundaries()
const spacer1 = {
type: 'spacer',
top: 0,
height: 200,
ids: expect.any(Set),
id: 'i0',
}
const items = [...new Array(14)].map((i, index) => ({
id: `${index + 2}i`,
top: (index + 2) * 100,
height: 100,
type: 'item',
}))
const spacer2 = {
type: 'spacer',
top: 1600,
height: 400,
ids: expect.any(Set),
id: 'i16',
}
expect(result.heightChart.value).toEqual([spacer1, ...items, spacer2])
})
it('should compensate for scroll when new items added on top', async () => {
let topPosition = 0
const body = ref({
getBoundingClientRect: () => ({
top: topPosition,
}),
})
const listOld = [...new Array(10)].map((i, index) => ({
id: `${index}ia`,
}))
const listNew = [...new Array(10)].map((i, index) => ({
id: `${index}ib`,
}))
const list = ref(listOld)
const result = useVirtualScrolling({
name: 'Test',
enabled: ref(true),
list,
body: ref(body),
offset: 0,
getPlaceholderHeight: () => ref(100),
scrollCompensation: true,
scrollPositionInstance,
})
listOld.forEach((i) => {
result.updateVirtualHeight({ id: i.id, height: 200 })
})
result.updateBoundaries()
await vi.advanceTimersToNextTimerAsync()
list.value = [...listNew, ...listOld]
result.updateBoundaries()
await vi.advanceTimersToNextTimerAsync()
const amount = 1000
- expect(scrollPositionInstance.scrollBy).to.have.been.calledOnceWith(
- 0,
- amount,
- )
+ expect(scrollPositionInstance.scrollBy).to.have.been.calledWith(0, amount)
topPosition -= amount
result.updateBoundaries()
const spacer1 = {
type: 'spacer',
top: 0,
height: 700,
ids: expect.any(Set),
id: 'i0',
}
const itemsNew = [...new Array(3)].map((i, index) => ({
id: `${index + 7}ib`,
top: (index + 7) * 100,
height: 100,
type: 'item',
}))
const itemsOld = [...new Array(6)].map((i, index) => ({
id: `${index}ia`,
top: index * 200 + 1000,
height: 200,
type: 'item',
}))
const spacer2 = {
type: 'spacer',
top: 2200,
height: 800,
ids: expect.any(Set),
id: 'i16',
}
expect(result.heightChart.value).toEqual([
spacer1,
...itemsNew,
...itemsOld,
spacer2,
])
})
it('should compensate for scroll when items are removed from top', async () => {
let topPosition = 0
const body = ref({
getBoundingClientRect: () => ({
top: topPosition,
}),
})
const listA = [...new Array(10)].map((i, index) => ({
id: `${index}ia`,
}))
const listB = [...new Array(10)].map((i, index) => ({
id: `${index}ib`,
}))
const listC = [...new Array(10)].map((i, index) => ({
id: `${index}ic`,
}))
const list = ref([...listA, ...listB, ...listC])
const result = useVirtualScrolling({
name: 'Test',
enabled: ref(true),
list,
body: ref(body),
offset: 0,
getPlaceholderHeight: () => ref(100),
scrollCompensation: true,
scrollPositionInstance,
})
list.value.forEach((i) => {
result.updateVirtualHeight({ id: i.id, height: 200 })
})
result.updateBoundaries()
await vi.advanceTimersToNextTimerAsync()
list.value = listB
result.updateBoundaries()
await vi.advanceTimersToNextTimerAsync()
const amount = -2000
- expect(scrollPositionInstance.scrollBy).to.have.been.calledOnceWith(
- 0,
- amount,
- )
+ expect(scrollPositionInstance.scrollBy).to.have.been.calledWith(0, amount)
// topPosition -= amount // not doing this because it would overscroll
result.updateBoundaries()
await vi.advanceTimersToNextTimerAsync()
const items = [...new Array(6)].map((i, index) => ({
id: `${index}ib`,
top: index * 200,
height: 200,
type: 'item',
}))
const spacer = {
type: 'spacer',
top: 1200,
height: 800,
ids: expect.any(Set),
id: 'i6',
}
expect(result.heightChart.value).toEqual([...items, spacer])
})
it('should exclude unsuspendable items from virtualization', async () => {
let topPosition = 0
const body = ref({
getBoundingClientRect: () => ({
top: topPosition,
}),
})
const list = ref(
[...new Array(20)].map((i, index) => ({
id: `${index}i`,
})),
)
const result = useVirtualScrolling({
name: 'Test',
enabled: ref(true),
list,
body: ref(body),
offset: 0,
getPlaceholderHeight: () => ref(100),
scrollPositionInstance,
})
list.value.forEach((i) => {
result.updateVirtualHeight({ id: i.id, height: 200 })
})
result.changeSuspendState({ id: '2i', suspendable: false })
result.changeSuspendState({ id: '3i', suspendable: true })
result.updateBoundaries()
await vi.advanceTimersToNextTimerAsync()
result.updateBoundaries()
await vi.advanceTimersToNextTimerAsync()
const amount = 2000
topPosition -= amount
result.updateBoundaries()
await vi.advanceTimersToNextTimerAsync()
const spacer1 = {
type: 'spacer',
top: 0,
height: 400,
ids: expect.any(Set),
id: 'i0',
}
const items1 = [
{
id: `2i`,
top: 400,
height: 200,
type: 'item',
},
]
const spacer2 = {
type: 'spacer',
top: 600,
height: 1000,
ids: expect.any(Set),
id: 'i3',
}
const items2 = [...new Array(8)].map((i, index) => ({
id: `${index + 8}i`,
top: (index + 8) * 200,
height: 200,
type: 'item',
}))
const spacer3 = {
type: 'spacer',
top: 3200,
height: 800,
ids: expect.any(Set),
id: 'i16',
}
expect(result.heightChart.value).toEqual([
spacer1,
...items1,
spacer2,
...items2,
spacer3,
])
})
})

File Metadata

Mime Type
text/x-diff
Expires
Sun, Oct 11, 3:04 PM (1 d, 17 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1785867
Default Alt Text
(182 KB)

Event Timeline