logo

pleroma-fe

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

chat.js (11949B)


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