Merge branch 'sss-objects' into 'develop'

Object/Map support for server-side storage + synchronized mutes

See merge request pleroma/pleroma-fe!2104
This commit is contained in:
HJ 2025-04-15 09:01:42 +00:00
commit 4bdbdb3920
29 changed files with 1223 additions and 407 deletions

View file

@ -0,0 +1 @@
Synchronized mutes, advanced mute control (regexp, expiry, naming)

View file

@ -46,6 +46,7 @@
"querystring-es3": "0.2.1",
"url": "0.11.4",
"utf8": "3.0.0",
"uuid": "8.3.2",
"vue": "3.5.13",
"vue-i18n": "11",
"vue-router": "4.5.0",

View file

@ -1,14 +1,19 @@
import SideDrawer from '../side_drawer/side_drawer.vue'
import Notifications from '../notifications/notifications.vue'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import GestureService from '../../services/gesture_service/gesture_service'
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
import {
unseenNotificationsFromStore,
countExtraNotifications
} from '../../services/notification_utils/notification_utils'
import GestureService from '../../services/gesture_service/gesture_service'
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
import { mapGetters } from 'vuex'
import { mapState } from 'pinia'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faTimes,
@ -18,7 +23,6 @@ import {
faMinus,
faCheckDouble
} from '@fortawesome/free-solid-svg-icons'
import { useAnnouncementsStore } from 'src/stores/announcements'
library.add(
faTimes,
@ -71,10 +75,9 @@ const MobileNav = {
return this.$route.name === 'chat'
},
...mapState(useAnnouncementsStore, ['unreadAnnouncementCount']),
...mapGetters(['unreadChatCount']),
chatsPinned () {
return new Set(this.$store.state.serverSideStorage.prefsStorage.collections.pinnedNavItems).has('chats')
},
...mapState(useServerSideStorageStore, {
pinnedItems: store => new Set(store.prefsStorage.collections.pinnedNavItems).has('chats')
}),
shouldConfirmLogout () {
return this.$store.getters.mergedConfig.modalOnLogout
},

View file

@ -7,7 +7,9 @@ import { filterNavigation } from 'src/components/navigation/filter.js'
import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
@ -76,19 +78,19 @@ const NavPanel = {
this.editMode = !this.editMode
},
toggleCollapse () {
this.$store.commit('setPreference', { path: 'simple.collapseNav', value: !this.collapsed })
this.$store.dispatch('pushServerSideStorage')
useServerSideStorageStore().setPreference({ path: 'simple.collapseNav', value: !this.collapsed })
useServerSideStorageStore().pushServerSideStorage()
},
isPinned (item) {
return this.pinnedItems.has(item)
},
togglePin (item) {
if (this.isPinned(item)) {
this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedNavItems', value: item })
useServerSideStorageStore().removeCollectionPreference({ path: 'collections.pinnedNavItems', value: item })
} else {
this.$store.commit('addCollectionPreference', { path: 'collections.pinnedNavItems', value: item })
useServerSideStorageStore().addCollectionPreference({ path: 'collections.pinnedNavItems', value: item })
}
this.$store.dispatch('pushServerSideStorage')
useServerSideStorageStore().pushServerSideStorage()
}
},
computed: {
@ -96,14 +98,16 @@ const NavPanel = {
unreadAnnouncementCount: 'unreadAnnouncementCount',
supportsAnnouncements: store => store.supportsAnnouncements
}),
...mapPiniaState(useServerSideStorageStore, {
collapsed: store => store.prefsStorage.simple.collapseNav,
pinnedItems: store => new Set(store.prefsStorage.collections.pinnedNavItems)
}),
...mapState({
currentUser: state => state.users.currentUser,
followRequestCount: state => state.api.followRequests.length,
privateMode: state => state.instance.private,
federating: state => state.instance.federating,
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable,
pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems),
collapsed: state => state.serverSideStorage.prefsStorage.simple.collapseNav,
bookmarkFolders: state => state.instance.pleromaBookmarkFoldersAvailable
}),
timelinesItems () {

View file

@ -3,8 +3,10 @@ import { routeTo } from 'src/components/navigation/navigation.js'
import OptionalRouterLink from 'src/components/optional_router_link/optional_router_link.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faThumbtack } from '@fortawesome/free-solid-svg-icons'
import { mapStores } from 'pinia'
import { mapStores, mapState as mapPiniaState } from 'pinia'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
library.add(faThumbtack)
@ -19,11 +21,11 @@ const NavigationEntry = {
},
togglePin (value) {
if (this.isPinned(value)) {
this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedNavItems', value })
useServerSideStorageStore().removeCollectionPreference({ path: 'collections.pinnedNavItems', value })
} else {
this.$store.commit('addCollectionPreference', { path: 'collections.pinnedNavItems', value })
useServerSideStorageStore().addCollectionPreference({ path: 'collections.pinnedNavItems', value })
}
this.$store.dispatch('pushServerSideStorage')
useServerSideStorageStore().pushServerSideStorage()
}
},
computed: {
@ -35,9 +37,11 @@ const NavigationEntry = {
},
...mapStores(useAnnouncementsStore),
...mapState({
currentUser: state => state.users.currentUser,
pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems)
})
currentUser: state => state.users.currentUser
}),
...mapPiniaState(useServerSideStorageStore, {
pinnedItems: store => new Set(store.prefsStorage.collections.pinnedNavItems)
}),
}
}

View file

@ -20,6 +20,7 @@ import {
import { useListsStore } from 'src/stores/lists'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
library.add(
faUsers,
@ -54,15 +55,17 @@ const NavPanel = {
supportsAnnouncements: store => store.supportsAnnouncements
}),
...mapPiniaState(useBookmarkFoldersStore, {
bookmarks: getBookmarkFolderEntries
bookmarks: getBookmarkFolderEntries,
}),
...mapPiniaState(useServerSideStorageStore, {
pinnedItems: store => new Set(store.prefsStorage.collections.pinnedNavItems)
}),
...mapState({
currentUser: state => state.users.currentUser,
followRequestCount: state => state.api.followRequests.length,
privateMode: state => state.instance.private,
federating: state => state.instance.federating,
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable,
pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems)
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
}),
pinnedList () {
if (!this.currentUser) {

View file

@ -363,12 +363,6 @@ const PostStatusForm = {
}
},
safeToSaveDraft () {
console.log('safe', (
this.newStatus.status ||
this.newStatus.spoilerText ||
this.newStatus.files?.length ||
this.newStatus.hasPoll
) && this.saveable)
return (
this.newStatus.status ||
this.newStatus.spoilerText ||

View file

@ -8,6 +8,7 @@ import Popover from 'components/popover/popover.vue'
import ConfirmModal from 'components/confirm_modal/confirm_modal.vue'
import ModifiedIndicator from '../helpers/modified_indicator.vue'
import EmojiEditingPopover from '../helpers/emoji_editing_popover.vue'
import { useInterfaceStore } from 'src/stores/interface'
const EmojiTab = {
components: {
@ -232,7 +233,7 @@ const EmojiTab = {
})
},
displayError (msg) {
this.$store.useInterfaceStore().pushGlobalNotice({
useInterfaceStore().pushGlobalNotice({
messageKey: 'admin_dash.emoji.error',
messageArgs: [msg],
level: 'error'

View file

@ -5,6 +5,7 @@ import StringSetting from '../helpers/string_setting.vue'
import GroupSetting from '../helpers/group_setting.vue'
import Popover from 'src/components/popover/popover.vue'
import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
import { useInterfaceStore } from 'src/stores/interface'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import { library } from '@fortawesome/fontawesome-svg-core'
@ -80,7 +81,7 @@ const FrontendsTab = {
this.$store.dispatch('loadFrontendsStuff')
if (response.error) {
const reason = await response.error.json()
this.$store.useInterfaceStore().pushGlobalNotice({
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'admin_dash.frontend.failure_installing_frontend',
messageArgs: {
@ -90,7 +91,7 @@ const FrontendsTab = {
timeout: 5000
})
} else {
this.$store.useInterfaceStore().pushGlobalNotice({
useInterfaceStore().pushGlobalNotice({
level: 'success',
messageKey: 'admin_dash.frontend.success_installing_frontend',
messageArgs: {

View file

@ -0,0 +1,46 @@
<template>
<span class="HelpIndicator">
<Popover
trigger="click"
:trigger-attrs="{ 'aria-label': $t('settings.setting_changed') }"
>
<template #trigger>
&nbsp;
<FAIcon icon="circle-question" />
</template>
<template #content>
<div class="help-tooltip">
<slot />
</div>
</template>
</Popover>
</span>
</template>
<script>
import Popover from 'src/components/popover/popover.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleQuestion } from '@fortawesome/free-solid-svg-icons'
library.add(
faCircleQuestion
)
export default {
components: { Popover }
}
</script>
<style lang="scss">
.HelpIndicator {
display: inline-block;
position: relative;
}
.help-tooltip {
margin: 0.5em 1em;
min-width: 10em;
max-width: 30vw;
text-align: center;
}
</style>

View file

@ -78,7 +78,6 @@ export default {
},
computed: {
draft: {
// TODO allow passing shared draft object?
get () {
if (this.realSource === 'admin' || this.path == null) {
return get(this.$store.state.adminSettings.draft, this.canonPath)

View file

@ -315,7 +315,7 @@ const AppearanceTab = {
},
onImportFailure (result) {
console.error('Failure importing theme:', result)
this.$store.useInterfaceStore().pushGlobalNotice({ messageKey: 'settings.invalid_theme_imported', level: 'error' })
useInterfaceStore().pushGlobalNotice({ messageKey: 'settings.invalid_theme_imported', level: 'error' })
},
importValidator (parsed, filename) {
if (filename.endsWith('.json')) {

View file

@ -1,48 +1,207 @@
import { filter, trim, debounce } from 'lodash'
import { cloneDeep } from 'lodash'
import { mapState, mapActions } from 'pinia'
import { v4 as uuidv4 } from 'uuid';
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import { useInterfaceStore } from 'src/stores/interface'
import {
newImporter,
newExporter
} from 'src/services/export_import/export_import.js'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import UnitSetting from '../helpers/unit_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import HelpIndicator from '../helpers/help_indicator.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Select from 'src/components/select/select.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
const SUPPORTED_TYPES = new Set(['word', 'regexp', 'user', 'user_regexp'])
const FilteringTab = {
data () {
return {
muteWordsStringLocal: this.$store.getters.mergedConfig.muteWords.join('\n'),
replyVisibilityOptions: ['all', 'following', 'self'].map(mode => ({
key: mode,
value: mode,
label: this.$t(`settings.reply_visibility_${mode}`)
}))
})),
muteFiltersDraftObject: cloneDeep(useServerSideStorageStore().prefsStorage.simple.muteFilters),
muteFiltersDraftDirty: Object.fromEntries(
Object.entries(
useServerSideStorageStore().prefsStorage.simple.muteFilters
).map(([k]) => [k, false])
),
exportedFilter: null,
filterImporter: newImporter({
validator (parsed) {
if (Array.isArray(parsed)) return false
if (!SUPPORTED_TYPES.has(parsed.type)) return false
return true
},
onImport: (data) => {
const {
enabled = true,
expires = null,
hide = false,
name = '',
value = ''
} = data
this.createFilter({
enabled,
expires,
hide,
name,
value
})
},
onImportFailure (result) {
console.error('Failure importing filter:', result)
useInterfaceStore()
.pushGlobalNotice({
messageKey: 'settings.filter.import_failure',
level: 'error'
})
}
}),
filterExporter: newExporter({
filename: 'pleromafe_mute-filter',
getExportedObject: () => this.exportedFilter
})
}
},
components: {
BooleanSetting,
ChoiceSetting,
UnitSetting,
IntegerSetting
IntegerSetting,
Checkbox,
Select,
HelpIndicator
},
computed: {
...SharedComputedObject(),
muteWordsString: {
get () {
return this.muteWordsStringLocal
},
set (value) {
this.muteWordsStringLocal = value
this.debouncedSetMuteWords(value)
...mapState(
useServerSideStorageStore,
{
muteFilters: store => Object.entries(store.prefsStorage.simple.muteFilters),
muteFiltersObject: store => store.prefsStorage.simple.muteFilters
}
),
muteFiltersDraft () {
return Object.entries(this.muteFiltersDraftObject)
},
debouncedSetMuteWords () {
return debounce((value) => {
this.$store.dispatch('setOption', {
name: 'muteWords',
value: filter(value.split('\n'), (word) => trim(word).length > 0)
})
}, 1000)
muteFiltersExpired () {
const now = Date.now()
return Object
.entries(this.muteFiltersDraftObject)
.filter(([, { expires }]) => expires != null && expires <= now)
}
},
methods: {
...mapActions(useServerSideStorageStore, ['setPreference', 'unsetPreference', 'pushServerSideStorage']),
getDatetimeLocal (timestamp) {
const date = new Date(timestamp)
let fmt = new Intl.NumberFormat("en-US", {minimumIntegerDigits: 2})
const datetime = [
date.getFullYear(),
'-',
fmt.format(date.getMonth() + 1),
'T',
fmt.format(date.getHours()),
':',
fmt.format(date.getMinutes())
].join('')
return datetime
},
checkRegexValid (id) {
const filter = this.muteFiltersObject[id]
if (filter.type !== 'regexp') return true
if (filter.type !== 'user_regexp') return true
const { value } = filter
let valid = true
try {
new RegExp(value)
} catch {
valid = false
console.error('Invalid RegExp: ' + value)
}
return valid
},
createFilter (filter = {
type: 'word',
value: '',
name: 'New Filter',
enabled: true,
expires: null,
hide: false,
}) {
const newId = uuidv4()
filter.order = this.muteFilters.length + 2
this.muteFiltersDraftObject[newId] = filter
this.setPreference({ path: 'simple.muteFilters.' + newId , value: filter })
this.pushServerSideStorage()
},
exportFilter(id) {
this.exportedFilter = { ...this.muteFiltersDraftObject[id] }
delete this.exportedFilter.order
this.filterExporter.exportData()
},
importFilter() {
this.filterImporter.importData()
},
copyFilter (id) {
const filter = { ...this.muteFiltersDraftObject[id] }
const newId = uuidv4()
this.muteFiltersDraftObject[newId] = filter
this.setPreference({ path: 'simple.muteFilters.' + newId , value: filter })
this.pushServerSideStorage()
},
deleteFilter (id) {
delete this.muteFiltersDraftObject[id]
this.unsetPreference({ path: 'simple.muteFilters.' + id , value: null })
this.pushServerSideStorage()
},
purgeExpiredFilters () {
this.muteFiltersExpired.forEach(([id]) => {
console.log(id)
delete this.muteFiltersDraftObject[id]
this.unsetPreference({ path: 'simple.muteFilters.' + id , value: null })
})
this.pushServerSideStorage()
},
updateFilter(id, field, value) {
const filter = { ...this.muteFiltersDraftObject[id] }
if (field === 'expires-never') {
if (!value) {
const offset = 1000 * 60 * 60 * 24 * 14 // 2 weeks
const date = Date.now() + offset
filter.expires = date
} else {
filter.expires = null
}
} else if (field === 'expires') {
const parsed = Date.parse(value)
filter.expires = parsed.valueOf()
} else {
filter[field] = value
}
this.muteFiltersDraftObject[id] = filter
this.muteFiltersDraftDirty[id] = true
},
saveFilter(id) {
this.setPreference({ path: 'simple.muteFilters.' + id , value: this.muteFiltersDraftObject[id] })
this.pushServerSideStorage()
this.muteFiltersDraftDirty[id] = false
},
},
// Updating nested properties
watch: {
replyVisibility () {

View file

@ -0,0 +1,89 @@
.filtering-tab {
.muteFilterContainer {
border: 1px solid var(--border);
border-radius: var(--roundness);
height: 33vh;
overflow-y: auto;
}
.mute-filter {
border: 1px solid var(--border);
border-radius: var(--roundness);
margin: 0.5em;
padding: 0.5em;
display: grid;
align-items: baseline;
grid-gap: 0.5em;
}
.never {
margin-left: 0.5em;
}
.filter-name {
grid-column: 1 / span 2;
grid-row: 1;
}
.alert,
.button-default {
display: inline-block;
line-height: 2;
padding: 0 0.5em;
}
.filter-enabled {
grid-column: 3;
grid-row: 1;
text-align: right;
line-height: 2;
}
.filter-field {
display: grid;
grid-template-columns: subgrid;
grid-template-rows: subgrid;
grid-column: 1 / span 3;
align-items: baseline;
label {
grid-column: 1;
text-align: right;
}
.filter-field-value {
grid-column: 2 / span 2;
}
}
.filter-buttons {
grid-column: 1 / span 3;
justify-self: end;
display: inline-grid;
grid-gap: 0.5em;
grid-template-columns: repeat(4, 1fr);
max-width: 100%;
justify-content: end;
}
.muteFiltersActions,
.muteFiltersActionsBottom {
display: grid;
align-items: baseline;
grid-auto-flow: column;
grid-template-columns: 1fr;
grid-gap: 0.5em;
margin: 0.5em 0;
.total {
flex: 1;
}
}
}
.settings-modal.-mobile .filtering-tab {
.filter-buttons {
grid-template-columns: repeat(1, 1fr);
}
}

View file

@ -1,11 +1,88 @@
<template>
<div :label="$t('settings.filtering')">
<div
:label="$t('settings.filtering')"
class="filtering-tab"
>
<div class="setting-item">
<h2>{{ $t('settings.posts') }}</h2>
<h2>{{ $t('settings.filter.clutter') }}</h2>
<ul class="setting-list">
<li>
<ChoiceSetting
v-if="user"
id="replyVisibility"
path="replyVisibility"
:options="replyVisibilityOptions"
>
{{ $t('settings.replies_in_timeline') }}
</ChoiceSetting>
</li>
<li>
<BooleanSetting
expert="1"
path="hidePostStats"
>
{{ $t('settings.hide_post_stats') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
expert="1"
path="hideUserStats"
>
{{ $t('settings.hide_user_stats') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="hideBotIndication">
{{ $t('settings.hide_actor_type_indication') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="hideScrobbles">
{{ $t('settings.hide_scrobbles') }}
</BooleanSetting>
<ul class="setting-list suboptions">
<li>
<UnitSetting
key="hideScrobblesAfter"
path="hideScrobblesAfter"
:units="['m', 'h', 'd']"
unit-set="time"
expert="1"
>
{{ $t('settings.hide_scrobbles_after') }}
</UnitSetting>
</li>
</ul>
</li>
<h3>{{ $t('settings.attachments') }}</h3>
<li>
<IntegerSetting
path="maxThumbnails"
expert="1"
:min="0"
>
{{ $t('settings.max_thumbnails') }}
</IntegerSetting>
</li>
<li>
<BooleanSetting path="hideAttachments">
{{ $t('settings.hide_attachments_in_tl') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="hideAttachmentsInConv">
{{ $t('settings.hide_attachments_in_convo') }}
</BooleanSetting>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.filter.mute_filter') }}</h2>
<ul class="setting-list">
<li>
<BooleanSetting path="hideFilteredStatuses">
{{ $t('settings.hide_filtered_statuses') }}
{{ $t('settings.hide_muted_statuses') }}
</BooleanSetting>
<ul class="setting-list suboptions">
<li>
@ -13,8 +90,9 @@
parent-path="hideFilteredStatuses"
:parent-invert="true"
path="hideWordFilteredPosts"
expert="1"
>
{{ $t('settings.hide_wordfiltered_statuses') }}
{{ $t('settings.hide_filtered_statuses') }}
</BooleanSetting>
</li>
<li>
@ -50,83 +128,218 @@
</BooleanSetting>
</li>
<li>
<BooleanSetting path="hidePostStats">
{{ $t('settings.hide_post_stats') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="hideBotIndication">
{{ $t('settings.hide_actor_type_indication') }}
</BooleanSetting>
</li>
<ChoiceSetting
v-if="user"
id="replyVisibility"
path="replyVisibility"
:options="replyVisibilityOptions"
>
{{ $t('settings.replies_in_timeline') }}
</ChoiceSetting>
<li>
<h3>{{ $t('settings.wordfilter') }}</h3>
<textarea
id="muteWords"
v-model="muteWordsString"
class="input resize-height"
/>
<div>{{ $t('settings.filtering_explanation') }}</div>
</li>
<h3>{{ $t('settings.attachments') }}</h3>
<li>
<IntegerSetting
path="maxThumbnails"
expert="1"
:min="0"
>
{{ $t('settings.max_thumbnails') }}
</IntegerSetting>
</li>
<li>
<BooleanSetting path="hideAttachments">
{{ $t('settings.hide_attachments_in_tl') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="hideAttachmentsInConv">
{{ $t('settings.hide_attachments_in_convo') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="hideScrobbles">
{{ $t('settings.hide_scrobbles') }}
</BooleanSetting>
</li>
<li>
<UnitSetting
key="hideScrobblesAfter"
path="hideScrobblesAfter"
:units="['m', 'h', 'd']"
unit-set="time"
expert="1"
>
{{ $t('settings.hide_scrobbles_after') }}
</UnitSetting>
</li>
</ul>
</div>
<div
v-if="expertLevel > 0"
class="setting-item"
>
<h2>{{ $t('settings.user_profiles') }}</h2>
<ul class="setting-list">
<li>
<BooleanSetting path="hideUserStats">
{{ $t('settings.hide_user_stats') }}
</BooleanSetting>
<h3>{{ $t('settings.filter.custom_filters') }}</h3>
<p class="muteFiltersActions">
<span class="total">
{{ $t('settings.filter.total_count', { count: muteFiltersDraft.length }) }}
</span>
<button
class="add-button button-default"
type="button"
@click="createFilter()"
>
{{ $t('settings.filter.new') }}
</button>
<button
class="add-button button-default"
type="button"
@click="importFilter()"
>
{{ $t('settings.filter.import') }}
</button>
</p>
<div class="muteFilterContainer">
<div
v-for="filter in muteFiltersDraft"
:key="filter[0]"
class="mute-filter"
:style="{ order: filter[1].order }"
>
<div class="filter-name">
<label
:for="'filterName' + filter[0]"
>
{{ $t('settings.filter.name') }}
</label>
{{ ' ' }}
<input
:id="'filterName' + filter[0]"
class="input"
:value="filter[1].name"
@input="updateFilter(filter[0], 'name', $event.target.value)"
>
<span
v-if="filter[1].expires !== null && Date.now() > filter[1].expires"
class="alert neutral"
>
{{ $t('settings.filter.expired') }}
</span>
</div>
<div class="filter-enabled">
<Checkbox
:id="'filterHide' + filter[0]"
:model-value="filter[1].hide"
:name="'filterHide' + filter[0]"
@update:model-value="updateFilter(filter[0], 'hide', $event)"
>
{{ $t('settings.filter.hide') }}
</Checkbox>
{{ ' ' }}
<Checkbox
:id="'filterEnabled' + filter[0]"
:model-value="filter[1].enabled"
:name="'filterEnabled' + filter[0]"
@update:model-value="updateFilter(filter[0], 'enabled', $event)"
>
{{ $t('settings.enabled') }}
</Checkbox>
</div>
<div class="filter-type filter-field">
<label :for="'filterType' + filter[0]">
<HelpIndicator>
<p>
{{ $t('settings.filter.help.word') }}
</p>
<p>
{{ $t('settings.filter.help.user') }}
</p>
<i18n-t
keypath="settings.filter.help.regexp"
tag="p"
>
<template #link>
<a
:href="$t('settings.filter.help.regexp_url')"
target="_blank"
>
{{ $t('settings.filter.help.regexp_link') }}
</a>
</template>
</i18n-t>
</HelpIndicator>
{{ $t('settings.filter.type') }}
</label>
<Select
:id="'filterType' + filter[0]"
class="filter-field-value"
:model-value="filter[1].type"
@update:model-value="updateFilter(filter[0], 'type', $event)"
>
<option value="word">
{{ $t('settings.filter.plain') }}
</option>
<option value="regexp">
{{ $t('settings.filter.regexp') }}
</option>
<option value="user">
{{ $t('settings.filter.user') }}
</option>
<option value="user_regexp">
{{ $t('settings.filter.user_regexp') }}
</option>
</Select>
</div>
<div class="filter-value filter-field">
<label
:for="'filterValue' + filter[0]"
>
{{ $t('settings.filter.value') }}
</label>
{{ ' ' }}
<input
:id="'filterValue' + filter[0]"
class="input filter-field-value"
:value="filter[1].value"
@input="updateFilter(filter[0], 'value', $event.target.value)"
>
</div>
<div class="filter-expires filter-field">
<label
:for="'filterExpires' + filter[0]"
>
{{ $t('settings.filter.expires') }}
</label>
{{ ' ' }}
<div class="filter-field-value">
<input
:id="'filterExpires' + filter[0]"
class="input"
:class="{ disabled: filter[1].expires === null }"
type="datetime-local"
:disabled="filter[1].expires === null"
:value="filter[1].expires ? getDatetimeLocal(filter[1].expires) : null"
@input="updateFilter(filter[0], 'expires', $event.target.value)"
>
{{ ' ' }}
<Checkbox
:id="'filterExpiresNever' + filter[0]"
:model-value="filter[1].expires === null"
:name="'filterExpiresNever' + filter[0]"
class="input-inset input-boolean never"
@update:model-value="updateFilter(filter[0], 'expires-never', $event)"
>
{{ $t('settings.filter.never_expires') }}
</Checkbox>
</div>
</div>
<div
v-if="!checkRegexValid(filter[0])"
class="alert error"
>
{{ $t('settings.filter.regexp_error') }}
</div>
<div class="filter-buttons">
<button
class="delete-button button-default -danger"
type="button"
@click="deleteFilter(filter[0])"
>
{{ $t('settings.filter.delete') }}
</button>
<button
class="export-button button-default"
type="button"
@click="exportFilter(filter[0])"
>
{{ $t('settings.filter.export') }}
</button>
<button
class="copy-button button-default"
type="button"
@click="copyFilter(filter[0])"
>
{{ $t('settings.filter.copy') }}
</button>
<button
class="save-button button-default"
:class="{ disabled: !muteFiltersDraftDirty[filter[0]] }"
:disabled="!muteFiltersDraftDirty[filter[0]]"
type="button"
@click="saveFilter(filter[0])"
>
{{ $t('settings.filter.save') }}
</button>
</div>
</div>
</div>
<p class="muteFiltersActionsBottom">
<span class="total">
{{ $t('settings.filter.expired_count', { count: muteFiltersExpired.length }) }}
</span>
<button
class="add-button button-default"
type="button"
:class="{ disabled: muteFiltersExpired.length === 0 }"
:disabled="muteFiltersExpired.length === 0"
@click="purgeExpiredFilters()"
>
{{ $t('settings.filter.purge_expired') }}
</button>
</p>
</li>
</ul>
</div>
</div>
</template>
<script src="./filtering_tab.js"></script>
<style src="./filtering_tab.scss"></style>

View file

@ -643,7 +643,7 @@ export default {
parser (string) { return deserialize(string) },
onImportFailure (result) {
console.error('Failure importing style:', result)
this.$store.useInterfaceStore().pushGlobalNotice({ messageKey: 'settings.invalid_theme_imported', level: 'error' })
useInterfaceStore().pushGlobalNotice({ messageKey: 'settings.invalid_theme_imported', level: 'error' })
},
onImport
})

View file

@ -14,8 +14,9 @@ import MentionLink from 'src/components/mention_link/mention_link.vue'
import StatusActionButtons from 'src/components/status_action_buttons/status_action_buttons.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import { muteWordHits } from '../../services/status_parser/status_parser.js'
import { muteFilterHits } from '../../services/status_parser/status_parser.js'
import { unescape, uniqBy } from 'lodash'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
@ -161,9 +162,6 @@ const Status = {
},
computed: {
...controlledOrUncontrolledGetters(['replying', 'mediaPlaying']),
muteWords () {
return this.mergedConfig.muteWords
},
showReasonMutedThread () {
return (
this.status.thread_muted ||
@ -221,8 +219,11 @@ const Status = {
loggedIn () {
return !!this.currentUser
},
muteWordHits () {
return muteWordHits(this.status, this.muteWords)
muteFilterHits () {
return muteFilterHits(
Object.values(useServerSideStorageStore().prefsStorage.simple.muteFilters),
this.status
)
},
botStatus () {
return this.status.user.actor_type === 'Service'
@ -256,7 +257,7 @@ const Status = {
return [
this.userIsMuted ? 'user' : null,
this.status.thread_muted ? 'thread' : null,
(this.muteWordHits.length > 0) ? 'wordfilter' : null,
(this.muteFilterHits.length > 0) ? 'filtered' : null,
(this.muteBotStatuses && this.botStatus) ? 'bot' : null,
(this.muteSensitiveStatuses && this.sensitiveStatus) ? 'nsfw' : null
].filter(_ => _)
@ -267,14 +268,14 @@ const Status = {
switch (this.muteReasons[0]) {
case 'user': return this.$t('status.muted_user')
case 'thread': return this.$t('status.thread_muted')
case 'wordfilter':
case 'filtered':
return this.$t(
'status.muted_words',
'status.muted_filters',
{
word: this.muteWordHits[0],
numWordsMore: this.muteWordHits.length - 1
name: this.muteFilterHits[0].name,
filterMore: this.muteFilterHits.length - 1
},
this.muteWordHits.length
this.muteFilterHits.length
)
case 'bot': return this.$t('status.bot_muted')
case 'nsfw': return this.$t('status.sensitive_muted')
@ -326,7 +327,7 @@ const Status = {
// Don't mute statuses in muted conversation when said conversation is opened
(this.inConversation && status.thread_muted)
// No excuses if post has muted words
) && !this.muteWordHits.length > 0
) && !this.muteFilterHits.length > 0
},
hideMutedUsers () {
return this.mergedConfig.hideMutedPosts
@ -345,7 +346,8 @@ const Status = {
(this.muted && this.hideFilteredStatuses) ||
(this.userIsMuted && this.hideMutedUsers) ||
(this.status.thread_muted && this.hideMutedThreads) ||
(this.muteWordHits.length > 0 && this.hideWordFilteredPosts)
(this.muteFilterHits.length > 0 && this.hideWordFilteredPosts) ||
(this.muteFilterHits.some(x => x.hide))
)
},
isFocused () {

View file

@ -1,10 +1,12 @@
import { mapState } from 'vuex'
import { mapState } from 'pinia'
import ConfirmModal from 'src/components/confirm_modal/confirm_modal.vue'
import ActionButtonContainer from './action_button_container.vue'
import Popover from 'src/components/popover/popover.vue'
import genRandomSeed from 'src/services/random_seed/random_seed.service.js'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import { BUTTONS } from './buttons_definitions.js'
import { library } from '@fortawesome/fontawesome-svg-core'
@ -36,8 +38,8 @@ const StatusActionButtons = {
ActionButtonContainer
},
computed: {
...mapState({
pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedStatusActions)
...mapState(useServerSideStorageStore, {
pinnedItems: store => new Set(store.prefsStorage.collections.pinnedStatusActions)
}),
buttons () {
return BUTTONS.filter(x => x.if ? x.if(this.funcArg) : true)
@ -101,12 +103,12 @@ const StatusActionButtons = {
return this.pinnedItems.has(button.name)
},
unpin (button) {
this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedStatusActions', value: button.name })
this.$store.dispatch('pushServerSideStorage')
useServerSideStorageStore().removeCollectionPreference({ path: 'collections.pinnedStatusActions', value: button.name })
useServerSideStorageStore().pushServerSideStorage()
},
pin (button) {
this.$store.commit('addCollectionPreference', { path: 'collections.pinnedStatusActions', value: button.name })
this.$store.dispatch('pushServerSideStorage')
useServerSideStorageStore().addCollectionPreference({ path: 'collections.pinnedStatusActions', value: button.name })
useServerSideStorageStore().pushServerSideStorage()
},
getComponent (button) {
if (!this.$store.state.users.currentUser && button.anonLink) {

View file

@ -3,6 +3,8 @@ import { library } from '@fortawesome/fontawesome-svg-core'
import pleromaTanMask from 'src/assets/pleromatan_apology_mask.png'
import pleromaTanFoxMask from 'src/assets/pleromatan_apology_fox_mask.png'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import {
faTimes
} from '@fortawesome/free-solid-svg-icons'
@ -36,8 +38,8 @@ const UpdateNotification = {
shouldShow () {
return !this.$store.state.instance.disableUpdateNotification &&
this.$store.state.users.currentUser &&
this.$store.state.serverSideStorage.flagStorage.updateCounter < CURRENT_UPDATE_COUNTER &&
!this.$store.state.serverSideStorage.prefsStorage.simple.dontShowUpdateNotifs
useServerSideStorageStore().flagStorage.updateCounter < CURRENT_UPDATE_COUNTER &&
!useServerSideStorageStore().prefsStorage.simple.dontShowUpdateNotifs
}
},
methods: {
@ -46,13 +48,13 @@ const UpdateNotification = {
},
neverShowAgain () {
this.toggleShow()
this.$store.commit('setFlag', { flag: 'updateCounter', value: CURRENT_UPDATE_COUNTER })
this.$store.commit('setPreference', { path: 'simple.dontShowUpdateNotifs', value: true })
this.$store.dispatch('pushServerSideStorage')
useServerSideStorageStore().setFlag({ flag: 'updateCounter', value: CURRENT_UPDATE_COUNTER })
useServerSideStorageStore().setPreference({ path: 'simple.dontShowUpdateNotifs', value: true })
useServerSideStorageStore().pushServerSideStorage()
},
dismiss () {
this.$store.commit('setFlag', { flag: 'updateCounter', value: CURRENT_UPDATE_COUNTER })
this.$store.dispatch('pushServerSideStorage')
useServerSideStorageStore().setFlag({ flag: 'updateCounter', value: CURRENT_UPDATE_COUNTER })
useServerSideStorageStore().pushServerSideStorage()
}
},
mounted () {

View file

@ -410,6 +410,41 @@
"visual_tweaks": "Minor visual tweaks",
"theme_debug": "Show what background theme engine assumes when dealing with transparancy (DEBUG)",
"scale_and_layout": "Interface scale and layout",
"enabled": "Enabled",
"filter": {
"clutter": "Remove clutter",
"mute_filter": "Mute Filters",
"type": "Filter type",
"regexp": "RegExp",
"plain": "Simple",
"user": "User (Simple)",
"user_regexp": "User (RegExp)",
"hide": "Hide completely",
"name": "Name",
"value": "Value",
"expires": "Expires",
"expired": "Expired",
"copy": "Duplicate",
"save": "Save",
"delete": "Remove",
"new": "Create new",
"import": "Import",
"export": "Export",
"regexp_error": "Invalid Regular Expression",
"never_expires": "Never",
"total_count": "Total {count} custom filter|Total {count} custom filters",
"expired_count": "{count} expired filter|{count} expired filters",
"custom_filters": "Custom filters",
"purge_expired": "Remove expired filters",
"import_failure": "The selected file is not a supported Pleroma filter.",
"help": {
"word": "Simple and RegExp filters test against post's content and subject.",
"user": "User filter matches full user handle (user@domain) in the following: author, reply-to and mentions",
"regexp": "Regex variants are more advanced and use {link} to match instead of simple substring search.",
"regexp_link": "Regular Expressions",
"regexp_url": "https://en.wikipedia.org/wiki/Regular_expression"
}
},
"mfa": {
"otp": "OTP",
"setup_otp": "Setup OTP",
@ -575,6 +610,7 @@
"hide_post_stats": "Hide post statistics (e.g. the number of favorites)",
"hide_user_stats": "Hide user statistics (e.g. the number of followers)",
"hide_filtered_statuses": "Hide all filtered posts",
"hide_muted_statuses": "Completely hide all muted posts",
"hide_wordfiltered_statuses": "Hide word-filtered statuses",
"hide_muted_threads": "Hide muted threads",
"import_blocks_from_a_csv_file": "Import blocks from a csv file",
@ -1265,6 +1301,7 @@
"copy_link": "Copy link to status",
"external_source": "External source",
"muted_words": "Wordfiltered: {word} | Wordfiltered: {word} and {numWordsMore} more words",
"muted_filters": "Filtered: {name} | Wordfiltered: {name} and {filtersMore} more words",
"multi_reason_mute": "{main} + one more reason | {main} + {numReasonsMore} more reasons",
"muted_user": "User muted",
"thread_muted": "Thread muted",

View file

@ -18,7 +18,6 @@ const saveImmedeatelyActions = [
'markNotificationsAsSeen',
'clearCurrentUser',
'setCurrentUser',
'setServerSideStorage',
'setHighlight',
'setOption',
'setClientData',

View file

@ -0,0 +1,42 @@
export const CONFIG_MIGRATION = 1
import { v4 as uuidv4 } from 'uuid';
// for future use
/*
const simpleDeclaration = {
store: 'server-side',
migrationFlag: 'configMigration',
migration(serverside, rootState) {
serverside.setPreference({ path: 'simple.' + field, value: rootState.config[oldField ?? field] })
}
}
*/
export const declarations = [
{
field: 'muteFilters',
store: 'server-side',
migrationFlag: 'configMigration',
migrationNum: 1,
description: 'Mute filters, wordfilter/regexp/etc',
valueType: 'complex',
migration (serverside, rootState) {
rootState.config.muteWords.forEach((word, order) => {
const uniqueId = uuidv4()
serverside.setPreference({
path: 'simple.muteFilters.' + uniqueId,
value: {
type: 'word',
value: word,
name: word,
enabled: true,
expires: null,
hide: false,
order
}
})
})
}
}
]

View file

@ -5,7 +5,6 @@ import users from './users.js'
import api from './api.js'
import config from './config.js'
import profileConfig from './profileConfig.js'
import serverSideStorage from './serverSideStorage.js'
import adminSettings from './adminSettings.js'
import authFlow from './auth_flow.js'
import oauthTokens from './oauth_tokens.js'
@ -20,7 +19,6 @@ export default {
api,
config,
profileConfig,
serverSideStorage,
adminSettings,
authFlow,
oauthTokens,

View file

@ -1,4 +1,5 @@
import apiService from '../services/api/api.service.js'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import {
isStatusNotification,
@ -112,7 +113,11 @@ export const notifications = {
commit('updateNotificationsMinMaxId', notification.id)
commit('addNewNotifications', { notifications: [notification] })
maybeShowNotification(store, notification)
maybeShowNotification(
store,
Object.values(useServerSideStorageStore().prefsStorage.simple.muteFilters),
notification
)
} else if (notification.seen) {
state.idStore[notification.id].seen = true
}

View file

@ -1,11 +1,16 @@
import { compact, map, each, mergeWith, last, concat, uniq, isArray } from 'lodash'
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { windowWidth, windowHeight } from '../services/window_utils/window_utils'
import apiService from '../services/api/api.service.js'
import oauthApi from '../services/new_api/oauth.js'
import { compact, map, each, mergeWith, last, concat, uniq, isArray } from 'lodash'
import { registerPushNotifications, unregisterPushNotifications } from '../services/sw/sw.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import { declarations } from 'src/modules/config_declaration'
// TODO: Unify with mergeOrAdd in statuses.js
export const mergeOrAdd = (arr, obj, item) => {
@ -605,7 +610,8 @@ const users = {
user.muteIds = []
user.domainMutes = []
commit('setCurrentUser', user)
commit('setServerSideStorage', user)
useServerSideStorageStore().setServerSideStorage(user)
commit('addNewUsers', [user])
dispatch('fetchEmoji')
@ -615,7 +621,35 @@ const users = {
// Set our new backend interactor
commit('setBackendInteractor', backendInteractorService(accessToken))
dispatch('pushServerSideStorage')
// Do server-side storage migrations
// Debug snippet to clean up storage and reset migrations
/*
// Reset wordfilter
Object.keys(
useServerSideStorageStore().prefsStorage.simple.muteFilters
).forEach(key => {
useServerSideStorageStore().unsetPreference({ path: 'simple.muteFilters.' + key, value: null })
})
// Reset flag to 0 to re-run migrations
useServerSideStorageStore().setFlag({ flag: 'configMigration', value: 0 })
/**/
const { configMigration } = useServerSideStorageStore().flagStorage
declarations
.filter(x => {
return x.store === 'server-side' &&
x.migrationNum > 0 &&
x.migrationNum > configMigration
})
.toSorted((a, b) => a.configMigration - b.configMigration)
.forEach(value => {
value.migration(useServerSideStorageStore(), store.rootState)
useServerSideStorageStore().setFlag({ flag: 'configMigration', value: value.migrationNum })
useServerSideStorageStore().pushServerSideStorage()
})
if (user.token) {
dispatch('setWsToken', user.token)

View file

@ -1,4 +1,4 @@
import { muteWordHits } from '../status_parser/status_parser.js'
import { muteFilterHits } from '../status_parser/status_parser.js'
import { showDesktopNotification } from '../desktop_notification_utils/desktop_notification_utils.js'
import { useI18nStore } from 'src/stores/i18n.js'
import { useAnnouncementsStore } from 'src/stores/announcements'
@ -58,10 +58,10 @@ const sortById = (a, b) => {
}
}
const isMutedNotification = (store, notification) => {
if (!notification.status) return
const rootGetters = store.rootGetters || store.getters
return notification.status.muted || muteWordHits(notification.status, rootGetters.mergedConfig.muteWords).length > 0
const isMutedNotification = (notification) => {
if (!notification.status) return false
if (notification.status.muted) return true
return muteFilterHits(notification.status).length > 0
}
export const maybeShowNotification = (store, notification) => {
@ -69,7 +69,7 @@ export const maybeShowNotification = (store, notification) => {
if (notification.seen) return
if (!visibleTypes(store).includes(notification.type)) return
if (notification.type === 'mention' && isMutedNotification(store, notification)) return
if (notification.type === 'mention' && isMutedNotification(notification)) return
const notificationObject = prepareNotificationObject(notification, useI18nStore().i18n)
showDesktopNotification(rootState, notificationObject)

View file

@ -1,11 +1,59 @@
import { filter } from 'lodash'
export const muteWordHits = (status, muteWords) => {
export const muteFilterHits = (muteFilters, status) => {
const statusText = status.text.toLowerCase()
const statusSummary = status.summary.toLowerCase()
const hits = filter(muteWords, (muteWord) => {
return statusText.includes(muteWord.toLowerCase()) || statusSummary.includes(muteWord.toLowerCase())
})
const replyToUser = status.in_reply_to_screen_name?.toLowerCase()
const poster = status.user.screen_name.toLowerCase()
const mentions = (status.attentions || []).map(att => att.screen_name.toLowerCase())
return hits
return muteFilters.toSorted((a,b) => b.order - a.order).map(filter => {
const { hide, expires, name, value, type, enabled} = filter
if (!enabled) return false
if (value === '') return false
if (expires !== null && expires < Date.now()) return false
switch (type) {
case 'word': {
if (statusText.includes(value) || statusSummary.includes(value)) {
return { hide, name }
}
break
}
case 'regexp': {
try {
const re = new RegExp(value, 'i')
if (re.test(statusText) || re.test(statusSummary)) {
return { hide, name }
}
return false
} catch {
return false
}
}
case 'user': {
if (
poster.includes(value) ||
replyToUser.includes(value) ||
mentions.some(mention => mention.includes(value))
) {
return { hide, name }
}
break
}
case 'user_regexp': {
try {
const re = new RegExp(value, 'i')
if (
re.test(poster) ||
re.test(replyToUser) ||
mentions.some(mention => re.test(mention))
) {
return { hide, name }
}
return false
} catch {
return false
}
}
}
}).filter(_ => _)
}

View file

@ -1,8 +1,10 @@
import { defineStore } from 'pinia'
import { toRaw } from 'vue'
import {
isEqual,
cloneDeep,
set,
unset,
get,
clamp,
flatten,
@ -26,6 +28,7 @@ export const defaultState = {
// storage of flags - stuff that can only be set and incremented
flagStorage: {
updateCounter: 0, // Counter for most recent update notification seen
configMigration: 0, // Counter for config -> server-side migrations
reset: 0 // special flag that can be used to force-reset all flags, debug purposes only
// special reset codes:
// 1000: trim keys to those known by currently running FE
@ -35,7 +38,8 @@ export const defaultState = {
_journal: [],
simple: {
dontShowUpdateNotifs: false,
collapseNav: false
collapseNav: false,
muteFilters: {}
},
collections: {
pinnedStatusActions: ['reply', 'retweet', 'favorite', 'emoji'],
@ -78,11 +82,16 @@ const _verifyPrefs = (state) => {
simple: {},
collections: {}
}
// Simple
Object.entries(defaultState.prefsStorage.simple).forEach(([k, v]) => {
if (typeof v === 'number' || typeof v === 'boolean') return
if (typeof v === 'object' && v != null) return
console.warn(`Preference simple.${k} as invalid type, 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, reinitializing`)
@ -219,13 +228,27 @@ export const _mergePrefs = (recent, stale) => {
const totalJournal = _mergeJournal(staleJournal, recentJournal)
totalJournal.forEach(({ path, operation, args }) => {
if (path.startsWith('_')) {
console.error(`journal contains entry to edit internal (starts with _) field '${path}', something is incorrect here, ignoring.`)
return
throw new Error(`journal contains entry to edit internal (starts with _) field '${path}', something is incorrect here, ignoring.`)
}
switch (operation) {
case 'set':
if (path.startsWith('collections') || path.startsWith('objectCollections')) {
throw new Error('Illegal operation "set" on a collection')
}
if (path.split(/\./g).length <= 1) {
throw new Error(`Calling set on depth <= 1 (path: ${path}) is not allowed`)
}
set(resultOutput, path, args[0])
break
case 'unset':
if (path.startsWith('collections') || path.startsWith('objectCollections')) {
throw new Error('Illegal operation "unset" on a collection')
}
if (path.split(/\./g).length <= 2) {
throw new Error(`Calling unset on depth <= 2 (path: ${path}) is not allowed`)
}
unset(resultOutput, path)
break
case 'addToCollection':
set(resultOutput, path, Array.from(new Set(get(resultOutput, path)).add(args[0])))
break
@ -241,7 +264,7 @@ export const _mergePrefs = (recent, stale) => {
break
}
default:
console.error(`Unknown journal operation: '${operation}', did we forget to run reverse migrations beforehand?`)
throw new Error(`Unknown journal operation: '${operation}', did we forget to run reverse migrations beforehand?`)
}
})
return { ...resultOutput, _journal: totalJournal }
@ -302,164 +325,194 @@ export const _doMigrations = (cache) => {
return cache
}
export const mutations = {
clearServerSideStorage (state) {
const blankState = { ...cloneDeep(defaultState) }
Object.keys(state).forEach(k => {
state[k] = blankState[k]
})
export const useServerSideStorageStore = defineStore('serverSideStorage', {
state() {
return cloneDeep(defaultState)
},
setServerSideStorage (state, userData) {
const live = userData.storage
state.raw = live
let cache = state.cache
if (cache && cache._user !== userData.fqn) {
console.warn('Cache belongs to another user! reinitializing local cache!')
cache = null
}
cache = _doMigrations(cache)
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 }
})
}
if (!needUpload && recent && stale) {
console.debug('Checking if data needs merging...')
// discarding timestamps and versions
/* eslint-disable no-unused-vars */
const { _timestamp: _0, _version: _1, ...recentData } = recent
const { _timestamp: _2, _version: _3, ...staleData } = stale
/* eslint-enable no-unused-vars */
dirty = !isEqual(recentData, 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
}
totalFlags = _resetFlags(totalFlags)
recent.flagStorage = { ...flagsTemplate, ...totalFlags }
recent.prefsStorage = { ...defaultState.prefsStorage, ...totalPrefs }
state.dirty = dirty || needUpload
state.cache = recent
// set local timestamp to smaller one if we don't have any changes
if (stale && recent && !state.dirty) {
state.cache._timestamp = Math.min(stale._timestamp, recent._timestamp)
}
state.flagStorage = state.cache.flagStorage
state.prefsStorage = state.cache.prefsStorage
},
setFlag (state, { flag, value }) {
state.flagStorage[flag] = value
state.dirty = true
},
setPreference (state, { path, value }) {
if (path.startsWith('_')) {
console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`)
return
}
set(state.prefsStorage, path, value)
state.prefsStorage._journal = [
...state.prefsStorage._journal,
{ operation: 'set', path, args: [value], timestamp: Date.now() }
]
state.dirty = true
},
addCollectionPreference (state, { path, value }) {
if (path.startsWith('_')) {
console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`)
return
}
const collection = new Set(get(state.prefsStorage, path))
collection.add(value)
set(state.prefsStorage, path, [...collection])
state.prefsStorage._journal = [
...state.prefsStorage._journal,
{ operation: 'addToCollection', path, args: [value], timestamp: Date.now() }
]
state.dirty = true
},
removeCollectionPreference (state, { path, value }) {
if (path.startsWith('_')) {
console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`)
return
}
const collection = new Set(get(state.prefsStorage, path))
collection.delete(value)
set(state.prefsStorage, path, [...collection])
state.prefsStorage._journal = [
...state.prefsStorage._journal,
{ operation: 'removeFromCollection', path, args: [value], timestamp: Date.now() }
]
state.dirty = true
},
reorderCollectionPreference (state, { path, value, movement }) {
if (path.startsWith('_')) {
console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`)
return
}
const collection = get(state.prefsStorage, path)
const newCollection = _moveItemInArray(collection, value, movement)
set(state.prefsStorage, path, newCollection)
state.prefsStorage._journal = [
...state.prefsStorage._journal,
{ operation: 'arrangeCollection', path, args: [value], timestamp: Date.now() }
]
state.dirty = true
},
updateCache (state, { username }) {
state.prefsStorage._journal = _mergeJournal(state.prefsStorage._journal)
state.cache = _wrapData({
flagStorage: toRaw(state.flagStorage),
prefsStorage: toRaw(state.prefsStorage)
}, username)
}
}
const serverSideStorage = {
state: {
...cloneDeep(defaultState)
},
mutations,
actions: {
pushServerSideStorage ({ state, rootState, commit }, { force = false } = {}) {
const needPush = state.dirty || force
setFlag ({ flag, value }) {
this.flagStorage[flag] = value
this.dirty = true
},
setPreference ({ path, value }) {
if (path.startsWith('_')) {
throw new Error(`Tried to edit internal (starts with _) field '${path}', ignoring.`)
}
if (path.startsWith('collections') || path.startsWith('objectCollections')) {
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`)
}
set(this.prefsStorage, path, value)
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') || path.startsWith('objectCollections')) {
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, value)
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])
} else if (path.startsWith('objectCollections')) {
const { _key } = value
if (!_key && typeof _key !== 'string') {
throw new Error('Object for storage is missing _key field!')
}
const collection = new Set(get(this.prefsStorage, path + '.index'))
collection.add(_key)
set(this.prefsStorage, path + '.index', [...collection])
set(this.prefsStorage, path + '.data.' + _key, value)
}
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 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)
},
clearServerSideStorage () {
const blankState = { ...cloneDeep(defaultState) }
Object.keys(this).forEach(k => {
this[k] = blankState[k]
})
},
setServerSideStorage (userData) {
const live = userData.storage
this.raw = live
let cache = this.cache
if (cache && cache._user !== userData.fqn) {
console.warn('Cache belongs to another user! reinitializing local cache!')
cache = null
}
cache = _doMigrations(cache)
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 }
})
}
if (!needUpload && recent && stale) {
console.debug('Checking if data needs merging...')
// discarding timestamps and versions
/* eslint-disable no-unused-vars */
const { _timestamp: _0, _version: _1, ...recentData } = recent
const { _timestamp: _2, _version: _3, ...staleData } = stale
/* eslint-enable no-unused-vars */
dirty = !isEqual(recentData, 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
}
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
},
pushServerSideStorage ({ force = false } = {}) {
const needPush = this.dirty || force
if (!needPush) return
commit('updateCache', { username: rootState.users.currentUser.fqn })
const params = { pleroma_settings_store: { 'pleroma-fe': state.cache } }
rootState.api.backendInteractor
this.updateCache({ username: window.vuex.state.users.currentUser.fqn })
const params = { pleroma_settings_store: { 'pleroma-fe': this.cache } }
window.vuex.state.api.backendInteractor
.updateProfile({ params })
.then((user) => {
commit('setServerSideStorage', user)
state.dirty = false
this.setServerSideStorage(user)
this.dirty = false
})
}
}
}
export default serverSideStorage
})

View file

@ -1,4 +1,5 @@
import { cloneDeep } from 'lodash'
import { setActivePinia, createPinia } from 'pinia'
import {
VERSION,
@ -10,49 +11,50 @@ import {
_mergeFlags,
_mergePrefs,
_resetFlags,
mutations,
defaultState,
newUserFlags
} from 'src/modules/serverSideStorage.js'
newUserFlags,
useServerSideStorageStore,
} from 'src/stores/serverSideStorage.js'
describe('The serverSideStorage module', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('mutations', () => {
describe('setServerSideStorage', () => {
const { setServerSideStorage } = mutations
const user = {
created_at: new Date('1999-02-09'),
storage: {}
}
it('should initialize storage if none present', () => {
const state = cloneDeep(defaultState)
setServerSideStorage(state, user)
expect(state.cache._version).to.eql(VERSION)
expect(state.cache._timestamp).to.be.a('number')
expect(state.cache.flagStorage).to.eql(defaultState.flagStorage)
expect(state.cache.prefsStorage).to.eql(defaultState.prefsStorage)
const store = useServerSideStorageStore()
store.setServerSideStorage(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)
expect(store.cache.prefsStorage).to.eql(defaultState.prefsStorage)
})
it('should initialize storage with proper flags for new users if none present', () => {
const state = cloneDeep(defaultState)
setServerSideStorage(state, { ...user, created_at: new Date() })
expect(state.cache._version).to.eql(VERSION)
expect(state.cache._timestamp).to.be.a('number')
expect(state.cache.flagStorage).to.eql(newUserFlags)
expect(state.cache.prefsStorage).to.eql(defaultState.prefsStorage)
const store = useServerSideStorageStore()
store.setServerSideStorage({ ...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', () => {
const state = {
...cloneDeep(defaultState),
cache: {
_timestamp: Date.now(),
_version: VERSION,
...cloneDeep(defaultState)
}
const store = useServerSideStorageStore()
store.cache = {
_timestamp: Date.now(),
_version: VERSION,
...cloneDeep(defaultState)
}
setServerSideStorage(
state,
store.setServerSideStorage(
{
...user,
storage: {
@ -68,19 +70,18 @@ describe('The serverSideStorage module', () => {
}
}
)
expect(state.cache.flagStorage).to.eql({
expect(store.cache.flagStorage).to.eql({
...defaultState.flagStorage,
updateCounter: 1
})
})
it('should reset local timestamp to remote if contents are the same', () => {
const state = {
...cloneDeep(defaultState),
cache: null
}
setServerSideStorage(
state,
const store = useServerSideStorageStore()
store.cache = null
store.setServerSideStorage(
{
...user,
storage: {
@ -93,72 +94,95 @@ describe('The serverSideStorage module', () => {
}
}
)
expect(state.cache._timestamp).to.eql(123)
expect(state.flagStorage.updateCounter).to.eql(999)
expect(state.cache.flagStorage.updateCounter).to.eql(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 remote version if local missing', () => {
const state = cloneDeep(defaultState)
setServerSideStorage(state, user)
expect(state.cache._version).to.eql(VERSION)
expect(state.cache._timestamp).to.be.a('number')
expect(state.cache.flagStorage).to.eql(defaultState.flagStorage)
const store = useServerSideStorageStore()
store.setServerSideStorage(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', () => {
const { setPreference, updateCache, addCollectionPreference, removeCollectionPreference } = mutations
it('should set preference and update journal log accordingly', () => {
const state = cloneDeep(defaultState)
setPreference(state, { path: 'simple.testing', value: 1 })
expect(state.prefsStorage.simple.testing).to.eql(1)
expect(state.prefsStorage._journal.length).to.eql(1)
expect(state.prefsStorage._journal[0]).to.eql({
const store = useServerSideStorageStore()
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: state.prefsStorage._journal[0].timestamp
timestamp: store.prefsStorage._journal[0].timestamp
})
})
it('should keep journal to a minimum', () => {
const state = cloneDeep(defaultState)
setPreference(state, { path: 'simple.testing', value: 1 })
setPreference(state, { path: 'simple.testing', value: 2 })
addCollectionPreference(state, { path: 'collections.testing', value: 2 })
removeCollectionPreference(state, { path: 'collections.testing', value: 2 })
updateCache(state, { username: 'test' })
expect(state.prefsStorage.simple.testing).to.eql(2)
expect(state.prefsStorage.collections.testing).to.eql([])
expect(state.prefsStorage._journal.length).to.eql(2)
expect(state.prefsStorage._journal[0]).to.eql({
const store = useServerSideStorageStore()
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: state.prefsStorage._journal[0].timestamp
timestamp: store.prefsStorage._journal[0].timestamp
})
expect(state.prefsStorage._journal[1]).to.eql({
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: state.prefsStorage._journal[1].timestamp
timestamp: store.prefsStorage._journal[1].timestamp
})
})
it('should remove duplicate entries from journal', () => {
const state = cloneDeep(defaultState)
setPreference(state, { path: 'simple.testing', value: 1 })
setPreference(state, { path: 'simple.testing', value: 1 })
addCollectionPreference(state, { path: 'collections.testing', value: 2 })
addCollectionPreference(state, { path: 'collections.testing', value: 2 })
updateCache(state, { username: 'test' })
expect(state.prefsStorage.simple.testing).to.eql(1)
expect(state.prefsStorage.collections.testing).to.eql([2])
expect(state.prefsStorage._journal.length).to.eql(2)
const store = useServerSideStorageStore()
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 = useServerSideStorageStore()
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 = useServerSideStorageStore()
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 = useServerSideStorageStore()
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()
})
})
})
@ -315,6 +339,58 @@ describe('The serverSideStorage module', () => {
]
})
})
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', () => {