Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85804797
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
47 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
index bf8eabdf2f..e0f54da0a7 100644
--- a/src/components/chat_message/chat_message.js
+++ b/src/components/chat_message/chat_message.js
@@ -1,217 +1,218 @@
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'],
components: {
Popover,
Attachment,
StatusContent,
StatusBody,
StatusActionButtons,
UserAvatar,
Gallery,
LinkPreview,
ChatMessageDate,
EmojiReactions,
UserPopover,
StatusPopover,
MentionLink,
Quote: defineAsyncComponent(() => import('src/components/quote/quote.vue')),
Timeago,
},
+ inject: ['deleteChatMessage'],
computed: {
isMessage() {
return this.chatItem.type === 'message'
},
message() {
if (!this.isMessage) return null
return this.chatItem.data.retweeted_status ?? this.chatItem.data
},
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
},
isBrokenReply() {
if (!this.previousItem) return false
return !this.message.in_reply_to_status_id
},
customReplyTo() {
return useStatusesStore().allStatuses.get(
this.message.in_reply_to_status_id,
)
},
replyToName() {
if (this.message.in_reply_to_screen_name) {
return this.message.in_reply_to_screen_name
} else {
const user = useUsersStore().findUser(this.message.in_reply_to_user_id)
return user?.screen_name_ui
}
},
replyProfileLink() {
if (this.isCustomReply) {
const user = useUsersStore().findUser(this.message.in_reply_to_user_id)
return user.statusnet_profile_url
}
},
// Quote stuff
quoteId() {
return this.message.quote_id
},
quoteUrl() {
return this.message.quote_url
},
quoteVisible() {
return this.message.quote_visible
},
// Content
messageForStatusContent() {
return {
...this.message,
summary: '',
emojis: this.message.emojis,
raw_html: this.message.content || this.message.raw_html || '',
text: this.message.content || '',
}
},
hasAttachment() {
return this.message.attachments.length > 0
},
// Stylistic
classnames() {
return {
'-outgoing': this.isCurrentUser,
'-incoming': !this.isCurrentUser,
'-pending': this.message.pending,
'-focused': this.focused,
}
},
popoverMarginStyle() {
if (this.isCurrentUser) {
return {}
} else {
return { left: 50 }
}
},
// Global stuff
...mapState(useInterfaceStore, {
betterShadow: (store) => store.browserSupport.cssFilter,
}),
...mapState(useUsersStore, ['currentUser']),
...mapState(useInstanceStore, ['restrictedNicknames']),
...mapState(useMergedConfigStore, ['mergedConfig']),
},
data() {
return {
hovered: false,
menuOpened: false,
}
},
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', {
+ await this.deleteChatMessage({
messageId: this.message.id,
chatId: this.message.chat_id,
})
}
this.hovered = false
this.menuOpened = false
},
},
}
export default ChatMessage
diff --git a/src/components/chat_view/chat_view.js b/src/components/chat_view/chat_view.js
index 7d80459bb5..aff7279289 100644
--- a/src/components/chat_view/chat_view.js
+++ b/src/components/chat_view/chat_view.js
@@ -1,725 +1,244 @@
-import { get, maxBy, minBy, sortBy, throttle } from 'lodash-es'
-import { mapState } from 'pinia'
-import { nextTick } from 'vue'
+import { storeToRefs } from 'pinia'
+import {
+ computed,
+ inject,
+ nextTick,
+ provide,
+ ref,
+ toRefs,
+ useTemplateRef,
+ watch,
+} from 'vue'
+import { useRouter } from 'vue-router'
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
import ChatTitle from 'src/components/chat_title/chat_title.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
-import { 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 { useDocumentFocus } from 'src/composables/useDocumentFocus.js'
+import { usePleromaChat } from 'src/composables/usePleromaChat.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronDown, faChevronLeft } from '@fortawesome/free-solid-svg-icons'
library.add(faChevronDown, faChevronLeft)
-const 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}`,
- )
- 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', ''),
+ 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',
)
+ })
- 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
- }
- }
-
- 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]
- }
- }
+ // # Conversation stuff
+ const {
+ conversation: conversationMessages,
+ loadError: conversationError,
+
+ focusedId,
+ setFocused,
+ fetchConversation,
+ } = useConversation(statusId, true)
+ watch(isConversation, (value) => {
+ if (!value) return
+ fetchConversation()
+ })
- 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
+ // # Chat stuff
+ const {
+ activate: chatActivate,
+ deactivate: chatDeactivate,
+ markAsRead: chatMarkRead,
+ fetchOlder: chatFetchOlder,
+ messages: chatMessages,
+ pendingMessages: chatPendingMessages,
+ newMessagesCount: chatNewMessagesCount,
+ fetchError: chatError,
+
+ sendMessage,
+ deleteChatMessage,
+ } = usePleromaChat(chatUserId)
+ provide('deleteChatMessage', deleteChatMessage)
+ watch(chatUserId, (neu, old) => {
+ if (old) {
+ chatDeactivate()
+ }
+ if (neu) {
+ chatActivate()
}
+ })
- 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()
+ return chatMessages.value
}
-
- const positionBeforeUpdate = getScrollPosition()
- this.addMessages({ messages })
-
- await nextTick()
- if (isFirstFetch) {
- this.scrollDown()
+ })
+ const pendingMessages = computed(() => {
+ if (isConversation.value) {
+ return [] // Not implemented yet
+ } else {
+ return chatPendingMessages.value
}
-
- const fetchOlderMessages = !!maxId
- if (fetchOlderMessages) {
- this.handleScrollUp(positionBeforeUpdate)
+ })
+ const newMessagesCount = computed(() => {
+ if (isConversation.value) {
+ return 0 // Not implemented yet
+ } else {
+ return chatNewMessagesCount.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 error = computed(() => {
+ if (isConversation.value) {
+ return conversationError.value
+ } else {
+ return chatError.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 fetchOlder = () => {
+ if (isChat.value) {
+ chatFetchOlder()
}
- },
-
- // Optimistic posting (chats only)
- async sendMessage({ status, media, idempotencyKey }) {
- const params = {
- id: this.chat.id,
- content: status,
- idempotencyKey,
+ // Unsupported in conversations
+ }
+ const markAsRead = () => {
+ if (isChat.value) {
+ chatMarkRead()
}
+ // Unsupported in conversations
+ }
- if (media[0]) {
- params.mediaId = media[0].id
- }
+ // Reply-to
+ const explicitReply = ref(null)
+ const lastMessage = computed(() => messages.value?.at(-1))
+ const replyTo = computed(() => explicitReply.value ?? lastMessage.value)
- const fakeMessage = buildFakeMessage({
- attachments: media,
- chatId: this.chat.id,
- content: status,
- userId: this.currentUser.id,
- idempotencyKey,
- })
+ const scroller = inject('bodyScrollPosition')
+ const rootElement = useTemplateRef('root')
+ const postForm = useTemplateRef('postform')
- this.pendingMessages.push(fakeMessage)
- this.pendingMessagesIndex[idempotencyKey] = fakeMessage
+ // Post form stuff
+ watch(replyTo, () => {
+ if (isConversation.value) postForm.update()
+ })
- this.handleAttachmentPosting()
+ // # Scroll stuff
+ const { hasReachedBottom, cHeight, scrollTo } = scroller
- return this.doSendMessage({
- params,
- retriesLeft: MAX_RETRIES,
+ // ## Bottom-sticking stuff
+ const stickToBottom = async (forceRead) => {
+ // We don't want to scroll to the bottom on a new message when the user is viewing older messages.
+ // Therefore we need to know whether the scroll position was at the bottom before the DOM update.
+ if (!hasReachedBottom.value) return
+ await nextTick()
+ scrollTo({
+ top: cHeight.value,
})
- },
- 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)
+ if (forceRead) {
+ markAsRead()
+ }
+ }
- this.handleMessageError({
- chatId: this.chat.id,
- idempotencyKey: params.idempotencyKey,
- isRetry: retriesLeft !== MAX_RETRIES,
- })
+ const postFormElement = computed(() => postForm.value?.$el)
+ const { focused } = useDocumentFocus()
+ const { height: postFormHeight } = useClientRectSize(postFormElement)
+ const { vHeight: viewportHeight } = scroller
+ watch(viewportHeight, stickToBottom)
+ watch(postFormHeight, stickToBottom)
+ watch(focused, async (value) => {
+ if (!value) return
+ stickToBottom(true)
+ })
+ watch(messages, (old, neu) => {
+ if (old.length === neu.length) return
+ stickToBottom(true)
+ })
+ // ## Load / Read
+ const { shouldLoadTop } = scroller
+ watch(shouldLoadTop, (value) => {
+ if (value) 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),
- )
+ markAsRead()
}
- }
- },
- handleMessageError(idempotencyKey, isRetry) {
- const fakeMessage = this.pendingMessagesIndex[idempotencyKey]
-
- if (fakeMessage) {
- fakeMessage.error = true
- fakeMessage.pending = false
- }
- },
+ }, MARK_AS_READ_DELAY)
+ })
- // Checks
- hasReachedTop() {
- return window.scrollY <= 0
- },
- cullOlderCheck() {
- if (this.conversationId) return
- window.setTimeout(() => {
- if (isBottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
- this.cullOlder()
- }
- }, 5000)
- },
+ // Misc UI stuff
+ const { layoutType } = storeToRefs(useInterfaceStore())
+ const mobileLayout = computed(() => layoutType.value === 'mobile')
+ const jumpToBottomButtonVisible = computed(() => !hasReachedBottom.value)
+ const router = useRouter()
+ const onPosted = (data) => {
+ explicitReply.value = null
- // Event handlers
- onPosted(data) {
- // only conversation poster has the returned data
- if (this.isConversation) {
- this.explicitReplyStatus = null
- this.$router.push({
+ if (isConversation.value) {
+ 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 })
- })
- },
-
- // Misc
- scrollDown(options = {}) {
- const { behavior = 'auto', forceRead = false } = options
- this.$nextTick(() => {
- window.scrollTo({
- top: document.documentElement.scrollHeight,
- behavior,
- })
- })
- if (forceRead) {
- this.readChat()
- }
- },
- goBack() {
- this.$router.back()
- },
-
- // 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
- }
- },
+ return {
+ messages,
+ pendingMessages,
+ newMessagesCount,
+ error,
+
+ // Conversation-exclusive
+ isConversation,
+ setFocused,
+ focusedId,
+
+ // Chats-exclusive
+ sendMessage,
+
+ // Misc
+ onPosted,
+ mobileLayout,
+ jumpToBottomButtonVisible,
+ }
},
}
export default Chat
diff --git a/src/components/chat_view/chat_view.vue b/src/components/chat_view/chat_view.vue
index 06862ab3c1..1d565aa87d 100644
--- a/src/components/chat_view/chat_view.vue
+++ b/src/components/chat_view/chat_view.vue
@@ -1,124 +1,123 @@
<template>
<div class="chat-view">
<div class="chat-view-inner">
<div
ref="inner"
class="panel-default panel chat-view-body"
>
<div
ref="header"
class="panel-heading -sticky chat-view-heading"
>
<button
class="button-unstyled go-back-button"
@click="goBack"
>
<FAIcon
size="lg"
icon="chevron-left"
/>
</button>
<div class="title">
<template v-if="isConversation">
<RichContent
v-if="messages[0]?.summary_raw_html"
:html="messages[0].summary_raw_html"
:emoji="messages[0].emojis"
/>
<template v-else>
{{ $t('timeline.conversation') }}
</template>
</template>
<ChatTitle
v-else
:user="recipient"
: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"
/>
</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
v-if="isConversation"
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"
:replied-status="replyStatus"
: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-submit="isConversation ? !replyStatus : (errorLoadingChat || !chat)"
:optimistic-posting="!isConversation"
chat-view
preserve-focus
:auto-focus="!mobileLayout"
:placeholder="formPlaceholder"
:file-limit="isConversation ? null : 1"
:max-height="160"
emoji-picker-placement="top"
:post-handler="isConversation ? null : sendMessage"
@resize="handleResize"
@posted="onPosted"
/>
</div>
</div>
</div>
</div>
</template>
<script src="./chat_view.js"></script>
<style src="./chat_view.scss" lang="scss" />
diff --git a/src/composables/usePleromaChat.js b/src/composables/usePleromaChat.js
new file mode 100644
index 0000000000..6dab1197b2
--- /dev/null
+++ b/src/composables/usePleromaChat.js
@@ -0,0 +1,342 @@
+import { maxBy, minBy } from 'lodash-es'
+import { storeToRefs } from 'pinia'
+import { computed, ref } from 'vue'
+
+import { useChatsStore } from 'src/stores/chats.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useOAuthStore } from 'src/stores/oauth.js'
+import { useStreamingStore } from 'src/stores/streaming.js'
+import { useUsersStore } from 'src/stores/users.js'
+
+import {
+ deleteChatMessage as apiDeleteChatMessage,
+ chatMessages,
+ getOrCreateChat,
+ readChat,
+ sendChatMessage,
+} from 'src/api/chats.js'
+import { WSConnectionStatus } from 'src/api/websocket.js'
+import { buildFakeMessage } from 'src/services/chat_utils/chat_utils.js'
+import { promiseInterval } from 'src/services/promise_interval/promise_interval.js'
+
+const MAX_RETRIES = 10
+
+export function usePleromaChat(userId) {
+ const { mergedConfig } = storeToRefs(useMergedConfigStore())
+ const { mastoUserSocketStatus } = storeToRefs(useStreamingStore())
+
+ const streamingEnabled = computed(
+ () =>
+ mergedConfig.value.useStreamingApi &&
+ mastoUserSocketStatus === WSConnectionStatus.JOINED,
+ )
+
+ const chat = ref(null)
+ const socket = ref(null)
+
+ const error = ref(null)
+ const streaming = ref(false)
+ const fetcher = ref(null)
+ const fetching = ref(true)
+
+ const minId = ref(undefined)
+ const maxId = ref(undefined)
+
+ const idempotencyKeyIndex = ref(new Map())
+ const messages = ref([])
+ const messagesIndex = ref(new Map())
+ const pendingMessages = ref([])
+ const pendingMessagesIndex = ref(new Map())
+ const messageRetriers = ref(new Map())
+
+ const lastReadMessageId = ref(null)
+ const newMessagesCount = ref(0)
+
+ // # Posting & Optimism
+ const { currentUser } = storeToRefs(useUsersStore())
+ const sendMessage = async ({ status, media, idempotencyKey }) => {
+ const params = {
+ id: chat.value.id,
+ content: status,
+ idempotencyKey,
+ }
+
+ if (media[0]) {
+ params.mediaId = media[0].id
+ }
+
+ const fakeMessage = buildFakeMessage({
+ attachments: media,
+ chatId: chat.value.id,
+ content: status,
+ userId: currentUser.value.id,
+ idempotencyKey,
+ })
+
+ pendingMessages.push(fakeMessage)
+ pendingMessagesIndex.set(idempotencyKey, fakeMessage)
+
+ return doSendMessage({
+ params,
+ retriesLeft: MAX_RETRIES,
+ })
+ }
+ const doSendMessage = async ({ params, retriesLeft = MAX_RETRIES }) => {
+ if (retriesLeft <= 0) return
+
+ const handleMessageError = ({ idempotencyKey, isRetry }) => {
+ const fakeMessage = pendingMessagesIndex.get(idempotencyKey)
+
+ if (fakeMessage) {
+ fakeMessage.error = true
+ fakeMessage.pending = false
+ }
+ }
+
+ try {
+ const { data } = await sendChatMessage({
+ ...params,
+ credentials: useOAuthStore().token,
+ })
+
+ addMessages({
+ messages: [{ ...data }],
+ })
+ } catch (error) {
+ if (
+ error.name !== 'StatusCodeError' ||
+ error.message === 'Failed to fetch'
+ ) {
+ throw error
+ }
+ console.error('Error sending message', error)
+
+ handleMessageError({
+ chatId: chat.value.id,
+ idempotencyKey: params.idempotencyKey,
+ isRetry: retriesLeft !== MAX_RETRIES,
+ })
+
+ const error5xx = error.statusCode >= 500 && error.statusCode < 600
+ if (error5xx || error.message === 'Failed to fetch') {
+ messageRetriers.set(
+ params.idempotencyKey,
+ setTimeout(
+ () => {
+ doSendMessage({
+ params,
+ retriesLeft: retriesLeft - 1,
+ })
+ },
+ 1000 * 2 ** (MAX_RETRIES - retriesLeft),
+ ),
+ )
+ }
+ }
+ }
+
+ // # Poll & Push
+ const startFetching = (reason, isFirstFetch) => {
+ console.debug('[Pleroma Chat] Started fetching', 'Reason:', reason)
+ fetcher.value = promiseInterval(() => fetchChat({ latest: true }), 5000)
+ clear()
+ fetching.value = true
+ }
+ const stopFetching = (reason) => {
+ console.debug('[Pleroma Chat] Stopped fetching', 'Reason:', reason)
+ fetcher.value.stop()
+ fetcher.value = null
+ fetching.value = false
+ }
+ const onStreamConnect = () => {
+ streaming.value = true
+ stopFetching('Socket connected')
+ }
+ const onStreamDisconnect = (closeEvent) => {
+ streaming.value = false
+ startFetching('Socket disconnected')
+ }
+ const onChatUpdate = ({ data: { chatUpdate } }) => {
+ const messages = [chatUpdate.lastMessage]
+ addMessages({ messages })
+ }
+
+ // # Actions
+ const markAsRead = async () => {
+ if (!maxId.value || document.hidden) {
+ return
+ }
+ const lastReadId = maxId.value
+ const isNewMessage = lastReadMessageId.value !== lastReadId
+
+ if (!isNewMessage) return
+
+ await readChat({
+ id: chat.value.id,
+ lastReadId,
+ credentials: useOAuthStore().token,
+ })
+
+ useChatsStore().readChat(chat.value.id)
+ lastReadMessageId.value = maxId.value
+ newMessagesCount.value = 0
+ }
+ const deleteChatMessage = async ({ chatId, messageId }) => {
+ await apiDeleteChatMessage({
+ chatId,
+ messageId,
+ credentials: useOAuthStore().token,
+ })
+
+ messages.value = messages.value.filter((m) => m.id !== messageId)
+ messagesIndex.delete(messageId)
+
+ if (maxId.value === messageId) {
+ const lastMessage = maxBy(messages.value, 'id')
+ maxId.value = lastMessage.id
+ }
+
+ if (minId.value === messageId) {
+ const firstMessage = minBy(messages.value, 'id')
+ minId.value = firstMessage.id
+ }
+ }
+
+ const fetchError = ref(null)
+ const fetchChat = async ({ older = false, latest = false }) => {
+ if (!older && streamingEnabled.value) {
+ return
+ }
+
+ try {
+ const { data: messages } = await chatMessages({
+ id: chat.value.id,
+ maxId: maxId.value,
+ sinceId: older ? null : maxId.value,
+ credentials: useOAuthStore().token,
+ })
+
+ addMessages({ messages })
+ fetchError.value = null
+ } catch (e) {
+ console.error('Error fetching chat', e)
+ fetchError.value = e
+ }
+ }
+ const fetchOlder = () => {
+ fetchChat({ older: true })
+ }
+
+ // # Message list forming
+ const addMessages = (newMessages) => {
+ for (let message of newMessages) {
+ // Clear any known pending messages
+ if (message.idempotency_key) {
+ if (pendingMessagesIndex.value.has(message.idempotency_key)) {
+ pendingMessagesIndex.value.delete(message.idempotency_key)
+ pendingMessages.value = pendingMessages.value.filter(
+ ({ idempotency_key }) =>
+ idempotency_key !== message.idempotency_key,
+ )
+ }
+ }
+
+ if (!minId.value || (!message.pending && message.id < minId.value)) {
+ minId.value = message.id
+ }
+
+ if (!maxId.value || message.id > maxId.value) {
+ maxId.value = message.id
+ }
+ const isConfirmation = (message) => {
+ if (!message.idempotency_key) return
+ return idempotencyKeyIndex.has(message.idempotency_key)
+ }
+
+ if (!messagesIndex.has(message.id) && !isConfirmation(message)) {
+ if (lastReadMessageId < message.id) {
+ newMessagesCount.value++
+ }
+ messagesIndex.value.set(message.id, message)
+ messages.value.push(messagesIndex.value.get(message.id))
+ idempotencyKeyIndex.set(message.idempotency_key, true)
+ }
+ }
+ }
+ const clear = () => {
+ messages.value = messages.value.filter((m) => m.error)
+ messagesIndex.value = messages.value.reduce((acc, m) => {
+ acc.set(m.id, m)
+ return acc
+ }, new Map())
+ newMessagesCount.value = 0
+ lastReadMessageId.value = null
+ minId.value = undefined
+ maxId.value = undefined
+ }
+
+ const attachSocket = () => {
+ const et = new EventTarget()
+ const newSocket = {
+ name: 'chatview',
+ et,
+ }
+
+ et.addEventListener('pleroma:chat_update', onChatUpdate)
+ et.addEventListener('open', onStreamConnect)
+ et.addEventListener('close', onStreamDisconnect)
+
+ socket.value = newSocket
+ useStreamingStore().addSubscriber(socket.value)
+ }
+ const detachSocket = () => {
+ const { et } = socket.value
+
+ et.removeEventListener('pleroma:chat_update', onChatUpdate)
+ et.removeEventListener('open', onStreamConnect)
+ et.removeEventListener('close', onStreamDisconnect)
+
+ useStreamingStore().removeSubscriber(socket.value)
+ }
+ const activate = async () => {
+ try {
+ attachSocket()
+ const result = await getOrCreateChat({
+ accountId: userId,
+ credentials: useOAuthStore().token,
+ })
+ const { data } = result
+
+ useUsersStore().addNewUsers({ ...result, data: data.account })
+ data.account = useUsersStore().findUser(data.account.id)
+
+ chat.value = data
+ maxId.value = chat.value.lastMessage?.id
+ } catch (e) {
+ console.error('Error creating or getting a chat', e)
+ error.value = e
+ }
+
+ startFetching('Chat activated', true)
+ }
+ const deactivate = () => {
+ if (fetching.value) {
+ stopFetching('Chat deactivated')
+ }
+ clear()
+ detachSocket()
+ }
+
+ return {
+ activate,
+ deactivate,
+ sendMessage,
+ newMessagesCount,
+ markAsRead,
+ deleteChatMessage,
+ messages,
+ messagesIndex,
+ fetchError,
+ fetchOlder,
+ }
+}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Oct 10, 2:49 PM (1 d, 13 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1785279
Default Alt Text
(47 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline
Log In to Comment