logo

pleroma-fe

My custom branche(s) on git.pleroma.social/pleroma/pleroma-fe git clone https://anongit.hacktivis.me/git/pleroma-fe.git/

chat.js (12098B)


  1. import _ from 'lodash'
  2. import { WSConnectionStatus } from '../../services/api/api.service.js'
  3. import { mapGetters, mapState } from 'vuex'
  4. import { mapState as mapPiniaState } from 'pinia'
  5. import ChatMessage from '../chat_message/chat_message.vue'
  6. import PostStatusForm from '../post_status_form/post_status_form.vue'
  7. import ChatTitle from '../chat_title/chat_title.vue'
  8. import chatService from '../../services/chat_service/chat_service.js'
  9. import { promiseInterval } from '../../services/promise_interval/promise_interval.js'
  10. import { getScrollPosition, getNewTopPosition, isBottomedOut, isScrollable } from './chat_layout_utils.js'
  11. import { library } from '@fortawesome/fontawesome-svg-core'
  12. import {
  13. faChevronDown,
  14. faChevronLeft
  15. } from '@fortawesome/free-solid-svg-icons'
  16. import { buildFakeMessage } from '../../services/chat_utils/chat_utils.js'
  17. import { useInterfaceStore } from 'src/stores/interface.js'
  18. library.add(
  19. faChevronDown,
  20. faChevronLeft
  21. )
  22. const BOTTOMED_OUT_OFFSET = 10
  23. const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 10
  24. const SAFE_RESIZE_TIME_OFFSET = 100
  25. const MARK_AS_READ_DELAY = 1500
  26. const MAX_RETRIES = 10
  27. const Chat = {
  28. components: {
  29. ChatMessage,
  30. ChatTitle,
  31. PostStatusForm
  32. },
  33. data () {
  34. return {
  35. jumpToBottomButtonVisible: false,
  36. hoveredMessageChainId: undefined,
  37. lastScrollPosition: {},
  38. scrollableContainerHeight: '100%',
  39. errorLoadingChat: false,
  40. messageRetriers: {}
  41. }
  42. },
  43. created () {
  44. this.startFetching()
  45. window.addEventListener('resize', this.handleResize)
  46. },
  47. mounted () {
  48. window.addEventListener('scroll', this.handleScroll)
  49. if (typeof document.hidden !== 'undefined') {
  50. document.addEventListener('visibilitychange', this.handleVisibilityChange, false)
  51. }
  52. this.$nextTick(() => {
  53. this.handleResize()
  54. })
  55. },
  56. unmounted () {
  57. window.removeEventListener('scroll', this.handleScroll)
  58. window.removeEventListener('resize', this.handleResize)
  59. if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false)
  60. this.$store.dispatch('clearCurrentChat')
  61. },
  62. computed: {
  63. recipient () {
  64. return this.currentChat && this.currentChat.account
  65. },
  66. recipientId () {
  67. return this.$route.params.recipient_id
  68. },
  69. formPlaceholder () {
  70. if (this.recipient) {
  71. return this.$t('chats.message_user', { nickname: this.recipient.screen_name_ui })
  72. } else {
  73. return ''
  74. }
  75. },
  76. chatViewItems () {
  77. return chatService.getView(this.currentChatMessageService)
  78. },
  79. newMessageCount () {
  80. return this.currentChatMessageService && this.currentChatMessageService.newMessageCount
  81. },
  82. streamingEnabled () {
  83. return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED
  84. },
  85. ...mapGetters([
  86. 'currentChat',
  87. 'currentChatMessageService',
  88. 'findOpenedChatByRecipientId',
  89. 'mergedConfig'
  90. ]),
  91. ...mapPiniaState(useInterfaceStore, {
  92. mobileLayout: store => store.layoutType === 'mobile'
  93. }),
  94. ...mapState({
  95. backendInteractor: state => state.api.backendInteractor,
  96. mastoUserSocketStatus: state => state.api.mastoUserSocketStatus,
  97. currentUser: state => state.users.currentUser
  98. })
  99. },
  100. watch: {
  101. chatViewItems () {
  102. // We don't want to scroll to the bottom on a new message when the user is viewing older messages.
  103. // Therefore we need to know whether the scroll position was at the bottom before the DOM update.
  104. const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET)
  105. this.$nextTick(() => {
  106. if (bottomedOutBeforeUpdate) {
  107. this.scrollDown()
  108. }
  109. })
  110. },
  111. $route: function () {
  112. this.startFetching()
  113. },
  114. mastoUserSocketStatus (newValue) {
  115. if (newValue === WSConnectionStatus.JOINED) {
  116. this.fetchChat({ isFirstFetch: true })
  117. }
  118. }
  119. },
  120. methods: {
  121. // Used to animate the avatar near the first message of the message chain when any message belonging to the chain is hovered
  122. onMessageHover ({ isHovered, messageChainId }) {
  123. this.hoveredMessageChainId = isHovered ? messageChainId : undefined
  124. },
  125. onFilesDropped () {
  126. this.$nextTick(() => {
  127. this.handleResize()
  128. })
  129. },
  130. handleVisibilityChange () {
  131. this.$nextTick(() => {
  132. if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) {
  133. this.scrollDown({ forceRead: true })
  134. }
  135. })
  136. },
  137. // "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport
  138. handleResize (opts = {}) {
  139. const { delayed = false } = opts
  140. if (delayed) {
  141. setTimeout(() => {
  142. this.handleResize({ ...opts, delayed: false })
  143. }, SAFE_RESIZE_TIME_OFFSET)
  144. return
  145. }
  146. this.$nextTick(() => {
  147. const { offsetHeight = undefined } = getScrollPosition()
  148. const diff = offsetHeight - this.lastScrollPosition.offsetHeight
  149. if (diff !== 0 && !this.bottomedOut()) {
  150. this.$nextTick(() => {
  151. window.scrollBy({ top: -Math.trunc(diff) })
  152. })
  153. }
  154. this.lastScrollPosition = getScrollPosition()
  155. })
  156. },
  157. scrollDown (options = {}) {
  158. const { behavior = 'auto', forceRead = false } = options
  159. this.$nextTick(() => {
  160. window.scrollTo({ top: document.documentElement.scrollHeight, behavior })
  161. })
  162. if (forceRead) {
  163. this.readChat()
  164. }
  165. },
  166. readChat () {
  167. if (!(this.currentChatMessageService && this.currentChatMessageService.maxId)) { return }
  168. if (document.hidden) { return }
  169. const lastReadId = this.currentChatMessageService.maxId
  170. this.$store.dispatch('readChat', {
  171. id: this.currentChat.id,
  172. lastReadId
  173. })
  174. },
  175. bottomedOut (offset) {
  176. return isBottomedOut(offset)
  177. },
  178. reachedTop () {
  179. return window.scrollY <= 0
  180. },
  181. cullOlderCheck () {
  182. window.setTimeout(() => {
  183. if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
  184. this.$store.dispatch('cullOlderMessages', this.currentChatMessageService.chatId)
  185. }
  186. }, 5000)
  187. },
  188. handleScroll: _.throttle(function () {
  189. this.lastScrollPosition = getScrollPosition()
  190. if (!this.currentChat) { return }
  191. if (this.reachedTop()) {
  192. this.fetchChat({ maxId: this.currentChatMessageService.minId })
  193. } else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
  194. this.jumpToBottomButtonVisible = false
  195. this.cullOlderCheck()
  196. if (this.newMessageCount > 0) {
  197. // Use a delay before marking as read to prevent situation where new messages
  198. // arrive just as you're leaving the view and messages that you didn't actually
  199. // get to see get marked as read.
  200. window.setTimeout(() => {
  201. // Don't mark as read if the element doesn't exist, user has left chat view
  202. if (this.$el) this.readChat()
  203. }, MARK_AS_READ_DELAY)
  204. }
  205. } else {
  206. this.jumpToBottomButtonVisible = true
  207. }
  208. }, 200),
  209. handleScrollUp (positionBeforeLoading) {
  210. const positionAfterLoading = getScrollPosition()
  211. window.scrollTo({
  212. top: getNewTopPosition(positionBeforeLoading, positionAfterLoading)
  213. })
  214. },
  215. fetchChat ({ isFirstFetch = false, fetchLatest = false, maxId }) {
  216. const chatMessageService = this.currentChatMessageService
  217. if (!chatMessageService) { return }
  218. if (fetchLatest && this.streamingEnabled) { return }
  219. const chatId = chatMessageService.chatId
  220. const fetchOlderMessages = !!maxId
  221. const sinceId = fetchLatest && chatMessageService.maxId
  222. return this.backendInteractor.chatMessages({ id: chatId, maxId, sinceId })
  223. .then((messages) => {
  224. // Clear the current chat in case we're recovering from a ws connection loss.
  225. if (isFirstFetch) {
  226. chatService.clear(chatMessageService)
  227. }
  228. const positionBeforeUpdate = getScrollPosition()
  229. this.$store.dispatch('addChatMessages', { chatId, messages }).then(() => {
  230. this.$nextTick(() => {
  231. if (fetchOlderMessages) {
  232. this.handleScrollUp(positionBeforeUpdate)
  233. }
  234. // In vertical screens, the first batch of fetched messages may not always take the
  235. // full height of the scrollable container.
  236. // If this is the case, we want to fetch the messages until the scrollable container
  237. // is fully populated so that the user has the ability to scroll up and load the history.
  238. if (!isScrollable() && messages.length > 0) {
  239. this.fetchChat({ maxId: this.currentChatMessageService.minId })
  240. }
  241. })
  242. })
  243. })
  244. },
  245. async startFetching () {
  246. let chat = this.findOpenedChatByRecipientId(this.recipientId)
  247. if (!chat) {
  248. try {
  249. chat = await this.backendInteractor.getOrCreateChat({ accountId: this.recipientId })
  250. } catch (e) {
  251. console.error('Error creating or getting a chat', e)
  252. this.errorLoadingChat = true
  253. }
  254. }
  255. if (chat) {
  256. this.$nextTick(() => {
  257. this.scrollDown({ forceRead: true })
  258. })
  259. this.$store.dispatch('addOpenedChat', { chat })
  260. this.doStartFetching()
  261. }
  262. },
  263. doStartFetching () {
  264. this.$store.dispatch('startFetchingCurrentChat', {
  265. fetcher: () => promiseInterval(() => this.fetchChat({ fetchLatest: true }), 5000)
  266. })
  267. this.fetchChat({ isFirstFetch: true })
  268. },
  269. handleAttachmentPosting () {
  270. this.$nextTick(() => {
  271. this.handleResize()
  272. // When the posting form size changes because of a media attachment, we need an extra resize
  273. // to account for the potential delay in the DOM update.
  274. this.scrollDown({ forceRead: true })
  275. })
  276. },
  277. sendMessage ({ status, media, idempotencyKey }) {
  278. const params = {
  279. id: this.currentChat.id,
  280. content: status,
  281. idempotencyKey
  282. }
  283. if (media[0]) {
  284. params.mediaId = media[0].id
  285. }
  286. const fakeMessage = buildFakeMessage({
  287. attachments: media,
  288. chatId: this.currentChat.id,
  289. content: status,
  290. userId: this.currentUser.id,
  291. idempotencyKey
  292. })
  293. this.$store.dispatch('addChatMessages', {
  294. chatId: this.currentChat.id,
  295. messages: [fakeMessage]
  296. }).then(() => {
  297. this.handleAttachmentPosting()
  298. })
  299. return this.doSendMessage({ params, fakeMessage, retriesLeft: MAX_RETRIES })
  300. },
  301. doSendMessage ({ params, fakeMessage, retriesLeft = MAX_RETRIES }) {
  302. if (retriesLeft <= 0) return
  303. this.backendInteractor.sendChatMessage(params)
  304. .then(data => {
  305. this.$store.dispatch('addChatMessages', {
  306. chatId: this.currentChat.id,
  307. updateMaxId: false,
  308. messages: [{ ...data, fakeId: fakeMessage.id }]
  309. })
  310. return data
  311. })
  312. .catch(error => {
  313. console.error('Error sending message', error)
  314. this.$store.dispatch('handleMessageError', {
  315. chatId: this.currentChat.id,
  316. fakeId: fakeMessage.id,
  317. isRetry: retriesLeft !== MAX_RETRIES
  318. })
  319. if ((error.statusCode >= 500 && error.statusCode < 600) || error.message === 'Failed to fetch') {
  320. this.messageRetriers[fakeMessage.id] = setTimeout(() => {
  321. this.doSendMessage({ params, fakeMessage, retriesLeft: retriesLeft - 1 })
  322. }, 1000 * (2 ** (MAX_RETRIES - retriesLeft)))
  323. }
  324. return {}
  325. })
  326. return Promise.resolve(fakeMessage)
  327. },
  328. goBack () {
  329. this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } })
  330. }
  331. }
  332. }
  333. export default Chat