Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85805044
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
51 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/boot/routes.js b/src/boot/routes.js
index 3736036cd3..beaa144e8c 100644
--- a/src/boot/routes.js
+++ b/src/boot/routes.js
@@ -1,323 +1,324 @@
import { defineAsyncComponent } from 'vue'
import AuthForm from 'src/components/auth_form/auth_form.js'
import BookmarkTimeline from 'src/components/bookmark_timeline/bookmark_timeline.vue'
import BubbleTimeline from 'src/components/bubble_timeline/bubble_timeline.vue'
import ConversationPage from 'src/components/conversation-page/conversation-page.vue'
import DMs from 'src/components/dm_timeline/dm_timeline.vue'
import FriendsTimeline from 'src/components/friends_timeline/friends_timeline.vue'
import NavPanel from 'src/components/nav_panel/nav_panel.vue'
import PublicAndExternalTimeline from 'src/components/public_and_external_timeline/public_and_external_timeline.vue'
import PublicTimeline from 'src/components/public_timeline/public_timeline.vue'
import QuotesTimeline from 'src/components/quotes_timeline/quotes_timeline.vue'
import RemoteUserResolver from 'src/components/remote_user_resolver/remote_user_resolver.vue'
import TagTimeline from 'src/components/tag_timeline/tag_timeline.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
export default (store) => {
const validateAuthenticatedRoute = (to, from, next) => {
if (store.state.users.currentUser) {
next()
} else {
next(
useInstanceStore().instanceIdentity.redirectRootNoLogin || '/main/all',
)
}
}
let routes = [
{
name: 'root',
path: '/',
redirect: () => {
return (
(store.state.users.currentUser
? useInstanceStore().instanceIdentity.redirectRootLogin
: useInstanceStore().instanceIdentity.redirectRootNoLogin) ||
'/main/all'
)
},
},
{
name: 'public-external-timeline',
path: '/main/all',
component: PublicAndExternalTimeline,
},
{
name: 'public-timeline',
path: '/main/public',
component: PublicTimeline,
},
{
name: 'friends',
path: '/main/friends',
component: FriendsTimeline,
beforeEnter: validateAuthenticatedRoute,
},
{ name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
{ name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline },
{ name: 'bubble', path: '/bubble', component: BubbleTimeline },
{
name: 'conversation',
path: '/notice/:id',
component: ConversationPage,
meta: { dontScroll: true },
},
{
name: 'conversation2',
path: '/conversation/:statusId',
component: defineAsyncComponent(
() => import('src/components/chat_view/chat_view.vue'),
),
props: true,
+ meta: { dontScroll: true },
beforeEnter: validateAuthenticatedRoute,
},
{ name: 'quotes', path: '/notice/:id/quotes', component: QuotesTimeline },
{
name: 'remote-user-profile-acct',
path: '/remote-users/:_(@)?:username([^/@]+)@:hostname([^/@]+)',
component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'remote-user-profile',
path: '/remote-users/:hostname/:username',
component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'external-user-profile',
path: '/users/$:id',
component: defineAsyncComponent(
() => import('src/components/user_profile/user_profile.vue'),
),
},
{
name: 'user-profile-admin-view',
path: '/users/$:id/admin_view',
component: defineAsyncComponent(
() => import('src/components/user_profile/user_profile_admin_view.vue'),
),
},
{
name: 'interactions',
path: '/users/:username/interactions',
component: defineAsyncComponent(
() => import('src/components/interactions/interactions.vue'),
),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'dms',
path: '/users/:username/dms',
component: DMs,
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'registration',
path: '/registration',
component: defineAsyncComponent(
() => import('src/components/registration/registration.vue'),
),
},
{
name: 'password-reset',
path: '/password-reset',
component: defineAsyncComponent(
() => import('src/components/password_reset/password_reset.vue'),
),
props: true,
},
{
name: 'registration-token',
path: '/registration/:token',
component: defineAsyncComponent(
() => import('src/components/registration/registration.vue'),
),
},
{
name: 'friend-requests',
path: '/friend-requests',
component: defineAsyncComponent(
() => import('src/components/follow_requests/follow_requests.vue'),
),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'notifications',
path: '/:username/notifications',
component: defineAsyncComponent(
() => import('src/components/notifications/notifications.vue'),
),
props: () => ({ disableTeleport: true }),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'login',
path: '/login',
component: AuthForm,
},
{
name: 'shout-panel',
path: '/shout-panel',
component: defineAsyncComponent(
() => import('src/components/shout_panel/shout_panel.vue'),
),
props: () => ({ floating: false }),
},
{
name: 'oauth-callback',
path: '/oauth-callback',
component: defineAsyncComponent(
() => import('src/components/oauth_callback/oauth_callback.vue'),
),
props: (route) => ({ code: route.query.code }),
},
{
name: 'search',
path: '/search',
component: defineAsyncComponent(
() => import('src/components/search/search.vue'),
),
props: (route) => ({ query: route.query.query }),
},
{
name: 'who-to-follow',
path: '/who-to-follow',
component: defineAsyncComponent(
() => import('src/components/who_to_follow/who_to_follow.vue'),
),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'about',
path: '/about',
component: defineAsyncComponent(
() => import('src/components/about/about.vue'),
),
},
{
name: 'announcements',
path: '/announcements',
component: defineAsyncComponent(
() =>
import('src/components/announcements_page/announcements_page.vue'),
),
},
{
name: 'drafts',
path: '/drafts',
component: defineAsyncComponent(
() => import('src/components/drafts/drafts.vue'),
),
},
{
name: 'user-profile',
path: '/users/:name',
component: defineAsyncComponent(
() => import('src/components/user_profile/user_profile.vue'),
),
},
{
name: 'legacy-user-profile',
path: '/:name',
component: defineAsyncComponent(
() => import('src/components/user_profile/user_profile.vue'),
),
},
{
name: 'lists',
path: '/lists',
component: defineAsyncComponent(
() => import('src/components/lists/lists.vue'),
),
},
{
name: 'lists-timeline',
path: '/lists/:id',
component: defineAsyncComponent(
() => import('src/components/lists_timeline/lists_timeline.vue'),
),
},
{
name: 'lists-edit',
path: '/lists/:id/edit',
component: defineAsyncComponent(
() => import('src/components/lists_edit/lists_edit.vue'),
),
},
{
name: 'lists-new',
path: '/lists/new',
component: defineAsyncComponent(
() => import('src/components/lists_edit/lists_edit.vue'),
),
},
{
name: 'edit-navigation',
path: '/nav-edit',
component: NavPanel,
props: () => ({ forceExpand: true, forceEditMode: true }),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'bookmark-folders',
path: '/bookmark_folders',
component: defineAsyncComponent(
() => import('src/components/bookmark_folders/bookmark_folders.vue'),
),
},
{
name: 'bookmark-folder-new',
path: '/bookmarks/new-folder',
component: defineAsyncComponent(
() =>
import(
'src/components/bookmark_folder_edit/bookmark_folder_edit.vue'
),
),
},
{
name: 'bookmark-folder',
path: '/bookmarks/:id',
component: BookmarkTimeline,
},
{
name: 'bookmark-folder-edit',
path: '/bookmarks/:id/edit',
component: defineAsyncComponent(
() =>
import(
'src/components/bookmark_folder_edit/bookmark_folder_edit.vue'
),
),
},
]
if (useInstanceCapabilitiesStore().pleromaChatMessagesAvailable) {
routes = routes.concat([
{
name: 'chat',
path: '/users/:username/chats/:recipient_id',
component: defineAsyncComponent(
() => import('src/components/chat_view/chat_view.vue'),
),
meta: { dontScroll: false },
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'chats',
path: '/users/:username/chats',
component: defineAsyncComponent(
() => import('src/components/chat_list/chat_list.vue'),
),
meta: { dontScroll: false },
beforeEnter: validateAuthenticatedRoute,
},
])
}
return routes
}
diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
index ac950a9791..516741a336 100644
--- a/src/components/chat_message/chat_message.js
+++ b/src/components/chat_message/chat_message.js
@@ -1,246 +1,225 @@
import { mapState as mapPiniaState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import { mapState } from 'vuex'
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 { 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'],
components: {
Popover,
Attachment,
StatusContent,
StatusBody,
StatusActionButtons,
UserAvatar,
Gallery,
LinkPreview,
ChatMessageDate,
EmojiReactions,
UserPopover,
StatusPopover,
MentionLink,
Quote: defineAsyncComponent(() => import('src/components/quote/quote.vue')),
Timeago,
},
computed: {
isMessage() {
return this.chatItem.type === 'message'
},
message() {
if (!this.isMessage) return null
return this.chatItem.data.retweeted_status ?? this.chatItem.data
},
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 this.$store.getters.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
},
isBrokenReply() {
if (!this.previousItem) return false
return !this.message.in_reply_to_status_id
},
customReplyTo() {
return this.$store.state.statuses.allStatusesObject[
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 = this.$store.getters.findUser(
this.status.in_reply_to_user_id,
)
return user && user.screen_name_ui
}
},
replyProfileLink() {
if (this.isCustomReply) {
const user = this.$store.getters.findUser(
this.message.in_reply_to_user_id,
)
// FIXME Why user not found sometimes???
return user ? user.statusnet_profile_url : 'NOT_FOUND'
}
},
// Quote stuff
quoteId() {
return this.message.quote_id
},
quoteUrl() {
return this.message.quote_url
},
quoteVisible() {
return this.message.quote_visible
},
// Content
messageForStatusContent() {
return {
...this.message,
summary: '',
emojis: this.message.emojis,
raw_html: this.message.content || this.message.raw_html || '',
text: this.message.content || '',
}
},
hasAttachment() {
return this.message.attachments.length > 0
},
// Stylistic
classnames() {
return {
'-outgoing': this.isCurrentUser,
'-incoming': !this.isCurrentUser,
'-pending': this.message.pending,
'-focused': this.focused,
}
},
popoverMarginStyle() {
if (this.isCurrentUser) {
return {}
} else {
return { left: 50 }
}
},
// Global stuff
...mapPiniaState(useInterfaceStore, {
betterShadow: (store) => store.browserSupport.cssFilter,
}),
...mapState({
currentUser: (state) => state.users.currentUser,
restrictedNicknames: (state) => useInstanceStore().restrictedNicknames,
}),
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
},
data() {
return {
hovered: false,
menuOpened: false,
}
},
- watch: {
- focused: function (value) {
- this.scrollIfFocused(value)
- },
- },
methods: {
onHover(bool) {
this.$emit('hover', {
isHovered: bool,
messageChainId: this.chatItem.messageChainId,
})
},
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', {
messageId: this.message.id,
chatId: this.message.chat_id,
})
}
this.hovered = false
this.menuOpened = false
},
- scrollIfFocused(focused) {
- if (this.$el.getBoundingClientRect == null) return
- if (focused) {
- const rect = this.$el.getBoundingClientRect()
- if (rect.top < 100) {
- // Post is above screen, match its top to screen top
- window.scrollBy(0, rect.top - 100)
- } else if (rect.height >= window.innerHeight - 50) {
- // Post we want to see is taller than screen so match its top to screen top
- window.scrollBy(0, rect.top - 100)
- } else if (rect.bottom > window.innerHeight - 50) {
- // Post is below screen, match its bottom to screen bottom
- window.scrollBy(0, rect.bottom - window.innerHeight + 50)
- }
- }
- },
},
}
export default ChatMessage
diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue
index f9095a3def..c3deac0a5c 100644
--- a/src/components/chat_message/chat_message.vue
+++ b/src/components/chat_message/chat_message.vue
@@ -1,239 +1,240 @@
<template>
<div
v-if="isMessage"
class="chat-message-wrapper"
:class="[classnames, { 'hovered-message-chain': hoveredMessageChain }]"
+ :id="`chatmessage-${message.id}`"
@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 && 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="author"
/>
</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
@toggle-replying="$emit('replyRequested', message)"
/>
<div
class="chat-message-toolbar"
:class="{ '-visible': hovered || menuOpened }"
v-else
>
<Popover
trigger="click"
:trigger-attrs="{ 'class': 'button-default menu-icon simple-button', title: $t('chats.more') }"
placement="top"
:margin="popoverMarginStyle"
@show="menuOpened = true"
@close="menuOpened = false"
>
<template #content>
<div class="dropdown-menu">
<div class="menu-item dropdown-item -icon">
<button
class="main-button"
@click="deleteMessage"
>
<FAIcon icon="times" /> {{ $t("chats.delete") }}
</button>
</div>
</div>
</template>
<template #trigger>
<FAIcon icon="ellipsis-h" />
</template>
</Popover>
</div>
<StatusContent
class="message-content"
:class="{ faint: message.pending }"
:status="messageForStatusContent"
:full-content="true"
>
<template #footer>
<EmojiReactions
v-if="isStatus"
:status="message"
/>
<Quote
v-if="isStatus"
class="quoted-post"
:status-id="quoteId"
:status-url="quoteUrl"
:status-visible="quoteVisible"
initially-expanded
/>
<span
class="created-at"
>
<span
v-if="message.favorited"
>
<FAIcon
class="fa-scale-110"
icon="star"
fixed-width
/>
</span>
<span
v-if="message.repeated"
>
<FAIcon
class="fa-scale-110"
icon="retweet"
fixed-width
/>
</span>
<span
v-if="message.visibility"
class="visibility-icon"
:title="visibilityLocalized"
>
<FAIcon
class="fa-scale-110"
:icon="visibilityIcon(message.visibility)"
fixed-width
/>
</span>
<span
v-if="message.pending"
class="loading-spinner"
>
<FAIcon
class="fa-old-padding"
icon="circle-notch"
spin
/>
</span>
{{ ' ' }}
<router-link
class="timeago faint"
:to="{ name: 'conversation2', params: { statusId: message.id } }"
>
<Timeago
:time="message.created_at"
:auto-update="60"
/>
</router-link>
</span>
</template>
</StatusContent>
</div>
</div>
<div
v-if="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"
>
<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_view/chat_view.js b/src/components/chat_view/chat_view.js
index de2ed88574..8d45707f3d 100644
--- a/src/components/chat_view/chat_view.js
+++ b/src/components/chat_view/chat_view.js
@@ -1,589 +1,621 @@
import { get, maxBy, minBy, sortBy, throttle } from 'lodash'
import { mapState as mapPiniaState } from 'pinia'
import { nextTick } from 'vue'
import { mapState } from 'vuex'
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 {
chatMessages,
deleteChatMessage,
getOrCreateChat,
readChat,
sendChatMessage,
} from 'src/api/chats.js'
import { WSConnectionStatus } from 'src/api/websocket.js'
import { fetchConversation, fetchStatus } from 'src/api/public.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronDown, faChevronLeft } from '@fortawesome/free-solid-svg-icons'
library.add(faChevronDown, faChevronLeft)
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: String,
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,
errorLoadingChat: false,
messageRetriers: {},
idempotencyKeyIndex: {},
}
},
created() {
if (this.testMode) return
this.startFetching()
- window.addEventListener('resize', this.handleResize)
},
mounted() {
+ window.addEventListener('resize', this.handleResize)
window.addEventListener('scroll', this.handleScroll)
if (typeof 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 (typeof document.hidden !== 'undefined')
document.removeEventListener(
'visibilitychange',
this.handleVisibilityChange,
false,
)
},
computed: {
conversationId() {
const status = this.$store.state.statuses.allStatusesObject[this.statusId]
return get(
status,
'retweeted_status.statusnet_conversation_id',
get(status, 'statusnet_conversation_id'),
)
},
isConversation() {
- return this.conversationId !== null
+ return this.statusId !== null
},
recipient() {
return this.chat?.account
},
- recipientId() {
- return this.$route.params.recipient_id
- },
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 &&
this.mastoUserSocketStatus === WSConnectionStatus.JOINED
)
},
...mapPiniaState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile',
}),
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
...mapState({
mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus,
currentUser: (state) => state.users.currentUser,
}),
},
watch: {
- messages() {
+ 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
this.$refs.postStatusForm.update()
},
- $route: function () {
+ $route: async function (newVal) {
+ if (this.messagesIndex[newVal.params.statusId]) {
+ const focused = document.getElementById(`chatmessage-${this.$route.params.statusId}`)
+ 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.clear()
this.startFetching()
},
mastoUserSocketStatus(newValue) {
if (newValue === WSConnectionStatus.JOINED) {
this.fetchChat({ isFirstFetch: true })
}
},
},
methods: {
// 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
},
scrollDown(options = {}) {
const { behavior = 'auto', forceRead = false } = options
this.$nextTick(() => {
window.scrollTo({
top: document.documentElement.scrollHeight,
behavior,
})
})
if (forceRead) {
this.readChat()
}
},
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]
}
}
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]
} 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()
+ }
const fetchOlderMessages = !!maxId
if (fetchOlderMessages) {
this.handleScrollUp(positionBeforeUpdate)
}
// 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.
if (!isScrollable() && messages.length > 0) {
this.fetchChat({
maxId: this.minId,
})
}
},
async startFetching() {
if (!this.isConversation) {
try {
const { data } = await getOrCreateChat({
accountId: this.recipientId,
credentials: useOAuthStore().token,
})
this.$store.commit('addNewUsers', [data.account])
data.account = this.$store.getters.findUser(data.account.id)
this.chat = data
} catch (e) {
console.error('Error creating or getting a chat', e)
this.errorLoadingChat = true
}
}
if (this.isConversation || this.chat) {
this.$nextTick(() => {
this.scrollDown({ forceRead: true })
})
this.doStartFetching()
}
},
doStartFetching() {
this.fetcher = promiseInterval(
() => this.fetchChat({ fetchLatest: true }),
5000,
)
this.fetchChat({ isFirstFetch: true })
},
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)) {
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.idempotencyKeyIndex]) {
delete this.pendingMessagesIndex[message.idempotencyKeyIndex]
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
}
}
},
goBack() {
this.$router.back()
},
// Optimistic posting (chats only)
async sendMessage({ status, media, idempotencyKey }) {
const params = {
id: this.chat.id,
content: status,
idempotencyKey,
}
if (media[0]) {
params.mediaId = media[0].id
}
const fakeMessage = buildFakeMessage({
attachments: media,
chatId: this.chat.id,
content: status,
userId: this.currentUser.id,
idempotencyKey,
})
this.pendingMessages.push(fakeMessage)
this.pendingMessagesIndex[idempotencyKey] = fakeMessage
this.handleAttachmentPosting()
return this.doSendMessage({
params,
retriesLeft: MAX_RETRIES,
})
},
async doSendMessage({ params, retriesLeft = MAX_RETRIES }) {
if (retriesLeft <= 0) return
try {
const { data } = await sendChatMessage({
...params,
credentials: useOAuthStore().token,
})
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,
})
if (
(error.statusCode >= 500 && error.statusCode < 600) ||
error.message === 'Failed to fetch'
) {
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()
}
}, 5000)
},
// Event handlers
onPosted(data) {
this.explicitReplyStatus = null
this.$router.push({ name: 'conversation2', params: { statusId: data.id } })
},
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 })
})
},
// Ugly
// TODO move to ChatMessage
async deleteChatMessage({ chatId, messageId }) {
if (!this.testMode)
await deleteChatMessage({
chatId,
messageId,
credentials: useOAuthStore().token,
})
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
}
},
},
}
export default Chat
diff --git a/src/components/chat_view/chat_view.vue b/src/components/chat_view/chat_view.vue
index 254b8712e0..5ea2fd2b53 100644
--- a/src/components/chat_view/chat_view.vue
+++ b/src/components/chat_view/chat_view.vue
@@ -1,123 +1,124 @@
<template>
<div class="chat-view">
<div class="chat-view-inner">
<div
ref="inner"
class="panel-default panel chat-view-body"
>
<div
ref="header"
class="panel-heading -sticky chat-view-heading"
>
<button
class="button-unstyled go-back-button"
@click="goBack"
>
<FAIcon
size="lg"
icon="chevron-left"
/>
</button>
- <div class="title text-center">
+ <div class="title">
<template v-if="isConversation">
<RichContent
v-if="messages[0]?.summary"
:html="messages[0].summary"
/>
<template v-else>
{{ $t('timeline.conversation') }}
</template>
</template>
<ChatTitle
v-else
:user="recipient"
: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"
/>
</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"
class="badge -notification unread-chat-count unread-message-count"
>
{{ newMessageCount }}
</div>
</span>
</div>
<div class="auto-reply-to-section">
<div class="reply-to-text">
{{ explicitReplyStatus ? $t('status.reply_to_selected') : $t('status.reply_to_last') }}
<button
v-if="explicitReplyStatus"
class="button-default"
@click="explicitReplyStatus = null"
>
<FAIcon icon="times" />
{{ $t('general.cancel') }}
</button>
</div>
</div>
<PostStatusForm
ref="postStatusForm"
:reply-to="replyStatus?.id"
:mentions-line="isConversation"
mentions-line-read-only
:attentions="replyStatus?.attentions"
:replied-user="replyStatus?.user"
:replied-subject="replyStatus?.summary"
:replied-scope="replyStatus?.visibility"
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-submit="isConversation ? false : (errorLoadingChat || !chat)"
:optimistic-posting="!isConversation"
:submit-on-enter="!mobileLayout"
:preserve-focus="!mobileLayout"
: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.scss b/src/components/conversation/conversation.scss
index cab0fdd555..99ecb338a0 100644
--- a/src/components/conversation/conversation.scss
+++ b/src/components/conversation/conversation.scss
@@ -1,110 +1,95 @@
.Conversation {
z-index: 1;
&.-hidden {
background: var(--__panel-background);
backdrop-filter: var(--__panel-backdrop-filter);
}
.conversation-dive-to-top-level-box {
padding: var(--status-margin);
border-bottom: 1px solid var(--border);
border-radius: 0;
/* Make the button stretch along the whole row */
display: flex;
align-items: stretch;
flex-direction: column;
}
.thread-ancestors {
margin-left: var(--status-margin);
border-left: 2px solid var(--border);
}
.thread-ancestor.-faded .RichContent {
/* stylelint-disable declaration-no-important */
--text: var(--textFaint) !important;
--link: var(--linkFaint) !important;
--funtextGreentext: var(--funtextGreentextFaint) !important;
--funtextCyantext: var(--funtextCyantextFaint) !important;
/* stylelint-enable declaration-no-important */
}
.thread-ancestor-dive-box {
padding-left: var(--status-margin);
border-bottom: 1px solid var(--border);
border-radius: 0;
/* Make the button stretch along the whole row */
&,
&-inner {
display: flex;
align-items: stretch;
flex-direction: column;
}
}
.thread-ancestor-dive-box-inner {
padding: var(--status-margin);
}
.conversation-status {
border-bottom: 1px solid var(--border);
border-radius: 0;
}
.thread-ancestor-has-other-replies .conversation-status,
&:last-child:not(.-expanded) .conversation-status,
&.-expanded .conversation-status:last-child,
.thread-ancestor:last-child .conversation-status,
.thread-ancestor:last-child .thread-ancestor-dive-box,
&.-expanded .thread-tree .conversation-status {
border-bottom: none;
}
.thread-ancestors + .thread-tree > .conversation-status {
border-top: 1px solid var(--border);
}
/* expanded conversation in timeline */
&.status-fadein.-expanded .thread-body {
border-left: 4px solid var(--cRed);
border-radius: var(--roundness);
border-top-left-radius: 0;
border-top-right-radius: 0;
border-bottom: 1px solid var(--border);
}
&.-expanded.status-fadein {
--___margin: calc(var(--status-margin) / 2);
background: var(--background);
margin: var(--___margin);
&::before {
z-index: -1;
content: "";
display: block;
position: absolute;
inset: calc(var(--___margin) * -1);
background: var(--background);
backdrop-filter: var(--__panel-backdrop-filter);
}
}
-
- .chat-view-reply-form {
- position: sticky;
- display: flex;
- flex-direction: column;
- align-items: stretch;
- bottom: 0;
- padding: 0;
- }
-
- .reply-to-text {
- line-height: 1.2;
- padding-top: 0.5em;
- margin-bottom: -0.5em;
- }
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Oct 10, 10:29 PM (1 d, 11 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1785479
Default Alt Text
(51 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline
Log In to Comment