logo

pleroma-fe

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

conversation.js (16963B)


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