Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85824561
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Flag For Later
Award Token
Authored By
Unknown
Size
147 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 1f92a7e550..7ebbeeaebf 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -1,932 +1,933 @@
import { debounce, map, reject, uniqBy } from 'lodash'
import { mapActions, mapState } from 'pinia'
import { mapGetters } from 'vuex'
import DraftCloser from 'src/components/draft_closer/draft_closer.vue'
import Gallery from 'src/components/gallery/gallery.vue'
import Popover from 'src/components/popover/popover.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 Attachment from '../attachment/attachment.vue'
import Checkbox from '../checkbox/checkbox.vue'
import EmojiInput from '../emoji_input/emoji_input.vue'
import suggestor from '../emoji_input/suggestor.js'
import MediaUpload from '../media_upload/media_upload.vue'
import PollForm from '../poll/poll_form.vue'
import ScopeSelector from '../scope_selector/scope_selector.vue'
import Select from '../select/select.vue'
import StatusContent from '../status_content/status_content.vue'
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,
faSmileBeam,
faTimes,
faUpload,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSmileBeam,
faPollH,
faUpload,
faBan,
faTimes,
faCircleNotch,
faChevronDown,
faChevronLeft,
faChevronRight,
)
const buildMentionsString = ({ user, attentions = [] }, currentUser) => {
let allAttentions = [...attentions]
allAttentions.unshift(user)
allAttentions = uniqBy(allAttentions, 'id')
allAttentions = reject(allAttentions, { id: currentUser.id })
const mentions = map(allAttentions, (attention) => {
return `@${attention.screen_name}`
})
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 typeAndRefId = ({ replyTo, profileMention, statusId }) => {
if (replyTo) {
return ['reply', replyTo]
} else if (profileMention) {
return ['mention', profileMention]
} else if (statusId) {
return ['edit', statusId]
} else {
return ['new', '']
}
}
const PostStatusForm = {
props: [
'statusId',
'statusText',
'statusIsSensitive',
'statusPoll',
'statusFiles',
'statusMediaDescriptions',
'statusScope',
'statusContentType',
'replyTo',
'repliedUser',
'attentions',
'copyMessageScope',
'subject',
'disableSubject',
'disableScopeSelector',
'disableVisibilitySelector',
'disableNotice',
'disableLockWarning',
'disablePolls',
'disableSensitivityCheckbox',
'disableSubmit',
'disablePreview',
'disableDraft',
'hideDraft',
'closeable',
'placeholder',
'maxHeight',
'postHandler',
'preserveFocus',
'autoFocus',
'fileLimit',
'submitOnEnter',
'emojiPickerPlacement',
'optimisticPosting',
'profileMention',
'draftId',
],
emits: [
'posted',
'draft-done',
'resize',
'mediaplay',
'mediapause',
'can-close',
'update',
],
components: {
MediaUpload,
EmojiInput,
PollForm,
ScopeSelector,
Checkbox,
Select,
Attachment,
StatusContent,
Gallery,
DraftCloser,
Popover,
},
mounted() {
this.updateIdempotencyKey()
this.resize(this.$refs.textarea)
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()
}
},
data() {
const preset = this.$route.query.message
let statusText = preset || ''
const { scopeCopy } = useMergedConfigStore().mergedConfig
const [statusType, refId] = typeAndRefId({
replyTo: this.replyTo,
profileMention: this.profileMention && this.repliedUser?.id,
statusId: this.statusId,
})
// If we are starting a new post, do not associate it with old drafts
let statusParams =
!this.disableDraft && (this.draftId || statusType !== 'new')
? this.getDraft(statusType, refId)
: null
if (!statusParams) {
if (statusType === 'reply' || statusType === 'mention') {
const currentUser = this.$store.state.users.currentUser
statusText = buildMentionsString(
{ user: this.repliedUser, attentions: this.attentions },
currentUser,
)
}
const scope =
(this.copyMessageScope && scopeCopy) ||
this.copyMessageScope === 'direct'
? this.copyMessageScope
: this.$store.state.users.currentUser.default_scope
const { postContentType: contentType, sensitiveByDefault } =
useMergedConfigStore().mergedConfig
statusParams = {
type: statusType,
refId,
spoilerText: this.subject || '',
status: statusText,
nsfw: !!sensitiveByDefault,
files: [],
poll: {},
hasPoll: false,
mediaDescriptions: {},
visibility: scope,
contentType,
quoting: false,
}
if (statusType === 'edit') {
const statusContentType = this.statusContentType || contentType
statusParams = {
type: statusType,
refId,
spoilerText: this.subject || '',
status: this.statusText || '',
nsfw: this.statusIsSensitive || !!sensitiveByDefault,
files: this.statusFiles || [],
poll: this.statusPoll || {},
hasPoll: false,
mediaDescriptions: this.statusMediaDescriptions || {},
visibility: this.statusScope || scope,
contentType: statusContentType,
}
}
}
return {
randomSeed: genRandomSeed(),
dropFiles: [],
uploadingFiles: false,
error: null,
posting: false,
highlighted: 0,
newStatus: statusParams,
caret: 0,
showDropIcon: 'hide',
dropStopTimeout: null,
preview: null,
previewLoading: false,
emojiInputShown: false,
idempotencyKey: '',
saveInhibited: true,
saveable: false,
}
},
computed: {
users() {
return this.$store.state.users.users
},
userDefaultScope() {
return this.$store.state.users.currentUser.default_scope
},
showAllScopes() {
return !this.mergedConfig.minimalScopesMode
},
hideExtraActions() {
return this.disableDraft || this.hideDraft
},
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
},
statusLength() {
return this.newStatus.status.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
},
minimalScopesMode() {
return useInstanceStore().minimalScopesMode
},
alwaysShowSubject() {
return this.mergedConfig.alwaysShowSubjectInput
},
postFormats() {
return useInstanceCapabilitiesStore().postFormats || []
},
safeDMEnabled() {
return useInstanceCapabilitiesStore().safeDM
},
pollsAvailable() {
return (
useInstanceCapabilitiesStore().pollsAvailable &&
useInstanceStore().limits.pollLimits.max_options >= 2 &&
this.disablePolls !== true
)
},
hideScopeNotice() {
return (
- this.disableNotice || useMergedConfigStore().mergedConfig.hideScopeNotice
+ this.disableNotice ||
+ useMergedConfigStore().mergedConfig.hideScopeNotice
)
},
pollContentError() {
return (
this.pollFormVisible && this.newStatus.poll && this.newStatus.poll.error
)
},
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
},
isEdit() {
return typeof this.statusId !== 'undefined' && this.statusId.trim() !== ''
},
quotable() {
if (!useInstanceCapabilitiesStore().quotingAvailable) {
return false
}
if (!this.replyTo) {
return false
}
const repliedStatus =
this.$store.state.statuses.allStatusesObject[this.replyTo]
if (!repliedStatus) {
return false
}
if (
repliedStatus.visibility === 'public' ||
repliedStatus.visibility === 'unlisted' ||
repliedStatus.visibility === 'local'
) {
return true
} else if (repliedStatus.visibility === 'private') {
return repliedStatus.user.id === this.$store.state.users.currentUser.id
}
return false
},
debouncedMaybeAutoSaveDraft() {
return debounce(this.maybeAutoSaveDraft, 3000)
},
pollFormVisible() {
return this.newStatus.hasPoll
},
shouldAutoSaveDraft() {
return useMergedConfigStore().mergedConfig.autoSaveDraft
},
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.newStatus.hasPoll) &&
this.saveable
)
},
hasEmptyDraft() {
return (
this.newStatus.id &&
!(
this.newStatus.status ||
this.newStatus.spoilerText ||
this.newStatus.files?.length ||
this.newStatus.hasPoll
)
)
},
...mapState(useMergedConfigStore, ['mergedConfig']),
...mapState(useInterfaceStore, {
mobileLayout: (store) => store.mobileLayout,
}),
},
watch: {
newStatus: {
deep: true,
handler() {
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()
}
},
},
beforeUnmount() {
this.maybeAutoSaveDraft()
this.removeBeforeUnloadListener()
},
methods: {
...mapActions(useMediaViewerStore, ['increment']),
statusChanged() {
this.autoPreview()
this.updateIdempotencyKey()
this.debouncedMaybeAutoSaveDraft()
this.saveable = true
this.saveInhibited = false
},
clearStatus() {
const newStatus = this.newStatus
this.saveInhibited = true
this.newStatus = {
status: '',
spoilerText: '',
files: [],
visibility: newStatus.visibility,
contentType: newStatus.contentType,
poll: {},
hasPoll: false,
mediaDescriptions: {},
quoting: false,
}
this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
this.clearPollForm()
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) {
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.emptyStatus || this.isOverLengthLimit)
) {
return
}
if (this.emptyStatus) {
this.error = this.$t('post_status.empty_status_error')
return
}
const poll = this.newStatus.hasPoll
? pollFormToMasto(this.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 replyOrQuoteAttr = newStatus.quoting
? 'quoteId'
: 'inReplyToStatusId'
const postingOptions = {
status: newStatus.status,
spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility,
sensitive: newStatus.nsfw,
media: newStatus.files,
store: this.$store,
[replyOrQuoteAttr]: this.replyTo,
contentType: newStatus.contentType,
poll,
idempotencyKey: this.idempotencyKey,
}
const postHandler = this.postHandler
? this.postHandler
: statusPoster.postStatus
postHandler(postingOptions).then((data) => {
if (!data.error) {
this.abandonDraft()
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
const replyOrQuoteAttr = newStatus.quoting
? 'quoteId'
: 'inReplyToStatusId'
statusPoster
.postStatus({
status: newStatus.status,
spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility,
sensitive: newStatus.nsfw,
media: [],
store: this.$store,
[replyOrQuoteAttr]: 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) {
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)
files.splice(index, 1)
files.splice(index - 1, 0, fileInfo)
},
shiftDnMediaFile(fileInfo) {
const { files } = this.newStatus
const index = this.newStatus.files.indexOf(fileInfo)
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'
}
},
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
}
},
clearError() {
this.error = null
},
changeVis(visibility) {
this.newStatus.visibility = visibility
},
togglePollForm() {
this.newStatus.hasPoll = !this.newStatus.hasPoll
},
setPoll(poll) {
this.newStatus.poll = poll
},
clearPollForm() {
if (this.$refs.pollForm) {
this.$refs.pollForm.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)
},
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.$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.$emit('draft-done')
}
})
}
}
return Promise.resolve()
},
maybeAutoSaveDraft() {
if (this.shouldAutoSaveDraft) {
this.saveDraft(false)
}
},
abandonDraft() {
return this.$store.dispatch('abandonDraft', { id: this.newStatus.id })
},
getDraft(statusType, refId) {
const maybeDraft = this.$store.state.drafts.drafts[this.draftId]
if (this.draftId && maybeDraft) {
return maybeDraft
} else {
const existingDrafts = this.$store.getters.draftsByTypeAndRefId(
statusType,
refId,
)
if (existingDrafts.length) {
return existingDrafts[0]
}
}
// No draft available, fall back
},
requestClose() {
if (!this.saveable) {
this.$emit('can-close')
} else {
this.$refs.draftCloser.requestClose()
}
},
saveAndCloseDraft() {
this.saveDraft().then(() => {
this.$emit('can-close')
})
},
discardAndCloseDraft() {
this.abandonDraft().then(() => {
this.$emit('can-close')
})
},
addBeforeUnloadListener() {
this._beforeUnloadListener ||= () => {
this.saveDraft()
}
window.addEventListener('beforeunload', this._beforeUnloadListener)
},
removeBeforeUnloadListener() {
if (this._beforeUnloadListener) {
window.removeEventListener('beforeunload', this._beforeUnloadListener)
}
},
},
}
export default PostStatusForm
diff --git a/src/components/settings_modal/settings_modal.js b/src/components/settings_modal/settings_modal.js
index f889cc66ff..4ca2857b2e 100644
--- a/src/components/settings_modal/settings_modal.js
+++ b/src/components/settings_modal/settings_modal.js
@@ -1,245 +1,245 @@
import { cloneDeep, isEqual } from 'lodash'
import { mapActions, mapState } from 'pinia'
import AsyncComponentError from 'src/components/async_component_error/async_component_error.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ConfirmModal from 'src/components/confirm_modal/confirm_modal.vue'
import Modal from 'src/components/modal/modal.vue'
import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
import Popover from '../popover/popover.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useLocalConfigStore } from 'src/stores/local_config.js'
-import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { LOCAL_ONLY_KEYS } from 'src/modules/default_config_state.js'
import {
newExporter,
newImporter,
} from 'src/services/export_import/export_import.js'
import getResettableAsyncComponent from 'src/services/resettable_async_component.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faWindowMinimize } from '@fortawesome/free-regular-svg-icons'
import {
faChevronDown,
faFileDownload,
faFileUpload,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
const PLEROMAFE_SETTINGS_MAJOR_VERSION = 1
const PLEROMAFE_SETTINGS_MINOR_VERSION = 0
library.add(
faTimes,
faWindowMinimize,
faFileUpload,
faFileDownload,
faChevronDown,
)
const SettingsModal = {
data() {
return {
dataImporter: newImporter({
validator: this.importValidator,
onImport: this.onImport,
onImportFailure: this.onImportFailure,
}),
dataThemeExporter: newExporter({
filename: 'pleromafe_settings.full',
getExportedObject: () => this.generateExport(true),
}),
dataExporter: newExporter({
filename: 'pleromafe_settings',
getExportedObject: () => this.generateExport(),
}),
}
},
components: {
Modal,
Popover,
Checkbox,
ConfirmModal,
SettingsModalUserContent: getResettableAsyncComponent(
() => import('./settings_modal_user_content.vue'),
{
loadingComponent: PanelLoading,
errorComponent: AsyncComponentError,
delay: 0,
},
),
SettingsModalAdminContent: getResettableAsyncComponent(
() => import('./settings_modal_admin_content.vue'),
{
loadingComponent: PanelLoading,
errorComponent: AsyncComponentError,
delay: 0,
},
),
},
methods: {
closeModal() {
useInterfaceStore().closeSettingsModal()
},
peekModal() {
useInterfaceStore().togglePeekSettingsModal()
},
importValidator(data) {
if (!Array.isArray(data._pleroma_settings_version)) {
return {
messageKey: 'settings.file_import_export.invalid_file',
}
}
const [major, minor] = data._pleroma_settings_version
if (major > PLEROMAFE_SETTINGS_MAJOR_VERSION) {
return {
messageKey: 'settings.file_export_import.errors.file_too_new',
messageArgs: {
fileMajor: major,
feMajor: PLEROMAFE_SETTINGS_MAJOR_VERSION,
},
}
}
if (major < PLEROMAFE_SETTINGS_MAJOR_VERSION) {
return {
messageKey: 'settings.file_export_import.errors.file_too_old',
messageArgs: {
fileMajor: major,
feMajor: PLEROMAFE_SETTINGS_MAJOR_VERSION,
},
}
}
if (minor > PLEROMAFE_SETTINGS_MINOR_VERSION) {
useInterfaceStore().pushGlobalNotice({
level: 'warning',
messageKey: 'settings.file_export_import.errors.file_slightly_new',
})
}
return true
},
onImportFailure(result) {
if (result.error) {
useInterfaceStore().pushGlobalNotice({
messageKey: 'settings.invalid_settings_imported',
level: 'error',
})
} else {
useInterfaceStore().pushGlobalNotice({
...result.validationResult,
level: 'error',
})
}
},
onImport(data) {
if (data) {
Object.entries(data).forEach(([path, value]) => {
if (LOCAL_ONLY_KEYS.has(path)) {
useLocalConfigStore().set({ path, value })
} else {
if (path.startsWith('muteFilters')) {
Object.keys(
useMergedConfigStore().mergedConfig.muteFilters,
).forEach((key) => {
useSyncConfigStore().unsetPreference({
path: `simple.${path}.${key}`,
})
})
Object.entries(value).forEach(([key, filter]) => {
useSyncConfigStore().setPreference({
path: `simple.${path}.${key}`,
value: filter,
})
})
} else {
useSyncConfigStore().setPreference({
path: `simple.${path}`,
value,
})
}
}
})
useSyncConfigStore().pushSyncConfig()
}
},
restore() {
this.dataImporter.importData()
},
backup() {
this.dataExporter.exportData()
},
backupWithTheme() {
this.dataThemeExporter.exportData()
},
generateExport(theme = false) {
const config = useMergedConfigStore().mergedConfigWithoutDefaults
let sample = config
if (!theme) {
const ignoreList = new Set([
'customTheme',
'customThemeSource',
'colors',
])
sample = Object.fromEntries(
Object.entries(sample).filter(
([key, value]) => !ignoreList.has(key) && value !== undefined,
),
)
}
const clone = cloneDeep(sample)
clone._pleroma_settings_version = [
PLEROMAFE_SETTINGS_MAJOR_VERSION,
PLEROMAFE_SETTINGS_MINOR_VERSION,
]
return clone
},
resetAdminDraft() {
this.$store.commit('resetAdminDraft')
},
pushAdminDraft() {
this.$store.dispatch('pushAdminDraft')
},
...mapActions(useInterfaceStore, [
'temporaryChangesRevert',
'temporaryChangesConfirm',
]),
},
computed: {
...mapState(useInterfaceStore, {
temporaryChangesCountdown: (store) => store.temporaryChangesCountdown,
currentSaveStateNotice: (store) => store.settings.currentSaveStateNotice,
modalActivated: (store) => store.settingsModalState !== 'hidden',
modalMode: (store) => store.settingsModalMode,
modalOpenedOnceUser: (store) => store.settingsModalLoadedUser,
modalOpenedOnceAdmin: (store) => store.settingsModalLoadedAdmin,
modalPeeked: (store) => store.settingsModalState === 'minimized',
}),
expertLevel: {
get() {
return useMergedConfigStore().mergedConfig.expertLevel > 0
},
set(value) {
useSyncConfigStore().setSimplePrefAndSave({
path: 'expertLevel',
value: value ? 1 : 0,
})
},
},
adminDraftAny() {
return !isEqual(
this.$store.state.adminSettings.config,
this.$store.state.adminSettings.draft,
)
},
},
}
export default SettingsModal
diff --git a/src/components/status_action_buttons/buttons_definitions.js b/src/components/status_action_buttons/buttons_definitions.js
index 01b0e8bdb7..cdb49b1acb 100644
--- a/src/components/status_action_buttons/buttons_definitions.js
+++ b/src/components/status_action_buttons/buttons_definitions.js
@@ -1,291 +1,291 @@
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'
-import { useMergedConfigStore } from 'src/stores/merged_config.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: 'none',
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', 'smile-beam'],
anonLink: true,
},
{
// =========
// MUTE
// =========
name: 'mute',
icon: 'eye-slash',
label: 'status.mute_ellipsis',
if: ({ loggedIn }) => loggedIn,
toggleable: true,
dropdown: true,
// action ({ status, dispatch, emit }) {
// }
},
{
// =========
// 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,
statusText: data.text,
statusIsSensitive: status.nsfw,
statusPoll: status.poll,
statusFiles: [...status.attachments],
visibility: status.visibility,
statusContentType: data.content_type,
}),
)
},
},
{
// =========
// DELETE
// =========
name: 'delete',
icon: 'times',
label: 'status.delete',
if({ status, loggedIn, currentUser }) {
return (
loggedIn &&
(status.user.id === currentUser.id ||
currentUser.privileges.includes('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 })
},
},
{
// =========
// 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 }) {
return useReportsStore().openUserReportingModal({
userId: status.user.id,
statusIds: [status.id],
})
},
},
].map((button) => {
return Object.fromEntries(
Object.entries(button).map(([k, v]) => [
k,
typeof v === 'function' || k === 'name' ? v : () => v,
]),
)
})
diff --git a/src/modules/default_config_state.js b/src/modules/default_config_state.js
index 5af6f6c5e5..3800d4deb3 100644
--- a/src/modules/default_config_state.js
+++ b/src/modules/default_config_state.js
@@ -1,780 +1,780 @@
import { get, set } from 'lodash'
const browserLocale = (navigator.language || 'en').split('-')[0]
export const convertDefinitions = (definitions) =>
Object.fromEntries(
Object.entries(definitions).map(([k, v]) => {
const defaultValue = v.default ?? null
return [k, defaultValue]
}),
)
/// Instance config entries provided by static config or pleroma api
/// Put settings here only if it does not make sense for a normal user
/// to override it.
export const INSTANCE_IDENTITY_DEFAULT_DEFINITIONS = {
style: {
description: 'Instance default style name',
type: 'string',
required: false,
},
palette: {
description: 'Instance default palette name',
type: 'string',
required: false,
},
theme: {
description: 'Instance default theme name',
type: 'string',
required: false,
},
defaultAvatar: {
description: "Default avatar image to use when user doesn't have one set",
type: 'string',
default: '/images/avi.png',
},
defaultBanner: {
description: "Default banner image to use when user doesn't have one set",
type: 'string',
default: '/images/banner.png',
},
background: {
description: 'Instance background/wallpaper',
type: 'string',
default: '/static/aurora_borealis.jpg',
},
embeddedToS: {
description: 'Whether to show Terms of Service title bar',
type: 'boolean',
default: true,
},
logo: {
description: 'Instance logo',
type: 'string',
default: '/static/logo.svg',
},
logoMargin: {
description: 'Margin for logo (spacing above/below)',
type: 'string',
default: '.2em',
},
logoMask: {
description:
'Use logo as a mask (works well for monochrome/transparent logos)',
type: 'boolean',
default: true,
},
logoLeft: {
description: 'Show logo on the left side of navbar',
type: 'boolean',
default: false,
},
redirectRootLogin: {
description: 'Where to redirect user after login',
type: 'string',
default: '/main/friends',
},
redirectRootNoLogin: {
description: 'Where to redirect anonymous visitors',
type: 'string',
default: '/main/all',
},
hideSitename: {
description: 'Hide the instance name in navbar',
type: 'boolean',
default: false,
},
nsfwCensorImage: {
description: 'Default NSFW censor image',
type: 'string',
required: false,
},
showFeaturesPanel: {
description: 'Show features panel to anonymous visitors',
type: 'boolean',
default: true,
},
showInstanceSpecificPanel: {
description: 'Show instance-specific panel',
type: 'boolean',
default: false,
},
// Html stuff
instanceSpecificPanelContent: {
description: 'HTML of Instance-specific panel',
type: 'string',
required: false,
},
tos: {
description: 'HTML of Terms of Service panel',
type: 'string',
required: false,
},
}
export const INSTANCE_IDENTITY_DEFAULT = convertDefinitions(
INSTANCE_IDENTITY_DEFAULT_DEFINITIONS,
)
/// This object contains setting entries that makes sense
/// at the user level. The defaults can also be overriden by
/// instance admins in the frontend_configuration endpoint or static config.
export const INSTANCE_DEFAULT_CONFIG_DEFINITIONS = {
expertLevel: {
description:
'Used to track which settings to show and hide in settings modal',
type: 'number', // not a boolean so we could potentially make multiple levels of expert-ness
default: 0,
},
hideISP: {
description: 'Hide Instance-specific panel',
default: false,
},
hideInstanceWallpaper: {
description: 'Hide Instance default background',
default: false,
},
hideShoutbox: {
description: 'Hide shoutbox if present',
default: false,
},
hideMutedPosts: {
// bad name
description: 'Hide posts of muted users entirely',
default: false,
},
hideMutedThreads: {
description: 'Hide muted threads entirely',
default: true,
},
hideWordFilteredPosts: {
description: 'Hide wordfiltered posts entirely',
default: false,
},
muteBotStatuses: {
description: 'Mute posts made by bots',
default: false,
},
muteSensitiveStatuses: {
description: 'Mute posts marked as NSFW',
default: false,
},
collapseMessageWithSubject: {
description: 'Collapse posts with subject',
default: false,
},
padEmoji: {
description: 'Pad emoji with spaces when using emoji picker',
default: true,
},
hideAttachmentsInConv: {
description: 'Hide attachments',
default: false,
},
hideScrobbles: {
description: 'Hide scrobbles',
default: false,
},
hideScrobblesAfter: {
description: 'Hide scrobbles older than',
default: '2d',
},
maxThumbnails: {
description: 'Maximum attachments to show',
default: 16,
},
loopVideo: {
description: 'Loop videos',
default: true,
},
loopVideoSilentOnly: {
description: 'Loop only videos without sound',
default: true,
},
/// This is not the streaming API configuration, but rather an option
/// for automatically loading new posts into the timeline without
/// the user clicking the Show New button.
streaming: {
description: 'Automatically show new posts',
default: false,
},
pauseOnUnfocused: {
description: 'Pause showing new posts when tab is unfocused',
default: true,
},
emojiReactionsOnTimeline: {
description: 'Show emoji reactions on timeline',
default: true,
},
alwaysShowNewPostButton: {
description: 'Always show mobile "new post" button, even in desktop mode',
default: false,
},
autohideFloatingPostButton: {
description:
'Automatically hide mobile "new post" button when scrolling down',
default: false,
},
stopGifs: {
description: 'Play animated gifs on hover only',
default: true,
},
replyVisibility: {
description: 'Type of replies to show',
default: 'all',
},
thirdColumnMode: {
description: 'What to display in third column',
default: 'notifications',
},
notificationVisibility: {
description: 'What types of notifications to show',
default: {
follows: true,
mentions: true,
statuses: true,
likes: true,
repeats: true,
moves: true,
emojiReactions: true,
followRequest: true,
reports: true,
chatMention: true,
polls: true,
},
},
notificationNative: {
description: 'What type of notifications to show desktop notification for',
default: {
follows: true,
mentions: true,
statuses: true,
likes: false,
repeats: false,
moves: false,
emojiReactions: false,
followRequest: true,
reports: true,
chatMention: true,
polls: true,
},
},
webPushNotifications: {
description: 'Use WebPush',
default: false,
},
webPushAlwaysShowNotifications: {
description: 'Ignore filter when using WebPush',
default: false,
},
interfaceLanguage: {
description: 'UI language',
default: browserLocale,
},
hideScopeNotice: {
description: 'Hide scope notification',
default: false,
},
scopeCopy: {
description: 'Copy scope like mastodon does',
default: true,
},
subjectLineBehavior: {
description: 'How to treat subject line',
default: 'email',
},
alwaysShowSubjectInput: {
description: 'Always show subject line field',
default: true,
},
minimalScopesMode: {
description: 'Minimize amount of options shown in scope selector',
default: false,
},
// This hides statuses filtered via a word filter
hideFilteredStatuses: {
description: 'Hide wordfiltered entirely',
default: false,
},
// Confirmations
modalOnRepeat: {
description: 'Show confirmation modal for repeat',
default: false,
},
modalOnUnfollow: {
description: 'Show confirmation modal for unfollow',
default: false,
},
modalOnBlock: {
description: 'Show confirmation modal for block',
default: true,
},
modalOnMute: {
description: 'Show confirmation modal for mute',
default: false,
},
modalOnMuteConversation: {
description: 'Show confirmation modal for mute conversation',
default: false,
},
modalOnMuteDomain: {
description: 'Show confirmation modal for mute domain',
default: true,
},
modalOnDelete: {
description: 'Show confirmation modal for delete',
default: true,
},
modalOnLogout: {
description: 'Show confirmation modal for logout',
default: true,
},
modalOnApproveFollow: {
description: 'Show confirmation modal for approve follow',
default: false,
},
modalOnDenyFollow: {
description: 'Show confirmation modal for deny follow',
default: false,
},
modalOnRemoveUserFromFollowers: {
description: 'Show confirmation modal for follower removal',
default: false,
},
// Expiry confirmations/default actions
onMuteDefaultAction: {
description: 'Default action when muting user',
default: 'ask',
},
onBlockDefaultAction: {
description: 'Default action when blocking user',
default: 'ask',
},
modalMobileCenter: {
description: 'Center mobile dialogs vertically',
default: false,
},
playVideosInModal: {
description: 'Play videos in gallery view',
default: false,
},
useContainFit: {
description: 'Use object-fit: contain for attachments',
default: true,
},
disableStickyHeaders: {
description: 'Disable sticky headers',
default: false,
},
showScrollbars: {
description: 'Always show scrollbars',
default: false,
},
userPopoverAvatarAction: {
description: 'What to do when clicking popover avatar',
default: 'open',
},
userPopoverOverlay: {
description: 'Overlay user popover with centering on avatar',
default: false,
},
userCardLeftJustify: {
description: 'Justify user bio to the left',
default: false,
},
userCardHidePersonalMarks: {
description: 'Hide highlight/personal note in user view',
default: false,
},
forcedRoundness: {
description: 'Force roundness of the theme',
default: -1,
},
greentext: {
description: 'Highlight plaintext >quotes',
default: false,
},
mentionLinkShowTooltip: {
description: 'Show tooltips for mention links',
default: true,
},
mentionLinkShowAvatar: {
description: 'Show avatar next to mention link',
default: false,
},
mentionLinkFadeDomain: {
description:
'Mute (fade) domain name in mention links if configured to show it',
default: true,
},
mentionLinkShowYous: {
description: 'Show (you)s when you are mentioned',
default: false,
},
mentionLinkBoldenYou: {
description: 'Boldern mentionlink of you',
default: true,
},
hidePostStats: {
description: 'Hide post stats (rt, favs)',
default: false,
},
hideBotIndication: {
description: 'Hide bot indicator',
default: false,
},
hideUserStats: {
description: 'Hide user stats (followers etc)',
default: false,
},
virtualScrolling: {
description: 'Timeline virtual scrolling',
default: true,
},
sensitiveByDefault: {
description: 'Assume attachments are NSFW by default',
default: false,
},
conversationDisplay: {
description: 'Style of conversation display',
default: 'linear',
},
conversationTreeAdvanced: {
description: 'Advanced features of tree view conversation',
default: false,
},
conversationOtherRepliesButton: {
description: 'Where to show "other replies" in tree conversation view',
default: 'below',
},
conversationTreeFadeAncestors: {
description: 'Fade ancestors in tree conversation view',
default: false,
},
showExtraNotifications: {
description:
'Show extra notifications (chats, announcements etc) in notification panel',
default: true,
},
showExtraNotificationsTip: {
description: 'Show tip for extra notifications (that user can remove them)',
default: true,
},
showChatsInExtraNotifications: {
description: 'Show chat messages in notifications',
default: true,
},
showAnnouncementsInExtraNotifications: {
description: 'Show announcements in notifications',
default: true,
},
showFollowRequestsInExtraNotifications: {
description: 'Show follow requests in notifications',
default: true,
},
maxDepthInThread: {
description: 'Maximum depth in tree conversation view',
default: 6,
},
autocompleteSelect: {
description: '',
default: false,
},
closingDrawerMarksAsSeen: {
description: 'Closing mobile notification pane marks everything as seen',
default: true,
},
unseenAtTop: {
description: 'Show unseen notifications above others',
default: false,
},
ignoreInactionableSeen: {
description: 'Treat inactionable (fav, rt etc) notifications as "seen"',
default: false,
},
unsavedPostAction: {
description: 'What to do if post is aborted',
default: 'confirm',
},
autoSaveDraft: {
description: 'Save drafts automatically',
default: false,
},
useAbsoluteTimeFormat: {
description: 'Use absolute time format',
default: false,
},
absoluteTimeFormatMinAge: {
description: 'Show absolute time format only after this post age',
default: '0d',
},
absoluteTime12h: {
description: 'Use 24h time format',
default: '24h',
},
themeChecksum: {
description: 'Checksum of theme used',
type: 'string',
required: false,
},
}
export const INSTANCE_DEFAULT_CONFIG = convertDefinitions(
INSTANCE_DEFAULT_CONFIG_DEFINITIONS,
)
export const LOCAL_DEFAULT_CONFIG_DEFINITIONS = {
// TODO these two used to be separate but since separation feature got broken it doesn't matter
hideAttachments: {
description: 'Hide attachments in timeline',
default: false,
},
hideAttachmentsInConv: {
description: 'Hide attachments in coversation',
default: false,
},
hideNsfw: {
description: 'Hide nsfw posts',
default: true,
},
useOneClickNsfw: {
description: 'Open NSFW images directly in media modal',
default: false,
},
preloadImage: {
description: 'Preload images for NSFW',
default: true,
},
postContentType: {
description: 'Default post content type',
default: 'text/plain',
},
sidebarRight: {
description: 'Reverse order of columns',
default: false,
},
sidebarColumnWidth: {
description: 'Sidebar column width',
default: '25rem',
},
contentColumnWidth: {
description: 'Middle column width',
default: '45rem',
},
notifsColumnWidth: {
description: 'Notifications column width',
default: '25rem',
},
themeEditorMinWidth: {
description: 'Hack for theme editor on mobile',
default: '0rem',
},
emojiReactionsScale: {
description: 'Emoji reactions scale factor',
default: 0.5,
},
textSize: {
description: 'Font size',
default: '1rem',
},
emojiSize: {
description: 'Emoji size',
default: '2.2rem',
},
navbarSize: {
description: 'Navbar size',
default: '3.5rem',
},
panelHeaderSize: {
description: 'Panel header size',
default: '3.2rem',
},
navbarColumnStretch: {
description: 'Stretch navbar to match columns width',
default: false,
},
mentionLinkDisplay: {
description: 'How to display mention links',
default: 'short',
},
imageCompression: {
description: 'Image compression (WebP/JPEG)',
default: true,
},
alwaysUseJpeg: {
description: 'Compress images using JPEG only',
default: false,
},
useStreamingApi: {
description: 'Streaming API (WebSocket)',
default: false,
},
underlay: {
description: 'Underlay override',
required: true,
default: 'none',
},
fontInterface: {
description: 'Interface font override',
type: 'object',
default: null,
},
fontInput: {
description: 'Input font override',
type: 'object',
default: null,
},
fontPosts: {
description: 'Post font override',
type: 'object',
default: null,
},
fontMonospace: {
description: 'Monospace font override',
type: 'object',
default: null,
},
themeDebug: {
description:
'Debug mode that uses computed backgrounds instead of real ones to debug contrast functions',
default: false,
},
forceThemeRecompilation: {
description: 'Flag that forces recompilation on boot even if cache exists',
default: false,
},
}
export const LOCAL_DEFAULT_CONFIG = convertDefinitions(
LOCAL_DEFAULT_CONFIG_DEFINITIONS,
)
export const LOCAL_ONLY_KEYS = new Set(Object.keys(LOCAL_DEFAULT_CONFIG))
export const SYNC_DEFAULT_CONFIG_DEFINITIONS = {
dontShowUpdateNotifs: {
description: 'Never show update notification (pleroma-tan)',
default: false,
},
collapseNav: {
description: 'Collapse navigation panel to header only',
- default: false
- }
+ default: false,
+ },
}
export const SYNC_DEFAULT_CONFIG = convertDefinitions(
SYNC_DEFAULT_CONFIG_DEFINITIONS,
)
export const SYNC_ONLY_KEYS = new Set(Object.keys(SYNC_DEFAULT_CONFIG))
export const THEME_CONFIG_DEFINITIONS = {
theme: {
description: 'Very old theme store, stores preset name, still in use',
default: null,
},
colors: {
- description: 'VERY old theme store, just colors of V1, probably not even used anymore',
+ description:
+ 'VERY old theme store, just colors of V1, probably not even used anymore',
default: {},
},
// V2
customTheme: {
- description: '"Snapshot", previously was used as actual theme store for V2 so it\'s still used in case of PleromaFE downgrade event.',
+ description:
+ '"Snapshot", previously was used as actual theme store for V2 so it\'s still used in case of PleromaFE downgrade event.',
default: null,
},
customThemeSource: {
description: '"Source", stores original theme data',
default: null,
},
// V3
style: {
description: 'Style name for builtins',
default: null,
},
styleCustomData: {
description: 'Custom style data (i.e. not builtin)',
default: null,
},
palette: {
description: 'Palette name for builtins',
default: null,
},
paletteCustomData: {
description: 'Custom palette data (i.e. not builtin)',
default: null,
},
}
-export const THEME_CONFIG = convertDefinitions(
- THEME_CONFIG_DEFINITIONS,
-)
+export const THEME_CONFIG = convertDefinitions(THEME_CONFIG_DEFINITIONS)
export const makeUndefined = (c) =>
Object.fromEntries(Object.keys(c).map((key) => [key, undefined]))
/// For properties with special processing or properties that does not
/// make sense to be overriden on a instance-wide level.
export const ROOT_CONFIG = {
// Set these to undefined so it does not interfere with default settings check
...INSTANCE_DEFAULT_CONFIG,
...LOCAL_DEFAULT_CONFIG,
...SYNC_DEFAULT_CONFIG,
...THEME_CONFIG,
}
export const ROOT_CONFIG_DEFINITIONS = {
...INSTANCE_DEFAULT_CONFIG_DEFINITIONS,
...LOCAL_DEFAULT_CONFIG_DEFINITIONS,
...SYNC_DEFAULT_CONFIG_DEFINITIONS,
...THEME_CONFIG_DEFINITIONS,
}
export const validateSetting = ({
value,
path,
definition,
throwError,
defaultState,
}) => {
if (value === undefined) return // only null is allowed as missing value
if (get(defaultState, path) === undefined) {
const string = `Unknown instance option ${path}, value: ${value}`
if (throwError) {
throw new Error(string)
} else {
console.error(string)
return value
}
}
let { required, type, default: defaultValue } = definition
if (type == null && defaultValue != null) {
type = typeof defaultValue
}
if (required && value == null) {
const string = `Value required for setting ${path} but was provided nullish; defaulting`
if (throwError) {
throw new Error(string)
} else {
console.error(string)
return defaultValue
}
}
if (value !== null && type != null && typeof value !== type) {
const string = `Invalid type for setting ${path}: expected type ${type}, got ${typeof value}, value ${value}; defaulting`
if (throwError) {
throw new Error(string)
} else {
console.error(string)
return defaultValue
}
}
return value
}
diff --git a/src/stores/local_config.js b/src/stores/local_config.js
index f8b76a26c0..5b15c6ff1e 100644
--- a/src/stores/local_config.js
+++ b/src/stores/local_config.js
@@ -1,65 +1,67 @@
import { cloneDeep, set } from 'lodash'
import { defineStore } from 'pinia'
import { toRaw } from 'vue'
import { useInstanceStore } from 'src/stores/instance'
import {
LOCAL_DEFAULT_CONFIG,
LOCAL_DEFAULT_CONFIG_DEFINITIONS,
- validateSetting,
makeUndefined,
+ validateSetting,
} from 'src/modules/default_config_state'
export const defaultState = {
prefsStorage: {
...makeUndefined(LOCAL_DEFAULT_CONFIG),
},
tempStorage: {
...makeUndefined(LOCAL_DEFAULT_CONFIG),
},
}
export const useLocalConfigStore = defineStore('local_config', {
state() {
return cloneDeep(defaultState)
},
actions: {
set({ path, value }) {
const definition = LOCAL_DEFAULT_CONFIG_DEFINITIONS[path]
const finalValue = validateSetting({
path,
value,
definition,
throwError: false,
defaultState: LOCAL_DEFAULT_CONFIG,
})
set(this.prefsStorage, path, finalValue)
},
setTemporarily({ path, value }) {
set(this.tempStorage, path, value)
},
unsetTemporarily({ path, value }) {
set(this.tempStorage, path, undefined)
},
unset({ path, value }) {
set(this.prefsStorage, path, undefined)
},
clearLocalConfig() {
Object.keys(this).forEach((k) => {
this.prefsStorage[k] = undefined
this.tempStorage[k] = undefined
})
},
},
persist: {
afterLoad(state) {
return {
- prefsStorage: state.prefsStorage ?? { ...makeUndefined(LOCAL_DEFAULT_CONFIG) },
+ prefsStorage: state.prefsStorage ?? {
+ ...makeUndefined(LOCAL_DEFAULT_CONFIG),
+ },
tempStorage: { ...makeUndefined(LOCAL_DEFAULT_CONFIG) },
}
},
},
})
diff --git a/src/stores/merged_config.js b/src/stores/merged_config.js
index 80d27fabfa..c4f27082c3 100644
--- a/src/stores/merged_config.js
+++ b/src/stores/merged_config.js
@@ -1,76 +1,67 @@
import { defineStore } from 'pinia'
import { useInstanceStore } from 'src/stores/instance.js'
import { useLocalConfigStore } from 'src/stores/local_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import {
INSTANCE_DEFAULT_CONFIG,
LOCAL_DEFAULT_CONFIG,
- THEME_CONFIG,
LOCAL_ONLY_KEYS,
+ THEME_CONFIG,
} from 'src/modules/default_config_state.js'
const ROOT_CONFIG = {
...INSTANCE_DEFAULT_CONFIG,
...LOCAL_DEFAULT_CONFIG,
...THEME_CONFIG,
}
export const useMergedConfigStore = defineStore('merged_config', {
getters: {
mergedConfig: () => {
const instancePrefs = useInstanceStore().prefsStorage
const tempPrefs = useLocalConfigStore().tempStorage
const localPrefs = useLocalConfigStore().prefsStorage
const syncPrefs = useSyncConfigStore().prefsStorage
const getValue = (k) => {
if (LOCAL_ONLY_KEYS.has(k)) {
return tempPrefs[k] ?? localPrefs[k] ?? syncPrefs.simple[k]
} else {
return tempPrefs[k] ?? syncPrefs.simple[k]
}
}
const getDefault = (k) => instancePrefs[k] ?? ROOT_CONFIG[k]
const result = Object.fromEntries(
- Object.keys(ROOT_CONFIG).map((k) => [
- k,
- getValue(k) ?? getDefault(k),
- ]),
+ Object.keys(ROOT_CONFIG).map((k) => [k, getValue(k) ?? getDefault(k)]),
)
return result
},
mergedConfigDefault: () => {
const instancePrefs = useInstanceStore().prefsStorage
const getDefault = (k) => instancePrefs[k] ?? ROOT_CONFIG[k]
const result = Object.fromEntries(
- Object.keys(ROOT_CONFIG).map((k) => [
- k,
- getDefault(k),
- ]),
+ Object.keys(ROOT_CONFIG).map((k) => [k, getDefault(k)]),
)
return result
},
mergedConfigWithoutDefaults: () => {
const instancePrefs = useInstanceStore().prefsStorage
const tempPrefs = useLocalConfigStore().tempStorage
const localPrefs = useLocalConfigStore().prefsStorage
const syncPrefs = useSyncConfigStore().prefsStorage
const getValue = (k) =>
tempPrefs[k] ?? localPrefs[k] ?? syncPrefs.simple[k] ?? instancePrefs[k]
const result = Object.fromEntries(
- Object.keys(ROOT_CONFIG).map(([k, value]) => [
- k,
- getValue(k),
- ]),
+ Object.keys(ROOT_CONFIG).map(([k, value]) => [k, getValue(k)]),
)
return result
},
},
})
diff --git a/src/stores/sync_config.js b/src/stores/sync_config.js
index a6580b5c7a..045579e415 100644
--- a/src/stores/sync_config.js
+++ b/src/stores/sync_config.js
@@ -1,758 +1,772 @@
import sum from 'hash-sum'
import {
merge as _merge,
clamp,
cloneDeep,
findLastIndex,
flatten,
get,
groupBy,
isEqual,
set,
take,
takeRight,
uniqWith,
unset,
} from 'lodash'
import { defineStore } from 'pinia'
import { toRaw } from 'vue'
import { CURRENT_UPDATE_COUNTER } from 'src/components/update_notification/update_notification.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useLocalConfigStore } from 'src/stores/local_config.js'
import { storage } from 'src/lib/storage.js'
import {
+ makeUndefined,
ROOT_CONFIG,
ROOT_CONFIG_DEFINITIONS,
validateSetting,
- makeUndefined,
} from 'src/modules/default_config_state.js'
import { oldDefaultConfigSync } from 'src/modules/old_default_config_state.js'
export const VERSION = 2
export const NEW_USER_DATE = new Date('2026-03-16') // date of writing this, basically
export const COMMAND_TRIM_FLAGS = 1000
export const COMMAND_TRIM_FLAGS_AND_RESET = 1001
export const COMMAND_WIPE_JOURNAL = 1010
export const COMMAND_WIPE_JOURNAL_AND_STORAGE = 1011
export const defaultState = {
// do we need to update data on server?
dirty: false,
// storage of flags - stuff that can only be set and incremented
flagStorage: {
updateCounter: 0, // Counter for most recent update notification seen
reset: 0, // special flag that can be used to force-reset all data, debug purposes only
// special reset codes:
// 1000: trim keys to those known by currently running FE
// 1001: same as above + reset everything to 0
},
prefsStorage: {
_journal: [],
simple: {
muteFilters: {},
...makeUndefined({ ...ROOT_CONFIG }),
},
collections: {
pinnedStatusActions: ['reply', 'retweet', 'favorite', 'emoji'],
pinnedNavItems: ['home', 'dms', 'chats'],
},
},
// raw data
raw: null,
// local cache
cache: null,
}
export const newUserFlags = {
...defaultState.flagStorage,
updateCounter: CURRENT_UPDATE_COUNTER, // new users don't need to see update notification
}
export const _moveItemInArray = (array, value, movement) => {
const oldIndex = array.indexOf(value)
const newIndex = oldIndex + movement
const newArray = [...array]
// remove old
newArray.splice(oldIndex, 1)
// add new
newArray.splice(clamp(newIndex, 0, newArray.length + 1), 0, value)
return newArray
}
const _wrapData = (data, userName) => {
return {
...data,
_user: userName,
_timestamp: Date.now(),
_version: VERSION,
}
}
const _checkValidity = (data) => data._timestamp > 0 && data._version > 0
const _verifyPrefs = (state) => {
state.prefsStorage = state.prefsStorage || {
simple: {},
collections: {},
}
// Simple
Object.entries(defaultState.prefsStorage.simple).forEach(([k, v]) => {
if (typeof v === 'undefined') return
if (typeof v === 'number' || typeof v === 'boolean') return
if (typeof v === 'object') return
console.warn(
`Preference simple.${k} as invalid type ${typeof v}, reinitializing`,
)
set(state.prefsStorage.simple, k, defaultState.prefsStorage.simple[k])
})
// Collections
Object.entries(defaultState.prefsStorage.collections).forEach(([k, v]) => {
if (Array.isArray(v)) return
console.warn(
`Preference collections.${k} as invalid type ${typeof v}, reinitializing`,
)
set(
state.prefsStorage.collections,
k,
defaultState.prefsStorage.collections[k],
)
})
}
export const _getRecentData = (cache, live, isTest) => {
const result = { recent: null, stale: null, needUpload: false }
const cacheValid = _checkValidity(cache || {})
const liveValid = _checkValidity(live || {})
if (!liveValid && cacheValid) {
result.needUpload = true
console.debug(
'Nothing valid stored on server, assuming cache to be source of truth',
)
result.recent = cache
result.stale = live
} else if (!cacheValid && liveValid) {
console.debug(
'Valid storage on server found, no local cache found, using live as source of truth',
)
result.recent = live
result.stale = cache
} else if (cacheValid && liveValid) {
console.debug('Both sources have valid data, figuring things out...')
if (
live._timestamp === cache._timestamp &&
live._version === cache._version
) {
console.debug(
'Same version/timestamp on both sources, source of truth irrelevant',
)
result.recent = cache
result.stale = live
} else {
console.debug(
'Different timestamp or version, figuring out which one is more recent',
)
if (live._timestamp < cache._timestamp) {
result.recent = cache
result.stale = live
} else {
result.recent = live
result.stale = cache
}
}
} else {
console.debug('Both sources are invalid, start from scratch')
result.needUpload = true
}
const merge = (a, b) => {
return {
_user: a._user ?? b._user,
_version: a._version ?? b._version,
_timestamp: a._timestamp ?? b._timestamp,
needUpload: b.needUpload ?? a.needUpload,
prefsStorage: _merge(cloneDeep(a.prefsStorage), b.prefsStorage),
flagStorage: _merge(a.flagStorage, b.flagStorage),
}
}
result.recent = isTest
? result.recent
: result.recent && merge(defaultState, result.recent)
result.stale = isTest
? result.stale
: result.stale && merge(defaultState, result.stale)
return result
}
export const _getAllFlags = (recent, stale) => {
return Array.from(
new Set([
...Object.keys(toRaw((recent || {}).flagStorage || {})),
...Object.keys(toRaw((stale || {}).flagStorage || {})),
]),
)
}
export const _mergeFlags = (recent, stale, allFlagKeys) => {
if (!stale.flagStorage) return recent.flagStorage
if (!recent.flagStorage) return stale.flagStorage
return Object.fromEntries(
allFlagKeys.map((flag) => {
const recentFlag = recent.flagStorage[flag]
const staleFlag = stale.flagStorage[flag]
// use flag that is of higher value
return [
flag,
Number((recentFlag > staleFlag ? recentFlag : staleFlag) || 0),
]
}),
)
}
export const _mergeJournal = (...journals) => {
// Ignore invalid journal entries
const allJournals = flatten(
journals.map((j) => (Array.isArray(j) ? j : [])),
).filter(
(entry) =>
Object.hasOwn(entry, 'path') &&
Object.hasOwn(entry, 'operation') &&
Object.hasOwn(entry, 'args') &&
Object.hasOwn(entry, 'timestamp'),
)
const grouped = groupBy(allJournals, 'path')
const trimmedGrouped = Object.entries(grouped).map(([path, journal]) => {
// side effect
journal.sort((a, b) => (a.timestamp > b.timestamp ? 1 : -1))
if (path.startsWith('collections')) {
const lastRemoveIndex = findLastIndex(
journal,
({ operation }) => operation === 'removeFromCollection',
)
// everything before last remove is unimportant
let remainder
if (lastRemoveIndex > 0) {
remainder = journal.slice(lastRemoveIndex)
} else {
// everything else doesn't need trimming
remainder = journal
}
return uniqWith(remainder, (a, b) => {
if (a.path !== b.path) {
return false
}
if (a.operation !== b.operation) {
return false
}
if (a.operation === 'addToCollection') {
return a.args[0] === b.args[0]
}
return false
})
} else if (path.startsWith('simple')) {
// Only the last record is important
return takeRight(journal)
} else {
return journal
}
})
const flat = flatten(trimmedGrouped).sort((a, b) =>
a.timestamp > b.timestamp ? 1 : -1,
)
return take(flat, 500)
}
export const _mergePrefs = (recent, stale) => {
if (!stale) return recent
if (!recent) return stale
const { _journal: recentJournal, ...recentData } = recent
const { _journal: staleJournal } = stale
/** Journal entry format:
* path: path to entry in prefsStorage
* timestamp: timestamp of the change
* operation: operation type
* arguments: array of arguments, depends on operation type
*
* currently only supported operation type is "set" which just sets the value
* to requested one. Intended only to be used with simple preferences (boolean, number)
* shouldn't be used with collections!
*/
const resultOutput = { ...recentData }
const totalJournal = _mergeJournal(staleJournal, recentJournal)
totalJournal.forEach(({ path, operation, args }) => {
if (path.startsWith('_')) {
throw new Error(
`journal contains entry to edit internal (starts with _) field '${path}', something is incorrect here, ignoring.`,
)
}
switch (operation) {
- case 'set':
+ case 'set': {
if (path.startsWith('collections')) {
return console.error('Illegal operation "set" on a collection')
}
if (path.split(/\./g).length <= 1) {
return console.error(
`Calling set on depth <= 1 (path: ${path}) is not allowed`,
)
}
- set(resultOutput, path, args[0])
+
+ const definition = path.startsWith('simple.muteFilters')
+ ? { default: {} }
+ : ROOT_CONFIG_DEFINITIONS[path.split('.')[1]]
+
+ const finalValue = validateSetting({
+ path: path.split('.')[1],
+ value: args[0],
+ definition,
+ throwError: false,
+ defaultState: ROOT_CONFIG,
+ })
+
+ set(resultOutput, path, finalValue)
break
+ }
case 'unset':
if (path.startsWith('collections')) {
return console.error('Illegal operation "unset" on a collection')
}
if (path.split(/\./g).length <= 2) {
return console.error(
`Calling unset on depth <= 2 (path: ${path}) is not allowed`,
)
}
unset(resultOutput, path)
break
case 'addToCollection':
if (!path.startsWith('collections')) {
return console.error(
'Illegal operation "addToCollection" on a non-collection',
)
}
set(
resultOutput,
path,
Array.from(new Set(get(resultOutput, path)).add(args[0])),
)
break
case 'removeFromCollection': {
if (!path.startsWith('collections')) {
return console.error(
'Illegal operation "removeFromCollection" on a non-collection',
)
}
const newSet = new Set(get(resultOutput, path))
newSet.delete(args[0])
set(resultOutput, path, Array.from(newSet))
break
}
case 'reorderCollection': {
const [value, movement] = args
set(
resultOutput,
path,
_moveItemInArray(get(resultOutput, path), value, movement),
)
break
}
default:
return console.error(
`Unknown journal operation: '${operation}', did we forget to run reverse migrations beforehand?`,
)
}
})
return { ...resultOutput, _journal: totalJournal }
}
export const _resetFlags = (
totalFlags,
knownKeys = defaultState.flagStorage,
) => {
let result = { ...totalFlags }
const allFlagKeys = Object.keys(totalFlags)
// flag reset functionality
if (
totalFlags.reset >= COMMAND_TRIM_FLAGS &&
totalFlags.reset <= COMMAND_TRIM_FLAGS_AND_RESET
) {
console.debug('Received command to trim the flags')
const knownKeysSet = new Set(Object.keys(knownKeys))
// Trim
result = {}
allFlagKeys.forEach((flag) => {
if (knownKeysSet.has(flag)) {
result[flag] = totalFlags[flag]
}
})
// Reset
if (totalFlags.reset === COMMAND_TRIM_FLAGS_AND_RESET) {
// 1001 - and reset everything to 0
console.debug('Received command to reset the flags')
Object.keys(knownKeys).forEach((flag) => {
result[flag] = 0
})
}
}
result.reset = 0
return result
}
const _resetPrefs = (
totalPrefs,
totalFlags,
knownKeys = defaultState.flagStorage,
) => {
// prefs reset functionality
if (
totalFlags.reset >= COMMAND_WIPE_JOURNAL &&
totalFlags.reset <= COMMAND_WIPE_JOURNAL_AND_STORAGE
) {
console.debug('Received command to reset journals')
this.flagStorage.reset = COMMAND_WIPE_JOURNAL
this.prefsStorage._journal = []
this.cache.prefsStorage._journal = []
this.raw.prefsStorage._journal = []
this.pushSyncConfig()
if (totalFlags.reset === COMMAND_WIPE_JOURNAL_AND_STORAGE) {
console.debug('Received command to reset storage')
return cloneDeep(defaultState)
}
}
return totalPrefs
}
const _doMigrations = async (data, setPreference) => {
if (data._version < VERSION) {
console.debug(
'Data has older version, seeing if there any migrations that can be applied',
)
}
if (data._version > VERSION) {
console.debug(
'Data has newer version, seeing if there any reverse migrations that can be applied',
)
// no reverse migrations right now but we leave a possibility of loading a hotpatch if need be
if (window._PLEROMA_HOTPATCH) {
if (window._PLEROMA_HOTPATCH.reverseMigrations) {
console.debug('Found hotpatch migration, applying')
return window._PLEROMA_HOTPATCH.reverseMigrations.call(
{},
'syncConfigStore',
{ from: data._version, to: VERSION },
data,
)
}
}
}
return data
}
export const useSyncConfigStore = defineStore('sync_config', {
state() {
return cloneDeep(defaultState)
},
actions: {
setFlag({ flag, value }) {
this.flagStorage[flag] = value
this.dirty = true
},
setSimplePrefAndSave({ path, value }) {
this.setPreference({ path: `simple.${path}`, value })
this.pushSyncConfig()
},
unsetSimplePrefAndSave({ path }) {
this.unsetPreference({ path: `simple.${path}` })
this.pushSyncConfig()
},
setPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`Tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
if (path.startsWith('collections')) {
throw new Error(
`Invalid operation 'set' for collection field '${path}', ignoring.`,
)
}
if (path.split(/\./g).length <= 1) {
throw new Error(
`Calling set on depth <= 1 (path: ${path}) is not allowed`,
)
}
if (path.split(/\./g).length > 3) {
throw new Error(
`Calling set on depth > 3 (path: ${path}) is not allowed`,
)
}
const definition = path.startsWith('simple.muteFilters')
- ? { default: {} }
- : ROOT_CONFIG_DEFINITIONS[path.split('.')[1]]
+ ? { default: {} }
+ : ROOT_CONFIG_DEFINITIONS[path.split('.')[1]]
const finalValue = validateSetting({
path: path.split('.')[1],
value,
definition,
throwError: false,
defaultState: ROOT_CONFIG,
})
set(this.prefsStorage, path, finalValue)
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{ operation: 'set', path, args: [value], timestamp: Date.now() },
]
this.dirty = true
},
unsetPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`Tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
if (path.startsWith('collections')) {
throw new Error(
`Invalid operation 'unset' for collection field '${path}', ignoring.`,
)
}
if (path.split(/\./g).length <= 2) {
throw new Error(
`Calling unset on depth <= 2 (path: ${path}) is not allowed`,
)
}
if (path.split(/\./g).length > 3) {
throw new Error(
`Calling unset on depth > 3 (path: ${path}) is not allowed`,
)
}
unset(this.prefsStorage, path)
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{ operation: 'unset', path, args: [], timestamp: Date.now() },
]
this.dirty = true
},
addCollectionPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`tried to edit internal (starts with _) field '${path}'`,
)
}
if (path.startsWith('collections')) {
const collection = new Set(get(this.prefsStorage, path))
collection.add(value)
set(this.prefsStorage, path, [...collection])
}
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{
operation: 'addToCollection',
path,
args: [value],
timestamp: Date.now(),
},
]
this.dirty = true
},
removeCollectionPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
const { _key } = value
if (path.startsWith('collection')) {
const collection = new Set(get(this.prefsStorage, path))
collection.delete(value)
set(this.prefsStorage, path, [...collection])
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{
operation: 'removeFromCollection',
path,
args: [value],
timestamp: Date.now(),
},
]
this.dirty = true
}
},
reorderCollectionPreference({ path, value, movement }) {
if (path.startsWith('_')) {
throw new Error(
`tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
const collection = get(this.prefsStorage, path)
const newCollection = _moveItemInArray(collection, value, movement)
set(this.prefsStorage, path, newCollection)
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{
operation: 'arrangeCollection',
path,
args: [value],
timestamp: Date.now(),
},
]
this.dirty = true
},
updateCache({ username }) {
this.prefsStorage._journal = _mergeJournal(this.prefsStorage._journal)
this.cache = _wrapData(
{
flagStorage: toRaw(this.flagStorage),
prefsStorage: toRaw(this.prefsStorage),
},
username,
)
},
clearSyncConfig() {
const blankState = { ...cloneDeep(defaultState) }
Object.keys(this).forEach((k) => {
this[k] = blankState[k]
})
this.flagStorage.reset = COMMAND_WIPE_JOURNAL_AND_STORAGE
},
async initSyncConfig(userData) {
const live = userData.storage
this.raw = live
let cache = this.cache
if (cache?._user !== userData.fqn) {
console.warn(
'Cache belongs to another user! reinitializing local cache!',
)
cache = null
}
let { recent, stale, needUpload } = _getRecentData(cache, live)
const userNew = userData.created_at > NEW_USER_DATE
const flagsTemplate = userNew ? newUserFlags : defaultState.flagStorage
let dirty = false
if (recent === null) {
console.debug(
`Data is empty, initializing for ${userNew ? 'new' : 'existing'} user`,
)
recent = _wrapData({
flagStorage: { ...flagsTemplate },
prefsStorage: { ...defaultState.prefsStorage },
})
}
recent = recent && (await _doMigrations(recent, this.setPreference))
stale = stale && (await _doMigrations(stale, this.setPreference))
// Various migrations
console.debug('Migrating from old config')
const vuexState = (await storage.getItem('vuex-lz')) ?? {}
vuexState.config = vuexState.config ?? {}
const migratedEntries = new Set(vuexState.config._syncMigration ?? [])
console.debug(
`Already migrated Values: ${[...migratedEntries].join() || '[none]'}`,
)
Object.entries(oldDefaultConfigSync).forEach(([key, value]) => {
const oldValue = vuexState.config[key]
const defaultValue = value
const present = oldValue !== undefined
const migrated = migratedEntries.has(key)
const different = !isEqual(oldValue, defaultValue)
if (present && !migrated && different) {
console.debug(`Migrating config ${key}: ${oldValue}`)
if (key === 'theme3hacks') {
useLocalConfigStore().set({
path: 'fontInterface',
value: oldValue.fonts.interface,
})
useLocalConfigStore().set({
path: 'fontInput',
value: oldValue.fonts.input,
})
useLocalConfigStore().set({
path: 'fontPost',
value: oldValue.fonts.post,
})
useLocalConfigStore().set({
path: 'fontMonospace',
value: oldValue.fonts.monospace,
})
useLocalConfigStore().set({
path: 'underlay',
value: oldValue.underlay,
})
} else {
this.setPreference({ path: `simple.${key}`, value: oldValue })
}
migratedEntries.add(key)
needUpload = true
}
})
vuexState.config._syncMigration = [...migratedEntries]
storage.setItem('vuex-lz', vuexState)
if (!needUpload && recent && stale) {
console.debug('Checking if data needs merging...')
// discarding timestamps and versions
const { _timestamp: _0, _version: _1, ...recentData } = recent
const { _timestamp: _2, _version: _3, ...staleData } = stale
dirty = sum(recentData) !== sum(staleData)
console.debug(`Data ${dirty ? 'needs' : "doesn't need"} merging`)
}
const allFlagKeys = _getAllFlags(recent, stale)
let totalFlags
let totalPrefs
if (dirty) {
// Merge the flags
console.debug('Merging the data...')
totalFlags = _mergeFlags(recent, stale, allFlagKeys)
_verifyPrefs(recent)
_verifyPrefs(stale)
totalPrefs = _mergePrefs(recent.prefsStorage, stale.prefsStorage)
} else {
totalFlags = recent.flagStorage
totalPrefs = recent.prefsStorage
}
totalPrefs = _resetPrefs(totalPrefs, totalFlags)
totalFlags = _resetFlags(totalFlags)
recent.flagStorage = { ...flagsTemplate, ...totalFlags }
recent.prefsStorage = { ...defaultState.prefsStorage, ...totalPrefs }
this.dirty = dirty || needUpload
this.cache = recent
// set local timestamp to smaller one if we don't have any changes
if (stale && recent && !this.dirty) {
this.cache._timestamp = Math.min(stale._timestamp, recent._timestamp)
}
this.flagStorage = this.cache.flagStorage
this.prefsStorage = this.cache.prefsStorage
this.pushSyncConfig()
console.log('C', this.cache)
},
pushSyncConfig({ force = false } = {}) {
const needPush = this.dirty || force
if (!needPush) return
this.updateCache({ username: window.vuex.state.users.currentUser.fqn })
const params = { pleroma_settings_store: { 'pleroma-fe': this.cache } }
window.vuex.state.api.backendInteractor.updateProfileJSON({ params })
},
},
persist: {
afterLoad(state) {
return state
},
},
})
diff --git a/src/stores/user_highlight.js b/src/stores/user_highlight.js
index c70c7f57f1..3627886ea7 100644
--- a/src/stores/user_highlight.js
+++ b/src/stores/user_highlight.js
@@ -1,358 +1,358 @@
import {
merge as _merge,
clamp,
clone,
cloneDeep,
findLastIndex,
flatten,
get,
groupBy,
isEqual,
takeRight,
uniqWith,
} from 'lodash'
import { defineStore } from 'pinia'
import { toRaw } from 'vue'
import { storage } from 'src/lib/storage.js'
export const NEW_USER_DATE = new Date('2022-08-04') // date of writing this, basically
export const defaultState = {
// do we need to update data on server?
dirty: false,
highlight: {
_journal: [],
},
// raw data
raw: null,
// local cache
cache: null,
}
export const _moveItemInArray = (array, value, movement) => {
const oldIndex = array.indexOf(value)
const newIndex = oldIndex + movement
const newArray = [...array]
// remove old
newArray.splice(oldIndex, 1)
// add new
newArray.splice(clamp(newIndex, 0, newArray.length + 1), 0, value)
return newArray
}
const _wrapData = (data, userName) => {
return {
...data,
_user: userName,
_timestamp: Date.now(),
}
}
const _checkValidity = (data) => data._timestamp > 0
const _verifyHighlights = (state) => {
state.highlight = state.highlight || {}
// Simple
Object.entries(defaultState.highlight).forEach(([k, v]) => {
if (typeof v === 'undefined') return
if (typeof v === 'object') return
console.warn(`User highlight ${k} is invalid type ${typeof v}, unsetting`)
delete state.highlight[k]
})
}
export const _getRecentData = (cache, live, isTest) => {
const result = { recent: null, stale: null, needUpload: false }
const cacheValid = _checkValidity(cache || {})
const liveValid = _checkValidity(live || {})
if (!liveValid && cacheValid) {
result.needUpload = true
console.debug(
'[HIGHLIGHT] Nothing valid stored on server, assuming cache to be source of truth',
)
result.recent = cache
result.stale = live
} else if (!cacheValid && liveValid) {
console.debug(
'[HIGHLIGHT] Valid storage on server found, no local cache found, using live as source of truth',
)
result.recent = live
result.stale = cache
} else if (cacheValid && liveValid) {
console.debug(
'[HIGHLIGHT] Both sources have valid data, figuring things out...',
)
if (live._timestamp === cache._timestamp) {
console.debug(
'[HIGHLIGHT] Same timestamp on both sources, source of truth irrelevant',
)
result.recent = cache
result.stale = live
} else {
console.debug(
'[HIGHLIGHT] Different timestamp, figuring out which one is more recent',
)
if (live._timestamp < cache._timestamp) {
result.recent = cache
result.stale = live
} else {
result.recent = live
result.stale = cache
}
}
} else {
console.debug('[HIGHLIGHT] Both sources are invalid, start from scratch')
result.needUpload = true
}
const merge = (a, b) => {
return {
_user: a._user ?? b._user,
_timestamp: a._timestamp ?? b._timestamp,
needUpload: b.needUpload ?? a.needUpload,
highlight: _merge(cloneDeep(a.highlight), b.highlight),
}
}
result.recent = isTest
? result.recent
: result.recent && merge(defaultState, result.recent)
result.stale = isTest
? result.stale
: result.stale && merge(defaultState, result.stale)
return result
}
const _mergeJournal = (...journals) => {
// Ignore invalid journal entries
const allJournals = flatten(
journals.map((j) => (Array.isArray(j) ? j : [])),
).filter(
(entry) =>
Object.hasOwn(entry, 'user') &&
Object.hasOwn(entry, 'operation') &&
Object.hasOwn(entry, 'args') &&
Object.hasOwn(entry, 'timestamp'),
)
const grouped = groupBy(allJournals, 'user')
const trimmedGrouped = Object.entries(grouped).map(([user, journal]) => {
// side effect
journal.sort((a, b) => (a.timestamp > b.timestamp ? 1 : -1))
return takeRight(journal)
})
return flatten(trimmedGrouped).sort((a, b) =>
a.timestamp > b.timestamp ? 1 : -1,
)
}
export const _mergeHighlights = (recent, stale) => {
if (!stale) return recent
if (!recent) return stale
- const { _journal: recentJournal, ...recentData } = recent
+ const { _journal: recentJournal, highlight: recentHighlight } = recent
const { _journal: staleJournal } = stale
+ console.log(recentHighlight)
/** Journal entry format:
* user: user to entry in highlight storage
* timestamp: timestamp of the change
* operation: operation type
* arguments: array of arguments, depends on operation type
*
* currently only supported operation type is "set" which just sets the value
* to requested one. Intended only to be used with simple preferences (boolean, number)
*/
- const resultOutput = { ...recentData }
+ const resultHighlight = { ...recentHighlight }
const Journal = _mergeJournal(staleJournal, recentJournal)
Journal.forEach(({ user, operation, args }) => {
if (user.startsWith('_')) {
throw new Error(
`journal contains entry to edit internal (starts with _) field '${user}', something is incorrect here, ignoring.`,
)
}
switch (operation) {
case 'set':
- resultOutput[user] = args[0]
+ resultHighlight[user] = args[0]
break
case 'unset':
- delete resultOutput[user]
+ delete resultHighlight[user]
break
default:
return console.error(`Unknown journal operation: '${operation}'`)
}
})
- return { ...resultOutput, _journal: Journal }
+ return { highlight: resultHighlight, _journal: Journal }
}
export const useUserHighlightStore = defineStore('user_highlight', {
state() {
return cloneDeep(defaultState)
},
actions: {
setAndSave({ user, value }) {
this.set({ user, value })
this.pushHighlight()
},
unsetAndSave({ user }) {
this.unset({ user })
this.pushHighlight()
},
get(user) {
const present = this.highlight[user] || {}
return {
user,
type: 'disabled',
color: '#FFFFFF',
...present,
}
},
set({ user, value }) {
if (user.startsWith('_')) {
throw new Error(
`Tried to edit internal (starts with _) field '${user}', ignoring.`,
)
}
const oldValue = this.highlight[user]
const newValue = {
user,
...oldValue,
...value,
}
this.highlight[user] = newValue
this.highlight._journal = [
...this.highlight._journal,
{ operation: 'set', user, args: [newValue], timestamp: Date.now() },
]
this.dirty = true
},
unset({ user, value }) {
if (user.startsWith('_')) {
throw new Error(
`Tried to edit internal (starts with _) field '${user}', ignoring.`,
)
}
delete this.highlight[user]
this.highlight._journal = [
...this.highlight._journal,
{ operation: 'unset', user, args: [], timestamp: Date.now() },
]
this.dirty = true
},
updateCache({ username }) {
this.highlight._journal = _mergeJournal(this.highlight._journal)
this.cache = _wrapData(
{
highlight: toRaw(this.highlight),
},
username,
)
},
clearSyncConfig() {
const blankState = { ...cloneDeep(defaultState) }
Object.keys(this).forEach((k) => {
this[k] = blankState[k]
})
},
clearJournals() {
this.highlight._journal = []
this.cache.highlight._journal = []
this.raw.highlight._journal = []
this.pushSyncConfig()
},
async initUserHighlight(userData) {
const live = userData.user_highlight
this.raw = live
let cache = this.cache
if (cache?._user !== userData.fqn) {
console.warn(
'Cache belongs to another user! reinitializing local cache!',
)
cache = null
}
let { recent, stale, needUpload } = _getRecentData(cache, live)
const userNew = userData.created_at > NEW_USER_DATE
let dirty = false
const vuexState = (await storage.getItem('vuex-lz')) ?? {}
vuexState.config = vuexState.config ?? {}
const highlight = vuexState.config.highlight ?? {}
Object.entries(highlight).forEach(([user, value]) => {
if ((highlight[user]._migrated || 0) < 1) {
dirty = true
needUpload = true
this.set({ user, value: clone(value) })
vuexState.config.highlight[user]._migrated = 1
console.debug(
`[HIGHLIGHT] Migrating user ${user}: ${JSON.stringify(value)}`,
)
}
})
storage.setItem('vuex-lz', vuexState)
if (recent === null) {
console.debug(
`[HIGHLIGHT] Data is empty, initializing for ${userNew ? 'new' : 'existing'} user`,
)
recent = _wrapData({
highlight: { ...defaultState.highlight },
})
}
if (!needUpload && recent && stale) {
console.debug('[HIGHLIGHT] Checking if data needs merging...')
// discarding timestamps
const { _timestamp: _0, ...recentData } = recent
const { _timestamp: _2, ...staleData } = stale
dirty = !isEqual(recentData, staleData)
console.debug(
`[HIGHLIGHT] Data ${dirty ? 'needs' : "doesn't need"} merging`,
)
}
let highlights
if (dirty) {
console.debug('[HIGHLIGHT] Merging the data...')
_verifyHighlights(recent)
_verifyHighlights(stale)
highlights = _mergeHighlights(recent.highlight, stale.highlight)
} else {
highlights = recent.highlight
}
recent.highlight = { ...defaultState.highlight, ...highlights }
this.dirty = dirty || needUpload
this.cache = recent
// set local timestamp to smaller one if we don't have any changes
if (stale && recent && !this.dirty) {
this.cache._timestamp = Math.min(stale._timestamp, recent._timestamp)
}
this.highlight = this.cache.highlight
- this.pushHighlight()
},
pushHighlight({ force = false } = {}) {
const needPush = this.dirty || force
if (!needPush) return
this.updateCache({ username: window.vuex.state.users.currentUser.fqn })
const params = {
pleroma_settings_store: { user_highlight: this.cache },
}
window.vuex.state.api.backendInteractor
.updateProfileJSON({ params })
.then((user) => {
this.initUserHighlight(user)
this.dirty = false
})
},
},
persist: {
afterLoad(state) {
return state
},
},
})
diff --git a/test/unit/specs/components/draft.spec.js b/test/unit/specs/components/draft.spec.js
index 386b2a7616..6abf2d75b4 100644
--- a/test/unit/specs/components/draft.spec.js
+++ b/test/unit/specs/components/draft.spec.js
@@ -1,180 +1,180 @@
import { createTestingPinia } from '@pinia/testing'
import { flushPromises, mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import { $t, mountOpts, waitForEvent } from '../../../fixtures/setup_test'
-import { useSyncConfigStore } from 'src/stores/sync_config.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
const autoSaveOrNot = (caseFn, caseTitle, runFn) => {
caseFn(`${caseTitle} with auto-save`, function () {
return runFn.bind(this)(true)
})
caseFn(`${caseTitle} with no auto-save`, function () {
return runFn.bind(this)(false)
})
}
const saveManually = async (wrapper) => {
const morePostActions = wrapper.findByText(
'button',
$t('post_status.more_post_actions'),
)
await morePostActions.trigger('click')
const btn = wrapper.findByText(
'button',
$t('post_status.save_to_drafts_button'),
)
await btn.trigger('click')
}
const waitSaveTime = 4000
afterEach(() => {
vi.useRealTimers()
})
describe('Draft saving', () => {
beforeEach(() => {
setActivePinia(createTestingPinia())
})
autoSaveOrNot(
it,
'should save when the button is clicked',
async (autoSave) => {
const wrapper = mount(PostStatusForm, mountOpts())
- const store = useSyncConfigStore()
+ const store = useMergedConfigStore()
store.mergedConfig = {
autoSaveDraft: autoSave,
}
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
const textarea = wrapper.get('textarea')
await textarea.setValue('mew mew')
await saveManually(wrapper)
expect(wrapper.vm.$store.getters.draftCount).to.equal(1)
expect(wrapper.vm.$store.getters.draftsArray[0].status).to.equal(
'mew mew',
)
},
)
it('should auto-save if it is enabled', async function () {
vi.useFakeTimers()
const wrapper = mount(PostStatusForm, mountOpts())
- const store = useSyncConfigStore()
+ const store = useMergedConfigStore()
store.mergedConfig = {
autoSaveDraft: true,
}
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
const textarea = wrapper.get('textarea')
await textarea.setValue('mew mew')
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
await vi.advanceTimersByTimeAsync(waitSaveTime)
expect(wrapper.vm.$store.getters.draftCount).to.equal(1)
expect(wrapper.vm.$store.getters.draftsArray[0].status).to.equal('mew mew')
})
it('should auto-save when close if auto-save is on', async () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
closeable: true,
},
}),
)
- const store = useSyncConfigStore()
+ const store = useMergedConfigStore()
store.mergedConfig = {
autoSaveDraft: true,
}
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
const textarea = wrapper.get('textarea')
await textarea.setValue('mew mew')
wrapper.vm.requestClose()
expect(wrapper.vm.$store.getters.draftCount).to.equal(1)
await waitForEvent(wrapper, 'can-close')
})
it('should save when close if auto-save is off, and unsavedPostAction is save', async () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
closeable: true,
},
}),
)
- const store = useSyncConfigStore()
+ const store = useMergedConfigStore()
store.mergedConfig = {
autoSaveDraft: false,
unsavedPostAction: 'save',
}
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
const textarea = wrapper.get('textarea')
await textarea.setValue('mew mew')
wrapper.vm.requestClose()
expect(wrapper.vm.$store.getters.draftCount).to.equal(1)
await waitForEvent(wrapper, 'can-close')
})
it('should discard when close if auto-save is off, and unsavedPostAction is discard', async () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
closeable: true,
},
}),
)
- const store = useSyncConfigStore()
+ const store = useMergedConfigStore()
store.mergedConfig = {
autoSaveDraft: false,
unsavedPostAction: 'discard',
}
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
const textarea = wrapper.get('textarea')
await textarea.setValue('mew mew')
wrapper.vm.requestClose()
await waitForEvent(wrapper, 'can-close')
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
})
it('should confirm when close if auto-save is off, and unsavedPostAction is confirm', async () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
closeable: true,
},
}),
)
- const store = useSyncConfigStore(createTestingPinia())
+ const store = useMergedConfigStore(createTestingPinia())
store.mergedConfig = {
autoSaveDraft: false,
unsavedPostAction: 'confirm',
}
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
const textarea = wrapper.get('textarea')
await textarea.setValue('mew mew')
wrapper.vm.requestClose()
await nextTick()
const saveButton = wrapper.findByText(
'button',
$t('post_status.close_confirm_save_button'),
)
expect(saveButton).to.be.ok
await saveButton.trigger('click')
console.info('clicked')
expect(wrapper.vm.$store.getters.draftCount).to.equal(1)
await flushPromises()
await waitForEvent(wrapper, 'can-close')
})
})
diff --git a/test/unit/specs/components/emoji_input.spec.js b/test/unit/specs/components/emoji_input.spec.js
index 62f90caa41..6a7c67fdbd 100644
--- a/test/unit/specs/components/emoji_input.spec.js
+++ b/test/unit/specs/components/emoji_input.spec.js
@@ -1,167 +1,167 @@
import { createTestingPinia } from '@pinia/testing'
import { shallowMount } from '@vue/test-utils'
import vClickOutside from 'click-outside-vue3'
import { h } from 'vue'
createTestingPinia()
import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
-import { useSyncConfigStore } from 'src/stores/sync_config.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
const generateInput = (value, padEmoji = true) => {
const wrapper = shallowMount(EmojiInput, {
global: {
renderStubDefaultSlot: true,
mocks: {
$t: (msg) => msg,
},
stubs: {
FAIcon: true,
Popover: {
template: `<div><slot trigger /></div>`,
methods: {
updateStyles() {
/* no-op */
},
},
},
},
directives: {
'click-outside': vClickOutside,
},
},
props: {
suggest: () => [],
enableEmojiPicker: true,
modelValue: value,
},
slots: {
default: () => h('input', ''),
},
})
return wrapper
}
describe('EmojiInput', () => {
beforeEach(() => {
- const store = useSyncConfigStore(createTestingPinia())
+ const store = useMergedConfigStore(createTestingPinia())
store.mergedConfig = {
padEmoji: true,
}
})
describe('insertion mechanism', () => {
it('inserts string at the end with trailing space', () => {
const initialString = 'Testing'
const wrapper = generateInput(initialString)
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: initialString.length })
wrapper.vm.insert({ insertion: '(test)', keepOpen: false })
const inputEvents = wrapper.emitted()['update:modelValue']
expect(inputEvents[inputEvents.length - 1][0]).to.eql('Testing (test) ')
})
it('inserts string at the end with trailing space (source has a trailing space)', () => {
const initialString = 'Testing '
const wrapper = generateInput(initialString)
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: initialString.length })
wrapper.vm.insert({ insertion: '(test)', keepOpen: false })
const inputEvents = wrapper.emitted()['update:modelValue']
expect(inputEvents[inputEvents.length - 1][0]).to.eql('Testing (test) ')
})
it('inserts string at the begginning without leading space', () => {
const initialString = 'Testing'
const wrapper = generateInput(initialString)
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: 0 })
wrapper.vm.insert({ insertion: '(test)', keepOpen: false })
const inputEvents = wrapper.emitted()['update:modelValue']
expect(inputEvents[inputEvents.length - 1][0]).to.eql('(test) Testing')
})
it('inserts string between words without creating extra spaces', () => {
const initialString = 'Spurdo Sparde'
const wrapper = generateInput(initialString)
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: 6 })
wrapper.vm.insert({ insertion: ':ebin:', keepOpen: false })
const inputEvents = wrapper.emitted()['update:modelValue']
expect(inputEvents[inputEvents.length - 1][0]).to.eql(
'Spurdo :ebin: Sparde',
)
})
it('inserts string between words without creating extra spaces (other caret)', () => {
const initialString = 'Spurdo Sparde'
const wrapper = generateInput(initialString)
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: 7 })
wrapper.vm.insert({ insertion: ':ebin:', keepOpen: false })
const inputEvents = wrapper.emitted()['update:modelValue']
expect(inputEvents[inputEvents.length - 1][0]).to.eql(
'Spurdo :ebin: Sparde',
)
})
it('inserts string without any padding if padEmoji setting is set to false', () => {
const initialString = 'Eat some spam!'
const wrapper = generateInput(initialString, false)
- const store = useSyncConfigStore(createTestingPinia())
+ const store = useMergedConfigStore(createTestingPinia())
store.mergedConfig = {
padEmoji: false,
}
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: initialString.length, keepOpen: false })
wrapper.vm.insert({ insertion: ':spam:' })
const inputEvents = wrapper.emitted()['update:modelValue']
expect(inputEvents[inputEvents.length - 1][0]).to.eql(
'Eat some spam!:spam:',
)
})
it('correctly sets caret after insertion at beginning', async () => {
const initialString = '1234'
const wrapper = generateInput(initialString)
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: 0 })
wrapper.vm.insert({ insertion: '1234', keepOpen: false })
await wrapper.vm.$nextTick()
expect(wrapper.vm.caret).to.eql(5)
})
it('correctly sets caret after insertion at end', async () => {
const initialString = '1234'
const wrapper = generateInput(initialString)
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: initialString.length })
wrapper.vm.insert({ insertion: '1234', keepOpen: false })
await wrapper.vm.$nextTick()
expect(wrapper.vm.caret).to.eql(10)
})
it('correctly sets caret after insertion if padEmoji setting is set to false', async () => {
const initialString = '1234'
const wrapper = generateInput(initialString, false)
- const store = useSyncConfigStore(createTestingPinia())
+ const store = useMergedConfigStore(createTestingPinia())
store.mergedConfig = {
padEmoji: false,
}
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: initialString.length })
wrapper.vm.insert({ insertion: '1234', keepOpen: false })
await wrapper.vm.$nextTick()
expect(wrapper.vm.caret).to.eql(8)
})
})
})
diff --git a/test/unit/specs/stores/sync_config.spec.js b/test/unit/specs/stores/sync_config.spec.js
index eef6e39ac2..adf4479309 100644
--- a/test/unit/specs/stores/sync_config.spec.js
+++ b/test/unit/specs/stores/sync_config.spec.js
@@ -1,608 +1,606 @@
import { cloneDeep } from 'lodash'
import { createPinia, setActivePinia } from 'pinia'
-import { CURRENT_UPDATE_COUNTER } from 'src/components/update_notification/update_notification.js'
-
import {
_getAllFlags,
_getRecentData,
_mergeFlags,
_mergePrefs,
_moveItemInArray,
_resetFlags,
COMMAND_TRIM_FLAGS,
COMMAND_TRIM_FLAGS_AND_RESET,
defaultState,
newUserFlags,
useSyncConfigStore,
VERSION,
} from 'src/stores/sync_config.js'
describe('The SyncConfig store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('mutations', () => {
describe('initSyncConfig', () => {
const user = {
created_at: new Date('1999-02-09'),
storage: {},
}
it('should initialize storage if none present', async () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
await store.initSyncConfig({ ...user })
expect(store.cache._version).to.eql(VERSION)
expect(store.cache._timestamp).to.be.a('number')
expect(store.cache.flagStorage).to.eql(defaultState.flagStorage)
expect(store.cache.prefsStorage).to.eql(defaultState.prefsStorage)
})
it('should initialize storage with proper flags for new users if none present', async () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
await store.initSyncConfig({ ...user, created_at: new Date() })
expect(store.cache._version).to.eql(VERSION)
expect(store.cache._timestamp).to.be.a('number')
expect(store.cache.flagStorage).to.eql(newUserFlags)
expect(store.cache.prefsStorage).to.eql(defaultState.prefsStorage)
})
it('should merge flags even if remote timestamp is older', async () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.cache = {
_timestamp: Date.now(),
_version: VERSION,
...cloneDeep(defaultState),
}
await store.initSyncConfig({
...user,
storage: {
_timestamp: 123,
_version: VERSION,
flagStorage: {
...defaultState.flagStorage,
- updateCounter: CURRENT_UPDATE_COUNTER,
+ updateCounter: 1,
},
prefsStorage: {
...defaultState.prefsStorage,
},
},
})
expect(store.flagStorage).to.eql({
...defaultState.flagStorage,
- updateCounter: CURRENT_UPDATE_COUNTER,
+ updateCounter: 1,
})
})
it('should trim journal to 500 entries', async () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.cache = {
_timestamp: Date.now(),
_version: VERSION,
...cloneDeep(defaultState),
}
const largeJournal = []
for (let value = 0; value < 1000; value++) {
largeJournal.push({
path: 'simple.testing' + value,
operation: 'set',
args: [value],
// should have A timestamp, we don't really care what it is
timestamp: 123456,
})
}
await store.initSyncConfig({
...user,
storage: {
_timestamp: 123,
_version: VERSION,
flagStorage: {
...defaultState.flagStorage,
- updateCounter: CURRENT_UPDATE_COUNTER,
+ updateCounter: 1,
},
prefsStorage: {
...defaultState.prefsStorage,
_journal: largeJournal,
},
},
})
expect(store.prefsStorage._journal.length).to.eql(500)
})
it('should reset local timestamp to remote if contents are the same', async () => {
const store = useSyncConfigStore()
store.cache = null
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
await store.initSyncConfig({
...user,
storage: {
_timestamp: 123,
_version: VERSION,
flagStorage: {
...defaultState.flagStorage,
updateCounter: 999,
},
},
})
expect(store.cache._timestamp).to.eql(123)
expect(store.flagStorage.updateCounter).to.eql(999)
expect(store.cache.flagStorage.updateCounter).to.eql(999)
})
it('should use remote version if local missing', async () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
await store.initSyncConfig(store, user)
expect(store.cache._version).to.eql(VERSION)
expect(store.cache._timestamp).to.be.a('number')
expect(store.cache.flagStorage).to.eql(defaultState.flagStorage)
})
})
describe('setPreference', () => {
it('should set preference and update journal log accordingly', () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.setPreference({ path: 'simple.testing', value: 1 })
expect(store.prefsStorage.simple.testing).to.eql(1)
expect(store.prefsStorage._journal.length).to.eql(1)
expect(store.prefsStorage._journal[0]).to.eql({
path: 'simple.testing',
operation: 'set',
args: [1],
// should have A timestamp, we don't really care what it is
timestamp: store.prefsStorage._journal[0].timestamp,
})
})
it('should keep journal to a minimum', () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.setPreference({ path: 'simple.testing', value: 1 })
store.setPreference({ path: 'simple.testing', value: 2 })
store.addCollectionPreference({ path: 'collections.testing', value: 2 })
store.removeCollectionPreference({
path: 'collections.testing',
value: 2,
})
store.updateCache({ username: 'test' })
expect(store.prefsStorage.simple.testing).to.eql(2)
expect(store.prefsStorage.collections.testing).to.eql([])
expect(store.prefsStorage._journal.length).to.eql(2)
expect(store.prefsStorage._journal[0]).to.eql({
path: 'simple.testing',
operation: 'set',
args: [2],
// should have A timestamp, we don't really care what it is
timestamp: store.prefsStorage._journal[0].timestamp,
})
expect(store.prefsStorage._journal[1]).to.eql({
path: 'collections.testing',
operation: 'removeFromCollection',
args: [2],
// should have A timestamp, we don't really care what it is
timestamp: store.prefsStorage._journal[1].timestamp,
})
})
it('should remove duplicate entries from journal', () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.setPreference({ path: 'simple.testing', value: 1 })
store.setPreference({ path: 'simple.testing', value: 1 })
store.addCollectionPreference({ path: 'collections.testing', value: 2 })
store.addCollectionPreference({ path: 'collections.testing', value: 2 })
store.updateCache({ username: 'test' })
expect(store.prefsStorage.simple.testing).to.eql(1)
expect(store.prefsStorage.collections.testing).to.eql([2])
expect(store.prefsStorage._journal.length).to.eql(2)
})
it('should remove depth = 3 set/unset entries from journal', () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.setPreference({ path: 'simple.object.foo', value: 1 })
store.unsetPreference({ path: 'simple.object.foo' })
store.updateCache(store, { username: 'test' })
expect(store.prefsStorage.simple.object).to.not.have.property('foo')
expect(store.prefsStorage._journal.length).to.eql(1)
})
it('should not allow unsetting depth <= 2', () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.setPreference({ path: 'simple.object.foo', value: 1 })
expect(() => store.unsetPreference({ path: 'simple' })).to.throw()
expect(() =>
store.unsetPreference({ path: 'simple.object' }),
).to.throw()
})
it('should not allow (un)setting depth > 3', () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.setPreference({ path: 'simple.object', value: {} })
expect(() =>
store.setPreference({ path: 'simple.object.lv3', value: 1 }),
).to.not.throw()
expect(() =>
store.setPreference({ path: 'simple.object.lv3.lv4', value: 1 }),
).to.throw()
expect(() =>
store.unsetPreference({ path: 'simple.object.lv3', value: 1 }),
).to.not.throw()
expect(() =>
store.unsetPreference({ path: 'simple.object.lv3.lv4', value: 1 }),
).to.throw()
})
})
})
describe('helper functions', () => {
describe('_moveItemInArray', () => {
it('should move item according to movement value', () => {
expect(_moveItemInArray([1, 2, 3, 4], 4, -1)).to.eql([1, 2, 4, 3])
expect(_moveItemInArray([1, 2, 3, 4], 1, 2)).to.eql([2, 3, 1, 4])
})
it('should clamp movement to within array', () => {
expect(_moveItemInArray([1, 2, 3, 4], 4, -10)).to.eql([4, 1, 2, 3])
expect(_moveItemInArray([1, 2, 3, 4], 3, 99)).to.eql([1, 2, 4, 3])
})
})
describe('_getRecentData', () => {
it('should handle nulls correctly', () => {
expect(_getRecentData(null, null, true)).to.eql({
recent: null,
stale: null,
needUpload: true,
})
})
it("doesn't choke on invalid data", () => {
expect(_getRecentData({ a: 1 }, { b: 2 }, true)).to.eql({
recent: null,
stale: null,
needUpload: true,
})
})
it('should prefer the valid non-null correctly, needUpload works properly', () => {
const nonNull = { _version: VERSION, _timestamp: 1 }
expect(_getRecentData(nonNull, null, true)).to.eql({
recent: nonNull,
stale: null,
needUpload: true,
})
expect(_getRecentData(null, nonNull, true)).to.eql({
recent: nonNull,
stale: null,
needUpload: false,
})
})
it('should prefer the one with higher timestamp', () => {
const a = { _version: VERSION, _timestamp: 1 }
const b = { _version: VERSION, _timestamp: 2 }
expect(_getRecentData(a, b, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
expect(_getRecentData(b, a, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
})
it('case where both are same', () => {
const a = { _version: VERSION, _timestamp: 3 }
const b = { _version: VERSION, _timestamp: 3 }
expect(_getRecentData(a, b, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
expect(_getRecentData(b, a, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
})
})
describe('_getAllFlags', () => {
it('should handle nulls properly', () => {
expect(_getAllFlags(null, null)).to.eql([])
})
it('should output list of keys if passed single object', () => {
expect(
_getAllFlags({ flagStorage: { a: 1, b: 1, c: 1 } }, null),
).to.eql(['a', 'b', 'c'])
})
it('should union keys of both objects', () => {
expect(
_getAllFlags(
{ flagStorage: { a: 1, b: 1, c: 1 } },
{ flagStorage: { c: 1, d: 1 } },
),
).to.eql(['a', 'b', 'c', 'd'])
})
})
describe('_mergeFlags', () => {
it('should handle merge two flag sets correctly picking higher numbers', () => {
expect(
_mergeFlags(
{ flagStorage: { a: 0, b: 3 } },
{ flagStorage: { b: 1, c: 4, d: 9 } },
['a', 'b', 'c', 'd'],
),
).to.eql({ a: 0, b: 3, c: 4, d: 9 })
})
})
describe('_mergePrefs', () => {
it('should prefer recent and apply journal to it', () => {
expect(
_mergePrefs(
// RECENT
{
simple: { a: 1, b: 0, c: true },
_journal: [
{ path: 'simple.b', operation: 'set', args: [0], timestamp: 2 },
{
path: 'simple.c',
operation: 'set',
args: [true],
timestamp: 4,
},
],
},
// STALE
{
simple: { a: 1, b: 1, c: false },
_journal: [
{ path: 'simple.a', operation: 'set', args: [1], timestamp: 1 },
{ path: 'simple.b', operation: 'set', args: [1], timestamp: 3 },
],
},
),
).to.eql({
simple: { a: 1, b: 1, c: true },
_journal: [
{ path: 'simple.a', operation: 'set', args: [1], timestamp: 1 },
{ path: 'simple.b', operation: 'set', args: [1], timestamp: 3 },
{ path: 'simple.c', operation: 'set', args: [true], timestamp: 4 },
],
})
})
it('should allow setting falsy values', () => {
expect(
_mergePrefs(
// RECENT
{
simple: { a: 1, b: 0, c: false },
_journal: [
{ path: 'simple.b', operation: 'set', args: [0], timestamp: 2 },
{
path: 'simple.c',
operation: 'set',
args: [false],
timestamp: 4,
},
],
},
// STALE
{
simple: { a: 0, b: 0, c: true },
_journal: [
{ path: 'simple.a', operation: 'set', args: [0], timestamp: 1 },
{ path: 'simple.b', operation: 'set', args: [0], timestamp: 3 },
],
},
),
).to.eql({
simple: { a: 0, b: 0, c: false },
_journal: [
{ path: 'simple.a', operation: 'set', args: [0], timestamp: 1 },
{ path: 'simple.b', operation: 'set', args: [0], timestamp: 3 },
{ path: 'simple.c', operation: 'set', args: [false], timestamp: 4 },
],
})
})
it('should work with strings', () => {
expect(
_mergePrefs(
// RECENT
{
simple: { a: 'foo' },
_journal: [
{
path: 'simple.a',
operation: 'set',
args: ['foo'],
timestamp: 2,
},
],
},
// STALE
{
simple: { a: 'bar' },
_journal: [
{
path: 'simple.a',
operation: 'set',
args: ['bar'],
timestamp: 4,
},
],
},
),
).to.eql({
simple: { a: 'bar' },
_journal: [
{ path: 'simple.a', operation: 'set', args: ['bar'], timestamp: 4 },
],
})
})
it('should work with objects', () => {
expect(
_mergePrefs(
// RECENT
{
simple: { lv2: { lv3: 'foo' } },
_journal: [
{
path: 'simple.lv2.lv3',
operation: 'set',
args: ['foo'],
timestamp: 2,
},
],
},
// STALE
{
simple: { lv2: { lv3: 'bar' } },
_journal: [
{
path: 'simple.lv2.lv3',
operation: 'set',
args: ['bar'],
timestamp: 4,
},
],
},
),
).to.eql({
simple: { lv2: { lv3: 'bar' } },
_journal: [
{
path: 'simple.lv2.lv3',
operation: 'set',
args: ['bar'],
timestamp: 4,
},
],
})
})
it('should work with unset', () => {
expect(
_mergePrefs(
// RECENT
{
simple: { lv2: { lv3: 'foo' } },
_journal: [
{
path: 'simple.lv2.lv3',
operation: 'set',
args: ['foo'],
timestamp: 2,
},
],
},
// STALE
{
simple: { lv2: {} },
_journal: [
{
path: 'simple.lv2.lv3',
operation: 'unset',
args: [],
timestamp: 4,
},
],
},
),
).to.eql({
simple: { lv2: {} },
_journal: [
{
path: 'simple.lv2.lv3',
operation: 'unset',
args: [],
timestamp: 4,
},
],
})
})
})
describe('_resetFlags', () => {
it('should trim all flags to known when reset is set to 1000', () => {
const totalFlags = { a: 0, b: 3, c: 33, reset: COMMAND_TRIM_FLAGS }
expect(_resetFlags(totalFlags, { a: 0, b: 0, reset: 0 })).to.eql({
a: 0,
b: 3,
reset: 0,
})
})
it('should trim all flags to known and reset when reset is set to 1001', () => {
const totalFlags = {
a: 0,
b: 3,
c: 33,
reset: COMMAND_TRIM_FLAGS_AND_RESET,
}
expect(_resetFlags(totalFlags, { a: 0, b: 0, reset: 0 })).to.eql({
a: 0,
b: 0,
reset: 0,
})
})
})
})
})
diff --git a/test/unit/specs/stores/user_highlight.spec.js b/test/unit/specs/stores/user_highlight.spec.js
index 742eec5221..4e66a9bbb8 100644
--- a/test/unit/specs/stores/user_highlight.spec.js
+++ b/test/unit/specs/stores/user_highlight.spec.js
@@ -1,311 +1,320 @@
import { cloneDeep } from 'lodash'
import { createPinia, setActivePinia } from 'pinia'
import {
_getRecentData,
- _mergePrefs,
+ _mergeHighlights,
_moveItemInArray,
useUserHighlightStore,
} from 'src/stores/user_highlight.js'
describe('The UserHighlight store', () => {
beforeEach(() => {
setActivePinia(createPinia())
+ window.vuex = {
+ state: {
+ users: {
+ currentUser: {
+ fqn: 'foo@bar.tld',
+ },
+ },
+ },
+ }
})
describe('mutations', () => {
describe('initUserHighlight', () => {
const user = {
created_at: new Date('1999-02-09'),
storage: {},
}
- it('should initialize storage if none present', () => {
+ it('should initialize storage if none present', async () => {
const store = useUserHighlightStore()
- store.initUserHighlight({ ...user })
+ await store.initUserHighlight({ ...user })
expect(store.cache._timestamp).to.be.a('number')
expect(store.cache.highlight).to.eql({ _journal: [] })
})
- it('should initialize storage for new users if none present', () => {
+ it('should initialize storage for new users if none present', async () => {
const store = useUserHighlightStore()
- store.initUserHighlight({ ...user, created_at: new Date() })
+ await store.initUserHighlight({ ...user, created_at: new Date() })
expect(store.cache._timestamp).to.be.a('number')
expect(store.cache.highlight).to.eql({ _journal: [] })
})
- it('should use remote version if local missing', () => {
+ it('should use remote version if local missing', async () => {
const store = useUserHighlightStore()
- store.initUserHighlight(store, user)
+ await store.initUserHighlight(store, user)
expect(store.cache._timestamp).to.be.a('number')
})
})
describe('set', () => {
it('should set preference and update journal log accordingly', () => {
const store = useUserHighlightStore()
store.set({ user: 'highlight@testing', value: { type: 'test' } })
expect(store.highlight['highlight@testing']).to.eql({
user: 'highlight@testing',
type: 'test',
})
expect(store.highlight._journal.length).to.eql(1)
expect(store.highlight._journal[0]).to.eql({
user: 'highlight@testing',
operation: 'set',
args: [{ user: 'highlight@testing', type: 'test' }],
// should have A timestamp, we don't really care what it is
timestamp: store.highlight._journal[0].timestamp,
})
})
it('should keep journal to a minimum', () => {
const store = useUserHighlightStore()
store.set({ user: 'highlight@testing.xyz', value: { type: 'test' } })
store.set({ user: 'highlight@testing.xyz', value: { type: 'test' } })
store.updateCache({ username: 'test' })
expect(store.highlight['highlight@testing.xyz']).to.eql({
user: 'highlight@testing.xyz',
type: 'test',
})
expect(store.highlight._journal.length).to.eql(1)
expect(store.highlight._journal[0]).to.eql({
user: 'highlight@testing.xyz',
operation: 'set',
args: [
{
user: 'highlight@testing.xyz',
type: 'test',
},
],
// should have A timestamp, we don't really care what it is
timestamp: store.highlight._journal[0].timestamp,
})
})
it('should remove duplicate entries from journal', () => {
const store = useUserHighlightStore()
store.set({ user: 'a@test.xyz', value: { type: 'foo' } })
store.set({ user: 'a@test.xyz', value: { type: 'foo' } })
store.updateCache({ username: 'test' })
expect(store.highlight['a@test.xyz']).to.eql({
user: 'a@test.xyz',
type: 'foo',
})
expect(store.highlight._journal.length).to.eql(1)
})
})
})
describe('helper functions', () => {
describe('_getRecentData', () => {
it('should handle nulls correctly', () => {
expect(_getRecentData(null, null, true)).to.eql({
recent: null,
stale: null,
needUpload: true,
})
})
it("doesn't choke on invalid data", () => {
expect(_getRecentData({ a: 1 }, { b: 2 }, true)).to.eql({
recent: null,
stale: null,
needUpload: true,
})
})
it('should prefer the valid non-null correctly, needUpload works properly', () => {
const nonNull = { _timestamp: 1 }
expect(_getRecentData(nonNull, null, true)).to.eql({
recent: nonNull,
stale: null,
needUpload: true,
})
expect(_getRecentData(null, nonNull, true)).to.eql({
recent: nonNull,
stale: null,
needUpload: false,
})
})
it('should prefer the one with higher timestamp', () => {
const a = { _timestamp: 1 }
const b = { _timestamp: 2 }
expect(_getRecentData(a, b, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
expect(_getRecentData(b, a, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
})
it('case where both are same', () => {
const a = { _timestamp: 3 }
const b = { _timestamp: 3 }
expect(_getRecentData(a, b, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
expect(_getRecentData(b, a, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
})
})
- describe('_mergePrefs', () => {
+ describe('_mergeHighlights', () => {
it('should prefer recent and apply journal to it', () => {
expect(
- _mergePrefs(
+ _mergeHighlights(
// RECENT
{
highlight: {
'a@test.xyz': 1,
'b@test.xyz': 0,
'c@test.xyz': true,
},
_journal: [
{
user: 'b@test.xyz',
operation: 'set',
args: [0],
timestamp: 2,
},
{
user: 'c@test.xyz',
operation: 'set',
args: [true],
timestamp: 4,
},
],
},
// STALE
{
highlight: {
'a@test.xyz': 1,
'b@test.xyz': 1,
'c@test.xyz': false,
},
_journal: [
{
user: 'a@test.xyz',
operation: 'set',
args: [1],
timestamp: 1,
},
{
user: 'b@test.xyz',
operation: 'set',
args: [1],
timestamp: 3,
},
],
},
),
).to.eql({
highlight: { 'a@test.xyz': 1, 'b@test.xyz': 1, 'c@test.xyz': true },
_journal: [
{ user: 'a@test.xyz', operation: 'set', args: [1], timestamp: 1 },
{ user: 'b@test.xyz', operation: 'set', args: [1], timestamp: 3 },
{
user: 'c@test.xyz',
operation: 'set',
args: [true],
timestamp: 4,
},
],
})
})
it('should work with objects', () => {
expect(
- _mergePrefs(
+ _mergeHighlights(
// RECENT
{
highlight: { 'a@test.xyz': { type: 'foo' } },
_journal: [
{
user: 'a@test.xyz',
operation: 'set',
args: [{ type: 'foo' }],
timestamp: 2,
},
],
},
// STALE
{
highlight: { 'a@test.xyz': { type: 'bar' } },
_journal: [
{
user: 'a@test.xyz',
operation: 'set',
args: [{ type: 'bar' }],
timestamp: 4,
},
],
},
),
).to.eql({
highlight: { 'a@test.xyz': { type: 'bar' } },
_journal: [
{
user: 'a@test.xyz',
operation: 'set',
args: [{ type: 'bar' }],
timestamp: 4,
},
],
})
})
it('should work with unset', () => {
expect(
- _mergePrefs(
+ _mergeHighlights(
// RECENT
{
highlight: { 'a@test.xyz': { type: 'foo' } },
_journal: [
{
user: 'a@test.xyz',
operation: 'set',
args: [{ type: 'foo' }],
timestamp: 2,
},
],
},
// STALE
{
highlight: {},
_journal: [
{
user: 'a@test.xyz',
operation: 'unset',
args: [],
timestamp: 4,
},
],
},
),
).to.eql({
highlight: {},
_journal: [
{
user: 'a@test.xyz',
operation: 'unset',
args: [],
timestamp: 4,
},
],
})
})
})
})
})
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Thu, Oct 29, 4:33 PM (1 d, 11 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1798354
Default Alt Text
(147 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline