logo

pleroma-fe

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

post_status_form.js (26819B)


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