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


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