diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index c98454342..38ec759a0 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -1,4 +1,4 @@ -import { debounce, reject, uniqBy } from 'lodash' +import { debounce, reject, uniqBy, isEqual, unescape as ldUnescape } from 'lodash' import { mapActions, mapState } from 'pinia' import { defineAsyncComponent } from 'vue' @@ -68,6 +68,7 @@ const PostStatusForm = { // Status editing stuff statusId: String, statusText: String, + statusSubject: String, statusIsSensitive: { type: Boolean, required: false, @@ -79,30 +80,34 @@ const PostStatusForm = { statusMediaDescriptions: Object, statusScope: String, statusContentType: String, + // Replies/mentions - replyTo: String, - repliedUser: Object, - mentionsLine: Boolean, - mentionsLineReadOnly: Boolean, - attentions: Array, - subject: String, - copyMessageScope: String, - profileMention: String, + replyTo: String, // id of the post replying to + repliedUser: Object, // user object replying to + attentions: Array, // list of users mentioned + repliedScope: String, // scope of the post replying to + repliedSubject: String, // subject of post replying to + profileMention: Boolean, // is mentioning a user (used in profile page -> mention) + // Draft stuff - hideDraft: Boolean, - closeable: Boolean, - draftId: String, + hideDraft: Boolean, // Disable drafts functionality + closeable: Boolean, // Whether form can be closed (i.e. in replies) + draftId: String, // ID of the draft to be used + // Chats stuff maxHeight: Number, placeholder: String, - postHandler: Function, - preserveFocus: Boolean, - autoFocus: Boolean, - fileLimit: Number, + postHandler: Function, // Used to override poster to use chats one instead of status one + preserveFocus: Boolean, // Keep focus on form after posting + autoFocus: Boolean, // Steal focus when form is opened + fileLimit: Number, // Chats only support 1 attachment :( submitOnEnter: Boolean, emojiPickerPlacement: String, - optimisticPosting: Boolean, + optimisticPosting: Boolean, // Don't wait for confirmation that post is done + // Feature toggles for special cases (mostly chats) + mentionsLine: Boolean, // Use separate field for specifying mentions + mentionsLineReadOnly: Boolean, // Make the said field read-only (for chat conversation view) disableSubject: Boolean, disableScopeSelector: Boolean, disableVisibilitySelector: Boolean, @@ -126,20 +131,19 @@ const PostStatusForm = { ], data() { return { - randomSeed: genRandomSeed(), - dropFiles: [], - uploadingFiles: false, - error: null, - posting: false, - highlighted: 0, initialized: false, - // Data is initialized first, but we have no access to .computed - // so we pre-fill with stuff meant for status editing and later - // back-fill with defaults in .created() + randomSeed: genRandomSeed(), + // Posting stuff + idempotencyKey: '', + /* Data is initialized first, but we have no access to .computed yet + * which we need for some defaults (i.e. user configration) + * so we pre-fill with stuff meant for status editing and later + * back-fill with defaults in .created() + */ newStatus: { status: this.statusText ?? null, mentions: this.statusMentionLine ?? null, - spoilerText: this.subject ?? null, + spoilerText: this.statusSubject ?? null, quote: this.statusQuote ?? null, files: this.statusFiles ?? null, poll: this.statusPoll ?? null, @@ -148,15 +152,25 @@ const PostStatusForm = { visibility: this.statusVisibility ?? null, contentType: this.statusContentType ?? null, }, - caret: 0, + + // Attachments + dropFiles: [], + uploadingFiles: false, showDropIcon: 'hide', dropStopTimeout: null, + + // Preview preview: null, previewLoading: false, - emojiInputShown: false, - idempotencyKey: '', + + // Draft saveInhibited: true, saveable: false, + + // Misc States + emojiInputShown: false, + error: null, + posting: false, } }, components: { @@ -178,6 +192,8 @@ const PostStatusForm = { Popover, }, created() { + this.updateIdempotencyKey() + // If we are starting a new post, do not associate it with old drafts const draft = !this.disableDraft && (this.draftId || this.statusType !== 'new') ? this.getDraft(this.statusType, this.refId) @@ -189,43 +205,14 @@ const PostStatusForm = { this.newStatus[key] = draft[key] ?? this.newStatus[key] }) } else { - const defaultNewStatus = { - spoilerText: '', - files: [], - poll: null, - quote: null, - mediaDescriptions: {}, - } - - const scope = - (this.copyMessageScope && this.userDefaultScopeCopy) || - this.copyMessageScope === 'direct' - ? this.copyMessageScope - : this.userDefaultScope - - const preset = this.$route.query.message - let statusText = preset ?? '' - - if (this.mentionsLine) { - defaultNewStatus.status = statusText - defaultNewStatus.mentions = this.mentionsString.trim() - } else { - defaultNewStatus.status = this.mentionsString + statusText - defaultNewStatus.mentions = '' - } - - defaultNewStatus.nsfw = this.userDefaultSensitive - defaultNewStatus.visibility = scope - defaultNewStatus.contentType = this.userDefaultPostContentType - - Object.entries(defaultNewStatus).forEach(([key, value]) => { + Object.entries(this.defaultNewStatus).forEach(([key, value]) => { this.newStatus[key] = this.newStatus[key] ?? value }) } + this.initialized = true }, mounted() { - this.updateIdempotencyKey() this.resize(this.$refs.textarea) if (this.replyTo) { @@ -249,7 +236,7 @@ const PostStatusForm = { return !this.disablePreview && (!!this.preview || this.previewLoading) }, - // Composition stuff + // Composing stuff statusType() { if (this.replyTo) { return 'reply' @@ -290,6 +277,39 @@ const PostStatusForm = { ? this.mentionsString + this.newStatus.status : this.newStatus.status }, + defaultNewStatus() { + const defaultNewStatus = { + files: [], + poll: null, + quote: null, + mediaDescriptions: {}, + } + + const scope = + (this.repliedScope && this.userDefaultScopeCopy) || + this.repliedScope === 'direct' + ? this.repliedScope + : this.userDefaultScope + + const preset = this.$route.query.message + const statusText = preset ?? '' + + if (this.mentionsLine) { + defaultNewStatus.status = statusText + defaultNewStatus.mentions = this.mentionsString.trim() + } else { + defaultNewStatus.status = this.mentionsString + statusText + defaultNewStatus.mentions = '' + } + + defaultNewStatus.spoilerText = this.replySubject ?? '' + defaultNewStatus.nsfw = this.userDefaultSensitive + defaultNewStatus.visibility = scope + defaultNewStatus.contentType = this.userDefaultPostContentType + + return defaultNewStatus + }, + // -Edit isEdit() { return typeof this.statusId !== 'undefined' && this.statusId.trim() !== '' }, @@ -297,13 +317,26 @@ const PostStatusForm = { isReply() { return this.statusType === 'reply' }, - inReplyStatusId() { + inReplyToStatusId() { return !this.hasQuote || !this.newStatus.quote.thread || !this.newStatus.quote.id ? this.replyTo : undefined }, + replySubject() { + if (!this.replySubject) return null + const decodedSummary = ldUnescape(this.replySubject) + const behavior = this.mergedConfig.subjectLineBehavior + const startsWithRe = decodedSummary.match(/^re[: ]/i) + if ((behavior !== 'noop' && startsWithRe) || behavior === 'masto') { + return decodedSummary + } else if (behavior === 'email') { + return 're: '.concat(decodedSummary) + } else if (behavior === 'noop') { + return '' + } + }, // -Poll hasPoll() { return this.newStatus.poll != null @@ -406,7 +439,7 @@ const PostStatusForm = { isOverLengthLimit() { return this.hasStatusLengthLimit && this.charactersLeft < 0 }, - emptyStatus() { + isEmptyStatus() { return ( this.newStatus.status.trim() === '' && this.newStatus.files.length === 0 ) @@ -416,6 +449,13 @@ const PostStatusForm = { }, // Drafts + isDirty() { + return Object.entries(this.defaultNewStatus).some(([key, defaultValue]) => { + const actualValue = this.newStatus[key] + if (actualValue === null) return false + return !isEqual(actualValue, defaultValue) + }) + }, shouldAutoSaveDraft() { return useMergedConfigStore().mergedConfig.autoSaveDraft }, @@ -525,11 +565,8 @@ const PostStatusForm = { }), }, watch: { - newStatus: { - deep: true, - handler() { - if (this.initialized) this.statusChanged() - }, + isDirty(newVal, oldVal) { + this.statusChanged() }, saveable(val) { // https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event#usage_notes @@ -541,35 +578,16 @@ const PostStatusForm = { } }, }, - beforeUnmount() { - this.maybeAutoSaveDraft() - this.removeBeforeUnloadListener() - }, methods: { - ...mapActions(useMediaViewerStore, ['increment']), - statusChanged() { - this.autoPreview() - this.updateIdempotencyKey() - this.debouncedMaybeAutoSaveDraft() - this.saveable = true - this.saveInhibited = false - }, + // Composing onMentionsLineUpdate(e) { if (this.mentionsLineReadOnly) return this.newStatus.mentionsLine = e }, - toggleQuoteForm() { - if (!this.hasQuote) { - this.newStatus.quote = {} - this.newStatus.quote.thread = false - this.newStatus.quote.id = null - this.newStatus.quote.url = '' - } else { - this.newStatus.quote = null - } + changeVis(visibility) { + this.newStatus.visibility = visibility }, clearStatus() { - const newStatus = this.newStatus this.saveInhibited = true this.newStatus.status = '' this.newStatus.mentionsLine = '', @@ -610,12 +628,12 @@ const PostStatusForm = { if ( this.optimisticPosting && - (this.emptyStatus || this.isOverLengthLimit) + (this.isEmptyStatus || this.isOverLengthLimit) ) { return } - if (this.emptyStatus) { + if (this.isEmptyStatus) { this.error = this.$t('post_status.empty_status_error') return } @@ -638,12 +656,12 @@ const PostStatusForm = { const postingOptions = { status: this.newStatusContent, - spoilerText: newStatus.spoilerText || null, + spoilerText: newStatus.spoilerText ?? null, visibility: newStatus.visibility, sensitive: newStatus.nsfw, media: newStatus.files, store: this.$store, - inReplyToStatusId: this.inReplyStatusId, + inReplyToStatusId: this.inReplyToStatusId, quoteId: this.quoteId, contentType: newStatus.contentType, poll, @@ -667,8 +685,10 @@ const PostStatusForm = { this.posting = false }) }, + + // Preview previewStatus() { - if (this.emptyStatus && this.newStatus.spoilerText.trim() === '') { + if (this.isEmptyStatus && this.newStatus.spoilerText.trim() === '') { this.preview = { error: this.$t('post_status.preview_empty') } this.previewLoading = false return @@ -684,10 +704,11 @@ const PostStatusForm = { sensitive: newStatus.nsfw, media: [], store: this.$store, - inReplyToStatusId: this.inReplyStatusId, + inReplyToStatusId: this.inReplyToStatusId, quoteId: this.quoteId, contentType: newStatus.contentType, - poll: {}, + poll: null, + quote: null, preview: true, }) .then((data) => { @@ -722,6 +743,21 @@ const PostStatusForm = { this.previewStatus() } }, + + // Attachments + 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))) + }, addMediaFile(fileInfo) { this.newStatus.files.push(fileInfo) this.$emit('resize', { delayed: true }) @@ -795,6 +831,9 @@ const PostStatusForm = { this.showDropIcon = 'show' } }, + + // Auto-sizable input field + // TODO separate into its own component pls onEmojiInputInput() { this.$nextTick(() => { this.resize(this.$refs.textarea) @@ -908,53 +947,39 @@ const PostStatusForm = { scrollerRef.scrollTop = targetScroll } }, - clearError() { - this.error = null - }, - changeVis(visibility) { - this.newStatus.visibility = visibility - }, + + // Poll togglePollForm() { this.newStatus.poll = this.hasPoll ? null : {} }, setPoll(poll) { this.newStatus.poll = poll }, + + // Quote + toggleQuoteForm() { + if (!this.hasQuote) { + this.newStatus.quote = {} + this.newStatus.quote.thread = false + this.newStatus.quote.id = null + this.newStatus.quote.url = '' + } else { + this.newStatus.quote = null + } + }, clearQuoteForm() { if (this.$refs.quoteForm) { this.$refs.quoteForm.clear() } }, - dismissScopeNotice() { - useSyncConfigStore().setSimplePrefAndSave({ - path: '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() { - useInterfaceStore().openSettingsModalTab('profile') - }, - propsToNative(props) { - return propsToNative(props) + + // Drafts + statusChanged() { + this.autoPreview() + this.updateIdempotencyKey() + this.debouncedMaybeAutoSaveDraft() + this.saveable = true + this.saveInhibited = false }, saveDraft() { if (!this.disableDraft && !this.saveInhibited) { @@ -1036,6 +1061,34 @@ const PostStatusForm = { window.removeEventListener('beforeunload', this._beforeUnloadListener) } }, + + // Misc + propsToNative(props) { + return propsToNative(props) + }, + handleEmojiInputShow(value) { + this.emojiInputShown = value + }, + updateIdempotencyKey() { + this.idempotencyKey = Date.now().toString() + }, + openProfileTab() { + useInterfaceStore().openSettingsModalTab('profile') + }, + dismissScopeNotice() { + useSyncConfigStore().setSimplePrefAndSave({ + path: 'hideScopeNotice', + value: true, + }) + }, + clearError() { + this.error = null + }, + ...mapActions(useMediaViewerStore, ['increment']), + }, + beforeUnmount() { + this.maybeAutoSaveDraft() + this.removeBeforeUnloadListener() }, } diff --git a/src/components/status/status.js b/src/components/status/status.js index 29493fb09..b9359b399 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -1,4 +1,4 @@ -import { unescape as ldUnescape, uniqBy } from 'lodash' +import { uniqBy } from 'lodash' import { defineAsyncComponent } from 'vue' import AvatarList from 'src/components/avatar_list/avatar_list.vue' @@ -377,19 +377,6 @@ const Status = { return user && user.screen_name_ui } }, - replySubject() { - if (!this.status.summary) return '' - const decodedSummary = ldUnescape(this.status.summary) - const behavior = this.mergedConfig.subjectLineBehavior - const startsWithRe = decodedSummary.match(/^re[: ]/i) - if ((behavior !== 'noop' && startsWithRe) || behavior === 'masto') { - return decodedSummary - } else if (behavior === 'email') { - return 're: '.concat(decodedSummary) - } else if (behavior === 'noop') { - return '' - } - }, combinedFavsAndRepeatsUsers() { // Use the status from the global status repository since favs and repeats are saved in it const combinedUsers = [].concat( diff --git a/src/components/status/status.vue b/src/components/status/status.vue index 59cd6aaca..3ddcf4e96 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -549,8 +549,8 @@ :reply-to="status.id" :attentions="status.attentions" :replied-user="status.user" - :copy-message-scope="status.visibility" - :subject="replySubject" + :replied-scope="status.visibility" + :replied-subject="status.summary" @posted="closeReplyForm" @draft-done="closeReplyForm" @close-accepted="closeReplyForm"