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 (17001B)


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