Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85804927
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
86 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index 91837779d4..ab1696eec2 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -1,1113 +1,1096 @@
import {
debounce,
isEqual,
unescape as ldUnescape,
reject,
uniqBy,
} from 'lodash'
import { mapActions, mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import Attachment from 'src/components/attachment/attachment.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import DraftCloser from 'src/components/draft_closer/draft_closer.vue'
import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
import suggestor from 'src/components/emoji_input/suggestor.js'
import Gallery from 'src/components/gallery/gallery.vue'
import MediaUpload from 'src/components/media_upload/media_upload.vue'
import Popover from 'src/components/popover/popover.vue'
import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
import Select from 'src/components/select/select.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import { propsToNative } from '../../services/attributes_helper/attributes_helper.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import genRandomSeed from '../../services/random_seed/random_seed.service.js'
import statusPoster from '../../services/status_poster/status_poster.service.js'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMediaViewerStore } from 'src/stores/media_viewer.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { pollFormToMasto } from 'src/services/poll/poll.service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBan,
faChevronDown,
faChevronLeft,
faChevronRight,
faCircleNotch,
faPollH,
faQuoteRight,
faSmileBeam,
faTimes,
faUpload,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSmileBeam,
faPollH,
faUpload,
faQuoteRight,
faBan,
faTimes,
faCircleNotch,
faChevronDown,
faChevronLeft,
faChevronRight,
)
// 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: {
// Status editing stuff
statusId: String,
statusText: String,
statusSubject: String,
statusIsSensitive: {
type: Boolean,
required: false,
default: null, // Avoiding automatic conversion null -> false
},
statusPoll: Object,
statusQuote: Object,
statusFiles: Array,
statusMediaDescriptions: Object,
- statusScope: String,
+ statusVisibility: String,
statusContentType: String,
// Replies/mentions
repliedStatus: Object, // Object of a status replying to
profileMention: Object, // Mentioned user (used in profile page -> mention)
// Draft stuff
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
chatView: Boolean, // Used for the "submit on enter" user setting
maxHeight: Number,
placeholder: String,
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 :(
emojiPickerPlacement: String,
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,
disableNotice: Boolean,
disableLockWarning: Boolean,
disablePolls: Boolean,
disableQuotes: Boolean,
disableSensitivityCheckbox: Boolean,
disableSubmit: Boolean,
disablePreview: Boolean,
disableDraft: Boolean,
},
emits: [
'posted',
'draft-done',
'resize',
'mediaplay',
'mediapause',
'close-accepted',
'update',
],
data() {
return {
initialized: false,
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.statusSubject ?? null,
quote: this.statusQuote ?? null,
files: this.statusFiles ?? null,
poll: this.statusPoll ?? null,
mediaDescriptions: this.statusMediaDescriptions ?? null,
nsfw: this.statusIsSensitive ?? null,
visibility: this.statusVisibility ?? null,
contentType: this.statusContentType ?? null,
},
// Attachments
dropFiles: [],
uploadingFiles: false,
showDropIcon: 'hide',
dropStopTimeout: null,
// Preview
preview: null,
previewLoading: false,
// Draft
saveInhibited: true,
saveable: false,
// Misc States
emojiInputShown: false,
error: null,
posting: false,
}
},
components: {
MediaUpload,
EmojiInput,
PollForm: defineAsyncComponent(
() => import('src/components/poll/poll_form.vue'),
),
QuoteForm: defineAsyncComponent(
() => import('src/components/quote/quote_form.vue'),
),
ScopeSelector,
Checkbox,
Select,
Attachment,
StatusContent,
Gallery,
DraftCloser,
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)
: null
if (draft) {
// Copying and overriding defaults from the draft for each field
Object.keys(this.newStatus).forEach((key) => {
this.newStatus[key] = draft[key] ?? this.newStatus[key]
})
} else {
Object.entries(this.defaultNewStatus).forEach(([key, value]) => {
this.newStatus[key] = this.newStatus[key] ?? value
})
}
this.initialized = true
},
mounted() {
this.resize(this.$refs.textarea)
if (this.repliedStatus) {
const textLength = this.$refs.textarea.value.length
this.$refs.textarea.setSelectionRange(textLength, textLength)
}
if (this.repliedStatus || this.autoFocus) {
this.$refs.textarea.focus()
}
},
computed: {
// Visibility / expansion state of subcomponents
pollFormVisible() {
return this.hasPoll
},
quoteFormVisible() {
return this.hasQuote && !this.newStatus.quote.thread
},
showPreview() {
return !this.disablePreview && (!!this.preview || this.previewLoading)
},
// Composing stuff
statusType() {
if (this.repliedStatus) {
return 'reply'
} else if (this.profileMention) {
return 'mention'
} else if (this.statusId) {
return 'edit'
} else {
return 'new'
}
},
refId() {
if (this.repliedStatus) {
return this.repliedStatus.id
} else if (this.profileMention) {
return this.profileMention.id
} else if (this.statusId) {
return this.statusId
} else {
return null
}
},
mentionsString() {
if (this.statusType !== 'reply' && this.statusType !== 'mention')
return ''
let allAttentions = [...(this.repliedStatus?.attentions || [])]
const repliedUser = this.repliedStatus?.user || this.profileMention
if (repliedUser) allAttentions.unshift(repliedUser)
allAttentions = uniqBy(allAttentions, 'id')
allAttentions = reject(allAttentions, { id: this.currentUser.id })
const mentions = allAttentions.map(
(attention) => `@${attention.screen_name}`,
)
return mentions.length > 0 ? mentions.join(' ') + ' ' : ''
},
newStatusContent() {
return this.mentionsLine
? this.mentionsString + this.newStatus.status
: this.newStatus.status
},
defaultNewStatus() {
const defaultNewStatus = {
files: [],
poll: null,
quote: null,
mediaDescriptions: {},
}
const scope = (() => {
if (this.repliedStatus) {
if (this.repliedStatus.visibility === 'direct') return 'direct'
if (this.userDefaultScopeCopy) return this.repliedStatus.visibility
}
return this.userDefaultScope
})()
const preset = this.$route.query.message
const statusText = preset ?? ''
if (this.mentionsLine) {
defaultNewStatus.status = statusText
} else {
defaultNewStatus.status = this.mentionsString + statusText
}
- defaultNewStatus.mentions = this.mentionsString
+ defaultNewStatus.mentions = this.mentionsString.trim()
defaultNewStatus.spoilerText = this.repliedSubjectString ?? ''
defaultNewStatus.nsfw = this.userDefaultSensitive
defaultNewStatus.visibility = scope
defaultNewStatus.contentType = this.userDefaultPostContentType
return defaultNewStatus
},
// -Edit
isEdit() {
return typeof this.statusId !== 'undefined' && this.statusId.trim() !== ''
},
// -Reply
isReply() {
return this.statusType === 'reply'
},
inReplyToStatusId() {
return !this.hasQuote ||
!this.newStatus.quote.thread ||
!this.newStatus.quote.id
? this.repliedStatus?.id
: undefined
},
repliedSubjectString() {
if (!this.repliedStatus?.summary) return null
const decodedSummary = ldUnescape(this.repliedStatus.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 ''
}
},
// -Poll
hasPoll() {
return this.newStatus.poll != null
},
// -Quotes
hasQuote() {
return this.newStatus.quote !== null
},
quotable() {
- return this.quotingAvailable && this.repliedStatus?.id
- },
- defaultQuotable() {
if (
!this.quotingAvailable ||
- !this.isReply ||
- !useMergedConfigStore().mergedConfig.quoteReply
+ !this.isReply
) {
return false
}
- if (!this.repliedStatus) {
- return false
- }
-
if (
this.repliedStatus.visibility === 'public' ||
this.repliedStatus.visibility === 'unlisted' ||
this.repliedStatus.visibility === 'local'
) {
return true
} else if (this.repliedStatus.visibility === 'private') {
return this.repliedStatus.user.id === this.currentUser.id
}
return false
},
quoteId() {
- return this.newStatus.quote?.id
+ return this.newStatus.quote?.id ?? null
},
+ // This is for "reply/quote" toggle
quoteThreadToggled: {
get() {
return this.newStatus.quote?.thread
},
set(value) {
if (value) {
this.newStatus.quote = {}
this.newStatus.quote.thread = value
this.newStatus.quote.id = value ? this.repliedStatus.id : ''
} else {
this.newStatus.quote = null
}
},
},
+ postingOptions() {
+ const poll = this.hasPoll ? pollFormToMasto(this.newStatus.poll) : null
+
+ return {
+ status: this.newStatusContent,
+ spoilerText: this.newStatus.spoilerText,
+ visibility: this.newStatus.visibility,
+ sensitive: this.newStatus.nsfw,
+ media: this.newStatus.files,
+ inReplyToStatusId: this.inReplyToStatusId,
+ quoteId: this.quoteId,
+ contentType: this.newStatus.contentType,
+ poll,
+ idempotencyKey: this.idempotencyKey,
+
+ store: this.$store,
+ }
+ },
// Emoji stuff
emojiUserSuggestor() {
return suggestor({
emoji: [
...useEmojiStore().standardEmojiList,
...useEmojiStore().customEmoji,
],
store: this.$store,
})
},
emojiSuggestor() {
return suggestor({
emoji: [
...useEmojiStore().standardEmojiList,
...useEmojiStore().customEmoji,
],
})
},
emoji() {
return useEmojiStore().standardEmojiList
},
customEmoji() {
return useEmojiStore().customEmoji
},
// Length & Limits
statusLength() {
return this.newStatusContent.length
},
spoilerTextLength() {
return this.newStatus.spoilerText.length
},
statusLengthLimit() {
return useInstanceStore().limits.textLimit
},
hasStatusLengthLimit() {
return this.statusLengthLimit > 0
},
charactersLeft() {
return (
this.statusLengthLimit - (this.statusLength + this.spoilerTextLength)
)
},
isOverLengthLimit() {
return this.hasStatusLengthLimit && this.charactersLeft < 0
},
isEmptyStatus() {
return (
this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
)
},
uploadFileLimitReached() {
return this.newStatus.files.length >= this.fileLimit
},
// 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
},
debouncedMaybeAutoSaveDraft() {
return debounce(this.maybeAutoSaveDraft, 3000)
},
autoSaveState() {
if (this.saveable) {
return this.$t('post_status.auto_save_saving')
} else if (this.newStatus.id) {
return this.$t('post_status.auto_save_saved')
} else {
return this.$t('post_status.auto_save_nothing_new')
}
},
safeToSaveDraft() {
return (
(this.newStatus.status ||
this.newStatus.spoilerText ||
this.newStatus.files.length ||
this.hasPoll ||
this.hasQuote) &&
this.saveable
)
},
hasEmptyDraft() {
return (
this.newStatus.id &&
!(
this.newStatus.status ||
this.newStatus.spoilerText ||
this.newStatus.files.length ||
this.hasPoll ||
this.hasQuote
)
)
},
// Error handling
pollContentError() {
return (
this.pollFormVisible && this.newStatus.poll && this.newStatus.poll.error
)
},
// Featureset detection
postFormats() {
return useInstanceCapabilitiesStore().postFormats || []
},
safeDMEnabled() {
return useInstanceCapabilitiesStore().safeDM
},
pollsAvailable() {
return (
useInstanceCapabilitiesStore().pollsAvailable &&
useInstanceStore().limits.pollLimits.max_options >= 2 &&
this.disablePolls !== true
)
},
hideExtraActions() {
return this.disableDraft || this.hideDraft
},
quotingAvailable() {
if (!useInstanceCapabilitiesStore().quotingAvailable) {
return false
}
return this.disableQuotes !== true
},
// User configuration
userDefaultScope() {
return this.currentUser.default_scope
},
userDefaultPostContentType() {
return this.mergedConfig.postContentType
},
userDefaultScopeCopy() {
return this.mergedConfig.scopeCopy
},
userDefaultSensitive() {
return this.mergedConfig.sensitiveByDefault
},
showAllScopes() {
return !this.mergedConfig.minimalScopesMode
},
minimalScopesMode() {
return this.mergedConfig.minimalScopesMode
},
alwaysShowSubject() {
return this.mergedConfig.alwaysShowSubjectInput
},
hideScopeNotice() {
return (
this.disableNotice ||
useMergedConfigStore().mergedConfig.hideScopeNotice
)
},
submitOnEnter() {
return this.chatView && this.mergedConfig.chatSubmitOnEnter
},
// Global stuff
currentUser() {
return this.$store.state.users.currentUser
},
...mapState(useMergedConfigStore, ['mergedConfig']),
...mapState(useInterfaceStore, {
mobileLayout: (store) => store.mobileLayout,
}),
},
watch: {
isDirty(newVal, oldVal) {
this.statusChanged()
},
saveable(val) {
// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event#usage_notes
// MDN says we'd better add the beforeunload event listener only when needed, and remove it when it's no longer needed
if (val) {
this.addBeforeUnloadListener()
} else {
this.removeBeforeUnloadListener()
}
},
},
methods: {
// Composing
update() {
Object.entries(this.defaultNewStatus).forEach(([key, value]) => {
if (key === 'status') return
this.newStatus[key] = value
})
},
onMentionsLineUpdate(e) {
if (this.mentionsLineReadOnly) return
- // TODO
- //this.newStatus.mentions = e
+ this.newStatus.mentions = e
},
changeVis(visibility) {
this.newStatus.visibility = visibility
},
clearStatus() {
this.saveInhibited = true
this.newStatus.status = ''
- this.newStatus.mentions = null
+ this.newStatus.mentions = ''
this.newStatus.spoilerText = ''
this.newStatus.files = []
this.newStatus.poll = null
this.newStatus.quote = null
this.newStatus.nsfw = this.defaultNewStatus.nsfw
this.newStatus.mediaDescriptions = {}
this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
- this.clearQuoteForm()
if (this.preserveFocus) {
this.$nextTick(() => {
this.$refs.textarea.focus()
})
}
const el = this.$el.querySelector('textarea')
el.style.height = 'auto'
el.style.height = undefined
this.error = null
if (this.preview) this.previewStatus()
this.saveable = false
},
- async postStatus(event, newStatus) {
+ async postStatus(event) {
if (this.posting && !this.optimisticPosting) {
return
}
if (this.disableSubmit) {
return
}
if (this.emojiInputShown) {
return
}
if (this.submitOnEnter) {
event.stopPropagation()
event.preventDefault()
}
if (
this.optimisticPosting &&
(this.isEmptyStatus || this.isOverLengthLimit)
) {
return
}
if (this.isEmptyStatus) {
this.error = this.$t('post_status.empty_status_error')
return
}
- const poll = this.hasPoll ? pollFormToMasto(newStatus.poll) : {}
if (this.pollContentError) {
this.error = this.pollContentError
return
}
this.posting = true
try {
await this.setAllMediaDescriptions()
} catch {
this.error = this.$t('post_status.media_description_error')
this.posting = false
return
}
- const postingOptions = {
- status: this.newStatusContent,
- spoilerText: newStatus.spoilerText ?? null,
- visibility: newStatus.visibility,
- sensitive: newStatus.nsfw,
- media: newStatus.files,
- store: this.$store,
- inReplyToStatusId: this.inReplyToStatusId,
- quoteId: this.quoteId,
- contentType: newStatus.contentType,
- poll,
- idempotencyKey: this.idempotencyKey,
- }
-
const postHandler = this.postHandler
? this.postHandler
: statusPoster.postStatus
- postHandler(postingOptions)
+ postHandler(this.postingOptions)
.then((data) => {
this.abandonDraft()
this.clearStatus()
this.updateIdempotencyKey()
this.$emit('posted', data)
})
.catch((error) => {
this.error = error
})
.finally(() => {
this.posting = false
})
},
// Preview
previewStatus() {
if (this.isEmptyStatus && 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: this.newStatusContent,
- spoilerText: newStatus.spoilerText || null,
- visibility: newStatus.visibility,
- sensitive: newStatus.nsfw,
+ ...this.postingOptions,
media: [],
- store: this.$store,
- inReplyToStatusId: this.inReplyToStatusId,
- quoteId: this.quoteId,
- contentType: newStatus.contentType,
poll: null,
- quote: null,
preview: true,
})
.then((data) => {
// Don't apply preview if not loading, because it means
// user has closed the preview manually.
if (!this.previewLoading) return
this.preview = data
})
.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()
}
},
// 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 })
},
removeMediaFile(fileInfo) {
const index = this.newStatus.files.indexOf(fileInfo)
this.newStatus.files.splice(index, 1)
this.$emit('resize')
},
editAttachment(fileInfo, newText) {
this.newStatus.mediaDescriptions[fileInfo.id] = newText
},
shiftUpMediaFile(fileInfo) {
const { files } = this.newStatus
const index = this.newStatus.files.indexOf(fileInfo)
+ if (index === 0) return
files.splice(index, 1)
files.splice(index - 1, 0, fileInfo)
},
shiftDnMediaFile(fileInfo) {
const { files } = this.newStatus
const index = this.newStatus.files.indexOf(fileInfo)
+ if (index === files.length - 1) return
files.splice(index, 1)
files.splice(index + 1, 0, fileInfo)
},
uploadFailed(errString, templateArgs) {
templateArgs = templateArgs || {}
this.error =
this.$t('upload.error.base') +
' ' +
this.$t('upload.error.' + errString, templateArgs)
},
startedUploadingFiles() {
this.uploadingFiles = true
},
finishedUploadingFiles() {
this.$emit('resize')
this.uploadingFiles = false
},
paste(e) {
this.autoPreview()
this.resize(e)
if (e.clipboardData.files.length > 0) {
// prevent pasting of file as text
e.preventDefault()
// Strangely, files property gets emptied after event propagation
// Trying to wrap it in array doesn't work. Plus I doubt it's possible
// to hold more than one file in clipboard.
this.dropFiles = [e.clipboardData.files[0]]
}
},
fileDrop(e) {
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
e.preventDefault() // allow dropping text like before
this.dropFiles = e.dataTransfer.files
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'hide'
}
},
fileDragStop() {
// The false-setting is done with delay because just using leave-events
// directly caused unwanted flickering, this is not perfect either but
// much less noticable.
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'fade'
this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500)
},
fileDrag(e) {
e.dataTransfer.dropEffect = this.uploadFileLimitReached ? 'none' : 'copy'
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'show'
}
},
// Auto-sizable input field
// TODO separate into its own component pls
onEmojiInputInput() {
this.$nextTick(() => {
this.resize(this.$refs.textarea)
})
},
resize(e) {
const target = e.target || e
if (!(target instanceof window.Element)) {
return
}
// Reset to default height for empty form, nothing else to do here.
if (target.value === '') {
target.style.height = null
this.$emit('resize')
return
}
const formRef = this.$refs.form
const bottomRef = this.$refs.bottom
/* Scroller is either `window` (replies in TL), sidebar (main post form,
* replies in notifs) or mobile post form. Note that getting and setting
* scroll is different for `Window` and `Element`s
*/
const bottomBottomPaddingStr =
window.getComputedStyle(bottomRef)['padding-bottom']
const bottomBottomPadding = pxStringToNumber(bottomBottomPaddingStr)
const scrollerRef =
this.$el.closest('.column.-scrollable') ||
this.$el.closest('.post-form-modal-view') ||
window
// 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 = 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
* scrollHeight returns element's scrollable content height, i.e. visible
* element + overscrolled parts of it. We use it to determine when text
* inside the textarea exceeded its height, so we can set height to prevent
* overscroll, i.e. make textarea grow with the text. HOWEVER, since we
* explicitly set new height, scrollHeight won't go below that, so we can't
* SHRINK the textarea when there's extra space. To workaround that we set
* height to 'auto' which makes textarea tiny again, so that scrollHeight
* will match text height again. HOWEVER, shrinking textarea can screw with
* the scroll since there might be not enough padding around form-bottom to even
* warrant a scroll, so it will jump to 0 and refuse to move anywhere,
* so we check current scroll position before shrinking and then restore it
* with needed delta.
*/
// this part has to be BEFORE the content size update
const currentScroll =
scrollerRef === window ? scrollerRef.scrollY : scrollerRef.scrollTop
const scrollerHeight =
scrollerRef === window
? scrollerRef.innerHeight
: scrollerRef.offsetHeight
const scrollerBottomBorder = currentScroll + scrollerHeight
// BEGIN content size update
target.style.height = 'auto'
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
// to find offset relative to scrollable container (scroller)
const bottomBottomBorder =
bottomRef.offsetHeight +
findOffset(bottomRef, scrollerRef).top +
bottomBottomPadding
const isBottomObstructed = scrollerBottomBorder < bottomBottomBorder
const isFormBiggerThanScroller = scrollerHeight < formRef.offsetHeight
const bottomChangeDelta = bottomBottomBorder - scrollerBottomBorder
// The intention is basically this;
// Keep form-bottom always visible so that submit button is in view EXCEPT
// if form element bigger than scroller and caret isn't at the end, so that
// if you scroll up and edit middle of text you won't get scrolled back to bottom
const shouldScrollToBottom =
isBottomObstructed &&
!(
isFormBiggerThanScroller &&
this.$refs.textarea.selectionStart !==
this.$refs.textarea.value.length
)
const totalDelta = shouldScrollToBottom ? bottomChangeDelta : 0
const targetScroll = Math.round(currentScroll + totalDelta)
if (scrollerRef === window) {
scrollerRef.scroll(0, targetScroll)
} else {
scrollerRef.scrollTop = targetScroll
}
},
// Poll
togglePollForm() {
this.newStatus.poll = this.hasPoll ? null : {}
},
setPoll(poll) {
this.newStatus.poll = poll
},
// Quote
- toggleQuoteForm() {
+ toggleQuoteForm() { // This is for the "attach quote" button
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()
- }
- },
// Drafts
statusChanged() {
this.autoPreview()
this.updateIdempotencyKey()
this.debouncedMaybeAutoSaveDraft()
this.saveable = true
this.saveInhibited = false
},
saveDraft() {
if (!this.disableDraft && !this.saveInhibited) {
if (this.safeToSaveDraft) {
return this.$store
.dispatch('addOrSaveDraft', { draft: this.newStatus })
.then((id) => {
if (this.newStatus.id !== id) {
this.newStatus.id = id
}
this.saveable = false
if (!this.shouldAutoSaveDraft) {
this.clearStatus()
this.updateIdempotencyKey()
this.$emit('draft-done')
}
})
} else if (this.hasEmptyDraft) {
// There is a draft, but there is nothing in it, clear it
return this.abandonDraft().then(() => {
this.saveable = false
if (!this.shouldAutoSaveDraft) {
this.clearStatus()
this.updateIdempotencyKey()
this.$emit('draft-done')
}
})
}
}
return Promise.resolve()
},
maybeAutoSaveDraft() {
if (this.shouldAutoSaveDraft) {
this.saveDraft(false)
}
},
abandonDraft() {
return this.$store.dispatch('abandonDraft', { id: this.newStatus.id })
},
getDraft() {
const maybeDraft = this.$store.state.drafts.drafts[this.draftId]
if (this.draftId && maybeDraft) {
return maybeDraft
} else {
const existingDrafts = this.$store.getters.draftsByTypeAndRefId(
this.statusType,
this.refId,
)
if (existingDrafts.length) {
return existingDrafts[0]
}
}
// No draft available, fall back
},
requestClose() {
if (!this.saveable) {
this.$emit('close-accepted')
} else {
this.$refs.draftCloser.requestClose()
}
},
saveAndCloseDraft() {
this.saveDraft().then(() => {
this.$emit('close-accepted')
})
},
discardAndCloseDraft() {
this.abandonDraft().then(() => {
this.$emit('close-accepted')
})
},
addBeforeUnloadListener() {
this._beforeUnloadListener ||= () => {
this.saveDraft()
}
window.addEventListener('beforeunload', this._beforeUnloadListener)
},
removeBeforeUnloadListener() {
if (this._beforeUnloadListener) {
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()
},
}
export default PostStatusForm
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index b188d1b347..e387862066 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -1,454 +1,454 @@
<template>
<div
ref="form"
class="post-status-form"
v-if="initialized"
>
<form
autocomplete="off"
@submit.prevent
@dragover.prevent="fileDrag"
>
<div class="form-group">
<div
v-if="!currentUser.locked && newStatus.visibility == 'private' && !disableLockWarning"
class="visibility-notice notice-dismissible"
>
<i18n-t
keypath="post_status.account_not_locked_warning"
tag="p"
class=""
scope="global"
>
<button
class="button-unstyled -link"
@click="openProfileTab"
>
{{ $t('post_status.account_not_locked_warning_link') }}
</button>
</i18n-t>
</div>
<p
v-if="!hideScopeNotice && newStatus.visibility === 'public'"
class="visibility-notice notice-dismissible"
>
<span>{{ $t('post_status.scope_notice.public') }}</span>
<a
class="fa-scale-110 fa-old-padding dismiss"
:title="$t('post_status.scope_notice_dismiss')"
role="button"
tabindex="0"
@click.prevent="dismissScopeNotice()"
>
<FAIcon icon="times" />
</a>
</p>
<p
v-else-if="!hideScopeNotice && newStatus.visibility === 'unlisted'"
class="visibility-notice notice-dismissible"
>
<span>{{ $t('post_status.scope_notice.unlisted') }}</span>
<a
class="fa-scale-110 fa-old-padding dismiss"
:title="$t('post_status.scope_notice_dismiss')"
role="button"
tabindex="0"
@click.prevent="dismissScopeNotice()"
>
<FAIcon icon="times" />
</a>
</p>
<p
v-else-if="!hideScopeNotice && newStatus.visibility === 'private' && currentUser.locked"
class="visibility-notice notice-dismissible"
>
<span>{{ $t('post_status.scope_notice.private') }}</span>
<a
class="fa-scale-110 fa-old-padding dismiss"
:title="$t('post_status.scope_notice_dismiss')"
role="button"
tabindex="0"
@click.prevent="dismissScopeNotice()"
>
<FAIcon icon="times" />
</a>
</p>
<p
v-else-if="!hideScopeNotice && newStatus.visibility === 'direct'"
class="visibility-notice notice-dismissible"
>
<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="isEdit"
class="visibility-notice edit-warning"
>
<p>{{ $t('post_status.edit_remote_warning') }}</p>
<p>{{ $t('post_status.edit_unsupported_warning') }}</p>
</div>
<div
v-if="!disablePreview"
class="preview-heading"
>
<a
class="preview-toggle faint"
@click.stop.prevent="togglePreview"
>
{{ $t('post_status.preview') }}
<FAIcon :icon="showPreview ? 'chevron-left' : 'chevron-right'" />
</a>
<div
v-show="previewLoading"
class="preview-spinner"
>
<FAIcon
class="fa-old-padding"
spin
icon="circle-notch"
/>
</div>
<div
v-if="quotable"
role="radiogroup"
class="reply-or-quote-selector"
>
<Checkbox
v-model="quoteThreadToggled"
:radio="true"
:disabled="quoteFormVisible"
>
{{ $t('post_status.quote_option') }}
</Checkbox>
<Checkbox
role="radio"
:radio="true"
:model-value="!quoteThreadToggled"
:disabled="quoteFormVisible"
@update:model-value="e => quoteThreadToggled = !e"
>
{{ $t('post_status.reply_option') }}
</Checkbox>
</div>
</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>
<div class="input inputs-wrapper">
<EmojiInput
v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)"
v-model="newStatus.spoilerText"
enable-emoji-picker
:suggest="emojiSuggestor"
class="input form-control subject-input unstyled"
>
<template #default="inputProps">
<input
v-model="newStatus.spoilerText"
type="text"
:placeholder="$t('post_status.content_warning')"
:disabled="posting && !optimisticPosting"
v-bind="propsToNative(inputProps)"
size="1"
class="input form-post-subject unstyled"
>
</template>
</EmojiInput>
<input
v-if="mentionsLine"
:value="mentionsLineReadOnly ? mentionsString : newStatus.mentionsLine"
@change="onMentionsLineUpdate"
type="text"
:placeholder="$t('post_status.mentions_line')"
:disabled="mentionsLineReadOnly || (posting && !optimisticPosting)"
size="1"
class="input mentions-input form-post-mentions unstyled"
>
<EmojiInput
ref="emoji-input"
v-model="newStatus.status"
:suggest="emojiUserSuggestor"
:placement="emojiPickerPlacement"
class="input form-control main-input unstyled"
enable-sticker-picker
enable-emoji-picker
:newline-on-ctrl-enter="submitOnEnter"
@input="onEmojiInputInput"
@sticker-uploaded="addMediaFile"
@sticker-upload-failed="uploadFailed"
@shown="handleEmojiInputShow"
>
<template #default="inputProps">
<textarea
ref="textarea"
v-model="newStatus.status"
:placeholder="placeholder || $t('post_status.default')"
rows="1"
cols="1"
:disabled="posting && !optimisticPosting"
class="input form-post-body"
:class="{ 'scrollable-form': !!maxHeight }"
v-bind="propsToNative(inputProps)"
- @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
+ @keydown.exact.enter="submitOnEnter && postStatus($event)"
@keydown.meta.enter="postStatus($event, newStatus)"
- @keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)"
+ @keydown.ctrl.enter="!submitOnEnter && postStatus($event)"
@input="resize"
@compositionupdate="resize"
@paste="paste"
/>
<p
v-if="hasStatusLengthLimit"
class="character-counter faint"
:class="{ error: isOverLengthLimit }"
>
{{ charactersLeft }}
</p>
</template>
</EmojiInput>
</div>
<div
v-if="!disableScopeSelector"
class="visibility-tray"
>
<scope-selector
v-if="!disableVisibilitySelector"
ref="scopeSelector"
:show-all="showAllScopes"
:user-default="userDefaultScope"
:original-scope="newStatus.visibility"
:initial-scope="newStatus.visibility"
@change="changeVis"
/>
<div
v-if="postFormats.length > 1"
class="text-format"
>
<Select
v-model="newStatus.contentType"
class="input form-control unstyled"
:attrs="{ 'aria-label': $t('post_status.content_type_selection') }"
unstyled="true"
>
<option
v-for="postFormat in postFormats"
:key="postFormat"
:value="postFormat"
>
{{ $t(`post_status.content_type["${postFormat}"]`) }}
</option>
</Select>
</div>
<div
v-if="postFormats.length === 1 && postFormats[0] !== 'text/plain'"
class="text-format"
>
<span class="only-format">
{{ $t(`post_status.content_type["${postFormats[0]}"]`) }}
</span>
</div>
</div>
</div>
<PollForm
v-if="pollsAvailable"
ref="pollForm"
:visible="pollFormVisible"
v-model="newStatus.poll"
/>
<QuoteForm
v-if="quotingAvailable"
ref="quoteForm"
:visible="quoteFormVisible"
:id="newStatus.quote?.id"
:url="newStatus.quote?.url"
@update:url="url => newStatus.quote.url = url"
@update:id="id => newStatus.quote.id = id"
/>
<span
v-if="!disableDraft && shouldAutoSaveDraft"
class="auto-save-status"
>
{{ autoSaveState }}
</span>
<div
ref="bottom"
class="form-bottom"
>
<div class="form-bottom-left">
<media-upload
ref="mediaUpload"
class="bottom-left-button media-upload-icon"
:drop-files="dropFiles"
:disabled="uploadFileLimitReached"
@uploading="startedUploadingFiles"
@uploaded="addMediaFile"
@upload-failed="uploadFailed"
@all-uploaded="finishedUploadingFiles"
/>
<button
v-if="pollsAvailable"
class="bottom-left-button poll-icon button-unstyled"
:class="{ toggled: pollFormVisible }"
:title="$t('polls.add_poll')"
@click="togglePollForm"
>
<FAIcon icon="poll-h" />
</button>
<button
v-if="quotingAvailable"
class="bottom-left-button quote-icon button-unstyled"
:disabled="quoteThreadToggled"
:class="{ toggled: quoteFormVisible }"
:title="$t('tool_tip.add_quote')"
@click="toggleQuoteForm"
>
<FAIcon icon="quote-right" />
</button>
</div>
<div class="btn-group post-button-group">
<button
class="btn button-default post-button"
:disabled="isOverLengthLimit || posting || uploadingFiles || disableSubmit"
- @click.stop.prevent="postStatus($event, newStatus)"
+ @click.stop.prevent="postStatus($event)"
>
<template v-if="posting">
{{ $t('post_status.posting') }}
</template>
<template v-else>
{{ $t('post_status.post') }}
</template>
</button>
<Popover
v-if="!hideExtraActions"
class="more-post-actions"
:normal-button="true"
trigger="click"
placement="bottom"
:offset="{ y: 5 }"
:trigger-attrs="{ 'aria-label': $t('post_status.more_post_actions') }"
>
<template #trigger>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="chevron-down"
/>
</template>
<template #content="{close}">
<div
class="dropdown-menu"
role="menu"
>
<div
class="menu-item dropdown-item"
:class="{ disabled: !safeToSaveDraft }"
>
<button
v-if="!hideDraft || !disableDraft"
class="main-button"
role="menu"
:disabled="!safeToSaveDraft"
@click.prevent="saveDraft"
@click="close"
>
<template v-if="closeable">
{{ $t('post_status.save_to_drafts_and_close_button') }}
</template>
<template v-else>
{{ $t('post_status.save_to_drafts_button') }}
</template>
</button>
</div>
</div>
</template>
</Popover>
</div>
</div>
<small class="keyboard-enter-hint faint">
<i v-if="submitOnEnter">
{{ $t('post_status.enter_submits') }}
</i>
<i v-else>
{{ $t('post_status.enter_newline') }}
</i>
</small>
<div
v-show="showDropIcon !== 'hide'"
:style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }"
class="drop-indicator"
@dragleave="fileDragStop"
@drop.stop="fileDrop"
>
<FAIcon :icon="uploadFileLimitReached ? 'ban' : 'upload'" />
</div>
<div
v-if="error"
class="alert error -dismissible"
>
<span>
{{ error }}
</span>
<button
class="button-unstyled"
@click="clearError"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
/>
</button>
</div>
<Gallery
v-if="newStatus.files && newStatus.files.length > 0"
class="attachments"
:grid="true"
:nsfw="false"
:attachments="newStatus.files"
:descriptions="newStatus.mediaDescriptions"
:set-media="() => setMedia()"
:editable="true"
:edit-attachment="editAttachment"
:remove-attachment="removeMediaFile"
:shift-up-attachment="newStatus.files.length > 1 && shiftUpMediaFile"
:shift-dn-attachment="newStatus.files.length > 1 && shiftDnMediaFile"
@play="$emit('mediaplay', 'newStatus')"
@pause="$emit('mediapause', 'newStatus')"
/>
<div
v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox"
class="upload_settings"
>
<Checkbox v-model="newStatus.nsfw">
{{ $t('post_status.attachments_sensitive') }}
</Checkbox>
</div>
</form>
<DraftCloser
ref="draftCloser"
@save="saveAndCloseDraft"
@discard="discardAndCloseDraft"
/>
</div>
</template>
<script src="./post_status_form.js"></script>
<style src="./post_status_form.scss" lang="scss"></style>
diff --git a/src/components/status_action_buttons/buttons_definitions.js b/src/components/status_action_buttons/buttons_definitions.js
index b3762b8ecb..6c94b43a5f 100644
--- a/src/components/status_action_buttons/buttons_definitions.js
+++ b/src/components/status_action_buttons/buttons_definitions.js
@@ -1,345 +1,345 @@
import { useEditStatusStore } from 'src/stores/editStatus.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useReportsStore } from 'src/stores/reports.js'
import { useStatusHistoryStore } from 'src/stores/statusHistory.js'
const PRIVATE_SCOPES = new Set(['private', 'direct'])
const PUBLIC_SCOPES = new Set(['public', 'unlisted'])
export const BUTTONS = [
{
// =========
// REPLY
// =========
name: 'reply',
label: 'tool_tip.reply',
icon: 'reply',
active: ({ replying }) => replying,
counter: ({ status }) => status.replies_count,
anon: true,
anonLink: true,
toggleable: true,
closeIndicator: 'times',
activeIndicator: null,
action({ emit }) {
emit('toggleReplying')
return Promise.resolve()
},
},
{
// =========
// REPEAT
// =========
name: 'retweet',
label: ({ status }) =>
status.repeated ? 'tool_tip.unrepeat' : 'tool_tip.repeat',
icon({ status, currentUser }) {
if (
currentUser.id !== status.user.id &&
PRIVATE_SCOPES.has(status.visibility)
) {
return 'lock'
}
return 'retweet'
},
animated: true,
active: ({ status }) => status.repeated,
counter: ({ status }) => status.repeat_num,
anonLink: true,
interactive: ({ status, currentUser }) =>
!!currentUser &&
(currentUser.id === status.user.id ||
!PRIVATE_SCOPES.has(status.visibility)),
toggleable: true,
confirm: ({ status, getters }) =>
!status.repeated && useMergedConfigStore().mergedConfig.modalOnRepeat,
confirmStrings: {
title: 'status.repeat_confirm_title',
body: 'status.repeat_confirm',
confirm: 'status.repeat_confirm_accept_button',
cancel: 'status.repeat_confirm_cancel_button',
},
action({ status, dispatch }) {
if (!status.repeated) {
return dispatch('retweet', { id: status.id })
} else {
return dispatch('unretweet', { id: status.id })
}
},
},
{
// =========
// FAVORITE
// =========
name: 'favorite',
label: ({ status }) =>
status.favorited ? 'tool_tip.unfavorite' : 'tool_tip.favorite',
icon: ({ status }) =>
status.favorited ? ['fas', 'star'] : ['far', 'star'],
animated: true,
active: ({ status }) => status.favorited,
counter: ({ status }) => status.fave_num,
anonLink: true,
toggleable: true,
action({ status, dispatch }) {
if (!status.favorited) {
return dispatch('favorite', { id: status.id })
} else {
return dispatch('unfavorite', { id: status.id })
}
},
},
{
// =========
// EMOJI REACTIONS
// =========
name: 'emoji',
label: 'tool_tip.add_reaction',
icon: ['far', 'face-smile-beam'],
interactive: () => true,
active: ({ emojiPickerShown }) => emojiPickerShown,
toggleable: true,
anonLink: true,
},
{
// =========
// MUTE
// =========
name: 'mute',
icon: 'eye-slash',
label: 'status.mute_ellipsis',
if: ({ loggedIn }) => loggedIn,
toggleable: false,
dropdown: true,
action({ status, dispatch, emit }) {
/* prevent hiding */
},
},
{
// =========
// PIN STATUS
// =========
name: 'pin',
icon: 'thumbtack',
label: ({ status }) => (status.pinned ? 'status.unpin' : 'status.pin'),
if({ status, loggedIn, currentUser }) {
return (
loggedIn &&
status.user.id === currentUser.id &&
PUBLIC_SCOPES.has(status.visibility)
)
},
action({ status, dispatch }) {
if (status.pinned) {
return dispatch('unpinStatus', status.id)
} else {
return dispatch('pinStatus', status.id)
}
},
},
{
// =========
// BOOKMARK
// =========
name: 'bookmark',
icon: ({ status }) =>
status.bookmarked ? ['fas', 'bookmark'] : ['far', 'bookmark'],
toggleable: true,
active: ({ status }) => status.bookmarked,
label: ({ status }) =>
status.bookmarked ? 'status.unbookmark' : 'status.bookmark',
if: ({ loggedIn }) => loggedIn,
action({ status, dispatch }) {
if (status.bookmarked) {
return dispatch('unbookmark', { id: status.id })
} else {
return dispatch('bookmark', { id: status.id })
}
},
},
{
// =========
// EDIT HISTORY
// =========
name: 'editHistory',
icon: 'history',
label: 'status.status_history',
if({ status, state }) {
return (
useInstanceCapabilitiesStore().editingAvailable &&
status.edited_at !== null
)
},
action({ status }) {
const originalStatus = { ...status }
const stripFieldsList = [
'attachments',
'created_at',
'emojis',
'text',
'raw_html',
'nsfw',
'poll',
'summary',
'summary_raw_html',
]
stripFieldsList.forEach((p) => delete originalStatus[p])
useStatusHistoryStore().openStatusHistoryModal(originalStatus)
return Promise.resolve()
},
},
{
// =========
// EDIT
// =========
name: 'edit',
icon: 'pen',
label: 'status.edit',
if({ status, loggedIn, currentUser, state }) {
return (
loggedIn &&
useInstanceCapabilitiesStore().editingAvailable &&
status.user.id === currentUser.id
)
},
action({ dispatch, status }) {
return dispatch('fetchStatusSource', { id: status.id }).then((data) =>
useEditStatusStore().openEditStatusModal({
statusId: status.id,
- subject: data.spoiler_text,
+ statusSubject: data.spoiler_text,
statusText: data.text,
statusIsSensitive: status.nsfw,
statusPoll: status.poll,
statusFiles: [...status.attachments],
- visibility: status.visibility,
+ statusVisibility: status.visibility,
statusContentType: data.content_type,
}),
)
},
},
{
// =========
// OPEN IN CHAT VIEW
// =========
name: 'chat_view',
icon: 'comments',
label: 'status.open_in_chat_view',
if({ chatView }) {
return !chatView
},
action({ router, status }) {
router.push({ name: 'conversation2', params: { statusId: status.id } })
},
},
{
// =========
// OPEN IN THREAD VIEW
// =========
name: 'thread_view',
icon: 'list',
label: 'status.open_in_thread_view',
if({ chatView }) {
return chatView
},
action({ router, status }) {
router.push({ name: 'conversation', params: { id: status.id } })
},
},
{
// =========
// DELETE
// =========
name: 'delete',
icon: 'times',
label: 'status.delete',
if({ status, loggedIn, currentUser }) {
return (
loggedIn &&
(status.user.id === currentUser.id ||
currentUser.privileges.has('messages_delete'))
)
},
confirm: ({ getters }) => useMergedConfigStore().mergedConfig.modalOnDelete,
confirmStrings: {
title: 'status.delete_confirm_title',
body: 'status.delete_confirm',
confirm: 'status.delete_confirm_accept_button',
cancel: 'status.delete_confirm_cancel_button',
},
action({ dispatch, status }) {
return dispatch('deleteStatus', { id: status.id })
},
},
{
// =========
// CHANGE SCOPE
// =========
name: 'changeScope',
icon: 'eye',
label: 'status.admin_change_scope',
if({ status, loggedIn, currentUser }) {
return (
loggedIn &&
(status.user.id === currentUser.id ||
currentUser.privileges.has('messages_delete'))
)
},
toggleable: false,
dropdown: true,
action({ status, dispatch, emit }) {
/* prevent hiding */
},
},
{
// =========
// SHARE/COPY
// =========
name: 'share',
icon: 'share-alt',
label: 'status.copy_link',
action({ state, status, router }) {
navigator.clipboard.writeText(
[
useInstanceStore().server,
router.resolve({ name: 'conversation', params: { id: status.id } })
.href,
].join(''),
)
return Promise.resolve()
},
},
{
// =========
// EXTERNAL
// =========
name: 'external',
icon: 'external-link-alt',
label: 'status.external_source',
link: ({ status }) => status.external_url,
},
{
// =========
// REPORT
// =========
name: 'report',
icon: 'flag',
label: 'user_card.report',
if: ({ loggedIn }) => loggedIn,
action({ status }) {
useReportsStore().openUserReportingModal({
userId: status.user.id,
statusIds: [status.id],
})
return Promise.resolve()
},
},
].map((button) => {
return Object.fromEntries(
Object.entries(button).map(([k, v]) => [
k,
typeof v === 'function' || k === 'name' ? v : () => v,
]),
)
})
diff --git a/test/fixtures/setup_test.js b/test/fixtures/setup_test.js
index 85a062cc76..1a04b9549c 100644
--- a/test/fixtures/setup_test.js
+++ b/test/fixtures/setup_test.js
@@ -1,145 +1,145 @@
import { createTestingPinia } from '@pinia/testing'
import { config } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import VueVirtualScroller from 'vue-virtual-scroller'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import Status from 'src/components/status/status.vue'
import StillImage from 'src/components/still-image/still-image.vue'
import makeMockStore from './mock_store'
import routes from 'src/boot/routes'
export const $t = (msg) => msg
const $i18n = { t: (msg) => msg }
const applyAfterStore = (store, afterStore) => {
afterStore(store)
return store
}
const getDefaultOpts = ({
afterStore = () => {
/* no-op */
},
} = {}) => ({
global: {
plugins: [
applyAfterStore(makeMockStore(), afterStore),
+ createTestingPinia(),
VueVirtualScroller,
createRouter({
history: createMemoryHistory(),
routes: routes({
state: {
users: {
currentUser: {},
},
instance: {},
},
}),
}),
(Vue) => {
Vue.directive('body-scroll-lock', {})
},
- createTestingPinia(),
],
components: {
RichContent,
Status,
StillImage,
},
stubs: {
I18nT: true,
teleport: true,
FAIcon: true,
FALayers: true,
},
mocks: {
$t,
$i18n,
},
},
})
// https://github.com/vuejs/vue-test-utils/issues/960
const customBehaviors = () => {
const filterByText = (keyword) => {
const match =
keyword instanceof RegExp
? (target) => target && keyword.test(target)
: (target) => keyword === target
return (wrapper) =>
match(wrapper.text()) ||
match(wrapper.attributes('aria-label')) ||
match(wrapper.attributes('title'))
}
return {
findComponentByText(searchedComponent, text) {
return this.findAllComponents(searchedComponent)
.filter(filterByText(text))
.at(0)
},
findByText(searchedElement, text) {
return this.findAll(searchedElement).filter(filterByText(text)).at(0)
},
}
}
config.plugins.VueWrapper.install(customBehaviors)
export const mountOpts = (allOpts = {}) => {
const { afterStore, ...opts } = allOpts
const defaultOpts = getDefaultOpts({ afterStore })
const mergedOpts = {
...opts,
global: {
...defaultOpts.global,
},
}
if (opts.global) {
mergedOpts.global.plugins = mergedOpts.global.plugins.concat(
opts.global.plugins || [],
)
Object.entries(opts.global).forEach(([k, v]) => {
if (k === 'plugins') {
return
}
if (defaultOpts.global[k]) {
mergedOpts.global[k] = {
...defaultOpts.global[k],
...v,
}
} else {
mergedOpts.global[k] = v
}
})
}
return mergedOpts
}
// https://stackoverflow.com/questions/78033718/how-can-i-wait-for-an-emitted-event-of-a-mounted-component-in-vue-test-utils
export const waitForEvent = (
wrapper,
event,
{ timeout = 1000, timesEmitted = 1 } = {},
) => {
const tick = 10
return vi.waitFor(
() => {
const e = wrapper.emitted(event)
if (e && e.length >= timesEmitted) {
return
}
throw new Error('event is not emitted')
},
{
timeout,
interval: tick,
},
)
}
diff --git a/test/unit/specs/components/chat_message_list.spec.js b/test/unit/specs/components/chat_message_list.spec.js
index 53e14b2a7e..126332b958 100644
--- a/test/unit/specs/components/chat_message_list.spec.js
+++ b/test/unit/specs/components/chat_message_list.spec.js
@@ -1,244 +1,244 @@
import { shallowMount } from '@vue/test-utils'
-import ChatMessageList from './chat_message_list.vue'
+import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
describe('ChatMessageList', () => {
describe('computed.chatItems', () => {
it('Inserts date separators', () => {
const component = shallowMount(ChatMessageList, {
props: {
messages: [
{
id: '0',
account_id: 'Alice',
created_at: new Date('2020-06-22T20:00:00.000Z'),
},
{
id: '1',
account_id: 'Alice',
created_at: new Date('2020-06-22T20:01:00.000Z'),
},
{
id: '2',
account_id: 'Alice',
created_at: new Date('2020-06-23T20:00:00.000Z'),
},
],
},
})
expect(component.vm.chatItems.map((i) => i.type)).to.eql([
'message',
'message',
'date',
'message',
])
})
it('Inserts date header if needed', () => {
const component = shallowMount(ChatMessageList, {
props: {
headerDate: true,
messages: [
{
id: '0',
account_id: 'Alice',
created_at: new Date('2020-06-23T20:00:00.000Z'),
},
],
},
})
expect(component.vm.chatItems.map((i) => i.type)).to.eql([
'date',
'message',
])
})
it('Inserts time separators if messages were sent with considerable delay (5 minutes)', () => {
const component = shallowMount(ChatMessageList, {
props: {
messages: [
{
id: '0',
account_id: 'Alice',
created_at: new Date('2020-06-22T20:00:00.000Z'),
},
{
id: '1',
account_id: 'Alice',
created_at: new Date('2020-06-22T20:06:00.000Z'),
},
{
id: '2',
account_id: 'Alice',
created_at: new Date('2020-06-23T20:00:00.000Z'),
},
],
},
})
expect(component.vm.chatItems.map((i) => i.type)).to.eql([
'message',
'date',
'message',
'date',
'message',
])
expect(component.vm.chatItems.map((i) => i.isTime)).to.eql([
undefined,
true,
undefined,
false,
undefined,
])
})
it('Groups message chains by time and author', () => {
const component = shallowMount(ChatMessageList, {
props: {
messages: [
{
id: '0',
account_id: 'Alice',
created_at: new Date('2020-06-22T20:00:00.000Z'),
},
{
id: '1',
account_id: 'Alice',
created_at: new Date('2020-06-22T20:06:00.000Z'),
},
{
id: '2',
account_id: 'Alice',
created_at: new Date('2020-06-23T20:00:00.000Z'),
},
{
id: '3',
account_id: 'Bob',
created_at: new Date('2020-06-23T20:01:00.000Z'),
},
{
id: '4',
account_id: 'Bob',
created_at: new Date('2020-06-23T20:02:00.000Z'),
},
{
id: '5',
account_id: 'Bob',
created_at: new Date('2020-06-23T20:03:00.000Z'),
},
{
id: '6',
account_id: 'Eve',
created_at: new Date('2020-06-23T20:04:00.000Z'),
},
],
},
})
// Type check
expect(component.vm.chatItems.map((i) => i.type)).to.eql([
'message',
'date',
'message',
'date',
'message',
'message',
'message',
'message',
'message',
])
// Chain head/Tail checks
expect(component.vm.chatItems.map((i) => [i.isHead, i.isTail])).to.eql([
[true, true],
[undefined, undefined],
[true, true],
[undefined, undefined],
[true, true],
[true, false],
[false, false],
[false, true],
[true, true],
])
// Unique ID is randomly generated so we have to compare data against itself
// Two messages from Bob next to each other
expect(component.vm.chatItems[5].messageChainId).to.eql(
component.vm.chatItems[6].messageChainId,
)
// Message from Even right after Bob
expect(component.vm.chatItems[7].messageChainId).to.not.eql(
component.vm.chatItems[8].messageChainId,
)
})
})
describe('methods.getPreviousItem', () => {
describe('Finds correct previous meaningful (non-separator) message in the chatlist', () => {
let component
beforeEach(() => {
component = shallowMount(ChatMessageList, {
props: {
messages: [
{
id: '0',
account_id: 'Alice',
created_at: new Date('2020-06-22T20:00:00.000Z'),
},
{
// Separator
id: '1', // 2
account_id: 'Alice',
created_at: new Date('2020-06-22T20:06:00.000Z'),
},
{
// Separator
id: '2', // 4
account_id: 'Alice',
created_at: new Date('2020-06-23T20:00:00.000Z'),
},
{
id: '3', // 5
account_id: 'Bob',
created_at: new Date('2020-06-23T20:01:00.000Z'),
},
{
id: '4', // 6
account_id: 'Bob',
created_at: new Date('2020-06-23T20:02:00.000Z'),
},
{
id: '5', // 7
account_id: 'Bob',
created_at: new Date('2020-06-23T20:03:00.000Z'),
},
{
id: '6', // 8
account_id: 'Eve',
created_at: new Date('2020-06-23T20:04:00.000Z'),
},
],
},
})
})
it('Directly next to each other', () => {
const correct = component.vm.chatItems[6]
expect(component.vm.getPreviousItem(7)).to.eql(correct)
})
it('Across separator', () => {
const correct = component.vm.chatItems[2]
expect(component.vm.getPreviousItem(4)).to.eql(correct)
})
it('Returns null if no previous item exist', () => {
const correct = null
expect(component.vm.getPreviousItem(0)).to.eql(correct)
})
})
})
})
diff --git a/test/unit/specs/components/chat_view.spec.js b/test/unit/specs/components/chat_view.spec.js
index 90adffa860..5eb56bc708 100644
--- a/test/unit/specs/components/chat_view.spec.js
+++ b/test/unit/specs/components/chat_view.spec.js
@@ -1,134 +1,134 @@
import { createTestingPinia } from '@pinia/testing'
import { shallowMount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
-import ChatView from './chat_view.vue'
+import ChatView from 'src/components/chat_view/chat_view.vue'
const message1 = {
id: '1',
chat_id: 2,
idempotency_key: '1',
created_at: new Date('2020-06-22T18:45:53.000Z'),
}
const message2 = {
id: '2',
chat_id: 2,
idempotency_key: '2',
account_id: '9vmRb29zLQReckr5ay',
created_at: new Date('2020-06-22T18:45:56.000Z'),
}
const message3 = {
id: '3',
chat_id: 2,
idempotency_key: '3',
account_id: '9vmRb29zLQReckr5ay',
created_at: new Date('2020-07-22T18:45:59.000Z'),
}
const global = {
mocks: {
$store: {
state: {
api: {},
users: {},
},
},
$route: {
params: {
recipient_id: 2,
},
},
$router: {
push: () => {
/* noop */
},
},
},
stubs: {
FAIcon: true,
},
}
describe('ChatView methods', () => {
let component
beforeEach(() => {
setActivePinia(createTestingPinia())
component = shallowMount(ChatView, { global, props: { testMode: true } })
component.vm.chat = { id: 2 }
})
describe('addMessages', () => {
it("Doesn't add duplicates", () => {
component.vm.addMessages({ messages: [message1] })
component.vm.addMessages({ messages: [message1] })
expect(component.vm.messages.length).to.eql(1)
component.vm.addMessages({ messages: [message2] })
expect(component.vm.messages.length).to.eql(2)
})
it('Updates minId and lastMessage and newMessageCount', () => {
component.vm.addMessages({ messages: [message1] })
expect(component.vm.maxId).to.eql(message1.id)
expect(component.vm.minId).to.eql(message1.id)
expect(component.vm.newMessageCount).to.eql(1)
component.vm.addMessages({ messages: [message2] })
expect(component.vm.maxId).to.eql(message2.id)
expect(component.vm.minId).to.eql(message1.id)
expect(component.vm.newMessageCount).to.eql(2)
component.vm.readChat()
expect(component.vm.newMessageCount).to.eql(0)
expect(component.vm.lastReadMessageId).to.eql(message2.id)
// Add message with higher id
component.vm.addMessages({ messages: [message3] })
expect(component.vm.newMessageCount).to.eql(1)
})
})
describe('deleteChatMessage', () => {
it('Updates minId and lastMessage', () => {
component.vm.addMessages({ messages: [message1] })
component.vm.addMessages({ messages: [message2] })
component.vm.addMessages({ messages: [message3] })
expect(component.vm.maxId).to.eql(message3.id)
expect(component.vm.minId).to.eql(message1.id)
component.vm.deleteChatMessage({ messageId: message3.id })
expect(component.vm.maxId).to.eql(message2.id)
expect(component.vm.minId).to.eql(message1.id)
component.vm.deleteChatMessage({ messageId: message1.id })
expect(component.vm.maxId).to.eql(message2.id)
expect(component.vm.minId).to.eql(message2.id)
})
})
describe('cullOlder', () => {
it('keeps 50 newest messages and messagesIndex matches', () => {
for (let i = 100; i > 0; i--) {
// Use decimal values with toFixed to hack together constant length predictable strings
component.vm.addMessages({
messages: [
{
...message1,
id: 'a' + (i / 1000).toFixed(3),
idempotency_key: i,
},
],
})
}
component.vm.cullOlder()
expect(component.vm.messages.length).to.eql(50)
expect(component.vm.messages[0].id).to.eql('a0.051')
expect(component.vm.minId).to.eql('a0.051')
expect(component.vm.messages[49].id).to.eql('a0.100')
expect(Object.keys(component.vm.messagesIndex).length).to.eql(50)
})
})
})
diff --git a/test/unit/specs/components/post_status_form.spec.js b/test/unit/specs/components/post_status_form.spec.js
index 8f3d6d80ff..0f8a076773 100644
--- a/test/unit/specs/components/post_status_form.spec.js
+++ b/test/unit/specs/components/post_status_form.spec.js
@@ -1,61 +1,297 @@
+import { vi } from 'vitest'
+
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import { mountOpts } from '../../../fixtures/setup_test'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
const currentUser = {
id: 'current-user',
default_scope: 'public',
locked: false,
}
const repliedUser = {
id: 'replied-user',
screen_name: 'replied',
}
const repliedStatus = {
id: 'status-1',
visibility: 'public',
user: repliedUser,
}
-const replyMountOpts = () =>
+const repliedStatus2 = {
+ id: 'status-2',
+ visibility: 'private',
+ summary: 'subject',
+ user: repliedUser,
+}
+
+const replyMountOpts = (props) =>
mountOpts({
- props: {
- replyTo: repliedStatus.id,
- repliedUser,
- attentions: [],
- copyMessageScope: repliedStatus.visibility,
- disableDraft: true,
- },
+ props,
afterStore(store) {
store.state.users.currentUser = currentUser
store.state.statuses.allStatusesObject = {
[repliedStatus.id]: repliedStatus,
}
},
})
describe('PostStatusForm', () => {
beforeEach(() => {
- setActivePinia(createTestingPinia())
+ vi.useFakeTimers()
})
- it('initializes a reply form when quoteReply is unset', () => {
- useInstanceCapabilitiesStore().quotingAvailable = true
+ it('Clean empty initial state', () => {
+ const wrapper = mount(PostStatusForm, replyMountOpts())
+ expect(wrapper.vm.statusType).to.equal('new')
+ expect(wrapper.vm.newStatus.spoilerText).to.eql('')
+ expect(wrapper.vm.newStatus.mentions).to.eql('')
+ expect(wrapper.vm.newStatus.status).to.eql('')
+ })
+
+ it('Reset cleans form to pristine state equal to state form was when created', () => {
const wrapper = mount(PostStatusForm, replyMountOpts())
- expect(wrapper.vm.newStatus.type).to.equal('reply')
- expect(wrapper.vm.newStatus.quote).to.eql({
- id: '',
- url: '',
- thread: false,
+ const initial = { ...wrapper.vm.newStatus }
+ wrapper.vm.clearStatus()
+
+ expect(wrapper.vm.newStatus).to.eql(initial)
+ })
+
+ it('Initializes a reply form', () => {
+ const wrapper = mount(PostStatusForm, replyMountOpts({
+ repliedStatus: repliedStatus,
+ }))
+
+ useInstanceCapabilitiesStore().quotingAvailable = true
+
+ expect(wrapper.vm.statusType).to.equal('reply')
+ expect(wrapper.vm.isReply).to.equal(true)
+ expect(wrapper.vm.refId).to.equal('status-1')
+ expect(wrapper.vm.quotable).to.equal(true)
+ expect(wrapper.vm.inReplyToStatusId).to.equal('status-1')
+ expect(wrapper.vm.newStatus.quote).to.eql(null)
+ expect(wrapper.vm.newStatus.poll).to.eql(null)
+ expect(wrapper.vm.newStatus.spoilerText).to.eql('')
+ expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
+ expect(wrapper.vm.newStatus.status).to.eql('@replied ')
+ expect(wrapper.vm.newStatus.visibility).to.eql('public')
+ })
+
+ it('Copies scope and subject line, disables quoting for locked posts', () => {
+ const wrapper = mount(PostStatusForm, replyMountOpts({
+ repliedStatus: repliedStatus2,
+ }))
+
+ useInstanceCapabilitiesStore().quotingAvailable = true
+
+ expect(wrapper.vm.statusType).to.equal('reply')
+ expect(wrapper.vm.isReply).to.equal(true)
+ expect(wrapper.vm.quotable).to.equal(false)
+ expect(wrapper.vm.newStatus.quote).to.eql(null)
+ expect(wrapper.vm.newStatus.poll).to.eql(null)
+ expect(wrapper.vm.newStatus.spoilerText).to.eql('re: subject')
+ expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
+ expect(wrapper.vm.newStatus.status).to.eql('@replied ')
+ expect(wrapper.vm.newStatus.visibility).to.eql('private')
+
+ expect(wrapper.vm.postingOptions.status).to.eql('@replied ')
+ expect(wrapper.vm.postingOptions.spoilerText).to.eql('re: subject')
+ expect(wrapper.vm.postingOptions.visibility).to.eql('private')
+ expect(wrapper.vm.postingOptions.sensitive).to.eql(false)
+ expect(wrapper.vm.postingOptions.media).to.eql([])
+ expect(wrapper.vm.postingOptions.inReplyToStatusId).to.eql('status-2')
+ expect(wrapper.vm.postingOptions.quoteId).to.eql(null)
+ expect(wrapper.vm.postingOptions.contentType).to.eql('text/plain')
+ expect(wrapper.vm.postingOptions.poll).to.eql(null)
+ })
+
+ it('Forces direct mode when replying to a DM, mastodon style subject handling', () => {
+ // We need to initialize pinia first which is happening here...
+ const options = replyMountOpts({
+ repliedStatus: { ...repliedStatus2, visibility: 'direct' },
})
+
+ // ...set our settings...
+ useMergedConfigStore().mergedConfig = {
+ ...useMergedConfigStore().mergedConfig,
+ subjectLineBehavior: 'masto'
+ }
+
+ // ...and only then mount our component
+ const wrapper = mount(PostStatusForm, options)
+
+ // Otherwise we get multiple instances of pinia that don't talk to each other
+
+ expect(wrapper.vm.statusType).to.equal('reply')
+ expect(wrapper.vm.isReply).to.equal(true)
+ expect(wrapper.vm.quotable).to.equal(false)
+ expect(wrapper.vm.newStatus.quote).to.eql(null)
+ expect(wrapper.vm.newStatus.poll).to.eql(null)
+ expect(wrapper.vm.newStatus.spoilerText).to.eql('subject')
+ expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
+ expect(wrapper.vm.newStatus.status).to.eql('@replied ')
+ expect(wrapper.vm.newStatus.visibility).to.eql('direct')
+ })
+
+ it('Sets status to statusText without mentions if mentions line is enabled', () => {
+ const wrapper = mount(PostStatusForm, replyMountOpts({
+ repliedStatus: repliedStatus2,
+ statusText: 'testing',
+ mentionsLine: true,
+ }))
+
+ expect(wrapper.vm.statusType).to.equal('reply')
+ expect(wrapper.vm.isReply).to.equal(true)
+ expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
+ expect(wrapper.vm.newStatus.status).to.eql('testing')
+ })
+
+ it('Sets mention when asked for it', () => {
+ const wrapper = mount(PostStatusForm, replyMountOpts({
+ profileMention: repliedUser,
+ }))
+
+ expect(wrapper.vm.statusType).to.equal('mention')
+ expect(wrapper.vm.isReply).to.equal(false)
+ expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
+ expect(wrapper.vm.newStatus.status).to.eql('@replied ')
+ })
+
+ it('Initializes quote when reply/quote toggled to quote', () => {
+ const wrapper = mount(PostStatusForm, replyMountOpts({
+ repliedStatus: repliedStatus2,
+ }))
+
+ expect(wrapper.vm.statusType).to.equal('reply')
+ expect(wrapper.vm.isReply).to.equal(true)
+
+ wrapper.vm.quoteThreadToggled = true
+
+ expect(wrapper.vm.newStatus.quote).to.eql({ thread: true, id: 'status-2' })
+ })
+
+ it('Resets quote when reply/quote toggled to reply', () => {
+ const wrapper = mount(PostStatusForm, replyMountOpts({
+ repliedStatus: repliedStatus2,
+ }))
+
+ expect(wrapper.vm.statusType).to.equal('reply')
+ expect(wrapper.vm.isReply).to.equal(true)
+
+ wrapper.vm.quoteThreadToggled = true
+ wrapper.vm.quoteThreadToggled = false
+
+ expect(wrapper.vm.newStatus.quote).to.eql(null)
+ })
+
+ it('Initializes and reset quote when toggling quote attachment', () => {
+ const wrapper = mount(PostStatusForm, replyMountOpts({
+ repliedStatus: repliedStatus2,
+ }))
+
+ expect(wrapper.vm.statusType).to.equal('reply')
+ expect(wrapper.vm.isReply).to.equal(true)
+
+ wrapper.vm.toggleQuoteForm()
+ expect(wrapper.vm.newStatus.quote).to.eql({ thread: false, id: null, url: '' })
+ wrapper.vm.toggleQuoteForm()
+ expect(wrapper.vm.newStatus.quote).to.eql(null)
+ })
+
+ it('Status editing', () => {
+ const wrapper = mount(PostStatusForm, replyMountOpts({
+ statusId: 'edited',
+ statusText: 'text',
+ statusSubject: 'heading',
+ statusIsSensitive: true,
+ statusPoll: {},
+ statusQuote: {},
+ statusFiles: [],
+ statusMediaDescriptions: {},
+ statusVisibility: 'unlisted',
+ statusContentType: 'text/markdown',
+ }))
+
+ expect(wrapper.vm.statusType).to.equal('edit')
+ expect(wrapper.vm.isReply).to.equal(false) // edits don't support changing reply-to so it's pretty much ignored
+ expect(wrapper.vm.isEdit).to.equal(true)
+ expect(wrapper.vm.newStatus.quote).to.eql({})
+ expect(wrapper.vm.newStatus.poll).to.eql({})
+ expect(wrapper.vm.newStatus.spoilerText).to.eql('heading')
+ expect(wrapper.vm.newStatus.mentions).to.eql('')
+ expect(wrapper.vm.newStatus.status).to.eql('text')
+ expect(wrapper.vm.newStatus.visibility).to.eql('unlisted')
+ expect(wrapper.vm.newStatus.contentType).to.eql('text/markdown')
+ expect(wrapper.vm.newStatus.nsfw).to.equal(true)
+ expect(wrapper.vm.newStatus.files).to.eql([])
+ })
+
+ it('Posting should reset idempotency key', async () => {
+ vi.setSystemTime(new Date(2027, 1, 1, 13))
+ const wrapper = mount(PostStatusForm, replyMountOpts())
+ const oldIdempotency = wrapper.vm.idempotencyKey
+
+ vi.setSystemTime(new Date(2028, 1, 1, 13))
+
+ wrapper.vm.newStatus.status = 'Testing'
+ await wrapper.vm.postStatus()
+
+ expect(wrapper.vm.idempotencyKey).to.not.eql(oldIdempotency)
+ })
+
+ // TODO Probably better to separate attachment upload/manipulation into its own component?
+ // we need to upload-on-submit for compression setting anyway
+ it('Attachments manipulations (moving, adding, removing)', () => {
+ const wrapper = mount(PostStatusForm, replyMountOpts())
+
+ const i1 = { id: '1', url: 'a' }
+ const i2 = { id: '2', url: 'b' }
+ const i3 = { id: '3', url: 'c' }
+ const i4 = { id: '4', url: 'd' }
+ const iX = { id: 'x', url: 'x' }
+
+ wrapper.vm.newStatus.files = [i3, i1, iX, i2]
+
+ wrapper.vm.removeMediaFile(iX)
+ expect(wrapper.vm.newStatus.files).to.eql([i3, i1, i2])
+
+ wrapper.vm.shiftUpMediaFile(i1)
+ expect(wrapper.vm.newStatus.files).to.eql([i1, i3, i2])
+
+ wrapper.vm.shiftUpMediaFile(i1) // should ignore
+ expect(wrapper.vm.newStatus.files).to.eql([i1, i3, i2])
+
+ wrapper.vm.shiftDnMediaFile(i3)
+ expect(wrapper.vm.newStatus.files).to.eql([i1, i2, i3])
+
+ wrapper.vm.shiftDnMediaFile(i3) // should ignore
+ expect(wrapper.vm.newStatus.files).to.eql([i1, i2, i3])
+
+ wrapper.vm.addMediaFile(i4)
+ expect(wrapper.vm.newStatus.files).to.eql([i1, i2, i3, i4])
+ })
+
+ it('Attachment descriptions', () => {
+ const wrapper = mount(PostStatusForm, replyMountOpts())
+
+ const i1 = { id: '1', url: 'a' }
+
+ wrapper.vm.addMediaFile(i1)
+ expect(wrapper.vm.newStatus.files).to.eql([i1])
+
+ wrapper.vm.editAttachment(i1, 'description')
+ expect(wrapper.vm.newStatus.mediaDescriptions['1']).to.eql('description')
})
+ // TODO: Drafts (needs vuex to pinia migration)
})
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Oct 10, 6:27 PM (1 d, 10 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1785387
Default Alt Text
(86 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline
Log In to Comment