logo

pleroma-fe

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

conversation.js (16814B)


  1. import { reduce, filter, findIndex, clone, get } from 'lodash'
  2. import Status from '../status/status.vue'
  3. import ThreadTree from '../thread_tree/thread_tree.vue'
  4. import { WSConnectionStatus } from '../../services/api/api.service.js'
  5. import { mapGetters, mapState } from 'vuex'
  6. import QuickFilterSettings from '../quick_filter_settings/quick_filter_settings.vue'
  7. import QuickViewSettings from '../quick_view_settings/quick_view_settings.vue'
  8. import { library } from '@fortawesome/fontawesome-svg-core'
  9. import {
  10. faAngleDoubleDown,
  11. faAngleDoubleLeft,
  12. faChevronLeft
  13. } from '@fortawesome/free-solid-svg-icons'
  14. library.add(
  15. faAngleDoubleDown,
  16. faAngleDoubleLeft,
  17. faChevronLeft
  18. )
  19. const sortById = (a, b) => {
  20. const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
  21. const idB = b.type === 'retweet' ? b.retweeted_status.id : b.id
  22. const seqA = Number(idA)
  23. const seqB = Number(idB)
  24. const isSeqA = !Number.isNaN(seqA)
  25. const isSeqB = !Number.isNaN(seqB)
  26. if (isSeqA && isSeqB) {
  27. return seqA < seqB ? -1 : 1
  28. } else if (isSeqA && !isSeqB) {
  29. return -1
  30. } else if (!isSeqA && isSeqB) {
  31. return 1
  32. } else {
  33. return idA < idB ? -1 : 1
  34. }
  35. }
  36. const sortAndFilterConversation = (conversation, statusoid) => {
  37. if (statusoid.type === 'retweet') {
  38. conversation = filter(
  39. conversation,
  40. (status) => (status.type === 'retweet' || status.id !== statusoid.retweeted_status.id)
  41. )
  42. } else {
  43. conversation = filter(conversation, (status) => status.type !== 'retweet')
  44. }
  45. return conversation.filter(_ => _).sort(sortById)
  46. }
  47. const conversation = {
  48. data () {
  49. return {
  50. highlight: null,
  51. expanded: false,
  52. threadDisplayStatusObject: {}, // id => 'showing' | 'hidden'
  53. statusContentPropertiesObject: {},
  54. inlineDivePosition: null
  55. }
  56. },
  57. props: [
  58. 'statusId',
  59. 'collapsable',
  60. 'isPage',
  61. 'pinnedStatusIdsObject',
  62. 'inProfile',
  63. 'profileUserId',
  64. 'virtualHidden'
  65. ],
  66. created () {
  67. if (this.isPage) {
  68. this.fetchConversation()
  69. }
  70. },
  71. computed: {
  72. maxDepthToShowByDefault () {
  73. // maxDepthInThread = max number of depths that is *visible*
  74. // since our depth starts with 0 and "showing" means "showing children"
  75. // there is a -2 here
  76. const maxDepth = this.$store.getters.mergedConfig.maxDepthInThread - 2
  77. return maxDepth >= 1 ? maxDepth : 1
  78. },
  79. streamingEnabled () {
  80. return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED
  81. },
  82. displayStyle () {
  83. return this.$store.getters.mergedConfig.conversationDisplay
  84. },
  85. isTreeView () {
  86. return !this.isLinearView
  87. },
  88. treeViewIsSimple () {
  89. return !this.$store.getters.mergedConfig.conversationTreeAdvanced
  90. },
  91. isLinearView () {
  92. return this.displayStyle === 'linear'
  93. },
  94. shouldFadeAncestors () {
  95. return this.$store.getters.mergedConfig.conversationTreeFadeAncestors
  96. },
  97. otherRepliesButtonPosition () {
  98. return this.$store.getters.mergedConfig.conversationOtherRepliesButton
  99. },
  100. showOtherRepliesButtonBelowStatus () {
  101. return this.otherRepliesButtonPosition === 'below'
  102. },
  103. showOtherRepliesButtonInsideStatus () {
  104. return this.otherRepliesButtonPosition === 'inside'
  105. },
  106. suspendable () {
  107. if (this.isTreeView) {
  108. return Object.entries(this.statusContentProperties)
  109. .every(([k, prop]) => !prop.replying && prop.mediaPlaying.length === 0)
  110. }
  111. if (this.$refs.statusComponent && this.$refs.statusComponent[0]) {
  112. return this.$refs.statusComponent.every(s => s.suspendable)
  113. } else {
  114. return true
  115. }
  116. },
  117. hideStatus () {
  118. return this.virtualHidden && this.suspendable
  119. },
  120. status () {
  121. return this.$store.state.statuses.allStatusesObject[this.statusId]
  122. },
  123. originalStatusId () {
  124. if (this.status.retweeted_status) {
  125. return this.status.retweeted_status.id
  126. } else {
  127. return this.statusId
  128. }
  129. },
  130. conversationId () {
  131. return this.getConversationId(this.statusId)
  132. },
  133. conversation () {
  134. if (!this.status) {
  135. return []
  136. }
  137. if (!this.isExpanded) {
  138. return [this.status]
  139. }
  140. const conversation = clone(this.$store.state.statuses.conversationsObject[this.conversationId])
  141. const statusIndex = findIndex(conversation, { id: this.originalStatusId })
  142. if (statusIndex !== -1) {
  143. conversation[statusIndex] = this.status
  144. }
  145. return sortAndFilterConversation(conversation, this.status)
  146. },
  147. statusMap () {
  148. return this.conversation.reduce((res, s) => {
  149. res[s.id] = s
  150. return res
  151. }, {})
  152. },
  153. threadTree () {
  154. const reverseLookupTable = this.conversation.reduce((table, status, index) => {
  155. table[status.id] = index
  156. return table
  157. }, {})
  158. const threads = this.conversation.reduce((a, cur) => {
  159. const id = cur.id
  160. a.forest[id] = this.getReplies(id)
  161. .map(s => s.id)
  162. return a
  163. }, {
  164. forest: {}
  165. })
  166. const walk = (forest, topLevel, depth = 0, processed = {}) => topLevel.map(id => {
  167. if (processed[id]) {
  168. return []
  169. }
  170. processed[id] = true
  171. return [{
  172. status: this.conversation[reverseLookupTable[id]],
  173. id,
  174. depth
  175. }, walk(forest, forest[id], depth + 1, processed)].reduce((a, b) => a.concat(b), [])
  176. }).reduce((a, b) => a.concat(b), [])
  177. const linearized = walk(threads.forest, this.topLevel.map(k => k.id))
  178. return linearized
  179. },
  180. replyIds () {
  181. return this.conversation.map(k => k.id)
  182. .reduce((res, id) => {
  183. res[id] = (this.replies[id] || []).map(k => k.id)
  184. return res
  185. }, {})
  186. },
  187. totalReplyCount () {
  188. const sizes = {}
  189. const subTreeSizeFor = (id) => {
  190. if (sizes[id]) {
  191. return sizes[id]
  192. }
  193. sizes[id] = 1 + this.replyIds[id].map(cid => subTreeSizeFor(cid)).reduce((a, b) => a + b, 0)
  194. return sizes[id]
  195. }
  196. this.conversation.map(k => k.id).map(subTreeSizeFor)
  197. return Object.keys(sizes).reduce((res, id) => {
  198. res[id] = sizes[id] - 1 // exclude itself
  199. return res
  200. }, {})
  201. },
  202. totalReplyDepth () {
  203. const depths = {}
  204. const subTreeDepthFor = (id) => {
  205. if (depths[id]) {
  206. return depths[id]
  207. }
  208. depths[id] = 1 + this.replyIds[id].map(cid => subTreeDepthFor(cid)).reduce((a, b) => a > b ? a : b, 0)
  209. return depths[id]
  210. }
  211. this.conversation.map(k => k.id).map(subTreeDepthFor)
  212. return Object.keys(depths).reduce((res, id) => {
  213. res[id] = depths[id] - 1 // exclude itself
  214. return res
  215. }, {})
  216. },
  217. depths () {
  218. return this.threadTree.reduce((a, k) => {
  219. a[k.id] = k.depth
  220. return a
  221. }, {})
  222. },
  223. topLevel () {
  224. const topLevel = this.conversation.reduce((tl, cur) =>
  225. tl.filter(k => this.getReplies(cur.id).map(v => v.id).indexOf(k.id) === -1), this.conversation)
  226. return topLevel
  227. },
  228. otherTopLevelCount () {
  229. return this.topLevel.length - 1
  230. },
  231. showingTopLevel () {
  232. if (this.canDive && this.diveRoot) {
  233. return [this.statusMap[this.diveRoot]]
  234. }
  235. return this.topLevel
  236. },
  237. diveRoot () {
  238. const statusId = this.inlineDivePosition || this.statusId
  239. const isTopLevel = !this.parentOf(statusId)
  240. return isTopLevel ? null : statusId
  241. },
  242. diveDepth () {
  243. return this.canDive && this.diveRoot ? this.depths[this.diveRoot] : 0
  244. },
  245. diveMode () {
  246. return this.canDive && !!this.diveRoot
  247. },
  248. shouldShowAllConversationButton () {
  249. // The "show all conversation" button tells the user that there exist
  250. // other toplevel statuses, so do not show it if there is only a single root
  251. return this.isTreeView && this.isExpanded && this.diveMode && this.topLevel.length > 1
  252. },
  253. shouldShowAncestors () {
  254. return this.isTreeView && this.isExpanded && this.ancestorsOf(this.diveRoot).length
  255. },
  256. replies () {
  257. let i = 1
  258. // eslint-disable-next-line camelcase
  259. return reduce(this.conversation, (result, { id, in_reply_to_status_id }) => {
  260. /* eslint-disable camelcase */
  261. const irid = in_reply_to_status_id
  262. /* eslint-enable camelcase */
  263. if (irid) {
  264. result[irid] = result[irid] || []
  265. result[irid].push({
  266. name: `#${i}`,
  267. id
  268. })
  269. }
  270. i++
  271. return result
  272. }, {})
  273. },
  274. isExpanded () {
  275. return !!(this.expanded || this.isPage)
  276. },
  277. hiddenStyle () {
  278. const height = (this.status && this.status.virtualHeight) || '120px'
  279. return this.virtualHidden ? { height } : {}
  280. },
  281. threadDisplayStatus () {
  282. return this.conversation.reduce((a, k) => {
  283. const id = k.id
  284. const depth = this.depths[id]
  285. const status = (() => {
  286. if (this.threadDisplayStatusObject[id]) {
  287. return this.threadDisplayStatusObject[id]
  288. }
  289. if ((depth - this.diveDepth) <= this.maxDepthToShowByDefault) {
  290. return 'showing'
  291. } else {
  292. return 'hidden'
  293. }
  294. })()
  295. a[id] = status
  296. return a
  297. }, {})
  298. },
  299. statusContentProperties () {
  300. return this.conversation.reduce((a, k) => {
  301. const id = k.id
  302. const props = (() => {
  303. const def = {
  304. showingTall: false,
  305. expandingSubject: false,
  306. showingLongSubject: false,
  307. isReplying: false,
  308. mediaPlaying: []
  309. }
  310. if (this.statusContentPropertiesObject[id]) {
  311. return {
  312. ...def,
  313. ...this.statusContentPropertiesObject[id]
  314. }
  315. }
  316. return def
  317. })()
  318. a[id] = props
  319. return a
  320. }, {})
  321. },
  322. canDive () {
  323. return this.isTreeView && this.isExpanded
  324. },
  325. focused () {
  326. return (id) => {
  327. return (this.isExpanded) && id === this.highlight
  328. }
  329. },
  330. maybeHighlight () {
  331. return this.isExpanded ? this.highlight : null
  332. },
  333. ...mapGetters(['mergedConfig']),
  334. ...mapState({
  335. mastoUserSocketStatus: state => state.api.mastoUserSocketStatus
  336. })
  337. },
  338. components: {
  339. Status,
  340. ThreadTree,
  341. QuickFilterSettings,
  342. QuickViewSettings
  343. },
  344. watch: {
  345. statusId (newVal, oldVal) {
  346. const newConversationId = this.getConversationId(newVal)
  347. const oldConversationId = this.getConversationId(oldVal)
  348. if (newConversationId && oldConversationId && newConversationId === oldConversationId) {
  349. this.setHighlight(this.originalStatusId)
  350. } else {
  351. this.fetchConversation()
  352. }
  353. },
  354. expanded (value) {
  355. if (value) {
  356. this.fetchConversation()
  357. } else {
  358. this.resetDisplayState()
  359. }
  360. },
  361. virtualHidden (value) {
  362. this.$store.dispatch(
  363. 'setVirtualHeight',
  364. { statusId: this.statusId, height: `${this.$el.clientHeight}px` }
  365. )
  366. }
  367. },
  368. methods: {
  369. fetchConversation () {
  370. if (this.status) {
  371. this.$store.state.api.backendInteractor.fetchConversation({ id: this.statusId })
  372. .then(({ ancestors, descendants }) => {
  373. this.$store.dispatch('addNewStatuses', { statuses: ancestors })
  374. this.$store.dispatch('addNewStatuses', { statuses: descendants })
  375. this.setHighlight(this.originalStatusId)
  376. })
  377. } else {
  378. this.$store.state.api.backendInteractor.fetchStatus({ id: this.statusId })
  379. .then((status) => {
  380. this.$store.dispatch('addNewStatuses', { statuses: [status] })
  381. this.fetchConversation()
  382. })
  383. }
  384. },
  385. getReplies (id) {
  386. return this.replies[id] || []
  387. },
  388. getHighlight () {
  389. return this.isExpanded ? this.highlight : null
  390. },
  391. setHighlight (id) {
  392. if (!id) return
  393. this.highlight = id
  394. if (!this.streamingEnabled) {
  395. this.$store.dispatch('fetchStatus', id)
  396. }
  397. this.$store.dispatch('fetchFavsAndRepeats', id)
  398. this.$store.dispatch('fetchEmojiReactionsBy', id)
  399. },
  400. toggleExpanded () {
  401. this.expanded = !this.expanded
  402. },
  403. getConversationId (statusId) {
  404. const status = this.$store.state.statuses.allStatusesObject[statusId]
  405. return get(status, 'retweeted_status.statusnet_conversation_id', get(status, 'statusnet_conversation_id'))
  406. },
  407. setThreadDisplay (id, nextStatus) {
  408. this.threadDisplayStatusObject = {
  409. ...this.threadDisplayStatusObject,
  410. [id]: nextStatus
  411. }
  412. },
  413. toggleThreadDisplay (id) {
  414. const curStatus = this.threadDisplayStatus[id]
  415. const nextStatus = curStatus === 'showing' ? 'hidden' : 'showing'
  416. this.setThreadDisplay(id, nextStatus)
  417. },
  418. setThreadDisplayRecursively (id, nextStatus) {
  419. this.setThreadDisplay(id, nextStatus)
  420. this.getReplies(id).map(k => k.id).map(id => this.setThreadDisplayRecursively(id, nextStatus))
  421. },
  422. showThreadRecursively (id) {
  423. this.setThreadDisplayRecursively(id, 'showing')
  424. },
  425. setStatusContentProperty (id, name, value) {
  426. this.statusContentPropertiesObject = {
  427. ...this.statusContentPropertiesObject,
  428. [id]: {
  429. ...this.statusContentPropertiesObject[id],
  430. [name]: value
  431. }
  432. }
  433. },
  434. toggleStatusContentProperty (id, name) {
  435. this.setStatusContentProperty(id, name, !this.statusContentProperties[id][name])
  436. },
  437. leastVisibleAncestor (id) {
  438. let cur = id
  439. let parent = this.parentOf(cur)
  440. while (cur) {
  441. // if the parent is showing it means cur is visible
  442. if (this.threadDisplayStatus[parent] === 'showing') {
  443. return cur
  444. }
  445. parent = this.parentOf(parent)
  446. cur = this.parentOf(cur)
  447. }
  448. // nothing found, fall back to toplevel
  449. return this.topLevel[0] ? this.topLevel[0].id : undefined
  450. },
  451. diveIntoStatus (id, preventScroll) {
  452. this.tryScrollTo(id)
  453. },
  454. diveToTopLevel () {
  455. this.tryScrollTo(this.topLevelAncestorOrSelfId(this.diveRoot) || this.topLevel[0].id)
  456. },
  457. // only used when we are not on a page
  458. undive () {
  459. this.inlineDivePosition = null
  460. this.setHighlight(this.statusId)
  461. },
  462. tryScrollTo (id) {
  463. if (!id) {
  464. return
  465. }
  466. if (this.isPage) {
  467. // set statusId
  468. this.$router.push({ name: 'conversation', params: { id } })
  469. } else {
  470. this.inlineDivePosition = id
  471. }
  472. // Because the conversation can be unmounted when out of sight
  473. // and mounted again when it comes into sight,
  474. // the `mounted` or `created` function in `status` should not
  475. // contain scrolling calls, as we do not want the page to jump
  476. // when we scroll with an expanded conversation.
  477. //
  478. // Now the method is to rely solely on the `highlight` watcher
  479. // in `status` components.
  480. // In linear views, all statuses are rendered at all times, but
  481. // in tree views, it is possible that a change in active status
  482. // removes and adds status components (e.g. an originally child
  483. // status becomes an ancestor status, and thus they will be
  484. // different).
  485. // Here, let the components be rendered first, in order to trigger
  486. // the `highlight` watcher.
  487. this.$nextTick(() => {
  488. this.setHighlight(id)
  489. })
  490. },
  491. goToCurrent () {
  492. this.tryScrollTo(this.diveRoot || this.topLevel[0].id)
  493. },
  494. statusById (id) {
  495. return this.statusMap[id]
  496. },
  497. parentOf (id) {
  498. const status = this.statusById(id)
  499. if (!status) {
  500. return undefined
  501. }
  502. const { in_reply_to_status_id: parentId } = status
  503. if (!this.statusMap[parentId]) {
  504. return undefined
  505. }
  506. return parentId
  507. },
  508. parentOrSelf (id) {
  509. return this.parentOf(id) || id
  510. },
  511. // Ancestors of some status, from top to bottom
  512. ancestorsOf (id) {
  513. const ancestors = []
  514. let cur = this.parentOf(id)
  515. while (cur) {
  516. ancestors.unshift(this.statusMap[cur])
  517. cur = this.parentOf(cur)
  518. }
  519. return ancestors
  520. },
  521. topLevelAncestorOrSelfId (id) {
  522. let cur = id
  523. let parent = this.parentOf(id)
  524. while (parent) {
  525. cur = this.parentOf(cur)
  526. parent = this.parentOf(parent)
  527. }
  528. return cur
  529. },
  530. resetDisplayState () {
  531. this.undive()
  532. this.threadDisplayStatusObject = {}
  533. }
  534. }
  535. }
  536. export default conversation