Page MenuHomePhorge

No OneTemporary

Size
47 KB
Referenced Files
None
Subscribers
None
diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
index 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

Mime Type
text/x-diff
Expires
Sat, Oct 10, 2:49 PM (1 d, 12 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1785279
Default Alt Text
(47 KB)

Event Timeline