Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85805574
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
182 KB
Referenced Files
None
Subscribers
None
View Options
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
Details
Attached
Mime Type
text/x-diff
Expires
Sun, Oct 11, 3:04 PM (1 d, 18 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1785867
Default Alt Text
(182 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline
Log In to Comment