diff options
| author | Maksim Pechnikov <parallel588@gmail.com> | 2020-09-07 09:47:17 +0300 |
|---|---|---|
| committer | Maksim Pechnikov <parallel588@gmail.com> | 2020-09-07 09:47:17 +0300 |
| commit | fa2b680855c790ba8ed8d7cc0dbf2a3a2e1dbaf6 (patch) | |
| tree | b2868a1c0d2fce025134af4167c824fc8ee49068 /src/components/post_status_form | |
| parent | 12519a54b55140a3e5f76e67ac53914654c2a8b0 (diff) | |
| parent | a73b09c73202117ffa3fecf7a9185981d6696912 (diff) | |
Merge branch 'develop' of git.pleroma.social:pleroma/pleroma-fe into develop
Diffstat (limited to 'src/components/post_status_form')
| -rw-r--r-- | src/components/post_status_form/post_status_form.js | 262 | ||||
| -rw-r--r-- | src/components/post_status_form/post_status_form.vue | 238 |
2 files changed, 390 insertions, 110 deletions
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 9027566f..ad149506 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -3,11 +3,13 @@ import MediaUpload from '../media_upload/media_upload.vue' import ScopeSelector from '../scope_selector/scope_selector.vue' import EmojiInput from '../emoji_input/emoji_input.vue' import PollForm from '../poll/poll_form.vue' +import Attachment from '../attachment/attachment.vue' +import StatusContent from '../status_content/status_content.vue' import fileTypeService from '../../services/file_type/file_type.service.js' import { findOffset } from '../../services/offset_finder/offset_finder.service.js' -import { reject, map, uniqBy } from 'lodash' +import { reject, map, uniqBy, debounce } from 'lodash' import suggestor from '../emoji_input/suggestor.js' -import { mapGetters } from 'vuex' +import { mapGetters, mapState } from 'vuex' import Checkbox from '../checkbox/checkbox.vue' const buildMentionsString = ({ user, attentions = [] }, currentUser) => { @@ -25,27 +27,54 @@ const buildMentionsString = ({ user, attentions = [] }, currentUser) => { return mentions.length > 0 ? mentions.join(' ') + ' ' : '' } +// Converts a string with px to a number like '2px' -> 2 +const pxStringToNumber = (str) => { + return Number(str.substring(0, str.length - 2)) +} + const PostStatusForm = { props: [ 'replyTo', 'repliedUser', 'attentions', 'copyMessageScope', - 'subject' + 'subject', + 'disableSubject', + 'disableScopeSelector', + 'disableNotice', + 'disableLockWarning', + 'disablePolls', + 'disableSensitivityCheckbox', + 'disableSubmit', + 'disablePreview', + 'placeholder', + 'maxHeight', + 'postHandler', + 'preserveFocus', + 'autoFocus', + 'fileLimit', + 'submitOnEnter', + 'emojiPickerPlacement' ], components: { MediaUpload, EmojiInput, PollForm, ScopeSelector, - Checkbox + Checkbox, + Attachment, + StatusContent }, mounted () { + this.updateIdempotencyKey() this.resize(this.$refs.textarea) - const textLength = this.$refs.textarea.value.length - this.$refs.textarea.setSelectionRange(textLength, textLength) if (this.replyTo) { + const textLength = this.$refs.textarea.value.length + this.$refs.textarea.setSelectionRange(textLength, textLength) + } + + if (this.replyTo || this.autoFocus) { this.$refs.textarea.focus() } }, @@ -68,7 +97,7 @@ const PostStatusForm = { return { dropFiles: [], - submitDisabled: false, + uploadingFiles: false, error: null, posting: false, highlighted: 0, @@ -78,13 +107,18 @@ const PostStatusForm = { nsfw: false, files: [], poll: {}, + mediaDescriptions: {}, visibility: scope, contentType }, caret: 0, pollFormVisible: false, showDropIcon: 'hide', - dropStopTimeout: null + dropStopTimeout: null, + preview: null, + previewLoading: false, + emojiInputShown: false, + idempotencyKey: '' } }, computed: { @@ -153,28 +187,81 @@ const PostStatusForm = { }, pollsAvailable () { return this.$store.state.instance.pollsAvailable && - this.$store.state.instance.pollLimits.max_options >= 2 + this.$store.state.instance.pollLimits.max_options >= 2 && + this.disablePolls !== true }, hideScopeNotice () { - return this.$store.getters.mergedConfig.hideScopeNotice + return this.disableNotice || this.$store.getters.mergedConfig.hideScopeNotice }, pollContentError () { return this.pollFormVisible && this.newStatus.poll && this.newStatus.poll.error }, - ...mapGetters(['mergedConfig']) + showPreview () { + return !this.disablePreview && (!!this.preview || this.previewLoading) + }, + emptyStatus () { + return this.newStatus.status.trim() === '' && this.newStatus.files.length === 0 + }, + uploadFileLimitReached () { + return this.newStatus.files.length >= this.fileLimit + }, + ...mapGetters(['mergedConfig']), + ...mapState({ + mobileLayout: state => state.interface.mobileLayout + }) + }, + watch: { + 'newStatus': { + deep: true, + handler () { + this.statusChanged() + } + } }, methods: { - postStatus (newStatus) { + statusChanged () { + this.autoPreview() + this.updateIdempotencyKey() + }, + clearStatus () { + const newStatus = this.newStatus + this.newStatus = { + status: '', + spoilerText: '', + files: [], + visibility: newStatus.visibility, + contentType: newStatus.contentType, + poll: {}, + mediaDescriptions: {} + } + this.pollFormVisible = false + this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile() + this.clearPollForm() + if (this.preserveFocus) { + this.$nextTick(() => { + this.$refs.textarea.focus() + }) + } + let el = this.$el.querySelector('textarea') + el.style.height = 'auto' + el.style.height = undefined + this.error = null + if (this.preview) this.previewStatus() + }, + async postStatus (event, newStatus, opts = {}) { if (this.posting) { return } - if (this.submitDisabled) { return } + if (this.disableSubmit) { return } + if (this.emojiInputShown) { return } + if (this.submitOnEnter) { + event.stopPropagation() + event.preventDefault() + } - if (this.newStatus.status === '') { - if (this.newStatus.files.length === 0) { - this.error = 'Cannot post an empty status with no files' - return - } + if (this.emptyStatus) { + this.error = this.$t('post_status.empty_status_error') + return } const poll = this.pollFormVisible ? this.newStatus.poll : {} @@ -184,7 +271,16 @@ const PostStatusForm = { } this.posting = true - statusPoster.postStatus({ + + try { + await this.setAllMediaDescriptions() + } catch (e) { + this.error = this.$t('post_status.media_description_error') + this.posting = false + return + } + + const postingOptions = { status: newStatus.status, spoilerText: newStatus.spoilerText || null, visibility: newStatus.visibility, @@ -193,52 +289,98 @@ const PostStatusForm = { store: this.$store, inReplyToStatusId: this.replyTo, contentType: newStatus.contentType, - poll - }).then((data) => { + poll, + idempotencyKey: this.idempotencyKey + } + + const postHandler = this.postHandler ? this.postHandler : statusPoster.postStatus + + postHandler(postingOptions).then((data) => { if (!data.error) { - this.newStatus = { - status: '', - spoilerText: '', - files: [], - visibility: newStatus.visibility, - contentType: newStatus.contentType, - poll: {} - } - this.pollFormVisible = false - this.$refs.mediaUpload.clearFile() - this.clearPollForm() - this.$emit('posted') - let el = this.$el.querySelector('textarea') - el.style.height = 'auto' - el.style.height = undefined - this.error = null + this.clearStatus() + this.$emit('posted', data) } else { this.error = data.error } this.posting = false }) }, + previewStatus () { + if (this.emptyStatus && this.newStatus.spoilerText.trim() === '') { + this.preview = { error: this.$t('post_status.preview_empty') } + this.previewLoading = false + return + } + const newStatus = this.newStatus + this.previewLoading = true + statusPoster.postStatus({ + status: newStatus.status, + spoilerText: newStatus.spoilerText || null, + visibility: newStatus.visibility, + sensitive: newStatus.nsfw, + media: [], + store: this.$store, + inReplyToStatusId: this.replyTo, + contentType: newStatus.contentType, + poll: {}, + preview: true + }).then((data) => { + // Don't apply preview if not loading, because it means + // user has closed the preview manually. + if (!this.previewLoading) return + if (!data.error) { + this.preview = data + } else { + this.preview = { error: data.error } + } + }).catch((error) => { + this.preview = { error } + }).finally(() => { + this.previewLoading = false + }) + }, + debouncePreviewStatus: debounce(function () { this.previewStatus() }, 500), + autoPreview () { + if (!this.preview) return + this.previewLoading = true + this.debouncePreviewStatus() + }, + closePreview () { + this.preview = null + this.previewLoading = false + }, + togglePreview () { + if (this.showPreview) { + this.closePreview() + } else { + this.previewStatus() + } + }, addMediaFile (fileInfo) { this.newStatus.files.push(fileInfo) + this.$emit('resize', { delayed: true }) }, removeMediaFile (fileInfo) { let index = this.newStatus.files.indexOf(fileInfo) this.newStatus.files.splice(index, 1) + this.$emit('resize') }, uploadFailed (errString, templateArgs) { templateArgs = templateArgs || {} this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs) }, - disableSubmit () { - this.submitDisabled = true + startedUploadingFiles () { + this.uploadingFiles = true }, - enableSubmit () { - this.submitDisabled = false + finishedUploadingFiles () { + this.$emit('resize') + this.uploadingFiles = false }, type (fileInfo) { return fileTypeService.fileType(fileInfo.mimetype) }, paste (e) { + this.autoPreview() this.resize(e) if (e.clipboardData.files.length > 0) { // prevent pasting of file as text @@ -266,7 +408,7 @@ const PostStatusForm = { this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500) }, fileDrag (e) { - e.dataTransfer.dropEffect = 'copy' + e.dataTransfer.dropEffect = this.uploadFileLimitReached ? 'none' : 'copy' if (e.dataTransfer && e.dataTransfer.types.includes('Files')) { clearTimeout(this.dropStopTimeout) this.showDropIcon = 'show' @@ -284,6 +426,7 @@ const PostStatusForm = { // Reset to default height for empty form, nothing else to do here. if (target.value === '') { target.style.height = null + this.$emit('resize') this.$refs['emoji-input'].resize() return } @@ -295,7 +438,7 @@ const PostStatusForm = { * scroll is different for `Window` and `Element`s */ const bottomBottomPaddingStr = window.getComputedStyle(bottomRef)['padding-bottom'] - const bottomBottomPadding = Number(bottomBottomPaddingStr.substring(0, bottomBottomPaddingStr.length - 2)) + const bottomBottomPadding = pxStringToNumber(bottomBottomPaddingStr) const scrollerRef = this.$el.closest('.sidebar-scroller') || this.$el.closest('.post-form-modal-view') || @@ -304,10 +447,12 @@ const PostStatusForm = { // Getting info about padding we have to account for, removing 'px' part const topPaddingStr = window.getComputedStyle(target)['padding-top'] const bottomPaddingStr = window.getComputedStyle(target)['padding-bottom'] - const topPadding = Number(topPaddingStr.substring(0, topPaddingStr.length - 2)) - const bottomPadding = Number(bottomPaddingStr.substring(0, bottomPaddingStr.length - 2)) + const topPadding = pxStringToNumber(topPaddingStr) + const bottomPadding = pxStringToNumber(bottomPaddingStr) const vertPadding = topPadding + bottomPadding + const oldHeight = pxStringToNumber(target.style.height) + /* Explanation: * * https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight @@ -336,8 +481,15 @@ const PostStatusForm = { // BEGIN content size update target.style.height = 'auto' - const newHeight = target.scrollHeight - vertPadding + const heightWithoutPadding = Math.floor(target.scrollHeight - vertPadding) + let newHeight = this.maxHeight ? Math.min(heightWithoutPadding, this.maxHeight) : heightWithoutPadding + // This is a bit of a hack to combat target.scrollHeight being different on every other input + // on some browsers for whatever reason. Don't change the height if difference is 1px or less. + if (Math.abs(newHeight - oldHeight) <= 1) { + newHeight = oldHeight + } target.style.height = `${newHeight}px` + this.$emit('resize', newHeight) // END content size update // We check where the bottom border of form-bottom element is, this uses findOffset @@ -388,6 +540,24 @@ const PostStatusForm = { }, dismissScopeNotice () { this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true }) + }, + setMediaDescription (id) { + const description = this.newStatus.mediaDescriptions[id] + if (!description || description.trim() === '') return + return statusPoster.setMediaDescription({ store: this.$store, id, description }) + }, + setAllMediaDescriptions () { + const ids = this.newStatus.files.map(file => file.id) + return Promise.all(ids.map(id => this.setMediaDescription(id))) + }, + handleEmojiInputShow (value) { + this.emojiInputShown = value + }, + updateIdempotencyKey () { + this.idempotencyKey = Date.now().toString() + }, + openProfileTab () { + this.$store.dispatch('openSettingsModalTab', 'profile') } } } diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index e3d8d087..d67d9ae9 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -5,26 +5,30 @@ > <form autocomplete="off" - @submit.prevent="postStatus(newStatus)" + @submit.prevent @dragover.prevent="fileDrag" > <div v-show="showDropIcon !== 'hide'" :style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }" - class="drop-indicator icon-upload" + class="drop-indicator" + :class="[uploadFileLimitReached ? 'icon-block' : 'icon-upload']" @dragleave="fileDragStop" @drop.stop="fileDrop" /> <div class="form-group"> <i18n - v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'" + v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private' && !disableLockWarning" path="post_status.account_not_locked_warning" tag="p" class="visibility-notice" > - <router-link :to="{ name: 'user-settings' }"> + <a + href="#" + @click="openProfileTab" + > {{ $t('post_status.account_not_locked_warning_link') }} - </router-link> + </a> </i18n> <p v-if="!hideScopeNotice && newStatus.visibility === 'public'" @@ -69,15 +73,52 @@ <span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span> <span v-else>{{ $t('post_status.direct_warning_to_all') }}</span> </p> + <div + v-if="!disablePreview" + class="preview-heading faint" + > + <a + class="preview-toggle faint" + @click.stop.prevent="togglePreview" + > + {{ $t('post_status.preview') }} + <i :class="showPreview ? 'icon-left-open' : 'icon-right-open'" /> + </a> + <i + v-show="previewLoading" + class="icon-spin3 animate-spin" + /> + </div> + <div + v-if="showPreview" + class="preview-container" + > + <div + v-if="!preview" + class="preview-status" + > + {{ $t('general.loading') }} + </div> + <div + v-else-if="preview.error" + class="preview-status preview-error" + > + {{ preview.error }} + </div> + <StatusContent + v-else + :status="preview" + class="preview-status" + /> + </div> <EmojiInput - v-if="newStatus.spoilerText || alwaysShowSubject" + v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)" v-model="newStatus.spoilerText" enable-emoji-picker :suggest="emojiSuggestor" class="form-control" > <input - v-model="newStatus.spoilerText" type="text" :placeholder="$t('post_status.content_warning')" @@ -89,23 +130,29 @@ ref="emoji-input" v-model="newStatus.status" :suggest="emojiUserSuggestor" + :placement="emojiPickerPlacement" class="form-control main-input" enable-emoji-picker hide-emoji-button + :newline-on-ctrl-enter="submitOnEnter" enable-sticker-picker @input="onEmojiInputInput" @sticker-uploaded="addMediaFile" @sticker-upload-failed="uploadFailed" + @shown="handleEmojiInputShow" > <textarea ref="textarea" v-model="newStatus.status" - :placeholder="$t('post_status.default')" + :placeholder="placeholder || $t('post_status.default')" rows="1" + cols="1" :disabled="posting" class="form-post-body" - @keydown.meta.enter="postStatus(newStatus)" - @keydown.ctrl.enter="postStatus(newStatus)" + :class="{ 'scrollable-form': !!maxHeight }" + @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)" + @keydown.meta.enter="postStatus($event, newStatus)" + @keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)" @input="resize" @compositionupdate="resize" @paste="paste" @@ -118,7 +165,10 @@ {{ charactersLeft }} </p> </EmojiInput> - <div class="visibility-tray"> + <div + v-if="!disableScopeSelector" + class="visibility-tray" + > <scope-selector :show-all="showAllScopes" :user-default="userDefaultScope" @@ -176,10 +226,11 @@ ref="mediaUpload" class="media-upload-icon" :drop-files="dropFiles" - @uploading="disableSubmit" + :disabled="uploadFileLimitReached" + @uploading="startedUploadingFiles" @uploaded="addMediaFile" @upload-failed="uploadFailed" - @all-uploaded="enableSubmit" + @all-uploaded="finishedUploadingFiles" /> <div class="emoji-icon" @@ -216,11 +267,13 @@ > {{ $t('general.submit') }} </button> + <!-- touchstart is used to keep the OSK at the same position after a message send --> <button v-else - :disabled="submitDisabled" - type="submit" + :disabled="uploadingFiles || disableSubmit" class="btn btn-default" + @touchstart.stop.prevent="postStatus($event, newStatus)" + @click.stop.prevent="postStatus($event, newStatus)" > {{ $t('general.submit') }} </button> @@ -245,31 +298,22 @@ class="fa button-icon icon-cancel" @click="removeMediaFile(file)" /> - <div class="media-upload-container attachment"> - <img - v-if="type(file) === 'image'" - class="thumbnail media-upload" - :src="file.url" - > - <video - v-if="type(file) === 'video'" - :src="file.url" - controls - /> - <audio - v-if="type(file) === 'audio'" - :src="file.url" - controls - /> - <a - v-if="type(file) === 'unknown'" - :href="file.url" - >{{ file.url }}</a> - </div> + <attachment + :attachment="file" + :set-media="() => $store.dispatch('setMedia', newStatus.files)" + size="small" + allow-play="false" + /> + <input + v-model="newStatus.mediaDescriptions[file.id]" + type="text" + :placeholder="$t('post_status.media_description')" + @keydown.enter.prevent="" + > </div> </div> <div - v-if="newStatus.files.length > 0" + v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox" class="upload_settings" > <Checkbox v-model="newStatus.nsfw"> @@ -303,14 +347,8 @@ } .post-status-form { - .visibility-tray { - display: flex; - justify-content: space-between; - padding-top: 5px; - } -} + position: relative; -.post-status-form { .form-bottom { display: flex; justify-content: space-between; @@ -336,6 +374,51 @@ max-width: 10em; } + .preview-heading { + padding-left: 0.5em; + display: flex; + width: 100%; + + .icon-spin3 { + margin-left: auto; + } + } + + .preview-toggle { + display: flex; + cursor: pointer; + user-select: none; + + &:hover { + text-decoration: underline; + } + i { + margin-left: 0.2em; + font-size: 0.8em; + transform: rotate(90deg); + } + } + + .preview-container { + margin-bottom: 1em; + } + + .preview-error { + font-style: italic; + color: $fallback--faint; + color: var(--faint, $fallback--faint); + } + + .preview-status { + border: 1px solid $fallback--border; + border: 1px solid var(--border, $fallback--border); + border-radius: $fallback--tooltipRadius; + border-radius: var(--tooltipRadius, $fallback--tooltipRadius); + padding: 0.5em; + margin: 0; + line-height: 1.4em; + } + .text-format { .only-format { color: $fallback--faint; @@ -343,6 +426,12 @@ } } + .visibility-tray { + display: flex; + justify-content: space-between; + padding-top: 5px; + } + .media-upload-icon, .poll-icon, .emoji-icon { font-size: 26px; flex: 1; @@ -354,6 +443,19 @@ color: var(--lightText, $fallback--lightText); } } + + &.disabled { + i { + cursor: not-allowed; + color: $fallback--icon; + color: var(--btnDisabledText, $fallback--icon); + + &:hover { + color: $fallback--icon; + color: var(--btnDisabledText, $fallback--icon); + } + } + } } // Order is not necessary but a good indicator @@ -381,11 +483,9 @@ } .media-upload-wrapper { - flex: 0 0 auto; - max-width: 100%; - min-width: 50px; margin-right: .2em; margin-bottom: .5em; + width: 18em; .icon-cancel { display: inline-block; @@ -399,6 +499,20 @@ border-bottom-left-radius: 0; border-bottom-right-radius: 0; } + + img, video { + object-fit: contain; + max-height: 10em; + } + + .video { + max-height: 10em; + } + + input { + flex: 1; + width: 100%; + } } .status-input-wrapper { @@ -408,28 +522,13 @@ flex-direction: column; } - .attachments { + .media-upload-wrapper .attachments { padding: 0 0.5em; .attachment { margin: 0; + padding: 0; position: relative; - flex: 0 0 auto; - border: 1px solid $fallback--border; - border: 1px solid var(--border, $fallback--border); - text-align: center; - - audio { - min-width: 300px; - flex: 1 0 auto; - } - - a { - display: block; - text-align: left; - line-height: 1.2; - padding: .5em; - } } i { @@ -482,6 +581,10 @@ padding-bottom: 1.75em; min-height: 1px; box-sizing: content-box; + + &.scrollable-form { + overflow-y: auto; + } } .main-input { @@ -544,4 +647,11 @@ border: 2px dashed var(--text, $fallback--text); } } + +// todo: unify with attachment.vue (otherwise the uploaded images are not minified unless a status with an attachment was displayed before) +img.media-upload, .media-upload-container > video { + line-height: 0; + max-height: 200px; + max-width: 100%; +} </style> |
