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


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