logo

pleroma-fe

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

post_status_form.js (21591B)


  1. import statusPoster from '../../services/status_poster/status_poster.service.js'
  2. import genRandomSeed from '../../services/random_seed/random_seed.service.js'
  3. import MediaUpload from '../media_upload/media_upload.vue'
  4. import ScopeSelector from '../scope_selector/scope_selector.vue'
  5. import EmojiInput from '../emoji_input/emoji_input.vue'
  6. import PollForm from '../poll/poll_form.vue'
  7. import Attachment from '../attachment/attachment.vue'
  8. import Gallery from 'src/components/gallery/gallery.vue'
  9. import StatusContent from '../status_content/status_content.vue'
  10. import fileTypeService from '../../services/file_type/file_type.service.js'
  11. import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
  12. import { propsToNative } from '../../services/attributes_helper/attributes_helper.service.js'
  13. import { reject, map, uniqBy, debounce } from 'lodash'
  14. import suggestor from '../emoji_input/suggestor.js'
  15. import { mapGetters, mapState } from 'vuex'
  16. import Checkbox from '../checkbox/checkbox.vue'
  17. import Select from '../select/select.vue'
  18. import { library } from '@fortawesome/fontawesome-svg-core'
  19. import {
  20. faSmileBeam,
  21. faPollH,
  22. faUpload,
  23. faBan,
  24. faTimes,
  25. faCircleNotch
  26. } from '@fortawesome/free-solid-svg-icons'
  27. library.add(
  28. faSmileBeam,
  29. faPollH,
  30. faUpload,
  31. faBan,
  32. faTimes,
  33. faCircleNotch
  34. )
  35. const buildMentionsString = ({ user, attentions = [] }, currentUser) => {
  36. let allAttentions = [...attentions]
  37. allAttentions.unshift(user)
  38. allAttentions = uniqBy(allAttentions, 'id')
  39. allAttentions = reject(allAttentions, { id: currentUser.id })
  40. const mentions = map(allAttentions, (attention) => {
  41. return `@${attention.screen_name}`
  42. })
  43. return mentions.length > 0 ? mentions.join(' ') + ' ' : ''
  44. }
  45. // Converts a string with px to a number like '2px' -> 2
  46. const pxStringToNumber = (str) => {
  47. return Number(str.substring(0, str.length - 2))
  48. }
  49. const PostStatusForm = {
  50. props: [
  51. 'statusId',
  52. 'statusText',
  53. 'statusIsSensitive',
  54. 'statusPoll',
  55. 'statusFiles',
  56. 'statusMediaDescriptions',
  57. 'statusScope',
  58. 'statusContentType',
  59. 'replyTo',
  60. 'repliedUser',
  61. 'attentions',
  62. 'copyMessageScope',
  63. 'subject',
  64. 'disableSubject',
  65. 'disableScopeSelector',
  66. 'disableVisibilitySelector',
  67. 'disableNotice',
  68. 'disableLockWarning',
  69. 'disablePolls',
  70. 'disableSensitivityCheckbox',
  71. 'disableSubmit',
  72. 'disablePreview',
  73. 'placeholder',
  74. 'maxHeight',
  75. 'postHandler',
  76. 'preserveFocus',
  77. 'autoFocus',
  78. 'fileLimit',
  79. 'submitOnEnter',
  80. 'emojiPickerPlacement',
  81. 'optimisticPosting'
  82. ],
  83. emits: [
  84. 'posted',
  85. 'resize',
  86. 'mediaplay',
  87. 'mediapause'
  88. ],
  89. components: {
  90. MediaUpload,
  91. EmojiInput,
  92. PollForm,
  93. ScopeSelector,
  94. Checkbox,
  95. Select,
  96. Attachment,
  97. StatusContent,
  98. Gallery
  99. },
  100. mounted () {
  101. this.updateIdempotencyKey()
  102. this.resize(this.$refs.textarea)
  103. if (this.replyTo) {
  104. const textLength = this.$refs.textarea.value.length
  105. this.$refs.textarea.setSelectionRange(textLength, textLength)
  106. }
  107. if (this.replyTo || this.autoFocus) {
  108. this.$refs.textarea.focus()
  109. }
  110. },
  111. data () {
  112. const preset = this.$route.query.message
  113. let statusText = preset || ''
  114. const { scopeCopy } = this.$store.getters.mergedConfig
  115. if (this.replyTo) {
  116. const currentUser = this.$store.state.users.currentUser
  117. statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser)
  118. }
  119. const scope = ((this.copyMessageScope && scopeCopy) || this.copyMessageScope === 'direct')
  120. ? this.copyMessageScope
  121. : this.$store.state.users.currentUser.default_scope
  122. const { postContentType: contentType, sensitiveByDefault } = this.$store.getters.mergedConfig
  123. let statusParams = {
  124. spoilerText: this.subject || '',
  125. status: statusText,
  126. nsfw: !!sensitiveByDefault,
  127. files: [],
  128. poll: {},
  129. mediaDescriptions: {},
  130. visibility: scope,
  131. contentType
  132. }
  133. if (this.statusId) {
  134. const statusContentType = this.statusContentType || contentType
  135. statusParams = {
  136. spoilerText: this.subject || '',
  137. status: this.statusText || '',
  138. nsfw: this.statusIsSensitive || !!sensitiveByDefault,
  139. files: this.statusFiles || [],
  140. poll: this.statusPoll || {},
  141. mediaDescriptions: this.statusMediaDescriptions || {},
  142. visibility: this.statusScope || scope,
  143. contentType: statusContentType,
  144. quoting: false
  145. }
  146. }
  147. return {
  148. randomSeed: genRandomSeed(),
  149. dropFiles: [],
  150. uploadingFiles: false,
  151. error: null,
  152. posting: false,
  153. highlighted: 0,
  154. newStatus: statusParams,
  155. caret: 0,
  156. pollFormVisible: false,
  157. showDropIcon: 'hide',
  158. dropStopTimeout: null,
  159. preview: null,
  160. previewLoading: false,
  161. emojiInputShown: false,
  162. idempotencyKey: ''
  163. }
  164. },
  165. computed: {
  166. users () {
  167. return this.$store.state.users.users
  168. },
  169. userDefaultScope () {
  170. return this.$store.state.users.currentUser.default_scope
  171. },
  172. showAllScopes () {
  173. return !this.mergedConfig.minimalScopesMode
  174. },
  175. emojiUserSuggestor () {
  176. return suggestor({
  177. emoji: [
  178. ...this.$store.getters.standardEmojiList,
  179. ...this.$store.state.instance.customEmoji
  180. ],
  181. store: this.$store
  182. })
  183. },
  184. emojiSuggestor () {
  185. return suggestor({
  186. emoji: [
  187. ...this.$store.getters.standardEmojiList,
  188. ...this.$store.state.instance.customEmoji
  189. ]
  190. })
  191. },
  192. emoji () {
  193. return this.$store.getters.standardEmojiList || []
  194. },
  195. customEmoji () {
  196. return this.$store.state.instance.customEmoji || []
  197. },
  198. statusLength () {
  199. return this.newStatus.status.length
  200. },
  201. spoilerTextLength () {
  202. return this.newStatus.spoilerText.length
  203. },
  204. statusLengthLimit () {
  205. return this.$store.state.instance.textlimit
  206. },
  207. hasStatusLengthLimit () {
  208. return this.statusLengthLimit > 0
  209. },
  210. charactersLeft () {
  211. return this.statusLengthLimit - (this.statusLength + this.spoilerTextLength)
  212. },
  213. isOverLengthLimit () {
  214. return this.hasStatusLengthLimit && (this.charactersLeft < 0)
  215. },
  216. minimalScopesMode () {
  217. return this.$store.state.instance.minimalScopesMode
  218. },
  219. alwaysShowSubject () {
  220. return this.mergedConfig.alwaysShowSubjectInput
  221. },
  222. postFormats () {
  223. return this.$store.state.instance.postFormats || []
  224. },
  225. safeDMEnabled () {
  226. return this.$store.state.instance.safeDM
  227. },
  228. pollsAvailable () {
  229. return this.$store.state.instance.pollsAvailable &&
  230. this.$store.state.instance.pollLimits.max_options >= 2 &&
  231. this.disablePolls !== true
  232. },
  233. hideScopeNotice () {
  234. return this.disableNotice || this.$store.getters.mergedConfig.hideScopeNotice
  235. },
  236. pollContentError () {
  237. return this.pollFormVisible &&
  238. this.newStatus.poll &&
  239. this.newStatus.poll.error
  240. },
  241. showPreview () {
  242. return !this.disablePreview && (!!this.preview || this.previewLoading)
  243. },
  244. emptyStatus () {
  245. return this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
  246. },
  247. uploadFileLimitReached () {
  248. return this.newStatus.files.length >= this.fileLimit
  249. },
  250. isEdit () {
  251. return typeof this.statusId !== 'undefined' && this.statusId.trim() !== ''
  252. },
  253. quotable () {
  254. if (!this.$store.state.instance.quotingAvailable) {
  255. return false
  256. }
  257. if (!this.replyTo) {
  258. return false
  259. }
  260. const repliedStatus = this.$store.state.statuses.allStatusesObject[this.replyTo]
  261. if (!repliedStatus) {
  262. return false
  263. }
  264. if (repliedStatus.visibility === 'public' ||
  265. repliedStatus.visibility === 'unlisted' ||
  266. repliedStatus.visibility === 'local') {
  267. return true
  268. } else if (repliedStatus.visibility === 'private') {
  269. return repliedStatus.user.id === this.$store.state.users.currentUser.id
  270. }
  271. return false
  272. },
  273. ...mapGetters(['mergedConfig']),
  274. ...mapState({
  275. mobileLayout: state => state.interface.mobileLayout
  276. })
  277. },
  278. watch: {
  279. newStatus: {
  280. deep: true,
  281. handler () {
  282. this.statusChanged()
  283. }
  284. }
  285. },
  286. methods: {
  287. statusChanged () {
  288. this.autoPreview()
  289. this.updateIdempotencyKey()
  290. },
  291. clearStatus () {
  292. const newStatus = this.newStatus
  293. this.newStatus = {
  294. status: '',
  295. spoilerText: '',
  296. files: [],
  297. visibility: newStatus.visibility,
  298. contentType: newStatus.contentType,
  299. poll: {},
  300. mediaDescriptions: {},
  301. quoting: false
  302. }
  303. this.pollFormVisible = false
  304. this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
  305. this.clearPollForm()
  306. if (this.preserveFocus) {
  307. this.$nextTick(() => {
  308. this.$refs.textarea.focus()
  309. })
  310. }
  311. const el = this.$el.querySelector('textarea')
  312. el.style.height = 'auto'
  313. el.style.height = undefined
  314. this.error = null
  315. if (this.preview) this.previewStatus()
  316. },
  317. async postStatus (event, newStatus, opts = {}) {
  318. if (this.posting && !this.optimisticPosting) { return }
  319. if (this.disableSubmit) { return }
  320. if (this.emojiInputShown) { return }
  321. if (this.submitOnEnter) {
  322. event.stopPropagation()
  323. event.preventDefault()
  324. }
  325. if (this.optimisticPosting && (this.emptyStatus || this.isOverLengthLimit)) { return }
  326. if (this.emptyStatus) {
  327. this.error = this.$t('post_status.empty_status_error')
  328. return
  329. }
  330. const poll = this.pollFormVisible ? this.newStatus.poll : {}
  331. if (this.pollContentError) {
  332. this.error = this.pollContentError
  333. return
  334. }
  335. this.posting = true
  336. try {
  337. await this.setAllMediaDescriptions()
  338. } catch (e) {
  339. this.error = this.$t('post_status.media_description_error')
  340. this.posting = false
  341. return
  342. }
  343. const replyOrQuoteAttr = newStatus.quoting ? 'quoteId' : 'inReplyToStatusId'
  344. const postingOptions = {
  345. status: newStatus.status,
  346. spoilerText: newStatus.spoilerText || null,
  347. visibility: newStatus.visibility,
  348. sensitive: newStatus.nsfw,
  349. media: newStatus.files,
  350. store: this.$store,
  351. [replyOrQuoteAttr]: this.replyTo,
  352. contentType: newStatus.contentType,
  353. poll,
  354. idempotencyKey: this.idempotencyKey
  355. }
  356. const postHandler = this.postHandler ? this.postHandler : statusPoster.postStatus
  357. postHandler(postingOptions).then((data) => {
  358. if (!data.error) {
  359. this.clearStatus()
  360. this.$emit('posted', data)
  361. } else {
  362. this.error = data.error
  363. }
  364. this.posting = false
  365. })
  366. },
  367. previewStatus () {
  368. if (this.emptyStatus && this.newStatus.spoilerText.trim() === '') {
  369. this.preview = { error: this.$t('post_status.preview_empty') }
  370. this.previewLoading = false
  371. return
  372. }
  373. const newStatus = this.newStatus
  374. this.previewLoading = true
  375. const replyOrQuoteAttr = newStatus.quoting ? 'quoteId' : 'inReplyToStatusId'
  376. statusPoster.postStatus({
  377. status: newStatus.status,
  378. spoilerText: newStatus.spoilerText || null,
  379. visibility: newStatus.visibility,
  380. sensitive: newStatus.nsfw,
  381. media: [],
  382. store: this.$store,
  383. [replyOrQuoteAttr]: this.replyTo,
  384. contentType: newStatus.contentType,
  385. poll: {},
  386. preview: true
  387. }).then((data) => {
  388. // Don't apply preview if not loading, because it means
  389. // user has closed the preview manually.
  390. if (!this.previewLoading) return
  391. if (!data.error) {
  392. this.preview = data
  393. } else {
  394. this.preview = { error: data.error }
  395. }
  396. }).catch((error) => {
  397. this.preview = { error }
  398. }).finally(() => {
  399. this.previewLoading = false
  400. })
  401. },
  402. debouncePreviewStatus: debounce(function () { this.previewStatus() }, 500),
  403. autoPreview () {
  404. if (!this.preview) return
  405. this.previewLoading = true
  406. this.debouncePreviewStatus()
  407. },
  408. closePreview () {
  409. this.preview = null
  410. this.previewLoading = false
  411. },
  412. togglePreview () {
  413. if (this.showPreview) {
  414. this.closePreview()
  415. } else {
  416. this.previewStatus()
  417. }
  418. },
  419. addMediaFile (fileInfo) {
  420. this.newStatus.files.push(fileInfo)
  421. this.$emit('resize', { delayed: true })
  422. },
  423. removeMediaFile (fileInfo) {
  424. const index = this.newStatus.files.indexOf(fileInfo)
  425. this.newStatus.files.splice(index, 1)
  426. this.$emit('resize')
  427. },
  428. editAttachment (fileInfo, newText) {
  429. this.newStatus.mediaDescriptions[fileInfo.id] = newText
  430. },
  431. shiftUpMediaFile (fileInfo) {
  432. const { files } = this.newStatus
  433. const index = this.newStatus.files.indexOf(fileInfo)
  434. files.splice(index, 1)
  435. files.splice(index - 1, 0, fileInfo)
  436. },
  437. shiftDnMediaFile (fileInfo) {
  438. const { files } = this.newStatus
  439. const index = this.newStatus.files.indexOf(fileInfo)
  440. files.splice(index, 1)
  441. files.splice(index + 1, 0, fileInfo)
  442. },
  443. uploadFailed (errString, templateArgs) {
  444. templateArgs = templateArgs || {}
  445. this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs)
  446. },
  447. startedUploadingFiles () {
  448. this.uploadingFiles = true
  449. },
  450. finishedUploadingFiles () {
  451. this.$emit('resize')
  452. this.uploadingFiles = false
  453. },
  454. type (fileInfo) {
  455. return fileTypeService.fileType(fileInfo.mimetype)
  456. },
  457. paste (e) {
  458. this.autoPreview()
  459. this.resize(e)
  460. if (e.clipboardData.files.length > 0) {
  461. // prevent pasting of file as text
  462. e.preventDefault()
  463. // Strangely, files property gets emptied after event propagation
  464. // Trying to wrap it in array doesn't work. Plus I doubt it's possible
  465. // to hold more than one file in clipboard.
  466. this.dropFiles = [e.clipboardData.files[0]]
  467. }
  468. },
  469. fileDrop (e) {
  470. if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
  471. e.preventDefault() // allow dropping text like before
  472. this.dropFiles = e.dataTransfer.files
  473. clearTimeout(this.dropStopTimeout)
  474. this.showDropIcon = 'hide'
  475. }
  476. },
  477. fileDragStop (e) {
  478. // The false-setting is done with delay because just using leave-events
  479. // directly caused unwanted flickering, this is not perfect either but
  480. // much less noticable.
  481. clearTimeout(this.dropStopTimeout)
  482. this.showDropIcon = 'fade'
  483. this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500)
  484. },
  485. fileDrag (e) {
  486. e.dataTransfer.dropEffect = this.uploadFileLimitReached ? 'none' : 'copy'
  487. if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
  488. clearTimeout(this.dropStopTimeout)
  489. this.showDropIcon = 'show'
  490. }
  491. },
  492. onEmojiInputInput (e) {
  493. this.$nextTick(() => {
  494. this.resize(this.$refs.textarea)
  495. })
  496. },
  497. resize (e) {
  498. const target = e.target || e
  499. if (!(target instanceof window.Element)) { return }
  500. // Reset to default height for empty form, nothing else to do here.
  501. if (target.value === '') {
  502. target.style.height = null
  503. this.$emit('resize')
  504. return
  505. }
  506. const formRef = this.$refs.form
  507. const bottomRef = this.$refs.bottom
  508. /* Scroller is either `window` (replies in TL), sidebar (main post form,
  509. * replies in notifs) or mobile post form. Note that getting and setting
  510. * scroll is different for `Window` and `Element`s
  511. */
  512. const bottomBottomPaddingStr = window.getComputedStyle(bottomRef)['padding-bottom']
  513. const bottomBottomPadding = pxStringToNumber(bottomBottomPaddingStr)
  514. const scrollerRef = this.$el.closest('.column.-scrollable') ||
  515. this.$el.closest('.post-form-modal-view') ||
  516. window
  517. // Getting info about padding we have to account for, removing 'px' part
  518. const topPaddingStr = window.getComputedStyle(target)['padding-top']
  519. const bottomPaddingStr = window.getComputedStyle(target)['padding-bottom']
  520. const topPadding = pxStringToNumber(topPaddingStr)
  521. const bottomPadding = pxStringToNumber(bottomPaddingStr)
  522. const vertPadding = topPadding + bottomPadding
  523. const oldHeight = pxStringToNumber(target.style.height)
  524. /* Explanation:
  525. *
  526. * https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight
  527. * scrollHeight returns element's scrollable content height, i.e. visible
  528. * element + overscrolled parts of it. We use it to determine when text
  529. * inside the textarea exceeded its height, so we can set height to prevent
  530. * overscroll, i.e. make textarea grow with the text. HOWEVER, since we
  531. * explicitly set new height, scrollHeight won't go below that, so we can't
  532. * SHRINK the textarea when there's extra space. To workaround that we set
  533. * height to 'auto' which makes textarea tiny again, so that scrollHeight
  534. * will match text height again. HOWEVER, shrinking textarea can screw with
  535. * the scroll since there might be not enough padding around form-bottom to even
  536. * warrant a scroll, so it will jump to 0 and refuse to move anywhere,
  537. * so we check current scroll position before shrinking and then restore it
  538. * with needed delta.
  539. */
  540. // this part has to be BEFORE the content size update
  541. const currentScroll = scrollerRef === window
  542. ? scrollerRef.scrollY
  543. : scrollerRef.scrollTop
  544. const scrollerHeight = scrollerRef === window
  545. ? scrollerRef.innerHeight
  546. : scrollerRef.offsetHeight
  547. const scrollerBottomBorder = currentScroll + scrollerHeight
  548. // BEGIN content size update
  549. target.style.height = 'auto'
  550. const heightWithoutPadding = Math.floor(target.scrollHeight - vertPadding)
  551. let newHeight = this.maxHeight ? Math.min(heightWithoutPadding, this.maxHeight) : heightWithoutPadding
  552. // This is a bit of a hack to combat target.scrollHeight being different on every other input
  553. // on some browsers for whatever reason. Don't change the height if difference is 1px or less.
  554. if (Math.abs(newHeight - oldHeight) <= 1) {
  555. newHeight = oldHeight
  556. }
  557. target.style.height = `${newHeight}px`
  558. this.$emit('resize', newHeight)
  559. // END content size update
  560. // We check where the bottom border of form-bottom element is, this uses findOffset
  561. // to find offset relative to scrollable container (scroller)
  562. const bottomBottomBorder = bottomRef.offsetHeight + findOffset(bottomRef, scrollerRef).top + bottomBottomPadding
  563. const isBottomObstructed = scrollerBottomBorder < bottomBottomBorder
  564. const isFormBiggerThanScroller = scrollerHeight < formRef.offsetHeight
  565. const bottomChangeDelta = bottomBottomBorder - scrollerBottomBorder
  566. // The intention is basically this;
  567. // Keep form-bottom always visible so that submit button is in view EXCEPT
  568. // if form element bigger than scroller and caret isn't at the end, so that
  569. // if you scroll up and edit middle of text you won't get scrolled back to bottom
  570. const shouldScrollToBottom = isBottomObstructed &&
  571. !(isFormBiggerThanScroller &&
  572. this.$refs.textarea.selectionStart !== this.$refs.textarea.value.length)
  573. const totalDelta = shouldScrollToBottom ? bottomChangeDelta : 0
  574. const targetScroll = Math.round(currentScroll + totalDelta)
  575. if (scrollerRef === window) {
  576. scrollerRef.scroll(0, targetScroll)
  577. } else {
  578. scrollerRef.scrollTop = targetScroll
  579. }
  580. },
  581. showEmojiPicker () {
  582. this.$refs.textarea.focus()
  583. this.$refs['emoji-input'].triggerShowPicker()
  584. },
  585. clearError () {
  586. this.error = null
  587. },
  588. changeVis (visibility) {
  589. this.newStatus.visibility = visibility
  590. },
  591. togglePollForm () {
  592. this.pollFormVisible = !this.pollFormVisible
  593. },
  594. setPoll (poll) {
  595. this.newStatus.poll = poll
  596. },
  597. clearPollForm () {
  598. if (this.$refs.pollForm) {
  599. this.$refs.pollForm.clear()
  600. }
  601. },
  602. dismissScopeNotice () {
  603. this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true })
  604. },
  605. setMediaDescription (id) {
  606. const description = this.newStatus.mediaDescriptions[id]
  607. if (!description || description.trim() === '') return
  608. return statusPoster.setMediaDescription({ store: this.$store, id, description })
  609. },
  610. setAllMediaDescriptions () {
  611. const ids = this.newStatus.files.map(file => file.id)
  612. return Promise.all(ids.map(id => this.setMediaDescription(id)))
  613. },
  614. handleEmojiInputShow (value) {
  615. this.emojiInputShown = value
  616. },
  617. updateIdempotencyKey () {
  618. this.idempotencyKey = Date.now().toString()
  619. },
  620. openProfileTab () {
  621. this.$store.dispatch('openSettingsModalTab', 'profile')
  622. },
  623. propsToNative (props) {
  624. return propsToNative(props)
  625. }
  626. }
  627. }
  628. export default PostStatusForm