Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85829331
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Flag For Later
Award Token
Authored By
Unknown
Size
81 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/App.js b/src/App.js
index 879da48a78..8574455a36 100644
--- a/src/App.js
+++ b/src/App.js
@@ -1,266 +1,268 @@
import { throttle } from 'lodash'
import { mapActions, mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import DesktopNav from 'src/components/desktop_nav/desktop_nav.vue'
import EditStatusModal from 'src/components/edit_status_modal/edit_status_modal.vue'
import FeaturesPanel from 'src/components/features_panel/features_panel.vue'
import GlobalNoticeList from 'src/components/global_notice_list/global_notice_list.vue'
import InstanceSpecificPanel from 'src/components/instance_specific_panel/instance_specific_panel.vue'
import MediaModal from 'src/components/media_modal/media_modal.vue'
import MobileNav from 'src/components/mobile_nav/mobile_nav.vue'
import MobilePostStatusButton from 'src/components/mobile_post_status_button/mobile_post_status_button.vue'
import NavPanel from 'src/components/nav_panel/nav_panel.vue'
import PostStatusModal from 'src/components/post_status_modal/post_status_modal.vue'
import ShoutPanel from 'src/components/shout_panel/shout_panel.vue'
import SideDrawer from 'src/components/side_drawer/side_drawer.vue'
import StatusHistoryModal from 'src/components/status_history_modal/status_history_modal.vue'
import UserPanel from 'src/components/user_panel/user_panel.vue'
import UserReportingModal from 'src/components/user_reporting_modal/user_reporting_modal.vue'
import WhoToFollowPanel from 'src/components/who_to_follow_panel/who_to_follow_panel.vue'
import { useI18nStore } from 'src/stores/i18n.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useShoutStore } from 'src/stores/shout.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { applyStyleConfig } from 'src/services/style_setter/style_setter.js'
import { getOrCreateServiceWorker } from 'src/services/sw/sw'
import {
windowHeight,
windowWidth,
} from 'src/services/window_utils/window_utils'
export default {
name: 'app',
components: {
UserPanel,
NavPanel,
Notifications: defineAsyncComponent(
() => import('./components/notifications/notifications.vue'),
),
InstanceSpecificPanel,
FeaturesPanel,
WhoToFollowPanel,
ShoutPanel,
MediaModal,
SideDrawer,
MobilePostStatusButton,
MobileNav,
DesktopNav,
SettingsModal: defineAsyncComponent(
() => import('./components/settings_modal/settings_modal.vue'),
),
UpdateNotification: defineAsyncComponent(
() => import('./components/update_notification/update_notification.vue'),
),
UserReportingModal,
PostStatusModal,
EditStatusModal,
StatusHistoryModal,
GlobalNoticeList,
},
data: () => ({
mobileActivePanel: 'timeline',
}),
watch: {
themeApplied() {
this.removeSplash()
},
currentTheme() {
this.setThemeBodyClass()
},
layoutType() {
document.getElementById('modal').classList = ['-' + this.layoutType]
},
},
created() {
document.getElementById('modal').classList = ['-' + this.layoutType]
useI18nStore().setLanguage()
applyStyleConfig(useSyncConfigStore().mergedConfig)
// Create bound handlers
this.updateScrollState = throttle(this.scrollHandler, 200)
this.updateMobileState = throttle(this.resizeHandler, 200)
},
mounted() {
window.addEventListener('resize', this.updateMobileState)
this.scrollParent.addEventListener('scroll', this.updateScrollState)
if (useInterfaceStore().themeApplied) {
this.setThemeBodyClass()
this.removeSplash()
}
getOrCreateServiceWorker()
},
unmounted() {
window.removeEventListener('resize', this.updateMobileState)
this.scrollParent.removeEventListener('scroll', this.updateScrollState)
},
computed: {
themeApplied() {
return useInterfaceStore().themeApplied
},
currentTheme() {
if (useInterfaceStore().styleDataUsed) {
const styleMeta = useInterfaceStore().styleDataUsed.find(
(x) => x.component === '@meta',
)
if (styleMeta !== undefined) {
return styleMeta.directives.name.replaceAll(' ', '-').toLowerCase()
}
}
return 'stock'
},
layoutModalClass() {
return '-' + this.layoutType
},
classes() {
return [
{
'-reverse': this.reverseLayout,
'-no-sticky-headers': this.noSticky,
'-has-new-post-button': this.newPostButtonShown,
},
'-' + this.layoutType,
]
},
navClasses() {
const { navbarColumnStretch } = useSyncConfigStore().mergedConfig
return [
'-' + this.layoutType,
...(navbarColumnStretch ? ['-column-stretch'] : []),
]
},
currentUser() {
return this.$store.state.users.currentUser
},
userBackground() {
return this.currentUser.background_image
},
background() {
return this.userBackground || this.instanceBackground
},
bgStyle() {
if (this.background) {
return {
'--body-background-image': `url(${this.background})`,
}
}
},
shout() {
return useShoutStore().joined
},
isChats() {
return this.$route.name === 'chat' || this.$route.name === 'chats'
},
isListEdit() {
return this.$route.name === 'lists-edit'
},
newPostButtonShown() {
if (this.isChats) return false
if (this.isListEdit) return false
return this.alwaysShowNewPostButton || this.layoutType === 'mobile'
},
reverseLayout() {
const { thirdColumnMode, sidebarRight: reverseSetting } =
useSyncConfigStore().mergedConfig
if (this.layoutType !== 'wide') {
return reverseSetting
} else {
return thirdColumnMode === 'notifications'
? reverseSetting
: !reverseSetting
}
},
scrollParent() {
return window /* this.$refs.appContentRef */
},
...mapState(useSyncConfigStore, {
shoutboxPosition: (store) =>
store.mergedConfig.alwaysShowSubjectInput || false,
alwaysShowSubjectInput: (store) =>
store.mergedConfig.alwaysShowSubjectInput,
}),
...mapState(useInterfaceStore, ['layoutType']),
...mapState(useSyncConfigStore, {
hideShoutbox: (store) => store.mergedConfig.hideShoutbox,
noSticky: (store) => store.mergedConfig.disableStickyHeaders,
showScrollbars: (store) => store.mergedConfig.showScrollbars,
}),
...mapState(useInstanceStore, {
instanceBackground: (store) =>
- this.mergedConfig.hideInstanceWallpaper ? null : store.background,
+ useSyncConfigStore().mergedConfig.hideInstanceWallpaper
+ ? null
+ : store.background,
showInstanceSpecificPanel: (store) =>
store.showInstanceSpecificPanel &&
!useSyncConfigStore().mergedConfig.hideISP &&
store.instanceSpecificPanelContent,
}),
...mapState(useInstanceStore, [
'editingAvailable',
'showFeaturesPanel',
'private',
'suggestionsEnabled',
]),
},
methods: {
resizeHandler() {
this.setLayoutWidth(windowWidth())
this.setLayoutHeight(windowHeight())
},
scrollHandler() {
const scrollPosition =
this.scrollParent === window
? window.scrollY
: this.scrollParent.scrollTop
if (scrollPosition != 0) {
this.$refs.appContentRef.classList.add(['-scrolled'])
} else {
this.$refs.appContentRef.classList.remove(['-scrolled'])
}
},
setThemeBodyClass() {
const themeName = this.currentTheme
const classList = Array.from(document.body.classList)
const oldTheme = classList.filter((c) => c.startsWith('theme-'))
if (themeName !== null && themeName !== '') {
const newTheme = `theme-${themeName.toLowerCase()}`
// remove old theme reference if there are any
if (oldTheme.length) {
document.body.classList.replace(oldTheme[0], newTheme)
} else {
document.body.classList.add(newTheme)
}
} else {
// remove theme reference if non-V3 theme is used
document.body.classList.remove(...oldTheme)
}
},
removeSplash() {
document.querySelector('#status').textContent = this.$t(
'splash.fun_' + Math.ceil(Math.random() * 4),
)
const splashscreenRoot = document.querySelector('#splash')
splashscreenRoot.addEventListener('transitionend', () => {
splashscreenRoot.remove()
})
setTimeout(() => {
splashscreenRoot.remove() // forcibly remove it, should fix my plasma browser widget t. HJ
}, 600)
splashscreenRoot.classList.add('hidden')
document.querySelector('#app').classList.remove('hidden')
},
...mapActions(useInterfaceStore, ['setLayoutWidth', 'setLayoutHeight']),
},
}
diff --git a/src/boot/routes.js b/src/boot/routes.js
index 6bdf23e2c1..0d135981d6 100644
--- a/src/boot/routes.js
+++ b/src/boot/routes.js
@@ -1,225 +1,225 @@
import About from 'components/about/about.vue'
import AnnouncementsPage from 'components/announcements_page/announcements_page.vue'
import AuthForm from 'components/auth_form/auth_form.js'
import BookmarkTimeline from 'components/bookmark_timeline/bookmark_timeline.vue'
import BubbleTimeline from 'components/bubble_timeline/bubble_timeline.vue'
import Chat from 'components/chat/chat.vue'
import ChatList from 'components/chat_list/chat_list.vue'
import ConversationPage from 'components/conversation-page/conversation-page.vue'
import DMs from 'components/dm_timeline/dm_timeline.vue'
import Drafts from 'components/drafts/drafts.vue'
import FollowRequests from 'components/follow_requests/follow_requests.vue'
import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue'
import Interactions from 'components/interactions/interactions.vue'
import Lists from 'components/lists/lists.vue'
import ListsEdit from 'components/lists_edit/lists_edit.vue'
import ListsTimeline from 'components/lists_timeline/lists_timeline.vue'
import Notifications from 'components/notifications/notifications.vue'
import OAuthCallback from 'components/oauth_callback/oauth_callback.vue'
import PasswordReset from 'components/password_reset/password_reset.vue'
import PublicAndExternalTimeline from 'components/public_and_external_timeline/public_and_external_timeline.vue'
import PublicTimeline from 'components/public_timeline/public_timeline.vue'
import Registration from 'components/registration/registration.vue'
import RemoteUserResolver from 'components/remote_user_resolver/remote_user_resolver.vue'
import Search from 'components/search/search.vue'
import ShoutPanel from 'components/shout_panel/shout_panel.vue'
import TagTimeline from 'components/tag_timeline/tag_timeline.vue'
import UserProfile from 'components/user_profile/user_profile.vue'
import WhoToFollow from 'components/who_to_follow/who_to_follow.vue'
import NavPanel from 'src/components/nav_panel/nav_panel.vue'
import BookmarkFolderEdit from '../components/bookmark_folder_edit/bookmark_folder_edit.vue'
import BookmarkFolders from '../components/bookmark_folders/bookmark_folders.vue'
import QuotesTimeline from '../components/quotes_timeline/quotes_timeline.vue'
import { useInstanceStore } from 'src/stores/instance.js'
export default (store) => {
const validateAuthenticatedRoute = (to, from, next) => {
if (store.state.users.currentUser) {
next()
} else {
next(useInstanceStore().redirectRootNoLogin || '/main/all')
}
}
let routes = [
{
name: 'root',
path: '/',
redirect: () => {
return (
(store.state.users.currentUser
- ? store.state.instance.redirectRootLogin
- : store.state.instance.redirectRootNoLogin) || '/main/all'
+ ? useInstanceStore().redirectRootLogin
+ : useInstanceStore().redirectRootNoLogin) || '/main/all'
)
},
},
{
name: 'public-external-timeline',
path: '/main/all',
component: PublicAndExternalTimeline,
},
{
name: 'public-timeline',
path: '/main/public',
component: PublicTimeline,
},
{
name: 'friends',
path: '/main/friends',
component: FriendsTimeline,
beforeEnter: validateAuthenticatedRoute,
},
{ name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
{ name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline },
{ name: 'bubble', path: '/bubble', component: BubbleTimeline },
{
name: 'conversation',
path: '/notice/:id',
component: ConversationPage,
meta: { dontScroll: true },
},
{ name: 'quotes', path: '/notice/:id/quotes', component: QuotesTimeline },
{
name: 'remote-user-profile-acct',
path: '/remote-users/:_(@)?:username([^/@]+)@:hostname([^/@]+)',
component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'remote-user-profile',
path: '/remote-users/:hostname/:username',
component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'external-user-profile',
path: '/users/$:id',
component: UserProfile,
},
{
name: 'interactions',
path: '/users/:username/interactions',
component: Interactions,
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'dms',
path: '/users/:username/dms',
component: DMs,
beforeEnter: validateAuthenticatedRoute,
},
{ name: 'registration', path: '/registration', component: Registration },
{
name: 'password-reset',
path: '/password-reset',
component: PasswordReset,
props: true,
},
{
name: 'registration-token',
path: '/registration/:token',
component: Registration,
},
{
name: 'friend-requests',
path: '/friend-requests',
component: FollowRequests,
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'notifications',
path: '/:username/notifications',
component: Notifications,
props: () => ({ disableTeleport: true }),
beforeEnter: validateAuthenticatedRoute,
},
{ name: 'login', path: '/login', component: AuthForm },
{
name: 'shout-panel',
path: '/shout-panel',
component: ShoutPanel,
props: () => ({ floating: false }),
},
{
name: 'oauth-callback',
path: '/oauth-callback',
component: OAuthCallback,
props: (route) => ({ code: route.query.code }),
},
{
name: 'search',
path: '/search',
component: Search,
props: (route) => ({ query: route.query.query }),
},
{
name: 'who-to-follow',
path: '/who-to-follow',
component: WhoToFollow,
beforeEnter: validateAuthenticatedRoute,
},
{ name: 'about', path: '/about', component: About },
{
name: 'announcements',
path: '/announcements',
component: AnnouncementsPage,
},
{ name: 'drafts', path: '/drafts', component: Drafts },
{ name: 'user-profile', path: '/users/:name', component: UserProfile },
{ name: 'legacy-user-profile', path: '/:name', component: UserProfile },
{ name: 'lists', path: '/lists', component: Lists },
{ name: 'lists-timeline', path: '/lists/:id', component: ListsTimeline },
{ name: 'lists-edit', path: '/lists/:id/edit', component: ListsEdit },
{ name: 'lists-new', path: '/lists/new', component: ListsEdit },
{
name: 'edit-navigation',
path: '/nav-edit',
component: NavPanel,
props: () => ({ forceExpand: true, forceEditMode: true }),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'bookmark-folders',
path: '/bookmark_folders',
component: BookmarkFolders,
},
{
name: 'bookmark-folder-new',
path: '/bookmarks/new-folder',
component: BookmarkFolderEdit,
},
{
name: 'bookmark-folder',
path: '/bookmarks/:id',
component: BookmarkTimeline,
},
{
name: 'bookmark-folder-edit',
path: '/bookmarks/:id/edit',
component: BookmarkFolderEdit,
},
]
if (useInstanceStore().featureSet.pleromaChatMessagesAvailable) {
routes = routes.concat([
{
name: 'chat',
path: '/users/:username/chats/:recipient_id',
component: Chat,
meta: { dontScroll: false },
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'chats',
path: '/users/:username/chats',
component: ChatList,
meta: { dontScroll: false },
beforeEnter: validateAuthenticatedRoute,
},
])
}
return routes
}
diff --git a/src/components/login_form/login_form.js b/src/components/login_form/login_form.js
index 3d94e4023a..c61571b97f 100644
--- a/src/components/login_form/login_form.js
+++ b/src/components/login_form/login_form.js
@@ -1,109 +1,109 @@
import { mapActions, mapState as mapPiniaState, mapStores } from 'pinia'
import { mapState } from 'vuex'
import oauthApi from '../../services/new_api/oauth.js'
import { useAuthFlowStore } from 'src/stores/auth_flow.js'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faTimes } from '@fortawesome/free-solid-svg-icons'
library.add(faTimes)
const LoginForm = {
data: () => ({
user: {},
error: false,
}),
computed: {
isPasswordAuth() {
return this.requiredPassword
},
isTokenAuth() {
return this.requiredToken
},
...mapStores(useOAuthStore),
...mapState({
- registrationOpen: (state) => state.instance.registrationOpen,
- instance: (state) => state.instance,
loggingIn: (state) => state.users.loggingIn,
}),
...mapPiniaState(useAuthFlowStore, [
'requiredPassword',
'requiredToken',
'requiredMFA',
]),
+ ...mapPiniaState(useInstanceStore, ['registrationOpen', 'server']),
},
methods: {
...mapActions(useAuthFlowStore, ['requireMFA', 'login']),
submit() {
this.isTokenAuth ? this.submitToken() : this.submitPassword()
},
submitToken() {
const data = {
- instance: this.instance.server,
+ instance: this.server,
commit: this.$store.commit,
}
// NOTE: we do not really need the app token, but obtaining a token and
// calling verify_credentials is the only way to ensure the app still works.
this.oauthStore.ensureAppToken().then(() => {
const app = {
clientId: this.oauthStore.clientId,
clientSecret: this.oauthStore.clientSecret,
}
oauthApi.login({ ...app, ...data })
})
},
submitPassword() {
this.error = false
// NOTE: we do not really need the app token, but obtaining a token and
// calling verify_credentials is the only way to ensure the app still works.
this.oauthStore.ensureAppToken().then(() => {
const app = {
clientId: this.oauthStore.clientId,
clientSecret: this.oauthStore.clientSecret,
}
oauthApi
.getTokenWithCredentials({
...app,
- instance: this.instance.server,
+ instance: this.server,
username: this.user.username,
password: this.user.password,
})
.then((result) => {
if (result.error) {
if (result.error === 'mfa_required') {
this.requireMFA({ settings: result })
} else if (result.identifier === 'password_reset_required') {
this.$router.push({
name: 'password-reset',
params: { passwordResetRequested: true },
})
} else {
this.error = result.error
this.focusOnPasswordInput()
}
return
}
this.login(result).then(() => {
this.$router.push({ name: 'friends' })
})
})
})
},
clearError() {
this.error = false
},
focusOnPasswordInput() {
const passwordInput = this.$refs.passwordInput
passwordInput.focus()
passwordInput.setSelectionRange(0, passwordInput.value.length)
},
},
}
export default LoginForm
diff --git a/src/components/settings_modal/helpers/shared_computed_object.js b/src/components/settings_modal/helpers/shared_computed_object.js
index 142fe30224..eef16e3cf6 100644
--- a/src/components/settings_modal/helpers/shared_computed_object.js
+++ b/src/components/settings_modal/helpers/shared_computed_object.js
@@ -1,18 +1,18 @@
import { mapState as mapPiniaState } from 'pinia'
-import { mapGetters, mapState } from 'vuex'
+import { mapState } from 'vuex'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
const SharedComputedObject = () => ({
...mapPiniaState(useSyncConfigStore, ['mergedConfig']),
...mapPiniaState(useSyncConfigStore, {
expertLevel: (store) => store.mergedConfig.expertLevel,
}),
...mapState({
adminConfig: (state) => state.adminSettings.config,
adminDraft: (state) => state.adminSettings.draft,
user: (state) => state.users.currentUser,
}),
})
export default SharedComputedObject
diff --git a/src/components/settings_modal/tabs/filtering_tab.js b/src/components/settings_modal/tabs/filtering_tab.js
index 593b5e2644..1360ea1f2f 100644
--- a/src/components/settings_modal/tabs/filtering_tab.js
+++ b/src/components/settings_modal/tabs/filtering_tab.js
@@ -1,271 +1,271 @@
import { cloneDeep } from 'lodash'
import { mapActions, mapState } from 'pinia'
import { v4 as uuidv4 } from 'uuid'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Select from 'src/components/select/select.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import HelpIndicator from '../helpers/help_indicator.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import UnitSetting from '../helpers/unit_setting.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import {
newExporter,
newImporter,
} from 'src/services/export_import/export_import.js'
const SUPPORTED_TYPES = new Set(['word', 'regexp', 'user', 'user_regexp'])
const FilteringTab = {
data() {
return {
replyVisibilityOptions: ['all', 'following', 'self'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.reply_visibility_${mode}`),
})),
muteBlockLv1Options: ['ask', 'forever', 'temporarily'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`user_card.mute_block_${mode}`),
})),
muteFiltersDraftObject: cloneDeep(
useSyncConfigStore().prefsStorage.simple.muteFilters,
),
muteFiltersDraftDirty: Object.fromEntries(
Object.entries(
useSyncConfigStore().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,
Checkbox,
Select,
HelpIndicator,
},
computed: {
onMuteDefaultActionLv1: {
get() {
const value = this.$store.state.config.onMuteDefaultAction
if (value === 'ask' || value === 'forever') {
return value
} else {
return 'temporarily'
}
},
set(value) {
let realValue = value
if (value !== 'ask' && value !== 'forever') {
realValue = '14d'
}
this.$store.dispatch('setOption', {
name: 'onMuteDefaultAction',
value: realValue,
})
},
},
onBlockDefaultActionLv1: {
get() {
const value = this.$store.state.config.onBlockDefaultAction
if (value === 'ask' || value === 'forever') {
return value
} else {
return 'temporarily'
}
},
set(value) {
let realValue = value
if (value !== 'ask' && value !== 'forever') {
realValue = '14d'
}
this.$store.dispatch('setOption', {
name: 'onBlockDefaultAction',
value: realValue,
})
},
},
muteFiltersDraft() {
return Object.entries(this.muteFiltersDraftObject)
},
muteFiltersExpired() {
const now = Date.now()
return Object.entries(this.muteFiltersDraftObject).filter(
([, { expires }]) => expires != null && expires <= now,
)
},
...mapState(useInstanceStore, {
instanceSpecificPanelPresent: (store) => store.showInstanceSpecificPanel,
- blockExpirationSupported: (store) => store.blockExpiration,
+ blockExpirationSupported: (store) => store.featureSet.blockExpiration,
}),
...SharedComputedObject(),
...mapState(useSyncConfigStore, {
muteFilters: (store) =>
Object.entries(store.prefsStorage.simple.muteFilters),
muteFiltersObject: (store) => store.prefsStorage.simple.muteFilters,
}),
},
methods: {
...mapActions(useSyncConfigStore, [
'setPreference',
'unsetPreference',
'pushSyncConfig',
]),
getDatetimeLocal(timestamp) {
const date = new Date(timestamp)
const fmt = new Intl.NumberFormat('en-US', { minimumIntegerDigits: 2 })
const datetime = [
date.getFullYear(),
'-',
fmt.format(date.getMonth() + 1),
'-',
fmt.format(date.getDate()),
'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.pushSyncConfig()
},
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.pushSyncConfig()
},
deleteFilter(id) {
delete this.muteFiltersDraftObject[id]
this.unsetPreference({ path: 'simple.muteFilters.' + id, value: null })
this.pushSyncConfig()
},
purgeExpiredFilters() {
this.muteFiltersExpired.forEach(([id]) => {
delete this.muteFiltersDraftObject[id]
this.unsetPreference({ path: 'simple.muteFilters.' + id, value: null })
})
this.pushSyncConfig()
},
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.pushSyncConfig()
this.muteFiltersDraftDirty[id] = false
},
},
// Updating nested properties
watch: {
replyVisibility() {
this.$store.dispatch('queueFlushAll')
},
},
}
export default FilteringTab
diff --git a/src/components/settings_modal/tabs/style_tab/style_tab.js b/src/components/settings_modal/tabs/style_tab/style_tab.js
index 6c23669994..8365645fc8 100644
--- a/src/components/settings_modal/tabs/style_tab/style_tab.js
+++ b/src/components/settings_modal/tabs/style_tab/style_tab.js
@@ -1,934 +1,934 @@
import { get, set, throttle, unset } from 'lodash'
import {
computed,
getCurrentInstance,
provide,
reactive,
ref,
watch,
} from 'vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ColorInput from 'src/components/color_input/color_input.vue'
import ComponentPreview from 'src/components/component_preview/component_preview.vue'
import ContrastRatio from 'src/components/contrast_ratio/contrast_ratio.vue'
import OpacityInput from 'src/components/opacity_input/opacity_input.vue'
import PaletteEditor from 'src/components/palette_editor/palette_editor.vue'
import RoundnessInput from 'src/components/roundness_input/roundness_input.vue'
import Select from 'src/components/select/select.vue'
import SelectMotion from 'src/components/select/select_motion.vue'
import ShadowControl from 'src/components/shadow_control/shadow_control.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import Tooltip from 'src/components/tooltip/tooltip.vue'
import StringSetting from '../../helpers/string_setting.vue'
import Preview from '../old_theme_tab/theme_preview.vue'
import VirtualDirectivesTab from './virtual_directives_tab.vue'
-import { useInterfaceStore } from 'src/stores/interface'
+import { useInterfaceStore, useSyncConfigStore } from 'src/stores/interface'
import {
getContrastRatio,
hex2rgb,
rgb2hex,
} from 'src/services/color_convert/color_convert.js'
import {
newExporter,
newImporter,
} from 'src/services/export_import/export_import.js'
import {
adoptStyleSheets,
createStyleSheet,
} from 'src/services/style_setter/style_setter.js'
import { getCssRules } from 'src/services/theme_data/css_utils.js'
import {
deserialize,
deserializeShadow,
} from 'src/services/theme_data/iss_deserializer.js'
import { serialize } from 'src/services/theme_data/iss_serializer.js'
import {
findColor,
init,
} from 'src/services/theme_data/theme_data_3.service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faArrowsRotate,
faCheck,
faFile,
faFloppyDisk,
faFolderOpen,
} from '@fortawesome/free-solid-svg-icons'
// helper for debugging
// const toValue = (x) => JSON.parse(JSON.stringify(x === undefined ? 'null' : x))
// helper to make states comparable
const normalizeStates = (states) =>
['normal', ...(states?.filter((x) => x !== 'normal') || [])].join(':')
library.add(faFile, faFloppyDisk, faFolderOpen, faArrowsRotate, faCheck)
export default {
components: {
Select,
SelectMotion,
Checkbox,
Tooltip,
StringSetting,
ComponentPreview,
TabSwitcher,
ShadowControl,
ColorInput,
PaletteEditor,
OpacityInput,
RoundnessInput,
ContrastRatio,
Preview,
VirtualDirectivesTab,
},
setup() {
const exports = {}
const interfaceStore = useInterfaceStore()
// All rules that are made by editor
const allEditedRules = ref(interfaceStore.styleDataUsed || {})
const styleDataUsed = computed(() => interfaceStore.styleDataUsed)
watch(
[styleDataUsed],
() => {
onImport(interfaceStore.styleDataUsed)
},
{ once: true },
)
exports.isActive = computed(() => {
const tabSwitcher = getCurrentInstance().parent.ctx
return tabSwitcher ? tabSwitcher.isActive('style') : false
})
// ## Meta stuff
exports.name = ref('')
exports.author = ref('')
exports.license = ref('')
exports.website = ref('')
const metaOut = computed(() => {
return [
'@meta {',
` name: ${exports.name.value};`,
` author: ${exports.author.value};`,
` license: ${exports.license.value};`,
` website: ${exports.website.value};`,
'}',
].join('\n')
})
const metaRule = computed(() => ({
component: '@meta',
directives: {
name: exports.name.value,
author: exports.author.value,
license: exports.license.value,
website: exports.website.value,
},
}))
// ## Palette stuff
const palettes = reactive([
{
name: 'default',
bg: '#121a24',
fg: '#182230',
text: '#b9b9ba',
link: '#d8a070',
accent: '#d8a070',
cRed: '#FF0000',
cBlue: '#0095ff',
cGreen: '#0fa00f',
cOrange: '#ffa500',
},
{
name: 'light',
bg: '#f2f6f9',
fg: '#d6dfed',
text: '#304055',
underlay: '#5d6086',
accent: '#f55b1b',
cBlue: '#0095ff',
cRed: '#d31014',
cGreen: '#0fa00f',
cOrange: '#ffa500',
border: '#d8e6f9',
},
])
exports.palettes = palettes
// This is kinda dumb but you cannot "replace" reactive() object
// and so v-model simply fails when you try to chage (increase only?)
// length of the array. Since linter complains about mutating modelValue
// inside SelectMotion, the next best thing is to just wipe existing array
// and replace it with new one.
const onPalettesUpdate = (e) => {
palettes.splice(0, palettes.length)
palettes.push(...e)
}
exports.onPalettesUpdate = onPalettesUpdate
const selectedPaletteId = ref(0)
const selectedPalette = computed({
get() {
return palettes[selectedPaletteId.value]
},
set(newPalette) {
palettes[selectedPaletteId.value] = newPalette
},
})
exports.selectedPaletteId = selectedPaletteId
exports.selectedPalette = selectedPalette
provide('selectedPalette', selectedPalette)
watch([selectedPalette], () => updateOverallPreview())
exports.getNewPalette = () => ({
name: 'new palette',
bg: '#121a24',
fg: '#182230',
text: '#b9b9ba',
link: '#d8a070',
accent: '#d8a070',
cRed: '#FF0000',
cBlue: '#0095ff',
cGreen: '#0fa00f',
cOrange: '#ffa500',
})
// Raw format
const palettesRule = computed(() => {
return palettes.map((palette) => {
const { name, ...rest } = palette
return {
component: '@palette',
variant: name,
directives: Object.entries(rest)
.filter(([k, v]) => v && k)
.reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {}),
}
})
})
// Text format
const palettesOut = computed(() => {
return palettes
.map(({ name, ...palette }) => {
const entries = Object.entries(palette)
.filter(([k, v]) => v && k)
.map(([slot, data]) => ` ${slot}: ${data};`)
.join('\n')
return `@palette.${name} {\n${entries}\n}`
})
.join('\n\n')
})
// ## Components stuff
// Getting existing components
const componentsContext = import.meta.glob(
['/src/**/*.style.js', '/src/**/*.style.json'],
{ eager: true },
)
const componentKeysAll = Object.keys(componentsContext)
const componentsMap = new Map(
componentKeysAll
.map((key) => [key, componentsContext[key].default])
.filter(
([, component]) => !component.virtual && !component.notEditable,
),
)
exports.componentsMap = componentsMap
const componentKeys = [...componentsMap.keys()]
exports.componentKeys = componentKeys
// Component list and selection
const selectedComponentKey = ref(componentsMap.keys().next().value)
exports.selectedComponentKey = selectedComponentKey
const selectedComponent = computed(() =>
componentsMap.get(selectedComponentKey.value),
)
const selectedComponentName = computed(() => selectedComponent.value.name)
// Selection basis
exports.selectedComponentVariants = computed(() => {
return Object.keys({
normal: null,
...(selectedComponent.value.variants || {}),
})
})
exports.selectedComponentStates = computed(() => {
const all = Object.keys({
normal: null,
...(selectedComponent.value.states || {}),
})
return all.filter((x) => x !== 'normal')
})
// selection
const selectedVariant = ref('normal')
exports.selectedVariant = selectedVariant
const selectedState = reactive(new Set())
exports.selectedState = selectedState
exports.updateSelectedStates = (state, v) => {
if (v) {
selectedState.add(state)
} else {
selectedState.delete(state)
}
}
// Reset variant and state on component change
const updateSelectedComponent = () => {
selectedVariant.value = 'normal'
selectedState.clear()
}
watch(selectedComponentName, updateSelectedComponent)
// ### Rules stuff aka meat and potatoes
// The native structure of separate rules and the child -> parent
// relation isn't very convenient for editor, we replace the array
// and child -> parent structure with map and parent -> child structure
const rulesToEditorFriendly = (rules, root = {}) =>
rules.reduce((acc, rule) => {
const { parent: rParent, component: rComponent } = rule
const parent = rParent ?? rule
const hasChildren = !!rParent
const child = hasChildren ? rule : null
const {
component: pComponent,
variant: pVariant = 'normal',
state: pState = [], // no relation to Intel CPUs whatsoever
} = parent
const pPath = `${hasChildren ? pComponent : rComponent}.${pVariant}.${normalizeStates(pState)}`
let output = get(acc, pPath)
if (!output) {
set(acc, pPath, {})
output = get(acc, pPath)
}
if (hasChildren) {
output._children = output._children ?? {}
const {
component: cComponent,
variant: cVariant = 'normal',
state: cState = [],
directives,
} = child
const cPath = `${cComponent}.${cVariant}.${normalizeStates(cState)}`
set(output._children, cPath, { directives })
} else {
output.directives = parent.directives
}
return acc
}, root)
const editorFriendlyFallbackStructure = computed(() => {
const root = {}
componentKeys.forEach((componentKey) => {
const componentValue = componentsMap.get(componentKey)
const { defaultRules, name } = componentValue
rulesToEditorFriendly(
defaultRules.map((rule) => ({ ...rule, component: name })),
root,
)
})
return root
})
// Checking whether component can support some "directives" which
// are actually virtual subcomponents, i.e. Text, Link etc
exports.componentHas = (subComponent) => {
return !!selectedComponent.value.validInnerComponents?.find(
(x) => x === subComponent,
)
}
// Path for lodash's get and set
const getPath = (component, directive) => {
const pathSuffix = component
? `._children.${component}.normal.normal`
: ''
const path = `${selectedComponentName.value}.${selectedVariant.value}.${normalizeStates([...selectedState])}${pathSuffix}.directives.${directive}`
return path
}
// Templates for directives
const isElementPresent = (component, directive, defaultValue = '') =>
computed({
get() {
return (
get(allEditedRules.value, getPath(component, directive)) != null
)
},
set(value) {
if (value) {
const fallback = get(
editorFriendlyFallbackStructure.value,
getPath(component, directive),
)
set(
allEditedRules.value,
getPath(component, directive),
fallback ?? defaultValue,
)
} else {
unset(allEditedRules.value, getPath(component, directive))
}
exports.updateOverallPreview()
},
})
const getEditedElement = (component, directive, postProcess = (x) => x) =>
computed({
get() {
let usedRule
const fallback = editorFriendlyFallbackStructure.value
const real = allEditedRules.value
const path = getPath(component, directive)
usedRule = get(real, path) // get real
if (usedRule === '') {
return usedRule
}
if (!usedRule) {
usedRule = get(fallback, path)
}
return postProcess(usedRule)
},
set(value) {
if (value != null) {
set(allEditedRules.value, getPath(component, directive), value)
} else {
unset(allEditedRules.value, getPath(component, directive))
}
exports.updateOverallPreview()
},
})
// All the editable stuff for the component
exports.editedBackgroundColor = getEditedElement(null, 'background')
exports.isBackgroundColorPresent = isElementPresent(
null,
'background',
'#FFFFFF',
)
exports.editedOpacity = getEditedElement(null, 'opacity')
exports.isOpacityPresent = isElementPresent(null, 'opacity', 1)
exports.editedRoundness = getEditedElement(null, 'roundness')
exports.isRoundnessPresent = isElementPresent(null, 'roundness', '0')
exports.editedTextColor = getEditedElement('Text', 'textColor')
exports.isTextColorPresent = isElementPresent(
'Text',
'textColor',
'#000000',
)
exports.editedTextAuto = getEditedElement('Text', 'textAuto')
exports.isTextAutoPresent = isElementPresent('Text', 'textAuto', '#000000')
exports.editedLinkColor = getEditedElement('Link', 'textColor')
exports.isLinkColorPresent = isElementPresent(
'Link',
'textColor',
'#000080',
)
exports.editedIconColor = getEditedElement('Icon', 'textColor')
exports.isIconColorPresent = isElementPresent(
'Icon',
'textColor',
'#909090',
)
exports.editedBorderColor = getEditedElement('Border', 'textColor')
exports.isBorderColorPresent = isElementPresent(
'Border',
'textColor',
'#909090',
)
const getContrast = (bg, text) => {
try {
const bgRgb = hex2rgb(bg)
const textRgb = hex2rgb(text)
const ratio = getContrastRatio(bgRgb, textRgb)
return {
// TODO this ideally should be part of <ContractRatio />
ratio,
text: ratio.toPrecision(3) + ':1',
// AA level, AAA level
aa: ratio >= 4.5,
aaa: ratio >= 7,
// same but for 18pt+ texts
laa: ratio >= 3,
laaa: ratio >= 4.5,
}
} catch (e) {
console.warn('Failure computing contrast', e)
return { error: e }
}
}
const normalizeShadows = (shadows) => {
return shadows?.map((shadow) => {
if (typeof shadow === 'object') {
return shadow
}
if (typeof shadow === 'string') {
try {
return deserializeShadow(shadow)
} catch (e) {
console.warn(e)
return shadow
}
}
return null
})
}
provide('normalizeShadows', normalizeShadows)
// Shadow is partially edited outside the ShadowControl
// for better space utilization
const editedShadow = getEditedElement(null, 'shadow', normalizeShadows)
exports.editedShadow = editedShadow
const editedSubShadowId = ref(null)
exports.editedSubShadowId = editedSubShadowId
const editedSubShadow = computed(() => {
if (editedShadow.value == null || editedSubShadowId.value == null)
return null
return editedShadow.value[editedSubShadowId.value]
})
exports.editedSubShadow = editedSubShadow
exports.isShadowPresent = isElementPresent(null, 'shadow', [])
exports.onSubShadow = (id) => {
if (id != null) {
editedSubShadowId.value = id
} else {
editedSubShadow.value = null
}
}
exports.updateSubShadow = (axis, value) => {
if (!editedSubShadow.value || editedSubShadowId.value == null) return
const newEditedShadow = [...editedShadow.value]
newEditedShadow[editedSubShadowId.value] = {
...newEditedShadow[editedSubShadowId.value],
[axis]: value,
}
editedShadow.value = newEditedShadow
}
exports.isShadowTabOpen = ref(false)
exports.onTabSwitch = (tab) => {
exports.isShadowTabOpen.value = tab === 'shadow'
}
// component preview
exports.editorHintStyle = computed(() => {
const editorHint = selectedComponent.value.editor
const styles = []
if (editorHint && Object.keys(editorHint).length > 0) {
if (editorHint.aspect != null) {
styles.push(`aspect-ratio: ${editorHint.aspect} !important;`)
}
if (editorHint.border != null) {
styles.push(`border-width: ${editorHint.border}px !important;`)
}
}
return styles.join('; ')
})
const editorFriendlyToOriginal = computed(() => {
const resultRules = []
const convert = (component, data = {}, parent) => {
const variants = Object.entries(data || {})
variants.forEach(([variant, variantData]) => {
const states = Object.entries(variantData)
states.forEach(([jointState, stateData]) => {
const state = jointState.split(/:/g)
const result = {
component,
variant,
state,
directives: stateData.directives || {},
}
if (parent) {
result.parent = {
component: parent,
}
}
resultRules.push(result)
// Currently we only support single depth for simplicity's sake
if (!parent) {
Object.entries(stateData._children || {}).forEach(
([cName, child]) => convert(cName, child, component),
)
}
})
})
}
;[...componentsMap.values()].forEach(({ name }) => {
convert(name, allEditedRules.value[name])
})
return resultRules
})
const allCustomVirtualDirectives = [...componentsMap.values()]
.map((c) => {
return c.defaultRules
.filter((c) => c.component === 'Root')
.map((x) => Object.entries(x.directives))
.flat()
})
.filter((x) => x)
.flat()
.map(([name, value]) => {
const [valType, valVal] = value.split('|')
return {
name: name.substring(2),
valType: valType?.trim(),
value: valVal?.trim(),
}
})
const virtualDirectives = ref(allCustomVirtualDirectives)
exports.virtualDirectives = virtualDirectives
exports.updateVirtualDirectives = (value) => {
virtualDirectives.value = value
}
// Raw format
const virtualDirectivesRule = computed(() => ({
component: 'Root',
directives: Object.fromEntries(
virtualDirectives.value.map((vd) => [
`--${vd.name}`,
`${vd.valType} | ${vd.value}`,
]),
),
}))
// Text format
const virtualDirectivesOut = computed(() => {
return [
'Root {',
...virtualDirectives.value
.filter((vd) => vd.name && vd.valType && vd.value)
.map((vd) => ` --${vd.name}: ${vd.valType} | ${vd.value};`),
'}',
].join('\n')
})
exports.computeColor = (color) => {
let computedColor
try {
computedColor = findColor(color, {
dynamicVars: dynamicVars.value,
staticVars: staticVars.value,
})
if (computedColor) {
return rgb2hex(computedColor)
}
} catch (e) {
console.warn(e)
}
return null
}
provide('computeColor', exports.computeColor)
exports.contrast = computed(() => {
return getContrast(
exports.computeColor(previewColors.value.background),
exports.computeColor(previewColors.value.text),
)
})
// ## Export and Import
const styleExporter = newExporter({
filename: () => exports.name.value ?? 'pleroma_theme',
mime: 'text/plain',
extension: 'iss',
getExportedObject: () => exportStyleData.value,
})
const onImport = (parsed) => {
const editorComponents = parsed.filter((x) => x.component.startsWith('@'))
const rootComponent = parsed.find((x) => x.component === 'Root')
const rules = parsed.filter(
(x) => !x.component.startsWith('@') && x.component !== 'Root',
)
const metaIn = editorComponents.find(
(x) => x.component === '@meta',
).directives
const palettesIn = editorComponents.filter(
(x) => x.component === '@palette',
)
exports.name.value = metaIn.name
exports.license.value = metaIn.license
exports.author.value = metaIn.author
exports.website.value = metaIn.website
const newVirtualDirectives = Object.entries(rootComponent.directives).map(
([name, value]) => {
const [valType, valVal] = value.split('|').map((x) => x.trim())
return { name: name.substring(2), valType, value: valVal }
},
)
virtualDirectives.value = newVirtualDirectives
onPalettesUpdate(
palettesIn.map((x) => ({ name: x.variant, ...x.directives })),
)
allEditedRules.value = rulesToEditorFriendly(rules)
exports.updateOverallPreview()
}
const styleImporter = newImporter({
accept: '.iss',
parser(string) {
return deserialize(string)
},
onImportFailure(result) {
console.error('Failure importing style:', result)
useInterfaceStore().pushGlobalNotice({
messageKey: 'settings.invalid_theme_imported',
level: 'error',
})
},
onImport,
})
// Raw format
const exportRules = computed(() => [
metaRule.value,
...palettesRule.value,
virtualDirectivesRule.value,
...editorFriendlyToOriginal.value,
])
// Text format
const exportStyleData = computed(() => {
return [
metaOut.value,
palettesOut.value,
virtualDirectivesOut.value,
serialize(editorFriendlyToOriginal.value),
].join('\n\n')
})
exports.clearStyle = () => {
onImport(interfaceStore.styleDataUsed)
}
exports.exportStyle = () => {
styleExporter.exportData()
}
exports.importStyle = () => {
styleImporter.importData()
}
exports.applyStyle = () => {
useInterfaceStore().setStyleCustom(exportRules.value)
}
const overallPreviewRules = ref([])
exports.overallPreviewRules = overallPreviewRules
watch([overallPreviewRules], () => {
let css = null
try {
css = getCssRules(overallPreviewRules.value).map((r) =>
r.replace('html', '&'),
)
} catch (e) {
console.error(e)
return
}
const sheet = createStyleSheet('style-tab-overall-preview', 90)
sheet.clear()
sheet.addRule(
['#edited-style-preview {\n', css.join('\n'), '\n}'].join(''),
)
sheet.ready = true
adoptStyleSheets()
})
const updateOverallPreview = throttle(() => {
try {
overallPreviewRules.value = init({
inputRuleset: [
...exportRules.value,
{
component: 'Root',
directives: Object.fromEntries(
Object.entries(selectedPalette.value)
.filter(([k, v]) => k && v && k !== 'name')
.map(([k, v]) => [`--${k}`, `color | ${v}`]),
),
},
],
ultimateBackgroundColor: '#000000',
debug: true,
}).eager
} catch (e) {
console.error('Could not compile preview theme', e)
return null
}
}, 1000)
//
// Apart from "hover" we can't really show how component looks like in
// certain states, so we have to fake them.
const simulatePseudoSelectors = (css, prefix) =>
css
.replace(prefix, '.preview-block')
.replace(':active', '.preview-active')
.replace(':hover', '.preview-hover')
.replace(':active', '.preview-active')
.replace(':focus', '.preview-focus')
.replace(':focus-within', '.preview-focus-within')
.replace(':disabled', '.preview-disabled')
const previewRules = computed(() => {
const filtered = overallPreviewRules.value.filter((r) => {
const componentMatch = r.component === selectedComponentName.value
const parentComponentMatch =
r.parent?.component === selectedComponentName.value
if (!componentMatch && !parentComponentMatch) return false
const rule = parentComponentMatch ? r.parent : r
if (rule.component !== selectedComponentName.value) return false
if (rule.variant !== selectedVariant.value) return false
const ruleState = new Set(rule.state.filter((x) => x !== 'normal'))
const differenceA = [...ruleState].filter((x) => !selectedState.has(x))
const differenceB = [...selectedState].filter((x) => !ruleState.has(x))
return differenceA.length + differenceB.length === 0
})
const sorted = [...filtered]
.filter((x) => x.component === selectedComponentName.value)
.sort((a, b) => {
const aSelectorLength = a.selector.split(/ /g).length
const bSelectorLength = b.selector.split(/ /g).length
return aSelectorLength - bSelectorLength
})
const prefix = sorted[0].selector
return filtered.filter((x) => x.selector.startsWith(prefix))
})
exports.previewClass = computed(() => {
const selectors = []
if (
!!selectedComponent.value.variants?.normal ||
selectedVariant.value !== 'normal'
) {
selectors.push(selectedComponent.value.variants[selectedVariant.value])
}
if (selectedState.size > 0) {
selectedState.forEach((state) => {
const original = selectedComponent.value.states[state]
selectors.push(simulatePseudoSelectors(original))
})
}
return selectors.map((x) => x.substring(1)).join('')
})
exports.previewCss = computed(() => {
try {
const prefix = previewRules.value[0].selector
const scoped = getCssRules(previewRules.value).map((x) =>
simulatePseudoSelectors(x, prefix),
)
return scoped.join('\n')
} catch (e) {
console.error('Invalid ruleset', e)
return null
}
})
const dynamicVars = computed(() => {
return previewRules.value[0].dynamicVars
})
const staticVars = computed(() => {
const rootComponent = overallPreviewRules.value.find((r) => {
return r.component === 'Root'
})
const rootDirectivesEntries = Object.entries(rootComponent.directives)
const directives = {}
rootDirectivesEntries
.filter(([k, v]) => k.startsWith('--') && v.startsWith('color | '))
.map(([k, v]) => [k.substring(2), v.substring('color | '.length)])
.forEach(([k, v]) => {
directives[k] = findColor(v, {
dynamicVars: {},
staticVars: directives,
})
})
return directives
})
provide('staticVars', staticVars)
exports.staticVars = staticVars
const previewColors = computed(() => {
const stacked = dynamicVars.value.stacked
const background =
typeof stacked === 'string' ? stacked : rgb2hex(stacked)
return {
text: previewRules.value.find((r) => r.component === 'Text')
?.virtualDirectives['--text'],
link: previewRules.value.find((r) => r.component === 'Link')
?.virtualDirectives['--link'],
border: previewRules.value.find((r) => r.component === 'Border')
?.virtualDirectives['--border'],
icon: previewRules.value.find((r) => r.component === 'Icon')
?.virtualDirectives['--icon'],
background,
}
})
exports.previewColors = previewColors
exports.updateOverallPreview = updateOverallPreview
updateOverallPreview()
watch(
[
allEditedRules.value,
palettes,
selectedPalette,
selectedState,
selectedVariant,
],
updateOverallPreview,
)
return exports
},
}
diff --git a/src/stores/i18n.js b/src/stores/i18n.js
index 525d6022ab..d102699165 100644
--- a/src/stores/i18n.js
+++ b/src/stores/i18n.js
@@ -1,33 +1,35 @@
import Cookies from 'js-cookie'
import { defineStore } from 'pinia'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import messages from 'src/i18n/messages'
import localeService from 'src/services/locale/locale.service.js'
const BACKEND_LANGUAGE_COOKIE_NAME = 'userLanguage'
export const useI18nStore = defineStore('i18n', {
state: () => ({
i18n: null,
}),
actions: {
setI18n(newI18n) {
this.$patch({
i18n: newI18n.global,
})
},
setLanguage(originalValue) {
const value =
originalValue || useSyncConfigStore().mergedConfig.interfaceLanguage
+
+ console.log(useSyncConfigStore().mergedConfig)
messages.setLanguage(this.i18n, value)
useEmojiStore().loadUnicodeEmojiData(value)
Cookies.set(
BACKEND_LANGUAGE_COOKIE_NAME,
localeService.internalToBackendLocaleMulti(value),
)
},
},
})
diff --git a/src/stores/sync_config.js b/src/stores/sync_config.js
index df6d80a49b..2ee3bba7dd 100644
--- a/src/stores/sync_config.js
+++ b/src/stores/sync_config.js
@@ -1,673 +1,675 @@
import {
merge as _merge,
clamp,
cloneDeep,
findLastIndex,
flatten,
get,
groupBy,
isEqual,
set,
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'
-import {
- defaultState as configDefaultState,
- instanceDefaultConfig,
-} from 'src/modules/default_config_state'
+import { defaultState as configDefaultState } from 'src/modules/default_config_state'
export const VERSION = 2
export const NEW_USER_DATE = new Date('2022-08-04') // date of writing this, basically
export const COMMAND_TRIM_FLAGS = 1000
export const COMMAND_TRIM_FLAGS_AND_RESET = 1001
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
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
// 1001: same as above + reset everything to 0
},
prefsStorage: {
_journal: [],
simple: {
dontShowUpdateNotifs: false,
collapseNav: false,
muteFilters: {},
...configDefaultState,
},
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) => ({
...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 === '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`)
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, 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) => ({
_user: a._user ?? b._user,
_version: a._version ?? b._version,
_timestamp: a._timestamp ?? b._timestamp,
needUpload: b.needUpload ?? a.needUpload,
prefsStorage: _merge(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),
]
}),
)
}
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')) {
+ if (path.startsWith('collections') || path.startsWith('objectCollection')) {
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') {
// TODO check how objectCollections behaves here
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
}
})
return flatten(trimmedGrouped).sort((a, b) =>
a.timestamp > b.timestamp ? 1 : -1,
)
}
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':
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
case 'removeFromCollection': {
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:
throw new 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
})
}
} else if (totalFlags.reset > 0 && totalFlags.reset < 9000) {
console.debug('Received command to reset the flags')
allFlagKeys.forEach((flag) => {
result[flag] = 0
})
}
result.reset = 0
return result
}
export const _doMigrations = (cache, live) => {
const data = cache ?? live
if (data._version < VERSION) {
console.debug(
'Data has older version, seeing if there any migrations that can be applied',
)
// no migrations right now since we only have one version
console.debug('No migrations found')
}
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 cache
}
export const useSyncConfigStore = defineStore('sync_config', {
state() {
return cloneDeep(defaultState)
},
actions: {
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)
- console.log(get(path, this.prefsStorage, path))
}
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
} else if (path.startsWith('objectCollection')) {
const collection = new Set(get(this.prefsStorage, path + '.index'))
collection.delete(_key)
const data = get(this.prefsStorage, path + '.data')
delete data[_key]
}
},
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]
})
},
setSyncConfig(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, live)
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
const { _timestamp: _0, _version: _1, ...recentData } = recent
const { _timestamp: _2, _version: _3, ...staleData } = stale
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
},
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 })
.then((user) => {
this.setSyncConfig(user)
this.dirty = false
})
},
},
getters: {
- mergedConfig: (state) => ({
- ...useInstanceStore().prefsStorage,
- ...state.prefsStorage.simple,
- }),
+ mergedConfig: (state) => {
+ const instancePrefs = useInstanceStore().prefsStorage
+ const result = Object.fromEntries(
+ Object.entries(state.prefsStorage.simple).map(([k, v]) => [
+ k,
+ v ?? instancePrefs[k],
+ ]),
+ )
+ return result
+ },
},
persist: {
afterLoad(state) {
return state
},
},
})
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Oct 31, 6:41 PM (16 h, 6 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1801667
Default Alt Text
(81 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline