diff --git a/changelog.d/mutes-sync.add b/changelog.d/mutes-sync.add new file mode 100644 index 000000000..e8e0e462a --- /dev/null +++ b/changelog.d/mutes-sync.add @@ -0,0 +1 @@ +Synchronized mutes, advanced mute control (regexp, expiry, naming) diff --git a/package.json b/package.json index 298823e98..0a9c33546 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js index 10a0892f4..2085d24e3 100644 --- a/src/components/mobile_nav/mobile_nav.js +++ b/src/components/mobile_nav/mobile_nav.js @@ -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 }, diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js index 9d569729f..681aaf05b 100644 --- a/src/components/nav_panel/nav_panel.js +++ b/src/components/nav_panel/nav_panel.js @@ -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 () { diff --git a/src/components/navigation/navigation_entry.js b/src/components/navigation/navigation_entry.js index dbef18fcd..11db1c9e3 100644 --- a/src/components/navigation/navigation_entry.js +++ b/src/components/navigation/navigation_entry.js @@ -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) + }), } } diff --git a/src/components/navigation/navigation_pins.js b/src/components/navigation/navigation_pins.js index f9cdef71b..50acbbaf1 100644 --- a/src/components/navigation/navigation_pins.js +++ b/src/components/navigation/navigation_pins.js @@ -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) { diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 87c88d8f7..4fb8e0428 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -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 || diff --git a/src/components/settings_modal/admin_tabs/emoji_tab.js b/src/components/settings_modal/admin_tabs/emoji_tab.js index 585254a9b..7f575bcb4 100644 --- a/src/components/settings_modal/admin_tabs/emoji_tab.js +++ b/src/components/settings_modal/admin_tabs/emoji_tab.js @@ -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' diff --git a/src/components/settings_modal/admin_tabs/frontends_tab.js b/src/components/settings_modal/admin_tabs/frontends_tab.js index 6d983104b..a9a4777da 100644 --- a/src/components/settings_modal/admin_tabs/frontends_tab.js +++ b/src/components/settings_modal/admin_tabs/frontends_tab.js @@ -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: { diff --git a/src/components/settings_modal/helpers/help_indicator.vue b/src/components/settings_modal/helpers/help_indicator.vue new file mode 100644 index 000000000..c48f97b70 --- /dev/null +++ b/src/components/settings_modal/helpers/help_indicator.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/src/components/settings_modal/helpers/setting.js b/src/components/settings_modal/helpers/setting.js index 2dc9653ea..df137157a 100644 --- a/src/components/settings_modal/helpers/setting.js +++ b/src/components/settings_modal/helpers/setting.js @@ -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) diff --git a/src/components/settings_modal/tabs/appearance_tab.js b/src/components/settings_modal/tabs/appearance_tab.js index da72c8f55..fd18b91e5 100644 --- a/src/components/settings_modal/tabs/appearance_tab.js +++ b/src/components/settings_modal/tabs/appearance_tab.js @@ -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')) { diff --git a/src/components/settings_modal/tabs/filtering_tab.js b/src/components/settings_modal/tabs/filtering_tab.js index fbace15df..37da9cc91 100644 --- a/src/components/settings_modal/tabs/filtering_tab.js +++ b/src/components/settings_modal/tabs/filtering_tab.js @@ -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 () { diff --git a/src/components/settings_modal/tabs/filtering_tab.scss b/src/components/settings_modal/tabs/filtering_tab.scss new file mode 100644 index 000000000..5b51c5e4d --- /dev/null +++ b/src/components/settings_modal/tabs/filtering_tab.scss @@ -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); + } +} diff --git a/src/components/settings_modal/tabs/filtering_tab.vue b/src/components/settings_modal/tabs/filtering_tab.vue index 32325d423..6db82d3c4 100644 --- a/src/components/settings_modal/tabs/filtering_tab.vue +++ b/src/components/settings_modal/tabs/filtering_tab.vue @@ -1,11 +1,88 @@