Page MenuHomePhorge

No OneTemporary

Size
1 MB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/build/emojis_plugin.js b/build/emojis_plugin.js
index 7979086ddc..9872f53317 100644
--- a/build/emojis_plugin.js
+++ b/build/emojis_plugin.js
@@ -1,69 +1,73 @@
import { access } from 'node:fs/promises'
import { resolve } from 'node:path'
import { languages } from '../src/i18n/languages.js'
const annotationsImportPrefix = '@kazvmoe-infra/unicode-emoji-json/annotations/'
const specialAnnotationsLocale = {
ja_easy: 'ja',
}
const internalToAnnotationsLocale = (internal) =>
specialAnnotationsLocale[internal] || internal
// This gets all the annotations that are accessible (whose language
// can be chosen in the settings). Data for other languages are
// discarded because there is no way for it to be fetched.
const getAllAccessibleAnnotations = async (projectRoot) => {
const imports = (
await Promise.all(
languages.map(async (lang) => {
const destLang = internalToAnnotationsLocale(lang)
const importModule = `${annotationsImportPrefix}${destLang}.json`
const importFile = resolve(projectRoot, 'node_modules', importModule)
try {
await access(importFile)
return `'${lang}': () => import('${importModule}')`
} catch (e) {
- console.error(e)
+ if (e.message.match(/ENOENT/)) {
+ console.warn(`Missing emoji annotations locale: ${destLang}`)
+ } else {
+ console.error('test', e.message)
+ }
return
}
}),
)
)
.filter((k) => k)
.join(',\n')
return `
export const annotationsLoader = {
${imports}
}
`
}
const emojiAnnotationsId = 'virtual:pleroma-fe/emoji-annotations'
const emojiAnnotationsIdResolved = '\0' + emojiAnnotationsId
const emojisPlugin = () => {
let projectRoot
return {
name: 'emojis-plugin',
configResolved(conf) {
projectRoot = conf.root
},
resolveId(id) {
if (id === emojiAnnotationsId) {
return emojiAnnotationsIdResolved
}
return null
},
async load(id) {
if (id === emojiAnnotationsIdResolved) {
return await getAllAccessibleAnnotations(projectRoot)
}
return null
},
}
}
export default emojisPlugin
diff --git a/src/App.js b/src/App.js
index a0bf0aaa44..4870905656 100644
--- a/src/App.js
+++ b/src/App.js
@@ -1,269 +1,283 @@
import { throttle } from 'lodash'
import { mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
-import { mapGetters } from 'vuex'
import DesktopNav from './components/desktop_nav/desktop_nav.vue'
import EditStatusModal from './components/edit_status_modal/edit_status_modal.vue'
import FeaturesPanel from './components/features_panel/features_panel.vue'
import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue'
import MediaModal from './components/media_modal/media_modal.vue'
import MobileNav from './components/mobile_nav/mobile_nav.vue'
import MobilePostStatusButton from './components/mobile_post_status_button/mobile_post_status_button.vue'
import NavPanel from './components/nav_panel/nav_panel.vue'
import PostStatusModal from './components/post_status_modal/post_status_modal.vue'
import ShoutPanel from './components/shout_panel/shout_panel.vue'
import SideDrawer from './components/side_drawer/side_drawer.vue'
import StatusHistoryModal from './components/status_history_modal/status_history_modal.vue'
import UserPanel from './components/user_panel/user_panel.vue'
import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue'
import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
import { getOrCreateServiceWorker } from './services/sw/sw'
import { windowHeight, windowWidth } from './services/window_utils/window_utils'
+import { useEmojiStore } from 'src/stores/emoji.js'
+import { useI18nStore } from 'src/stores/i18n.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useShoutStore } from 'src/stores/shout.js'
+import messages from 'src/i18n/messages'
+import localeService from 'src/services/locale/locale.service.js'
+
+// Helper to unwrap reactive proxies
+window.toValue = (x) => JSON.parse(JSON.stringify(x))
+
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() {
// Load the locale from the storage
- const val = this.$store.getters.mergedConfig.interfaceLanguage
- this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
+ const value = useMergedConfigStore().mergedConfig.interfaceLanguage
+ useI18nStore().setLanguage(value)
+ useEmojiStore().loadUnicodeEmojiData(value)
+
document.getElementById('modal').classList = ['-' + this.layoutType]
// 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 (this.themeApplied) {
this.setThemeBodyClass()
this.removeSplash()
}
getOrCreateServiceWorker()
},
unmounted() {
window.removeEventListener('resize', this.updateMobileState)
this.scrollParent.removeEventListener('scroll', this.updateScrollState)
},
computed: {
currentTheme() {
if (this.styleDataUsed) {
const styleMeta = this.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 } = this.$store.getters.mergedConfig
+ const { navbarColumnStretch } = useMergedConfigStore().mergedConfig
return [
'-' + this.layoutType,
...(navbarColumnStretch ? ['-column-stretch'] : []),
]
},
currentUser() {
return this.$store.state.users.currentUser
},
userBackground() {
return this.currentUser.background_image
},
instanceBackground() {
- return this.mergedConfig.hideInstanceWallpaper ? null : this.background
+ return useMergedConfigStore().mergedConfig.hideInstanceWallpaper
+ ? null
+ : this.instanceBackgroundUrl
},
background() {
return this.userBackground || this.instanceBackground
},
bgStyle() {
if (this.background) {
return {
'--body-background-image': `url(${this.background})`,
}
}
},
shoutJoined() {
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.$store.getters.mergedConfig.alwaysShowNewPostButton ||
+ useMergedConfigStore().mergedConfig.alwaysShowNewPostButton ||
this.layoutType === 'mobile'
)
},
shoutboxPosition() {
- return this.$store.getters.mergedConfig.alwaysShowNewPostButton || false
+ return (
+ useMergedConfigStore().mergedConfig.alwaysShowNewPostButton || false
+ )
},
hideShoutbox() {
- return this.$store.getters.mergedConfig.hideShoutbox
+ return useMergedConfigStore().mergedConfig.hideShoutbox
},
reverseLayout() {
const { thirdColumnMode, sidebarRight: reverseSetting } =
- this.$store.getters.mergedConfig
+ useMergedConfigStore().mergedConfig
if (this.layoutType !== 'wide') {
return reverseSetting
} else {
return thirdColumnMode === 'notifications'
? reverseSetting
: !reverseSetting
}
},
noSticky() {
- return this.$store.getters.mergedConfig.disableStickyHeaders
+ return useMergedConfigStore().mergedConfig.disableStickyHeaders
},
showScrollbars() {
- return this.$store.getters.mergedConfig.showScrollbars
+ return useMergedConfigStore().mergedConfig.showScrollbars
},
scrollParent() {
return window /* this.$refs.appContentRef */
},
showInstanceSpecificPanel() {
return (
this.instanceSpecificPanelPresent &&
- !this.$store.getters.mergedConfig.hideISP
+ !useMergedConfigStore().mergedConfig.hideISP
)
},
- ...mapGetters(['mergedConfig']),
+ ...mapState(useMergedConfigStore, ['mergedConfig']),
...mapState(useInterfaceStore, [
'themeApplied',
'styleDataUsed',
'layoutType',
]),
...mapState(useInstanceStore, ['styleDataUsed']),
...mapState(useInstanceCapabilitiesStore, [
'suggestionsEnabled',
'editingAvailable',
]),
...mapState(useInstanceStore, {
- background: (store) => store.instanceIdentity.background,
+ instanceBackgroundUrl: (store) => store.instanceIdentity.background,
showFeaturesPanel: (store) => store.instanceIdentity.showFeaturesPanel,
instanceSpecificPanelPresent: (store) =>
store.instanceIdentity.showInstanceSpecificPanel &&
store.instanceIdentity.instanceSpecificPanelContent,
}),
},
methods: {
resizeHandler() {
useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().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')
},
},
}
diff --git a/src/App.scss b/src/App.scss
index 1eee63a7e0..b36e702d5a 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -1,1108 +1,1111 @@
// stylelint-disable rscss/class-format
/* stylelint-disable no-descending-specificity */
@use "panel";
@import '@fortawesome/fontawesome-svg-core/styles.css';
@import '@kazvmoe-infra/pinch-zoom-element/dist/pinch-zoom.css';
:root {
--status-margin: 0.75em;
--post-line-height: 1.4;
// Z-Index stuff
--ZI_media_modal: 9000;
--ZI_modals_popovers: 8500;
--ZI_modals: 8000;
--ZI_navbar_popovers: 7500;
--ZI_navbar: 7000;
--ZI_popovers: 6000;
// Fallback for when stuff is loading
--background: var(--bg);
}
html {
font-size: var(--textSize, 1rem);
--navbar-height: var(--navbarSize, 3.5rem);
--emoji-size: var(--emojiSize, 32px);
--panel-header-height: var(--panelHeaderSize, 3.2rem);
// overflow-x: clip causes my browser's tab to crash with SIGILL lul
}
body {
font-family: sans-serif;
font-family: var(--font);
margin: 0;
padding: 0;
color: var(--text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overscroll-behavior-y: none;
overflow: clip scroll;
&.hidden {
display: none;
}
}
// ## Custom scrollbars
// Only show custom scrollbars on devices which
// have a cursor/pointer to operate them
@media (any-pointer: fine) {
* {
scrollbar-color: var(--fg) transparent;
&::-webkit-scrollbar {
background: transparent;
}
&::-webkit-scrollbar-corner {
background: transparent;
}
&::-webkit-resizer {
/* stylelint-disable-next-line declaration-no-important */
background-color: transparent !important;
background-image:
linear-gradient(
135deg,
transparent calc(50% - 1px),
var(--textFaint) 50%,
transparent calc(50% + 1px),
transparent calc(75% - 1px),
var(--textFaint) 75%,
transparent calc(75% + 1px),
);
}
&::-webkit-scrollbar-button,
&::-webkit-scrollbar-thumb {
box-shadow: var(--shadow);
border-radius: var(--roundness);
}
// horizontal/vertical/increment/decrement are webkit-specific stuff
// that indicates whether we're affecting vertical scrollbar, increase button etc
// stylelint-disable selector-pseudo-class-no-unknown
&::-webkit-scrollbar-button {
--___bgPadding: 2px;
color: var(--text);
background-repeat: no-repeat, no-repeat;
&:horizontal {
background-size: 50% calc(50% - var(--___bgPadding)), 50% calc(50% - var(--___bgPadding));
&:increment {
background-image:
linear-gradient(45deg, var(--text) 50%, transparent 51%),
linear-gradient(-45deg, transparent 50%, var(--text) 51%);
background-position: top var(--___bgPadding) left 50%, right 50% bottom var(--___bgPadding);
}
&:decrement {
background-image:
linear-gradient(45deg, transparent 50%, var(--text) calc(50% + 1px)),
linear-gradient(-45deg, var(--text) 50%, transparent 51%);
background-position: bottom var(--___bgPadding) right 50%, left 50% top var(--___bgPadding);
}
}
&:vertical {
background-size: calc(50% - var(--___bgPadding)) 50%, calc(50% - var(--___bgPadding)) 50%;
&:increment {
background-image:
linear-gradient(-45deg, transparent 50%, var(--text) 51%),
linear-gradient(45deg, transparent 50%, var(--text) 51%);
background-position: right var(--___bgPadding) top 50%, left var(--___bgPadding) top 50%;
}
&:decrement {
background-image:
linear-gradient(-45deg, var(--text) 50%, transparent 51%),
linear-gradient(45deg, var(--text) 50%, transparent 51%);
background-position: left var(--___bgPadding) top 50%, right var(--___bgPadding) top 50%;
}
}
}
// stylelint-enable selector-pseudo-class-no-unknown
}
// Body should have background to scrollbar otherwise it will use white (body color?)
html {
scrollbar-color: var(--fg) var(--wallpaper);
background: var(--wallpaper);
}
}
a {
text-decoration: none;
color: var(--link);
}
h4 {
margin: 0;
}
.iconLetter {
display: inline-block;
text-align: center;
font-weight: 1000;
}
i[class*="icon-"],
.svg-inline--fa,
.iconLetter {
color: var(--icon);
}
nav {
z-index: var(--ZI_navbar);
box-shadow: var(--shadow);
box-sizing: border-box;
height: var(--navbar-height);
font-size: calc(var(--navbar-height) / 3.5);
position: fixed;
}
#sidebar {
grid-area: sidebar;
}
#modal {
position: absolute;
z-index: var(--ZI_modals);
}
.column.-scrollable {
top: var(--navbar-height);
position: sticky;
}
#main-scroller {
grid-area: content;
position: relative;
}
#notifs-column {
grid-area: notifs;
}
.app-bg-wrapper {
position: fixed;
height: 100%;
top: var(--navbar-height);
z-index: -1000;
left: 0;
right: -20px;
background-size: cover;
background-repeat: no-repeat;
background-color: var(--wallpaper);
background-image: var(--body-background-image);
background-position: 50%;
}
.underlay {
grid-column: 1 / span 3;
grid-row: 1 / 1;
pointer-events: none;
background-color: var(--underlay);
z-index: -1000;
}
.app-layout {
--miniColumn: 25rem;
--maxiColumn: 45rem;
--columnGap: 1rem;
--effectiveSidebarColumnWidth: minmax(var(--miniColumn), var(--sidebarColumnWidth, var(--miniColumn)));
--effectiveNotifsColumnWidth: minmax(var(--miniColumn), var(--notifsColumnWidth, var(--miniColumn)));
--effectiveContentColumnWidth: minmax(var(--miniColumn), var(--contentColumnWidth, var(--maxiColumn)));
position: relative;
display: grid;
grid-template-columns:
var(--effectiveSidebarColumnWidth)
var(--effectiveContentColumnWidth);
grid-template-areas: "sidebar content";
grid-template-rows: 1fr;
box-sizing: border-box;
margin: 0 auto;
place-content: flex-start center;
flex-wrap: wrap;
min-height: 100vh;
overflow-x: clip;
.column {
--___columnMargin: var(--columnGap);
display: grid;
grid-template-columns: 100%;
box-sizing: border-box;
grid-row: 1 / 1;
margin: 0 calc(var(--___columnMargin) / 2);
padding: calc(var(--___columnMargin)) 0;
row-gap: var(--___columnMargin);
align-content: start;
&:not(.-scrollable) {
margin-top: var(--navbar-height);
}
&:hover {
z-index: 2;
}
&.-full-height {
margin-bottom: 0;
padding-top: 0;
padding-bottom: 0;
}
&.-scrollable {
--___paddingIncrease: calc(var(--columnGap) / 2);
position: sticky;
top: var(--navbar-height);
max-height: calc(100vh - var(--navbar-height));
overflow: hidden auto;
margin-left: calc(var(--___paddingIncrease) * -1);
padding-left: calc(var(--___paddingIncrease) + var(--___columnMargin) / 2);
// On browsers that don't support hiding scrollbars we enforce "show scrolbars" mode
// might implement old style of hiding scrollbars later if there's demand
@supports (scrollbar-width: none) or (-webkit-text-fill-color: initial) {
&:not(.-show-scrollbar) {
scrollbar-width: none;
margin-right: calc(var(--___paddingIncrease) * -1);
padding-right: calc(var(--___paddingIncrease) + var(--___columnMargin) / 2);
&::-webkit-scrollbar {
display: block;
width: 0;
}
}
}
.panel-heading.-sticky {
top: calc(var(--columnGap) / -1);
}
}
}
&.-has-new-post-button {
.column {
padding-bottom: 10rem;
}
}
&.-no-sticky-headers {
.column {
.panel-heading.-sticky {
position: relative;
top: 0;
}
}
}
.column-inner {
display: grid;
grid-template-columns: 100%;
box-sizing: border-box;
row-gap: 1em;
align-content: start;
}
&.-reverse:not(.-wide, .-mobile) {
grid-template-columns:
var(--effectiveContentColumnWidth)
var(--effectiveSidebarColumnWidth);
grid-template-areas: "content sidebar";
}
&.-wide {
grid-template-columns:
var(--effectiveSidebarColumnWidth)
var(--effectiveContentColumnWidth)
var(--effectiveNotifsColumnWidth);
grid-template-areas: "sidebar content notifs";
&.-reverse {
grid-template-columns:
var(--effectiveNotifsColumnWidth)
var(--effectiveContentColumnWidth)
var(--effectiveSidebarColumnWidth);
grid-template-areas: "notifs content sidebar";
}
}
&.-mobile {
grid-template-columns: 100vw;
grid-template-areas: "content";
padding: 0;
.column {
padding-top: 0;
margin: var(--navbar-height) 0 0 0;
}
.panel-heading,
.panel-heading::after,
.panel-heading::before,
.panel,
.panel::after {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
#sidebar,
#notifs-column {
display: none;
}
}
&.-normal {
#notifs-column {
display: none;
}
}
}
.text-center {
text-align: center;
}
.button-default {
user-select: none;
color: var(--text);
border: none;
cursor: pointer;
background-color: var(--background);
box-shadow: var(--shadow);
font-size: 1em;
font-family: sans-serif;
font-family: var(--font);
&.-transparent {
backdrop-filter: blur(0.125em) contrast(60%);
}
&::-moz-focus-inner {
border: none;
}
&:disabled {
cursor: not-allowed;
}
&:active {
transform: translate(1px, 1px);
}
}
.menu-item {
line-height: var(--__line-height);
font-family: inherit;
font-weight: 400;
font-size: 100%;
cursor: pointer;
a,
button:not(.button-default) {
color: var(--text);
font-size: 100%;
}
&.disabled {
cursor: not-allowed;
}
}
.list-item {
border-color: var(--border);
border-style: solid;
border-width: 0;
border-top-width: 1px;
&.-active,
&:hover {
border-top-width: 1px;
border-bottom-width: 1px;
}
&.-active + &,
&:hover + & {
border-top-width: 0;
}
&:hover + .menu-item-collapsible:not(.-expanded) + &,
&.-active + .menu-item-collapsible:not(.-expanded) + & {
border-top-width: 0;
}
&[aria-expanded="true"] {
border-bottom-width: 1px;
}
&:first-child {
border-top-right-radius: var(--roundness);
border-top-left-radius: var(--roundness);
border-top-width: 0;
}
&:last-child {
border-bottom-right-radius: var(--roundness);
border-bottom-left-radius: var(--roundness);
border-bottom-width: 0;
}
}
.menu-item,
.list-item {
display: block;
box-sizing: border-box;
border: none;
outline: none;
text-align: initial;
color: inherit;
clear: both;
position: relative;
white-space: nowrap;
width: 100%;
padding: var(--__vertical-gap) var(--__horizontal-gap);
background: transparent;
--__line-height: 1.5em;
--__horizontal-gap: 0.75em;
--__vertical-gap: 0.5em;
&.-non-interactive {
cursor: auto;
}
a,
button:not(.button-default) {
text-align: initial;
padding: 0;
background: none;
border: none;
outline: none;
display: inline;
font-family: inherit;
line-height: unset;
}
}
.button-unstyled {
border: none;
outline: none;
display: inline;
text-align: initial;
font-size: 100%;
font-family: inherit;
box-shadow: var(--shadow);
background-color: transparent;
padding: 0;
line-height: unset;
cursor: pointer;
box-sizing: content-box;
color: inherit;
&.-link {
/* stylelint-disable-next-line declaration-no-important */
color: var(--link) !important;
}
}
label {
&.-disabled {
color: var(--textFaint);
}
}
input,
textarea {
border: none;
display: inline-block;
outline: none;
}
.input {
&.unstyled {
border-radius: 0;
/* stylelint-disable-next-line declaration-no-important */
background: none !important;
box-shadow: none;
height: unset;
}
&::placeholder {
color: var(--textFaint)
}
--_padding: 0.5em;
border: none;
background-color: var(--background);
color: var(--text);
box-shadow: var(--shadow);
font-family: var(--font);
font-size: 1em;
margin: 0;
box-sizing: border-box;
display: inline-block;
position: relative;
line-height: 2;
hyphens: none;
padding: 0 var(--_padding);
&:disabled,
&[disabled="disabled"],
&.disabled {
cursor: not-allowed;
color: var(--textFaint);
/* stylelint-disable-next-line declaration-no-important */
background-color: transparent !important;
}
&[type="range"] {
background: none;
border: none;
margin: 0;
box-shadow: none;
flex: 1;
}
&[type="radio"] {
display: none;
&:checked + label::before {
box-shadow: var(--shadow);
background-color: var(--background);
color: var(--text);
}
&:disabled {
&,
& + label,
& + label::before {
opacity: 0.5;
}
background-color: var(--background);
}
+ label::before {
flex-shrink: 0;
display: inline-block;
content: "•";
transition: box-shadow 200ms;
width: 1.1em;
height: 1.1em;
border-radius: 100%; // Radio buttons should always be circle
background-color: var(--background);
box-shadow: var(--shadow);
margin-right: 0.5em;
vertical-align: top;
text-align: center;
line-height: 1.1;
font-size: 1.1em;
box-sizing: border-box;
color: transparent;
overflow: hidden;
}
}
&[type="checkbox"] {
&:checked + label::before {
color: var(--text);
background-color: var(--background);
box-shadow: var(--shadow);
}
&:disabled {
&,
& + label,
& + label::before {
opacity: 0.5;
}
}
+ label::before {
flex-shrink: 0;
display: inline-block;
content: "✓";
transition: color 200ms;
width: 1.1em;
height: 1.1em;
border-radius: var(--roundness);
box-shadow: var(--shadow);
margin-right: 0.5em;
vertical-align: top;
text-align: center;
line-height: 1.1;
font-size: 1.1em;
box-sizing: border-box;
color: transparent;
overflow: hidden;
}
}
&.resize-height {
resize: vertical;
}
}
.input,
.button-default {
--_roundness-left: var(--roundness);
--_roundness-right: var(--roundness);
border-top-left-radius: var(--_roundness-left);
border-bottom-left-radius: var(--_roundness-left);
border-top-right-radius: var(--_roundness-right);
border-bottom-right-radius: var(--_roundness-right);
}
// Textareas should have stock line-height + vertical padding instead of huge line-height
textarea.input {
padding: var(--_padding);
line-height: var(--post-line-height);
}
option {
color: var(--text);
background-color: var(--background);
}
.hide-number-spinner {
appearance: textfield;
&[type="number"]::-webkit-inner-spin-button,
&[type="number"]::-webkit-outer-spin-button {
opacity: 0;
display: none;
}
}
.cards-list {
list-style: none;
display: grid;
grid-auto-flow: row dense;
grid-template-columns: repeat(auto-fit, minmax(20em, 1fr));
grid-gap: 0.5em;
li {
border: 1px solid var(--border);
border-radius: var(--roundness);
padding: 0.5em;
margin: 0.25em;
}
}
.btn-group {
position: relative;
display: inline-flex;
vertical-align: middle;
> *,
> * .button-default {
--_roundness-left: 0;
--_roundness-right: 0;
position: relative;
}
> *:first-child,
> *:first-child .button-default {
--_roundness-left: var(--roundness);
}
> *:last-child,
> *:last-child .button-default {
--_roundness-right: var(--roundness);
}
}
.fa {
color: grey;
}
.mobile-shown {
display: none;
}
.badge {
box-sizing: border-box;
display: inline-block;
border-radius: 99px;
max-width: 10em;
min-width: 1.7em;
height: 1.3em;
padding: 0.15em;
vertical-align: middle;
font-weight: normal;
font-style: normal;
font-size: 0.9em;
line-height: 1;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.-dot,
&.-counter {
margin: 0;
position: absolute;
}
&.-dot {
min-height: 0.6em;
max-height: 0.6em;
min-width: 0.6em;
max-width: 0.6em;
left: calc(50% + 0.5em);
top: calc(50% - 1em);
line-height: 0;
padding: 0;
margin: 0;
}
&.-counter {
border-radius: var(--roundness);
font-size: 0.75em;
line-height: 1;
text-align: right;
padding: 0.2em;
min-width: 0;
left: calc(50% - 0.5em);
top: calc(50% - 0.4em);
margin-left: 0.7em;
margin-top: -1em;
}
}
.alert {
margin: 0 0.35em;
padding: 0 0.25em;
border-radius: var(--roundness);
border: 1px solid var(--border);
}
.faint {
--text: var(--textFaint);
--link: var(--linkFaint);
color: var(--text);
}
.notice-dismissible {
- padding-right: 4rem;
- position: relative;
+ display: flex;
+ padding: 0.75em 1em;
+ align-items: baseline;
+ line-height: 1.5;
+
+ span {
+ display: block;
+ flex: 1 1 auto;
+ }
.dismiss {
- position: absolute;
- top: 0;
- right: 0;
- padding: 0.5em;
color: inherit;
}
}
.fa-scale-110 {
&.svg-inline--fa,
&.iconLetter {
font-size: 1.1em;
}
&.svg-inline--fa {
vertical-align: -0.15em;
}
}
.fa-old-padding {
&.iconLetter,
&.svg-inline--fa,
&-layer {
padding: 0 0.3em;
}
}
.veryfaint {
opacity: 0.25;
}
.timeago {
--link: var(--text);
--linkFaint: var(--textFaint);
}
.login-hint {
text-align: center;
@media all and (width >= 801px) {
display: none;
}
a {
display: inline-block;
padding: 1em 0;
width: 100%;
}
}
.btn.button-default {
min-height: 2em;
}
.new-status-notification {
position: relative;
font-size: 1.1em;
z-index: 1;
flex: 1;
}
@media all and (width <= 800px) {
.mobile-hidden {
display: none;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(359deg);
}
}
@keyframes shakeError {
0% {
transform: translateX(0);
}
15% {
transform: translateX(0.375rem);
}
30% {
transform: translateX(-0.375rem);
}
45% {
transform: translateX(0.375rem);
}
60% {
transform: translateX(-0.375rem);
}
75% {
transform: translateX(0.375rem);
}
90% {
transform: translateX(-0.375rem);
}
100% {
transform: translateX(0);
}
}
// Vue transitions
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-active {
opacity: 0;
}
/* stylelint-enable no-descending-specificity */
.visible-for-screenreader-only {
display: block;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
visibility: visible;
clip: rect(0 0 0 0);
padding: 0;
position: absolute;
}
*::selection {
color: var(--selectionText);
background-color: var(--selectionBackground);
}
#splash {
pointer-events: none;
// transition: opacity 0.5s;
#status {
&.css-ok {
&::before {
display: inline-block;
content: "CSS OK";
}
}
.initial-text {
display: none;
}
}
#throbber {
animation-duration: 3s;
animation-name: bounce;
animation-iteration-count: infinite;
animation-direction: normal;
transform-origin: bottom center;
&.dead {
animation-name: dead;
animation-duration: 0.5s;
animation-iteration-count: 1;
transform: rotateX(90deg) rotateY(0) rotateZ(-45deg);
}
@keyframes dead {
0% {
transform: rotateX(0) rotateY(0) rotateZ(0);
}
5% {
transform: rotateX(0) rotateY(0) rotateZ(1deg);
}
10% {
transform: rotateX(0) rotateY(0) rotateZ(-2deg);
}
15% {
transform: rotateX(0) rotateY(0) rotateZ(3deg);
}
20% {
transform: rotateX(0) rotateY(0) rotateZ(0);
}
25% {
transform: rotateX(0) rotateY(0) rotateZ(0);
}
30% {
transform: rotateX(10deg) rotateY(0) rotateZ(0);
}
35% {
transform: rotateX(-10deg) rotateY(0) rotateZ(0);
}
40% {
transform: rotateX(10deg) rotateY(0) rotateZ(0);
}
45% {
transform: rotateX(-10deg) rotateY(0) rotateZ(0);
}
50% {
transform: rotateX(10deg) rotateY(0) rotateZ(0);
}
100% {
transform: rotateX(90deg) rotateY(0) rotateZ(-45deg);
transition-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06); /* easeInQuint */
}
}
@keyframes bounce {
0% {
scale: 1 1;
translate: 0 0;
animation-timing-function: ease-out;
}
10% {
scale: 1.2 0.8;
translate: 0 0;
transform: rotateZ(var(--defaultZ));
animation-timing-function: ease-out;
}
30% {
scale: 0.9 1.1;
translate: 0 -40%;
transform: rotateZ(var(--defaultZ));
animation-timing-function: ease-in;
}
40% {
scale: 1.1 0.9;
translate: 0 -50%;
transform: rotateZ(var(--defaultZ));
animation-timing-function: ease-in;
}
45% {
scale: 0.9 1.1;
translate: 0 -45%;
transform: rotateZ(var(--defaultZ));
animation-timing-function: ease-in;
}
50% {
scale: 1.05 0.95;
translate: 0 -40%;
animation-timing-function: ease-in;
}
55% {
scale: 0.985 1.025;
translate: 0 -35%;
transform: rotateZ(var(--defaultZ));
animation-timing-function: ease-in;
}
60% {
scale: 1.0125 0.9985;
translate: 0 -30%;
transform: rotateZ(var(--defaultZ));
animation-timing-function: ease-in;
}
80% {
scale: 1.0063 0.9938;
translate: 0 -10%;
transform: rotateZ(var(--defaultZ));
animation-timing-function: ease-in-out;
}
90% {
scale: 1.2 0.8;
translate: 0 0;
transform: rotateZ(var(--defaultZ));
animation-timing-function: ease-out;
}
100% {
scale: 1 1;
translate: 0 0;
transform: rotateZ(var(--defaultZ));
animation-timing-function: ease-out;
}
}
}
}
@property --shadow {
syntax: "*";
inherits: false;
}
diff --git a/src/boot/after_store.js b/src/boot/after_store.js
index 97f9b8a426..d16852bf8a 100644
--- a/src/boot/after_store.js
+++ b/src/boot/after_store.js
@@ -1,606 +1,619 @@
/* global process */
import vClickOutside from 'click-outside-vue3'
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import VueVirtualScroller from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import { config } from '@fortawesome/fontawesome-svg-core'
import {
FontAwesomeIcon,
FontAwesomeLayers,
} from '@fortawesome/vue-fontawesome'
config.autoAddCss = false
import App from '../App.vue'
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import FaviconService from '../services/favicon_service/favicon_service.js'
import { applyStyleConfig } from '../services/style_setter/style_setter.js'
import { initServiceWorker, updateFocus } from '../services/sw/sw.js'
import {
windowHeight,
windowWidth,
} from '../services/window_utils/window_utils'
import routes from './routes'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useAuthFlowStore } from 'src/stores/auth_flow'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useI18nStore } from 'src/stores/i18n'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
+import { useLocalConfigStore } from 'src/stores/local_config.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
+import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import VBodyScrollLock from 'src/directives/body_scroll_lock'
import {
- instanceDefaultConfig,
- staticOrApiConfigDefault,
+ INSTANCE_DEFAULT_CONFIG_DEFINITIONS,
+ INSTANCE_IDENTITY_DEFAULT_DEFINITIONS,
+ INSTANCE_IDENTIY_EXTERNAL,
} from 'src/modules/default_config_state.js'
let staticInitialResults = null
const parsedInitialResults = () => {
if (!document.getElementById('initial-results')) {
return null
}
if (!staticInitialResults) {
staticInitialResults = JSON.parse(
document.getElementById('initial-results').textContent,
)
}
return staticInitialResults
}
const decodeUTF8Base64 = (data) => {
const rawData = atob(data)
const array = Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)))
const text = new TextDecoder().decode(array)
return text
}
const preloadFetch = async (request) => {
const data = parsedInitialResults()
if (!data || !data[request]) {
return window.fetch(request)
}
const decoded = decodeUTF8Base64(data[request])
const requestData = JSON.parse(decoded)
return {
ok: true,
json: () => requestData,
text: () => requestData,
}
}
const getInstanceConfig = async ({ store }) => {
try {
const res = await preloadFetch('/api/v1/instance')
if (res.ok) {
const data = await res.json()
- const textlimit = data.max_toot_chars
+ const textLimit = data.max_toot_chars
const vapidPublicKey = data.pleroma.vapid_public_key
useInstanceCapabilitiesStore().set(
'pleromaExtensionsAvailable',
data.pleroma,
)
useInstanceStore().set({
- path: 'textlimit',
- value: textlimit,
+ path: 'limits.textLimit',
+ value: textLimit,
})
useInstanceStore().set({
path: 'accountApprovalRequired',
value: data.approval_required,
})
useInstanceStore().set({
path: 'birthdayRequired',
value: !!data.pleroma?.metadata.birthday_required,
})
useInstanceStore().set({
path: 'birthdayMinAge',
value: data.pleroma?.metadata.birthday_min_age || 0,
})
if (vapidPublicKey) {
useInstanceStore().set({
path: 'vapidPublicKey',
value: vapidPublicKey,
})
}
} else {
throw res
}
} catch (error) {
console.error('Could not load instance config, potentially fatal')
console.error(error)
}
// We should check for scrobbles support here but it requires userId
// so instead we check for it where it's fetched (statuses.js)
}
const getBackendProvidedConfig = async () => {
try {
const res = await window.fetch('/api/pleroma/frontend_configurations')
if (res.ok) {
const data = await res.json()
return data.pleroma_fe
} else {
throw res
}
} catch (error) {
console.error(
'Could not load backend-provided frontend config, potentially fatal',
)
console.error(error)
}
}
const getStaticConfig = async () => {
try {
const res = await window.fetch('/static/config.json')
if (res.ok) {
return res.json()
} else {
throw res
}
} catch (error) {
console.warn('Failed to load static/config.json, continuing without it.')
console.warn(error)
return {}
}
}
const setSettings = async ({ apiConfig, staticConfig, store }) => {
const overrides = window.___pleromafe_dev_overrides || {}
const env = window.___pleromafe_mode.NODE_ENV
// This takes static config and overrides properties that are present in apiConfig
let config = {}
if (overrides.staticConfigPreference && env === 'development') {
console.warn('OVERRIDING API CONFIG WITH STATIC CONFIG')
config = Object.assign({}, apiConfig, staticConfig)
} else {
config = Object.assign({}, staticConfig, apiConfig)
}
- const copyInstanceOption = ({ source, destination }) => {
- if (typeof config[source] !== 'undefined') {
- useInstanceStore().set({ path: destination, value: config[source] })
- }
- }
+ Object.keys(INSTANCE_IDENTITY_DEFAULT_DEFINITIONS).forEach((source) => {
+ if (source === 'name') return
+ if (INSTANCE_IDENTIY_EXTERNAL.has(source)) return
+ useInstanceStore().set({
+ value: config[source],
+ path: `instanceIdentity.${source}`,
+ })
+ })
- Object.keys(staticOrApiConfigDefault)
- .map((k) => ({ source: k, destination: `instanceIdentity.${k}` }))
- .forEach(copyInstanceOption)
- Object.keys(instanceDefaultConfig)
- .map((k) => ({ source: k, destination: `prefsStorage.${k}` }))
- .forEach(copyInstanceOption)
+ Object.keys(INSTANCE_DEFAULT_CONFIG_DEFINITIONS).forEach((source) =>
+ useInstanceStore().set({
+ value: config[source],
+ path: `prefsStorage.${source}`,
+ }),
+ )
useAuthFlowStore().setInitialStrategy(config.loginMethod)
}
const getTOS = async ({ store }) => {
try {
const res = await window.fetch('/static/terms-of-service.html')
if (res.ok) {
const html = await res.text()
- useInstanceStore().set({ name: 'instanceIdentity.tos', value: html })
+ useInstanceStore().set({ path: 'instanceIdentity.tos', value: html })
} else {
throw res
}
} catch (e) {
console.warn("Can't load TOS\n", e)
}
}
const getInstancePanel = async ({ store }) => {
try {
const res = await preloadFetch('/instance/panel.html')
if (res.ok) {
const html = await res.text()
useInstanceStore().set({
path: 'instanceIdentity.instanceSpecificPanelContent',
value: html,
})
} else {
throw res
}
} catch (e) {
console.warn("Can't load instance panel\n", e)
}
}
const getStickers = async ({ store }) => {
try {
const res = await window.fetch('/static/stickers.json')
if (res.ok) {
const values = await res.json()
const stickers = (
await Promise.all(
Object.entries(values).map(async ([name, path]) => {
const resPack = await window.fetch(path + 'pack.json')
let meta = {}
if (resPack.ok) {
meta = await resPack.json()
}
return {
pack: name,
path,
meta,
}
}),
)
).sort((a, b) => {
return a.meta.title.localeCompare(b.meta.title)
})
useEmojiStore().setStickers(stickers)
} else {
throw res
}
} catch (e) {
console.warn("Can't load stickers\n", e)
}
}
const getAppSecret = async ({ store }) => {
const oauth = useOAuthStore()
if (oauth.userToken) {
store.commit(
'setBackendInteractor',
backendInteractorService(oauth.getToken),
)
}
}
const resolveStaffAccounts = ({ store, accounts }) => {
const nicknames = accounts.map((uri) => uri.split('/').pop())
useInstanceStore().set({
path: 'staffAccounts',
value: nicknames,
})
}
const getNodeInfo = async ({ store }) => {
try {
let res = await preloadFetch('/nodeinfo/2.1.json')
if (!res.ok) res = await preloadFetch('/nodeinfo/2.0.json')
if (res.ok) {
const data = await res.json()
const metadata = data.metadata
const features = metadata.features
useInstanceStore().set({
- path: 'name',
+ path: 'instanceIdentity.name',
value: metadata.nodeName,
})
useInstanceStore().set({
path: 'registrationOpen',
value: data.openRegistrations,
})
useInstanceCapabilitiesStore().set(
'mediaProxyAvailable',
features.includes('media_proxy'),
)
useInstanceCapabilitiesStore().set(
'safeDM',
features.includes('safe_dm_mentions'),
)
useInstanceCapabilitiesStore().set(
'shoutAvailable',
features.includes('chat'),
)
useInstanceCapabilitiesStore().set(
'pleromaChatMessagesAvailable',
features.includes('pleroma_chat_messages'),
)
useInstanceCapabilitiesStore().set(
'pleromaCustomEmojiReactionsAvailable',
features.includes('pleroma_custom_emoji_reactions') ||
features.includes('custom_emoji_reactions'),
)
useInstanceCapabilitiesStore().set(
'pleromaBookmarkFoldersAvailable',
features.includes('pleroma:bookmark_folders'),
)
useInstanceCapabilitiesStore().set(
'gopherAvailable',
features.includes('gopher'),
)
useInstanceCapabilitiesStore().set(
'pollsAvailable',
features.includes('polls'),
)
useInstanceCapabilitiesStore().set(
'editingAvailable',
features.includes('editing'),
)
useInstanceCapabilitiesStore().set(
'mailerEnabled',
metadata.mailerEnabled,
)
useInstanceCapabilitiesStore().set(
'quotingAvailable',
features.includes('quote_posting'),
)
useInstanceCapabilitiesStore().set(
'groupActorAvailable',
features.includes('pleroma:group_actors'),
)
useInstanceCapabilitiesStore().set(
'blockExpiration',
features.includes('pleroma:block_expiration'),
)
useInstanceStore().set({
path: 'localBubbleInstances',
value: metadata.localBubbleInstances ?? [],
})
useInstanceCapabilitiesStore().set(
'localBubble',
(metadata.localBubbleInstances ?? []).length > 0,
)
useInstanceStore().set({
path: 'limits.pollLimits',
value: metadata.pollLimits,
})
const uploadLimits = metadata.uploadLimits
useInstanceStore().set({
path: 'limits.uploadlimit',
value: parseInt(uploadLimits.general),
})
useInstanceStore().set({
path: 'limits.avatarlimit',
value: parseInt(uploadLimits.avatar),
})
useInstanceStore().set({
path: 'limits.backgroundlimit',
value: parseInt(uploadLimits.background),
})
useInstanceStore().set({
path: 'limits.bannerlimit',
value: parseInt(uploadLimits.banner),
})
useInstanceStore().set({
path: 'limits.fieldsLimits',
value: metadata.fieldsLimits,
})
useInstanceStore().set({
path: 'restrictedNicknames',
value: metadata.restrictedNicknames,
})
useInstanceCapabilitiesStore().set('postFormats', metadata.postFormats)
const suggestions = metadata.suggestions
useInstanceCapabilitiesStore().set(
'suggestionsEnabled',
suggestions.enabled,
)
// this is unused, why?
useInstanceCapabilitiesStore().set('suggestionsWeb', suggestions.web)
const software = data.software
useInstanceStore().set({
path: 'backendVersion',
value: software.version,
})
useInstanceStore().set({
path: 'backendRepository',
value: software.repository,
})
const priv = metadata.private
useInstanceStore().set({ path: 'privateMode', value: priv })
const frontendVersion = window.___pleromafe_commit_hash
useInstanceStore().set({
path: 'frontendVersion',
value: frontendVersion,
})
const federation = metadata.federation
useInstanceCapabilitiesStore().set(
'tagPolicyAvailable',
typeof federation.mrf_policies === 'undefined'
? false
: metadata.federation.mrf_policies.includes('TagPolicy'),
)
useInstanceStore().set({
path: 'federationPolicy',
value: federation,
})
useInstanceStore().set({
path: 'federating',
value:
typeof federation.enabled === 'undefined' ? true : federation.enabled,
})
const accountActivationRequired = metadata.accountActivationRequired
useInstanceStore().set({
path: 'accountActivationRequired',
value: accountActivationRequired,
})
const accounts = metadata.staffAccounts
resolveStaffAccounts({ store, accounts })
} else {
throw res
}
} catch (e) {
console.warn('Could not load nodeinfo')
console.warn(e)
}
}
const setConfig = async ({ store }) => {
// apiConfig, staticConfig
const configInfos = await Promise.all([
getBackendProvidedConfig({ store }),
getStaticConfig(),
])
const apiConfig = configInfos[0]
const staticConfig = configInfos[1]
getAppSecret({ store })
await setSettings({ store, apiConfig, staticConfig })
}
const checkOAuthToken = async ({ store }) => {
const oauth = useOAuthStore()
if (oauth.getUserToken) {
return store.dispatch('loginUser', oauth.getUserToken)
}
return Promise.resolve()
}
const afterStoreSetup = async ({ pinia, store, storageError, i18n }) => {
const app = createApp(App)
// Must have app use pinia before we do anything that touches the store
// https://pinia.vuejs.org/core-concepts/plugins.html#Introduction
// "Plugins are only applied to stores created after the plugins themselves, and after pinia is passed to the app, otherwise they won't be applied."
app.use(pinia)
const waitForAllStoresToLoad = async () => {
// the stores that do not persist technically do not need to be awaited here,
// but that involves either hard-coding the stores in some place (prone to errors)
// or writing another vite plugin to analyze which stores needs persisting (++load time)
const allStores = import.meta.glob('../stores/*.js', { eager: true })
if (process.env.NODE_ENV === 'development') {
// do some checks to avoid common errors
if (!Object.keys(allStores).length) {
throw new Error(
'No stores are available. Check the code in src/boot/after_store.js',
)
}
}
await Promise.all(
Object.entries(allStores).map(async ([name, mod]) => {
const isStoreName = (name) => name.startsWith('use')
if (process.env.NODE_ENV === 'development') {
if (Object.keys(mod).filter(isStoreName).length !== 1) {
throw new Error(
'Each store file must export exactly one store as a named export. Check your code in src/stores/',
)
}
}
const storeFuncName = Object.keys(mod).find(isStoreName)
if (storeFuncName && typeof mod[storeFuncName] === 'function') {
const p = mod[storeFuncName]().$persistLoaded
if (!(p instanceof Promise)) {
throw new Error(
`${name} store's $persistLoaded is not a Promise. The persist plugin is not applied.`,
)
}
await p
} else {
throw new Error(
`Store module ${name} does not export a 'use...' function`,
)
}
}),
)
}
try {
await waitForAllStoresToLoad()
} catch (e) {
console.error('Cannot load stores:', e)
storageError = e
}
if (storageError) {
useInterfaceStore().pushGlobalNotice({
messageKey: 'errors.storage_unavailable',
level: 'error',
})
}
useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight())
+ window.syncConfig = useSyncConfigStore()
+ window.mergedConfig = useMergedConfigStore()
+ window.localConfig = useLocalConfigStore()
+ window.highlightConfig = useUserHighlightStore()
+
FaviconService.initFaviconService()
initServiceWorker(store)
window.addEventListener('focus', () => updateFocus())
const overrides = window.___pleromafe_dev_overrides || {}
const server =
typeof overrides.target !== 'undefined'
? overrides.target
: window.location.origin
- useInstanceStore().set({ name: 'server', value: server })
+ useInstanceStore().set({ path: 'server', value: server })
await setConfig({ store })
try {
await useInterfaceStore()
.applyTheme()
.catch((e) => {
console.error('Error setting theme', e)
})
} catch (e) {
window.splashError(e)
return Promise.reject(e)
}
- applyStyleConfig(store.state.config, i18n.global)
+ applyStyleConfig(useMergedConfigStore().mergedConfig, i18n.global)
// Now we can try getting the server settings and logging in
// Most of these are preloaded into the index.html so blocking is minimized
await Promise.all([
checkOAuthToken({ store }),
getInstancePanel({ store }),
getNodeInfo({ store }),
getInstanceConfig({ store }),
]).catch((e) => Promise.reject(e))
// Start fetching things that don't need to block the UI
store.dispatch('fetchMutes')
store.dispatch('loadDrafts')
useAnnouncementsStore().startFetchingAnnouncements()
getTOS({ store })
getStickers({ store })
const router = createRouter({
history: createWebHistory(),
routes: routes(store),
scrollBehavior: (to, _from, savedPosition) => {
if (to.matched.some((m) => m.meta.dontScroll)) {
return false
}
return savedPosition || { left: 0, top: 0 }
},
})
useI18nStore().setI18n(i18n)
app.use(router)
app.use(store)
app.use(i18n)
// Little thing to get out of invalid theme state
window.resetThemes = () => {
useInterfaceStore().resetThemeV3()
useInterfaceStore().resetThemeV3Palette()
useInterfaceStore().resetThemeV2()
}
app.use(vClickOutside)
app.use(VBodyScrollLock)
app.use(VueVirtualScroller)
app.component('FAIcon', FontAwesomeIcon)
app.component('FALayers', FontAwesomeLayers)
// remove after vue 3.3
app.config.unwrapInjectedRef = true
app.mount('#app')
return app
}
export default afterStoreSetup
diff --git a/src/components/about/about.js b/src/components/about/about.js
index f52d5c7978..404843e8bc 100644
--- a/src/components/about/about.js
+++ b/src/components/about/about.js
@@ -1,31 +1,32 @@
import FeaturesPanel from '../features_panel/features_panel.vue'
import InstanceSpecificPanel from '../instance_specific_panel/instance_specific_panel.vue'
import MRFTransparencyPanel from '../mrf_transparency_panel/mrf_transparency_panel.vue'
import StaffPanel from '../staff_panel/staff_panel.vue'
import TermsOfServicePanel from '../terms_of_service_panel/terms_of_service_panel.vue'
import { useInstanceStore } from 'src/stores/instance.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
const About = {
components: {
InstanceSpecificPanel,
FeaturesPanel,
TermsOfServicePanel,
StaffPanel,
MRFTransparencyPanel,
},
computed: {
showFeaturesPanel() {
return useInstanceStore().instanceIdentity.showFeaturesPanel
},
showInstanceSpecificPanel() {
return (
useInstanceStore().instanceIdentity.showInstanceSpecificPanel &&
- !this.$store.getters.mergedConfig.hideISP &&
+ !useMergedConfigStore().mergedConfig.hideISP &&
useInstanceStore().instanceIdentity.instanceSpecificPanelContent
)
},
},
}
export default About
diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js
index 8fce4b5af2..f204adbdea 100644
--- a/src/components/account_actions/account_actions.js
+++ b/src/components/account_actions/account_actions.js
@@ -1,104 +1,105 @@
import { mapState } from 'pinia'
import UserListMenu from 'src/components/user_list_menu/user_list_menu.vue'
import UserTimedFilterModal from 'src/components/user_timed_filter_modal/user_timed_filter_modal.vue'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import Popover from '../popover/popover.vue'
import ProgressButton from '../progress_button/progress_button.vue'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useReportsStore } from 'src/stores/reports'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faEllipsisV } from '@fortawesome/free-solid-svg-icons'
library.add(faEllipsisV)
const AccountActions = {
props: ['user', 'relationship'],
data() {
return {
showingConfirmBlock: false,
showingConfirmRemoveFollower: false,
}
},
components: {
ProgressButton,
Popover,
UserListMenu,
ConfirmModal,
UserTimedFilterModal,
},
methods: {
showConfirmRemoveUserFromFollowers() {
this.showingConfirmRemoveFollower = true
},
hideConfirmRemoveUserFromFollowers() {
this.showingConfirmRemoveFollower = false
},
hideConfirmBlock() {
this.showingConfirmBlock = false
},
showRepeats() {
this.$store.dispatch('showReblogs', this.user.id)
},
hideRepeats() {
this.$store.dispatch('hideReblogs', this.user.id)
},
blockUser() {
if (this.$refs.timedBlockDialog) {
this.$refs.timedBlockDialog.optionallyPrompt()
} else {
if (!this.shouldConfirmBlock) {
this.doBlockUser()
} else {
this.showingConfirmBlock = true
}
}
},
doBlockUser() {
this.$store.dispatch('blockUser', { id: this.user.id })
this.hideConfirmBlock()
},
unblockUser() {
this.$store.dispatch('unblockUser', this.user.id)
},
removeUserFromFollowers() {
if (!this.shouldConfirmRemoveUserFromFollowers) {
this.doRemoveUserFromFollowers()
} else {
this.showConfirmRemoveUserFromFollowers()
}
},
doRemoveUserFromFollowers() {
this.$store.dispatch('removeUserFromFollowers', this.user.id)
this.hideConfirmRemoveUserFromFollowers()
},
reportUser() {
useReportsStore().openUserReportingModal({ userId: this.user.id })
},
openChat() {
this.$router.push({
name: 'chat',
params: {
username: this.$store.state.users.currentUser.screen_name,
recipient_id: this.user.id,
},
})
},
},
computed: {
shouldConfirmBlock() {
- return this.$store.getters.mergedConfig.modalOnBlock
+ return useMergedConfigStore().mergedConfig.modalOnBlock
},
shouldConfirmRemoveUserFromFollowers() {
- return this.$store.getters.mergedConfig.modalOnRemoveUserFromFollowers
+ return useMergedConfigStore().mergedConfig.modalOnRemoveUserFromFollowers
},
...mapState(useInstanceCapabilitiesStore, [
'blockExpiration',
'pleromaChatMessagesAvailable',
]),
},
}
export default AccountActions
diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js
index b1a3d82b88..fbe77a6879 100644
--- a/src/components/attachment/attachment.js
+++ b/src/components/attachment/attachment.js
@@ -1,217 +1,219 @@
-import { mapGetters } from 'vuex'
+import { mapState } from 'pinia'
import nsfwImage from '../../assets/nsfw.png'
import Flash from '../flash/flash.vue'
import StillImage from '../still-image/still-image.vue'
import VideoAttachment from '../video_attachment/video_attachment.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useMediaViewerStore } from 'src/stores/media_viewer'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAlignRight,
faFile,
faImage,
faMusic,
faPencilAlt,
faPlayCircle,
faSearchPlus,
faStop,
faTimes,
faTrashAlt,
faVideo,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faFile,
faMusic,
faImage,
faVideo,
faPlayCircle,
faTimes,
faStop,
faSearchPlus,
faTrashAlt,
faPencilAlt,
faAlignRight,
)
const Attachment = {
props: [
'attachment',
'compact',
'description',
'hideDescription',
'nsfw',
'size',
'setMedia',
'remove',
'shiftUp',
'shiftDn',
'edit',
],
data() {
return {
localDescription: this.description || this.attachment.description,
nsfwImage:
useInstanceStore().instanceIdentity.nsfwCensorImage || nsfwImage,
- hideNsfwLocal: this.$store.getters.mergedConfig.hideNsfw,
- preloadImage: this.$store.getters.mergedConfig.preloadImage,
+ hideNsfwLocal: useMergedConfigStore().mergedConfig.hideNsfw,
+ preloadImage: useMergedConfigStore().mergedConfig.preloadImage,
loading: false,
img: this.attachment.type === 'image' && document.createElement('img'),
modalOpen: false,
showHidden: false,
flashLoaded: false,
showDescription: false,
}
},
components: {
Flash,
StillImage,
VideoAttachment,
},
computed: {
classNames() {
return [
{
'-loading': this.loading,
'-nsfw-placeholder': this.hidden,
'-editable': this.edit !== undefined,
'-compact': this.compact,
},
'-type-' + this.attachment.type,
this.size && '-size-' + this.size,
`-${this.useContainFit ? 'contain' : 'cover'}-fit`,
]
},
usePlaceholder() {
return this.size === 'hide'
},
useContainFit() {
- return this.$store.getters.mergedConfig.useContainFit
+ return this.mergedConfig.useContainFit
},
placeholderName() {
if (this.attachment.description === '' || !this.attachment.description) {
return this.attachment.type.toUpperCase()
}
return this.attachment.description
},
placeholderIconClass() {
if (this.attachment.type === 'image') return 'image'
if (this.attachment.type === 'video') return 'video'
if (this.attachment.type === 'audio') return 'music'
return 'file'
},
referrerpolicy() {
return useInstanceCapabilitiesStore().mediaProxyAvailable
? ''
: 'no-referrer'
},
hidden() {
return this.nsfw && this.hideNsfwLocal && !this.showHidden
},
isEmpty() {
return this.attachment.type === 'html' && !this.attachment.oembed
},
useModal() {
let modalTypes = []
switch (this.size) {
case 'hide':
case 'small':
modalTypes = ['image', 'video', 'audio', 'flash']
break
default:
modalTypes = this.mergedConfig.playVideosInModal
? ['image', 'video', 'flash']
: ['image']
break
}
return modalTypes.includes(this.attachment.type)
},
videoTag() {
return this.useModal ? 'button' : 'span'
},
- ...mapGetters(['mergedConfig']),
+ ...mapState(useMergedConfigStore, ['mergedConfig']),
},
watch: {
'attachment.description'(newVal) {
this.localDescription = newVal
},
localDescription(newVal) {
this.onEdit(newVal)
},
},
methods: {
linkClicked({ target }) {
if (target.tagName === 'A') {
window.open(target.href, '_blank')
}
},
openModal() {
if (this.useModal) {
this.$emit('setMedia')
useMediaViewerStore().setCurrentMedia(this.attachment)
} else if (this.attachment.type === 'unknown') {
window.open(this.attachment.url)
}
},
openModalForce() {
this.$emit('setMedia')
useMediaViewerStore().setCurrentMedia(this.attachment)
},
onEdit(event) {
this.edit && this.edit(this.attachment, event)
},
onRemove() {
this.remove && this.remove(this.attachment)
},
onShiftUp() {
this.shiftUp && this.shiftUp(this.attachment)
},
onShiftDn() {
this.shiftDn && this.shiftDn(this.attachment)
},
stopFlash() {
this.$refs.flash.closePlayer()
},
setFlashLoaded(event) {
this.flashLoaded = event
},
toggleDescription() {
this.showDescription = !this.showDescription
},
toggleHidden(event) {
if (
this.mergedConfig.useOneClickNsfw &&
!this.showHidden &&
- (this.attachment.type !== 'video' || this.mergedConfig.playVideosInModal)
+ (this.attachment.type !== 'video' ||
+ this.mergedConfig.playVideosInModal)
) {
this.openModal(event)
return
}
if (this.img && !this.preloadImage) {
if (this.img.onload) {
this.img.onload()
} else {
this.loading = true
this.img.src = this.attachment.url
this.img.onload = () => {
this.loading = false
this.showHidden = !this.showHidden
}
}
} else {
this.showHidden = !this.showHidden
}
},
onImageLoad(image) {
const width = image.naturalWidth
const height = image.naturalHeight
this.$emit('naturalSizeLoad', { id: this.attachment.id, width, height })
},
},
}
export default Attachment
diff --git a/src/components/chat_list_item/chat_list_item.js b/src/components/chat_list_item/chat_list_item.js
index 202455c539..74df26c1ff 100644
--- a/src/components/chat_list_item/chat_list_item.js
+++ b/src/components/chat_list_item/chat_list_item.js
@@ -1,73 +1,71 @@
import { mapState } from 'vuex'
import AvatarList from '../avatar_list/avatar_list.vue'
import ChatTitle from '../chat_title/chat_title.vue'
import StatusBody from '../status_content/status_content.vue'
import Timeago from '../timeago/timeago.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
const ChatListItem = {
name: 'ChatListItem',
props: ['chat'],
components: {
UserAvatar,
AvatarList,
Timeago,
ChatTitle,
StatusBody,
},
computed: {
...mapState({
currentUser: (state) => state.users.currentUser,
}),
attachmentInfo() {
if (this.chat.lastMessage.attachments.length === 0) {
return
}
- const types = this.chat.lastMessage.attachments.map((file) =>
- file.type,
- )
+ const types = this.chat.lastMessage.attachments.map((file) => file.type)
if (types.includes('video')) {
return this.$t('file_type.video')
} else if (types.includes('audio')) {
return this.$t('file_type.audio')
} else if (types.includes('image')) {
return this.$t('file_type.image')
} else {
return this.$t('file_type.file')
}
},
messageForStatusContent() {
const message = this.chat.lastMessage
const messageEmojis = message ? message.emojis : []
const isYou = message && message.account_id === this.currentUser.id
const content = message ? this.attachmentInfo || message.content : ''
const messagePreview = isYou
? `<i>${this.$t('chats.you')}</i> ${content}`
: content
return {
summary: '',
emojis: messageEmojis,
raw_html: messagePreview,
text: messagePreview,
attachments: [],
}
},
},
methods: {
openChat() {
if (this.chat.id) {
this.$router.push({
name: 'chat',
params: {
username: this.currentUser.screen_name,
recipient_id: this.chat.account.id,
},
})
}
},
},
}
export default ChatListItem
diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
index af3701ebff..1675f9ddd1 100644
--- a/src/components/chat_message/chat_message.js
+++ b/src/components/chat_message/chat_message.js
@@ -1,117 +1,118 @@
import { mapState as mapPiniaState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import { mapGetters, mapState } from 'vuex'
import Attachment from '../attachment/attachment.vue'
import ChatMessageDate from '../chat_message_date/chat_message_date.vue'
import Gallery from '../gallery/gallery.vue'
import LinkPreview from '../link-preview/link-preview.vue'
import Popover from '../popover/popover.vue'
import StatusContent from '../status_content/status_content.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faEllipsisH, faTimes } from '@fortawesome/free-solid-svg-icons'
library.add(faTimes, faEllipsisH)
const ChatMessage = {
name: 'ChatMessage',
props: [
'author',
'edited',
'noHeading',
'chatViewItem',
'hoveredMessageChain',
],
emits: ['hover'],
components: {
Popover,
Attachment,
StatusContent,
UserAvatar,
Gallery,
LinkPreview,
ChatMessageDate,
UserPopover: defineAsyncComponent(
() => import('../user_popover/user_popover.vue'),
),
},
computed: {
// Returns HH:MM (hours and minutes) in local time.
createdAt() {
const time = this.chatViewItem.data.created_at
return time.toLocaleTimeString('en', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
},
isCurrentUser() {
return this.message.account_id === this.currentUser.id
},
message() {
return this.chatViewItem.data
},
isMessage() {
return this.chatViewItem.type === 'message'
},
messageForStatusContent() {
return {
summary: '',
emojis: this.message.emojis,
raw_html: this.message.content || '',
text: this.message.content || '',
attachments: this.message.attachments,
}
},
hasAttachment() {
return this.message.attachments.length > 0
},
...mapPiniaState(useInterfaceStore, {
betterShadow: (store) => store.browserSupport.cssFilter,
}),
...mapState({
currentUser: (state) => state.users.currentUser,
restrictedNicknames: (state) => useInstanceStore().restrictedNicknames,
}),
popoverMarginStyle() {
if (this.isCurrentUser) {
return {}
} else {
return { left: 50 }
}
},
- ...mapGetters(['mergedConfig', 'findUser']),
+ ...mapPiniaState(useMergedConfigStore, ['mergedConfig', 'findUser']),
},
data() {
return {
hovered: false,
menuOpened: false,
}
},
methods: {
onHover(bool) {
this.$emit('hover', {
isHovered: bool,
messageChainId: this.chatViewItem.messageChainId,
})
},
async deleteMessage() {
const confirmed = window.confirm(this.$t('chats.delete_confirm'))
if (confirmed) {
await this.$store.dispatch('deleteChatMessage', {
messageId: this.chatViewItem.data.id,
chatId: this.chatViewItem.data.chat_id,
})
}
this.hovered = false
this.menuOpened = false
},
},
}
export default ChatMessage
diff --git a/src/components/confirm_modal/mute_confirm.js b/src/components/confirm_modal/mute_confirm.js
index c486ad7e4c..ffd3a3076e 100644
--- a/src/components/confirm_modal/mute_confirm.js
+++ b/src/components/confirm_modal/mute_confirm.js
@@ -1,87 +1,89 @@
-import { mapGetters } from 'vuex'
+import { mapState } from 'pinia'
import Select from 'src/components/select/select.vue'
import ConfirmModal from './confirm_modal.vue'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+
export default {
props: ['type', 'user', 'status'],
emits: ['hide', 'show', 'muted'],
data: () => ({
showing: false,
}),
components: {
ConfirmModal,
Select,
},
computed: {
domain() {
return this.user.fqn.split('@')[1]
},
keypath() {
if (this.type === 'domain') {
return 'user_card.mute_domain_confirm'
} else if (this.type === 'conversation') {
return 'user_card.mute_conversation_confirm'
}
},
conversationIsMuted() {
return this.status.conversation_muted
},
domainIsMuted() {
return new Set(this.$store.state.users.currentUser.domainMutes).has(
this.domain,
)
},
shouldConfirm() {
switch (this.type) {
case 'domain': {
return this.mergedConfig.modalOnMuteDomain
}
default: {
// conversation
return this.mergedConfig.modalOnMuteConversation
}
}
},
- ...mapGetters(['mergedConfig']),
+ ...mapState(useMergedConfigStore, ['mergedConfig']),
},
methods: {
optionallyPrompt() {
if (this.shouldConfirm) {
this.show()
} else {
this.doMute()
}
},
show() {
this.showing = true
this.$emit('show')
},
hide() {
this.showing = false
this.$emit('hide')
},
doMute() {
switch (this.type) {
case 'domain': {
if (!this.domainIsMuted) {
this.$store.dispatch('muteDomain', this.domain)
} else {
this.$store.dispatch('unmuteDomain', this.domain)
}
break
}
case 'conversation': {
if (!this.conversationIsMuted) {
this.$store.dispatch('muteConversation', { id: this.status.id })
} else {
this.$store.dispatch('unmuteConversation', { id: this.status.id })
}
break
}
}
this.$emit('muted')
this.hide()
},
},
}
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index cb7cf47828..b0d3a304cf 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,629 +1,630 @@
import { clone, filter, findIndex, get, reduce } from 'lodash'
import { mapState as mapPiniaState } from 'pinia'
-import { mapGetters, mapState } from 'vuex'
+import { mapState } from 'vuex'
import { WSConnectionStatus } from '../../services/api/api.service.js'
import QuickFilterSettings from '../quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from '../quick_view_settings/quick_view_settings.vue'
import Status from '../status/status.vue'
import ThreadTree from '../thread_tree/thread_tree.vue'
import { useInterfaceStore } from 'src/stores/interface'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft,
} from '@fortawesome/free-solid-svg-icons'
library.add(faAngleDoubleDown, faAngleDoubleLeft, faChevronLeft)
const sortById = (a, b) => {
const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
const idB = b.type === 'retweet' ? b.retweeted_status.id : b.id
const seqA = Number(idA)
const seqB = Number(idB)
const isSeqA = !Number.isNaN(seqA)
const isSeqB = !Number.isNaN(seqB)
if (isSeqA && isSeqB) {
return seqA < seqB ? -1 : 1
} else if (isSeqA && !isSeqB) {
return -1
} else if (!isSeqA && isSeqB) {
return 1
} else {
return idA < idB ? -1 : 1
}
}
const sortAndFilterConversation = (conversation, statusoid) => {
if (statusoid.type === 'retweet') {
conversation = filter(
conversation,
(status) =>
status.type === 'retweet' ||
status.id !== statusoid.retweeted_status.id,
)
} else {
conversation = filter(conversation, (status) => status.type !== 'retweet')
}
return conversation.filter((_) => _).sort(sortById)
}
const conversation = {
data() {
return {
highlight: null,
expanded: false,
threadDisplayStatusObject: {}, // id => 'showing' | 'hidden'
statusContentPropertiesObject: {},
inlineDivePosition: null,
loadStatusError: null,
}
},
props: [
'statusId',
'collapsable',
'isPage',
'pinnedStatusIdsObject',
'inProfile',
'profileUserId',
'virtualHidden',
],
created() {
if (this.isPage) {
this.fetchConversation()
}
},
computed: {
maxDepthToShowByDefault() {
// maxDepthInThread = max number of depths that is *visible*
// since our depth starts with 0 and "showing" means "showing children"
// there is a -2 here
- const maxDepth = this.$store.getters.mergedConfig.maxDepthInThread - 2
+ const maxDepth = this.mergedConfig.maxDepthInThread - 2
return maxDepth >= 1 ? maxDepth : 1
},
streamingEnabled() {
return (
this.mergedConfig.useStreamingApi &&
this.mastoUserSocketStatus === WSConnectionStatus.JOINED
)
},
displayStyle() {
- return this.$store.getters.mergedConfig.conversationDisplay
+ return this.mergedConfig.conversationDisplay
},
isTreeView() {
return !this.isLinearView
},
treeViewIsSimple() {
- return !this.$store.getters.mergedConfig.conversationTreeAdvanced
+ return !this.mergedConfig.conversationTreeAdvanced
},
isLinearView() {
return this.displayStyle === 'linear'
},
shouldFadeAncestors() {
- return this.$store.getters.mergedConfig.conversationTreeFadeAncestors
+ return this.mergedConfig.conversationTreeFadeAncestors
},
otherRepliesButtonPosition() {
- return this.$store.getters.mergedConfig.conversationOtherRepliesButton
+ return this.mergedConfig.conversationOtherRepliesButton
},
showOtherRepliesButtonBelowStatus() {
return this.otherRepliesButtonPosition === 'below'
},
showOtherRepliesButtonInsideStatus() {
return this.otherRepliesButtonPosition === 'inside'
},
suspendable() {
if (this.isTreeView) {
return Object.entries(this.statusContentProperties).every(
([, prop]) => !prop.replying && prop.mediaPlaying.length === 0,
)
}
if (this.$refs.statusComponent && this.$refs.statusComponent[0]) {
return this.$refs.statusComponent.every((s) => s.suspendable)
} else {
return true
}
},
hideStatus() {
return this.virtualHidden && this.suspendable
},
status() {
return this.$store.state.statuses.allStatusesObject[this.statusId]
},
originalStatusId() {
if (this.status.retweeted_status) {
return this.status.retweeted_status.id
} else {
return this.statusId
}
},
conversationId() {
return this.getConversationId(this.statusId)
},
conversation() {
if (!this.status) {
return []
}
if (!this.isExpanded) {
return [this.status]
}
const conversation = clone(
this.$store.state.statuses.conversationsObject[this.conversationId],
)
const statusIndex = findIndex(conversation, { id: this.originalStatusId })
if (statusIndex !== -1) {
conversation[statusIndex] = this.status
}
return sortAndFilterConversation(conversation, this.status)
},
statusMap() {
return this.conversation.reduce((res, s) => {
res[s.id] = s
return res
}, {})
},
threadTree() {
const reverseLookupTable = this.conversation.reduce(
(table, status, index) => {
table[status.id] = index
return table
},
{},
)
const threads = this.conversation.reduce(
(a, cur) => {
const id = cur.id
a.forest[id] = this.getReplies(id).map((s) => s.id)
return a
},
{
forest: {},
},
)
const walk = (forest, topLevel, depth = 0, processed = {}) =>
topLevel
.map((id) => {
if (processed[id]) {
return []
}
processed[id] = true
return [
{
status: this.conversation[reverseLookupTable[id]],
id,
depth,
},
walk(forest, forest[id], depth + 1, processed),
].reduce((a, b) => a.concat(b), [])
})
.reduce((a, b) => a.concat(b), [])
const linearized = walk(
threads.forest,
this.topLevel.map((k) => k.id),
)
return linearized
},
replyIds() {
return this.conversation
.map((k) => k.id)
.reduce((res, id) => {
res[id] = (this.replies[id] || []).map((k) => k.id)
return res
}, {})
},
totalReplyCount() {
const sizes = {}
const subTreeSizeFor = (id) => {
if (sizes[id]) {
return sizes[id]
}
sizes[id] =
1 +
this.replyIds[id]
.map((cid) => subTreeSizeFor(cid))
.reduce((a, b) => a + b, 0)
return sizes[id]
}
this.conversation.map((k) => k.id).map(subTreeSizeFor)
return Object.keys(sizes).reduce((res, id) => {
res[id] = sizes[id] - 1 // exclude itself
return res
}, {})
},
totalReplyDepth() {
const depths = {}
const subTreeDepthFor = (id) => {
if (depths[id]) {
return depths[id]
}
depths[id] =
1 +
this.replyIds[id]
.map((cid) => subTreeDepthFor(cid))
.reduce((a, b) => (a > b ? a : b), 0)
return depths[id]
}
this.conversation.map((k) => k.id).map(subTreeDepthFor)
return Object.keys(depths).reduce((res, id) => {
res[id] = depths[id] - 1 // exclude itself
return res
}, {})
},
depths() {
return this.threadTree.reduce((a, k) => {
a[k.id] = k.depth
return a
}, {})
},
topLevel() {
const topLevel = this.conversation.reduce(
(tl, cur) =>
tl.filter(
(k) =>
this.getReplies(cur.id)
.map((v) => v.id)
.indexOf(k.id) === -1,
),
this.conversation,
)
return topLevel
},
otherTopLevelCount() {
return this.topLevel.length - 1
},
showingTopLevel() {
if (this.canDive && this.diveRoot) {
return [this.statusMap[this.diveRoot]]
}
return this.topLevel
},
diveRoot() {
const statusId = this.inlineDivePosition || this.statusId
const isTopLevel = !this.parentOf(statusId)
return isTopLevel ? null : statusId
},
diveDepth() {
return this.canDive && this.diveRoot ? this.depths[this.diveRoot] : 0
},
diveMode() {
return this.canDive && !!this.diveRoot
},
shouldShowAllConversationButton() {
// The "show all conversation" button tells the user that there exist
// other toplevel statuses, so do not show it if there is only a single root
return (
this.isTreeView &&
this.isExpanded &&
this.diveMode &&
this.topLevel.length > 1
)
},
shouldShowAncestors() {
return (
this.isTreeView &&
this.isExpanded &&
this.ancestorsOf(this.diveRoot).length
)
},
replies() {
let i = 1
return reduce(
this.conversation,
(result, { id, in_reply_to_status_id: irid }) => {
if (irid) {
result[irid] = result[irid] || []
result[irid].push({
name: `#${i}`,
id,
})
}
i++
return result
},
{},
)
},
isExpanded() {
return !!(this.expanded || this.isPage)
},
hiddenStyle() {
const height = (this.status && this.status.virtualHeight) || '120px'
return this.virtualHidden ? { height } : {}
},
threadDisplayStatus() {
return this.conversation.reduce((a, k) => {
const id = k.id
const depth = this.depths[id]
const status = (() => {
if (this.threadDisplayStatusObject[id]) {
return this.threadDisplayStatusObject[id]
}
if (depth - this.diveDepth <= this.maxDepthToShowByDefault) {
return 'showing'
} else {
return 'hidden'
}
})()
a[id] = status
return a
}, {})
},
statusContentProperties() {
return this.conversation.reduce((a, k) => {
const id = k.id
const props = (() => {
const def = {
showingTall: false,
expandingSubject: false,
showingLongSubject: false,
isReplying: false,
mediaPlaying: [],
}
if (this.statusContentPropertiesObject[id]) {
return {
...def,
...this.statusContentPropertiesObject[id],
}
}
return def
})()
a[id] = props
return a
}, {})
},
canDive() {
return this.isTreeView && this.isExpanded
},
maybeHighlight() {
return this.isExpanded ? this.highlight : null
},
- ...mapGetters(['mergedConfig']),
+ ...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
...mapState({
mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus,
}),
...mapPiniaState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile',
}),
},
components: {
Status,
ThreadTree,
QuickFilterSettings,
QuickViewSettings,
},
watch: {
statusId(newVal, oldVal) {
const newConversationId = this.getConversationId(newVal)
const oldConversationId = this.getConversationId(oldVal)
if (
newConversationId &&
oldConversationId &&
newConversationId === oldConversationId
) {
this.setHighlight(this.originalStatusId)
} else {
this.fetchConversation()
}
},
expanded(value) {
if (value) {
this.fetchConversation()
} else {
this.resetDisplayState()
}
},
virtualHidden() {
this.$store.dispatch('setVirtualHeight', {
statusId: this.statusId,
height: `${this.$el.clientHeight}px`,
})
},
},
methods: {
fetchConversation() {
if (this.status) {
this.$store.state.api.backendInteractor
.fetchConversation({ id: this.statusId })
.then(({ ancestors, descendants }) => {
this.$store.dispatch('addNewStatuses', { statuses: ancestors })
this.$store.dispatch('addNewStatuses', { statuses: descendants })
this.setHighlight(this.originalStatusId)
})
} else {
this.loadStatusError = null
this.$store.state.api.backendInteractor
.fetchStatus({ id: this.statusId })
.then((status) => {
this.$store.dispatch('addNewStatuses', { statuses: [status] })
this.fetchConversation()
})
.catch((error) => {
this.loadStatusError = error
})
}
},
isFocused(id) {
return this.isExpanded && id === this.highlight
},
getReplies(id) {
return this.replies[id] || []
},
getHighlight() {
return this.isExpanded ? this.highlight : null
},
setHighlight(id) {
if (!id) return
this.highlight = id
if (!this.streamingEnabled) {
this.$store.dispatch('fetchStatus', id)
}
this.$store.dispatch('fetchFavsAndRepeats', id)
this.$store.dispatch('fetchEmojiReactionsBy', id)
},
toggleExpanded() {
this.expanded = !this.expanded
},
getConversationId(statusId) {
const status = this.$store.state.statuses.allStatusesObject[statusId]
return get(
status,
'retweeted_status.statusnet_conversation_id',
get(status, 'statusnet_conversation_id'),
)
},
setThreadDisplay(id, nextStatus) {
this.threadDisplayStatusObject = {
...this.threadDisplayStatusObject,
[id]: nextStatus,
}
},
toggleThreadDisplay(id) {
const curStatus = this.threadDisplayStatus[id]
const nextStatus = curStatus === 'showing' ? 'hidden' : 'showing'
this.setThreadDisplay(id, nextStatus)
},
setThreadDisplayRecursively(id, nextStatus) {
this.setThreadDisplay(id, nextStatus)
this.getReplies(id)
.map((k) => k.id)
.map((id) => this.setThreadDisplayRecursively(id, nextStatus))
},
showThreadRecursively(id) {
this.setThreadDisplayRecursively(id, 'showing')
},
setStatusContentProperty(id, name, value) {
this.statusContentPropertiesObject = {
...this.statusContentPropertiesObject,
[id]: {
...this.statusContentPropertiesObject[id],
[name]: value,
},
}
},
toggleStatusContentProperty(id, name) {
this.setStatusContentProperty(
id,
name,
!this.statusContentProperties[id][name],
)
},
leastVisibleAncestor(id) {
let cur = id
let parent = this.parentOf(cur)
while (cur) {
// if the parent is showing it means cur is visible
if (this.threadDisplayStatus[parent] === 'showing') {
return cur
}
parent = this.parentOf(parent)
cur = this.parentOf(cur)
}
// nothing found, fall back to toplevel
return this.topLevel[0] ? this.topLevel[0].id : undefined
},
diveIntoStatus(id) {
this.tryScrollTo(id)
},
diveToTopLevel() {
this.tryScrollTo(
this.topLevelAncestorOrSelfId(this.diveRoot) || this.topLevel[0].id,
)
},
// only used when we are not on a page
undive() {
this.inlineDivePosition = null
this.setHighlight(this.statusId)
},
tryScrollTo(id) {
if (!id) {
return
}
if (this.isPage) {
// set statusId
this.$router.push({ name: 'conversation', params: { id } })
} else {
this.inlineDivePosition = id
}
// Because the conversation can be unmounted when out of sight
// and mounted again when it comes into sight,
// the `mounted` or `created` function in `status` should not
// contain scrolling calls, as we do not want the page to jump
// when we scroll with an expanded conversation.
//
// Now the method is to rely solely on the `highlight` watcher
// in `status` components.
// In linear views, all statuses are rendered at all times, but
// in tree views, it is possible that a change in active status
// removes and adds status components (e.g. an originally child
// status becomes an ancestor status, and thus they will be
// different).
// Here, let the components be rendered first, in order to trigger
// the `highlight` watcher.
this.$nextTick(() => {
this.setHighlight(id)
})
},
goToCurrent() {
this.tryScrollTo(this.diveRoot || this.topLevel[0].id)
},
statusById(id) {
return this.statusMap[id]
},
parentOf(id) {
const status = this.statusById(id)
if (!status) {
return undefined
}
const { in_reply_to_status_id: parentId } = status
if (!this.statusMap[parentId]) {
return undefined
}
return parentId
},
parentOrSelf(id) {
return this.parentOf(id) || id
},
// Ancestors of some status, from top to bottom
ancestorsOf(id) {
const ancestors = []
let cur = this.parentOf(id)
while (cur) {
ancestors.unshift(this.statusMap[cur])
cur = this.parentOf(cur)
}
return ancestors
},
topLevelAncestorOrSelfId(id) {
let cur = id
let parent = this.parentOf(id)
while (parent) {
cur = this.parentOf(cur)
parent = this.parentOf(parent)
}
return cur
},
resetDisplayState() {
this.undive()
this.threadDisplayStatusObject = {}
},
},
}
export default conversation
diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue
index 2f3de3a863..8d47340830 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -1,331 +1,319 @@
<template>
<div
v-if="!hideStatus"
:style="hiddenStyle"
class="Conversation"
:class="{ '-expanded' : isExpanded, 'panel' : isExpanded }"
>
<div
v-if="isExpanded"
class="panel-heading conversation-heading -sticky"
>
<h1 class="title">
{{ $t('timeline.conversation') }}
</h1>
<button
v-if="collapsable"
class="button-unstyled -link"
@click.prevent="toggleExpanded"
>
{{ $t('timeline.collapse') }}
</button>
<QuickFilterSettings
v-if="!collapsable && mobileLayout"
:conversation="true"
class="rightside-button"
/>
<QuickViewSettings
v-if="!collapsable"
:conversation="true"
class="rightside-button"
/>
</div>
<div
v-if="isPage && !status"
class="conversation-body"
:class="{ 'panel-body': isExpanded }"
>
<p v-if="!loadStatusError">
<FAIcon
spin
icon="circle-notch"
/>
{{ $t('status.loading') }}
</p>
<p v-else>
{{ $t('status.load_error', { error: loadStatusError }) }}
</p>
</div>
<div
v-else
class="conversation-body"
:class="{ 'panel-body': isExpanded }"
>
<div
v-if="isTreeView"
class="thread-body"
>
<div
v-if="shouldShowAllConversationButton"
class="conversation-dive-to-top-level-box"
>
<i18n-t
keypath="status.show_all_conversation_with_icon"
tag="button"
class="button-unstyled -link"
scope="global"
@click.prevent="diveToTopLevel"
>
<template #icon>
<FAIcon
icon="angle-double-left"
/>
</template>
<template #text>
<span>
{{ $t('status.show_all_conversation', { numStatus: otherTopLevelCount }, otherTopLevelCount) }}
</span>
</template>
</i18n-t>
</div>
<div
v-if="shouldShowAncestors"
class="thread-ancestors"
>
<article
v-for="status in ancestorsOf(diveRoot)"
:key="status.id"
class="thread-ancestor"
:class="{'thread-ancestor-has-other-replies': getReplies(status.id).length > 1, '-faded': shouldFadeAncestors}"
>
- <status
+ <Status
ref="statusComponent"
- :inline-expanded="collapsable && isExpanded"
+ class="conversation-status status-fadein panel-body"
+
:statusoid="status"
+ :replies="getReplies(status.id)"
+
:expandable="!isExpanded"
- :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:focused="isFocused(status.id)"
- :in-conversation="isExpanded"
:highlight="getHighlight()"
- :replies="getReplies(status.id)"
+ :inline-expanded="collapsable && isExpanded"
+ :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:in-profile="inProfile"
+ :in-conversation="isExpanded"
:profile-user-id="profileUserId"
- class="conversation-status status-fadein panel-body"
-
:simple-tree="treeViewIsSimple"
- :toggle-thread-display="toggleThreadDisplay"
- :thread-display-status="threadDisplayStatus"
- :show-thread-recursively="showThreadRecursively"
- :total-reply-count="totalReplyCount"
- :total-reply-depth="totalReplyDepth"
:show-other-replies-as-button="showOtherRepliesButtonInsideStatus"
:dive="() => diveIntoStatus(status.id)"
:controlled-showing-tall="statusContentProperties[status.id].showingTall"
- :controlled-expanding-subject="statusContentProperties[status.id].expandingSubject"
- :controlled-showing-long-subject="statusContentProperties[status.id].showingLongSubject"
- :controlled-replying="statusContentProperties[status.id].replying"
- :controlled-media-playing="statusContentProperties[status.id].mediaPlaying"
:controlled-toggle-showing-tall="() => toggleStatusContentProperty(status.id, 'showingTall')"
+ :controlled-expanding-subject="statusContentProperties[status.id].expandingSubject"
:controlled-toggle-expanding-subject="() => toggleStatusContentProperty(status.id, 'expandingSubject')"
+ :controlled-showing-long-subject="statusContentProperties[status.id].showingLongSubject"
:controlled-toggle-showing-long-subject="() => toggleStatusContentProperty(status.id, 'showingLongSubject')"
+ :controlled-replying="statusContentProperties[status.id].replying"
:controlled-toggle-replying="() => toggleStatusContentProperty(status.id, 'replying')"
+ :controlled-media-playing="statusContentProperties[status.id].mediaPlaying"
:controlled-set-media-playing="(newVal) => toggleStatusContentProperty(status.id, 'mediaPlaying', newVal)"
@goto="setHighlight"
@toggle-expanded="toggleExpanded"
/>
<div
v-if="showOtherRepliesButtonBelowStatus && getReplies(status.id).length > 1"
class="thread-ancestor-dive-box"
>
<div
class="thread-ancestor-dive-box-inner"
>
<i18n-t
tag="button"
scope="global"
keypath="status.ancestor_follow_with_icon"
class="button-unstyled -link thread-tree-show-replies-button"
@click.prevent="diveIntoStatus(status.id)"
>
<template #icon>
<FAIcon
icon="angle-double-right"
/>
</template>
<template #text>
<span>
{{ $t('status.ancestor_follow', { numReplies: getReplies(status.id, getReplies(status.id).length - 1).length - 1 }) }}
</span>
</template>
</i18n-t>
</div>
</div>
</article>
</div>
<thread-tree
v-for="status in showingTopLevel"
:key="status.id"
ref="statusComponent"
:depth="0"
:status="status"
:in-profile="inProfile"
:conversation="conversation"
:collapsable="collapsable"
:is-expanded="isExpanded"
:pinned-status-ids-object="pinnedStatusIdsObject"
:profile-user-id="profileUserId"
:is-focused-function="isFocused"
:get-replies="getReplies"
:highlight="maybeHighlight"
:set-highlight="setHighlight"
:toggle-expanded="toggleExpanded"
:simple="treeViewIsSimple"
:toggle-thread-display="toggleThreadDisplay"
:thread-display-status="threadDisplayStatus"
:show-thread-recursively="showThreadRecursively"
:total-reply-count="totalReplyCount"
:total-reply-depth="totalReplyDepth"
:status-content-properties="statusContentProperties"
:set-status-content-property="setStatusContentProperty"
:toggle-status-content-property="toggleStatusContentProperty"
:dive="canDive ? diveIntoStatus : undefined"
/>
</div>
<div
v-if="isLinearView"
class="thread-body"
>
<article>
<status
v-for="status in conversation"
:key="status.id"
ref="statusComponent"
- :inline-expanded="collapsable && isExpanded"
+ class="conversation-status status-fadein panel-body"
:statusoid="status"
+ :replies="getReplies(status.id)"
+
:expandable="!isExpanded"
- :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:focused="isFocused(status.id)"
- :in-conversation="isExpanded"
:highlight="getHighlight()"
- :replies="getReplies(status.id)"
+ :inline-expanded="collapsable && isExpanded"
+ :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:in-profile="inProfile"
+ :in-conversation="isExpanded"
:profile-user-id="profileUserId"
- class="conversation-status status-fadein panel-body"
-
- :toggle-thread-display="toggleThreadDisplay"
- :thread-display-status="threadDisplayStatus"
- :show-thread-recursively="showThreadRecursively"
- :total-reply-count="totalReplyCount"
- :total-reply-depth="totalReplyDepth"
- :status-content-properties="statusContentProperties"
- :set-status-content-property="setStatusContentProperty"
- :toggle-status-content-property="toggleStatusContentProperty"
@goto="setHighlight"
@toggle-expanded="toggleExpanded"
/>
</article>
</div>
</div>
</div>
<div
v-else
class="Conversation -hidden"
:style="hiddenStyle"
/>
</template>
<script src="./conversation.js"></script>
<style lang="scss">
.Conversation {
z-index: 1;
&.-hidden {
background: var(--__panel-background);
backdrop-filter: var(--__panel-backdrop-filter);
}
.conversation-dive-to-top-level-box {
padding: var(--status-margin);
border-bottom: 1px solid var(--border);
border-radius: 0;
/* Make the button stretch along the whole row */
display: flex;
align-items: stretch;
flex-direction: column;
}
.thread-ancestors {
margin-left: var(--status-margin);
border-left: 2px solid var(--border);
}
.thread-ancestor.-faded .RichContent {
/* stylelint-disable declaration-no-important */
--text: var(--textFaint) !important;
--link: var(--linkFaint) !important;
--funtextGreentext: var(--funtextGreentextFaint) !important;
--funtextCyantext: var(--funtextCyantextFaint) !important;
/* stylelint-enable declaration-no-important */
}
.thread-ancestor-dive-box {
padding-left: var(--status-margin);
border-bottom: 1px solid var(--border);
border-radius: 0;
/* Make the button stretch along the whole row */
&,
&-inner {
display: flex;
align-items: stretch;
flex-direction: column;
}
}
.thread-ancestor-dive-box-inner {
padding: var(--status-margin);
}
.conversation-status {
border-bottom: 1px solid var(--border);
border-radius: 0;
}
.thread-ancestor-has-other-replies .conversation-status,
&:last-child:not(.-expanded) .conversation-status,
&.-expanded .conversation-status:last-child,
.thread-ancestor:last-child .conversation-status,
.thread-ancestor:last-child .thread-ancestor-dive-box,
&.-expanded .thread-tree .conversation-status {
border-bottom: none;
}
.thread-ancestors + .thread-tree > .conversation-status {
border-top: 1px solid var(--border);
}
/* expanded conversation in timeline */
&.status-fadein.-expanded .thread-body {
border-left: 4px solid var(--cRed);
border-radius: var(--roundness);
border-top-left-radius: 0;
border-top-right-radius: 0;
border-bottom: 1px solid var(--border);
}
&.-expanded.status-fadein {
--___margin: calc(var(--status-margin) / 2);
background: var(--background);
margin: var(--___margin);
&::before {
z-index: -1;
content: "";
display: block;
position: absolute;
inset: calc(var(--___margin) * -1);
background: var(--background);
backdrop-filter: var(--__panel-backdrop-filter);
}
}
}
</style>
diff --git a/src/components/desktop_nav/desktop_nav.js b/src/components/desktop_nav/desktop_nav.js
index 943c66d1ea..3382413a0a 100644
--- a/src/components/desktop_nav/desktop_nav.js
+++ b/src/components/desktop_nav/desktop_nav.js
@@ -1,129 +1,130 @@
import SearchBar from 'components/search_bar/search_bar.vue'
import { mapActions, mapState } from 'pinia'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBell,
faBullhorn,
faCog,
faComments,
faHome,
faInfoCircle,
faSearch,
faSignInAlt,
faSignOutAlt,
faTachometerAlt,
faUserPlus,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSignInAlt,
faSignOutAlt,
faHome,
faComments,
faBell,
faUserPlus,
faBullhorn,
faSearch,
faTachometerAlt,
faCog,
faInfoCircle,
)
export default {
components: {
SearchBar,
ConfirmModal,
},
data: () => ({
searchBarHidden: true,
supportsMask:
window.CSS &&
window.CSS.supports &&
(window.CSS.supports('mask-size', 'contain') ||
window.CSS.supports('-webkit-mask-size', 'contain') ||
window.CSS.supports('-moz-mask-size', 'contain') ||
window.CSS.supports('-ms-mask-size', 'contain') ||
window.CSS.supports('-o-mask-size', 'contain')),
showingConfirmLogout: false,
}),
computed: {
enableMask() {
return this.supportsMask && this.logoMask
},
logoStyle() {
return {
visibility: this.enableMask ? 'hidden' : 'visible',
}
},
logoMaskStyle() {
return this.enableMask
? {
'mask-image': `url(${this.logo})`,
}
: {
'background-color': this.enableMask ? '' : 'transparent',
}
},
logoBgStyle() {
return Object.assign(
{
margin: `${this.logoMargin} 0`,
opacity: this.searchBarHidden ? 1 : 0,
},
this.enableMask
? {}
: {
'background-color': this.enableMask ? '' : 'transparent',
},
)
},
...mapState(useInstanceStore, ['privateMode']),
...mapState(useInstanceStore, {
logoMask: (store) => store.instanceIdentity.logoMask,
logo: (store) => store.instanceIdentity.logo,
logoLeft: (store) => store.instanceIdentity.logoLeft,
logoMargin: (store) => store.instanceIdentity.logoMargin,
sitename: (store) => store.instanceIdentity.name,
hideSitename: (store) => store.instanceIdentity.hideSitename,
}),
currentUser() {
return this.$store.state.users.currentUser
},
shouldConfirmLogout() {
- return this.$store.getters.mergedConfig.modalOnLogout
+ return useMergedConfigStore().mergedConfig.modalOnLogout
},
},
methods: {
scrollToTop() {
window.scrollTo(0, 0)
},
showConfirmLogout() {
this.showingConfirmLogout = true
},
hideConfirmLogout() {
this.showingConfirmLogout = false
},
logout() {
if (!this.shouldConfirmLogout) {
this.doLogout()
} else {
this.showConfirmLogout()
}
},
doLogout() {
this.$router.replace('/main/public')
this.$store.dispatch('logout')
this.hideConfirmLogout()
},
onSearchBarToggled(hidden) {
this.searchBarHidden = hidden
},
...mapActions(useInterfaceStore, ['openSettingsModal']),
},
}
diff --git a/src/components/dialog_modal/dialog_modal.js b/src/components/dialog_modal/dialog_modal.js
index 8070d34298..083f7a21da 100644
--- a/src/components/dialog_modal/dialog_modal.js
+++ b/src/components/dialog_modal/dialog_modal.js
@@ -1,21 +1,23 @@
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+
const DialogModal = {
props: {
darkOverlay: {
default: true,
type: Boolean,
},
onCancel: {
default: () => {
/* no-op */
},
type: Function,
},
},
computed: {
mobileCenter() {
- return this.$store.getters.mergedConfig.modalMobileCenter
+ return useMergedConfigStore().mergedConfig.modalMobileCenter
},
},
}
export default DialogModal
diff --git a/src/components/draft/draft.js b/src/components/draft/draft.js
index 4f0d8cbee7..45786614e2 100644
--- a/src/components/draft/draft.js
+++ b/src/components/draft/draft.js
@@ -1,105 +1,107 @@
import { cloneDeep } from 'lodash'
import ConfirmModal from 'src/components/confirm_modal/confirm_modal.vue'
import EditStatusForm from 'src/components/edit_status_form/edit_status_form.vue'
import Gallery from 'src/components/gallery/gallery.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+
import { library } from '@fortawesome/fontawesome-svg-core'
import { faPollH } from '@fortawesome/free-solid-svg-icons'
library.add(faPollH)
const Draft = {
components: {
PostStatusForm,
EditStatusForm,
ConfirmModal,
StatusContent,
Gallery,
},
props: {
draft: {
type: Object,
required: true,
},
},
data() {
return {
referenceDraft: cloneDeep(this.draft),
editing: false,
showingConfirmDialog: false,
}
},
computed: {
relAttrs() {
if (this.draft.type === 'edit') {
return { statusId: this.draft.refId }
} else if (this.draft.type === 'reply') {
return { replyTo: this.draft.refId }
} else {
return {}
}
},
safeToSave() {
return (
this.draft.status ||
this.draft.files?.length ||
this.draft.hasPoll ||
this.draft.hasQuote
)
},
postStatusFormProps() {
return {
draftId: this.draft.id,
...this.relAttrs,
}
},
refStatus() {
return this.draft.refId
? this.$store.state.statuses.allStatusesObject[this.draft.refId]
: undefined
},
localCollapseSubjectDefault() {
- return this.$store.getters.mergedConfig.collapseMessageWithSubject
+ return useMergedConfigStore().mergedConfig.collapseMessageWithSubject
},
nsfwClickthrough() {
if (!this.draft.nsfw) {
return false
}
if (this.draft.summary && this.localCollapseSubjectDefault) {
return false
}
return true
},
},
watch: {
editing(newVal) {
if (newVal) return
if (this.safeToSave) {
this.$store.dispatch('addOrSaveDraft', { draft: this.draft })
} else {
this.$store.dispatch('addOrSaveDraft', { draft: this.referenceDraft })
}
},
},
methods: {
toggleEditing() {
this.editing = !this.editing
},
abandon() {
this.showingConfirmDialog = true
},
doAbandon() {
this.$store.dispatch('abandonDraft', { id: this.draft.id }).then(() => {
this.hideConfirmDialog()
})
},
hideConfirmDialog() {
this.showingConfirmDialog = false
},
},
}
export default Draft
diff --git a/src/components/draft_closer/draft_closer.js b/src/components/draft_closer/draft_closer.js
index d724ab4ac3..2648b40c5f 100644
--- a/src/components/draft_closer/draft_closer.js
+++ b/src/components/draft_closer/draft_closer.js
@@ -1,49 +1,51 @@
import DialogModal from 'src/components/dialog_modal/dialog_modal.vue'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+
const DraftCloser = {
data() {
return {
showing: false,
}
},
components: {
DialogModal,
},
emits: ['save', 'discard'],
computed: {
action() {
- if (this.$store.getters.mergedConfig.autoSaveDraft) {
+ if (useMergedConfigStore().mergedConfig.autoSaveDraft) {
return 'save'
} else {
- return this.$store.getters.mergedConfig.unsavedPostAction
+ return useMergedConfigStore().mergedConfig.unsavedPostAction
}
},
shouldConfirm() {
return this.action === 'confirm'
},
},
methods: {
requestClose() {
if (this.shouldConfirm) {
this.showing = true
} else if (this.action === 'save') {
this.save()
} else {
this.discard()
}
},
save() {
this.$emit('save')
this.showing = false
},
discard() {
this.$emit('discard')
this.showing = false
},
cancel() {
this.showing = false
},
},
}
export default DraftCloser
diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js
index 24794640e5..0146a3de84 100644
--- a/src/components/emoji_input/emoji_input.js
+++ b/src/components/emoji_input/emoji_input.js
@@ -1,604 +1,606 @@
import { take } from 'lodash'
import Popover from 'src/components/popover/popover.vue'
import ScreenReaderNotice from 'src/components/screen_reader_notice/screen_reader_notice.vue'
import { ensureFinalFallback } from '../../i18n/languages.js'
import Completion from '../../services/completion/completion.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import genRandomSeed from '../../services/random_seed/random_seed.service.js'
import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+
import { library } from '@fortawesome/fontawesome-svg-core'
import { faSmileBeam } from '@fortawesome/free-regular-svg-icons'
library.add(faSmileBeam)
/**
* EmojiInput - augmented inputs for emoji and autocomplete support in inputs
* without having to give up the comfort of <input/> and <textarea/> elements
*
* Intended usage is:
* <EmojiInput v-model="something">
* <input v-model="something"/>
* </EmojiInput>
*
* Works only with <input> and <textarea>. Intended to use with only one nested
* input. It will find first input or textarea and work with that, multiple
* nested children not tested. You HAVE TO duplicate v-model for both
* <emoji-input> and <input>/<textarea> otherwise it will not work.
*
* Be prepared for CSS troubles though because it still wraps component in a div
* while TRYING to make it look like nothing happened, but it could break stuff.
*/
const EmojiInput = {
emits: ['update:modelValue', 'shown'],
props: {
suggest: {
/**
* suggest: function (input: String) => Suggestion[]
*
* Function that takes input string which takes string (textAtCaret)
* and returns an array of Suggestions
*
* Suggestion is an object containing following properties:
* displayText: string. Main display text, what actual suggestion
* represents (user's screen name/emoji shortcode)
* replacement: string. Text that should replace the textAtCaret
* detailText: string, optional. Subtitle text, providing additional info
* if present (user's nickname)
* imageUrl: string, optional. Image to display alongside with suggestion,
* currently if no image is provided, replacement will be used (for
* unicode emojis)
*
* TODO: make it asynchronous when adding proper server-provided user
* suggestions
*
* For commonly used suggestors (emoji, users, both) use suggestor.js
*/
required: true,
type: Function,
},
modelValue: {
/**
* Used for v-model
*/
required: true,
type: String,
},
enableEmojiPicker: {
/**
* Enables emoji picker support, this implies that custom emoji are supported
*/
required: false,
type: Boolean,
default: false,
},
hideEmojiButton: {
/**
* intended to use with external picker trigger, i.e. you have a button outside
* input that will open up the picker, see triggerShowPicker()
*/
required: false,
type: Boolean,
default: false,
},
enableStickerPicker: {
/**
* Enables sticker picker support, only makes sense when enableEmojiPicker=true
*/
required: false,
type: Boolean,
default: false,
},
placement: {
/**
* Forces the panel to take a specific position relative to the input element.
* The 'auto' placement chooses either bottom or top depending on which has the available space (when both have available space, bottom is preferred).
*/
required: false,
type: String, // 'auto', 'top', 'bottom'
default: 'auto',
},
newlineOnCtrlEnter: {
required: false,
type: Boolean,
default: false,
},
},
data() {
return {
randomSeed: genRandomSeed(),
input: undefined,
caretEl: undefined,
highlighted: -1,
caret: 0,
focused: false,
blurTimeout: null,
temporarilyHideSuggestions: false,
disableClickOutside: false,
suggestions: [],
overlayStyle: {},
pickerShown: false,
}
},
components: {
Popover,
EmojiPicker,
UnicodeDomainIndicator,
ScreenReaderNotice,
},
computed: {
padEmoji() {
- return this.$store.getters.mergedConfig.padEmoji
+ return useMergedConfigStore().mergedConfig.padEmoji
},
defaultCandidateIndex() {
- return this.$store.getters.mergedConfig.autocompleteSelect ? 0 : -1
+ return useMergedConfigStore().mergedConfig.autocompleteSelect ? 0 : -1
},
preText() {
return this.modelValue.slice(0, this.caret)
},
postText() {
return this.modelValue.slice(this.caret)
},
showSuggestions() {
return (
this.focused &&
this.suggestions &&
this.suggestions.length > 0 &&
!this.pickerShown &&
!this.temporarilyHideSuggestions
)
},
textAtCaret() {
return this.wordAtCaret?.word
},
wordAtCaret() {
if (this.modelValue && this.caret) {
const word =
Completion.wordAtPosition(this.modelValue, this.caret - 1) || {}
return word
}
},
languages() {
return ensureFinalFallback(
- this.$store.getters.mergedConfig.interfaceLanguage,
+ useMergedConfigStore().mergedConfig.interfaceLanguage,
)
},
maybeLocalizedEmojiNamesAndKeywords() {
return (emoji) => {
const names = [emoji.displayText]
const keywords = []
if (emoji.displayTextI18n) {
names.push(
this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args),
)
}
if (emoji.annotations) {
this.languages.forEach((lang) => {
names.push(emoji.annotations[lang]?.name)
keywords.push(...(emoji.annotations[lang]?.keywords || []))
})
}
return {
names: names.filter((k) => k),
keywords: keywords.filter((k) => k),
}
}
},
maybeLocalizedEmojiName() {
return (emoji) => {
if (!emoji.annotations) {
return emoji.displayText
}
if (emoji.displayTextI18n) {
return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)
}
for (const lang of this.languages) {
if (emoji.annotations[lang]?.name) {
return emoji.annotations[lang].name
}
}
return emoji.displayText
}
},
suggestionListId() {
return `suggestions-${this.randomSeed}`
},
suggestionItemId() {
return (index) => `suggestion-item-${index}-${this.randomSeed}`
},
},
mounted() {
const { root, hiddenOverlayCaret, suggestorPopover } = this.$refs
const input =
root.querySelector('.emoji-input > input') ||
root.querySelector('.emoji-input > textarea')
if (!input) return
this.input = input
this.caretEl = hiddenOverlayCaret
if (suggestorPopover.setAnchorEl) {
suggestorPopover.setAnchorEl(this.caretEl) // unit test compat
this.$refs.picker.setAnchorEl(this.caretEl)
} else {
console.warn('setAnchorEl not found, are we in a unit test?')
}
const style = getComputedStyle(this.input)
this.overlayStyle.padding = style.padding
this.overlayStyle.border = style.border
this.overlayStyle.margin = style.margin
this.overlayStyle.lineHeight = style.lineHeight
this.overlayStyle.fontFamily = style.fontFamily
this.overlayStyle.fontSize = style.fontSize
this.overlayStyle.wordWrap = style.wordWrap
this.overlayStyle.whiteSpace = style.whiteSpace
input.addEventListener('blur', this.onBlur)
input.addEventListener('focus', this.onFocus)
input.addEventListener('paste', this.onPaste)
input.addEventListener('keyup', this.onKeyUp)
input.addEventListener('keydown', this.onKeyDown)
input.addEventListener('click', this.onClickInput)
input.addEventListener('transitionend', this.onTransition)
input.addEventListener('input', this.onInput)
input.addEventListener('scroll', this.onInputScroll)
},
unmounted() {
const { input } = this
if (input) {
input.removeEventListener('blur', this.onBlur)
input.removeEventListener('focus', this.onFocus)
input.removeEventListener('paste', this.onPaste)
input.removeEventListener('keyup', this.onKeyUp)
input.removeEventListener('keydown', this.onKeyDown)
input.removeEventListener('click', this.onClickInput)
input.removeEventListener('transitionend', this.onTransition)
input.removeEventListener('input', this.onInput)
input.removeEventListener('scroll', this.onInputScroll)
}
},
watch: {
showSuggestions: function (newValue) {
this.$emit('shown', newValue)
if (newValue) {
this.$refs.suggestorPopover.showPopover()
} else {
this.$refs.suggestorPopover.hidePopover()
}
},
textAtCaret: async function (newWord) {
if (newWord === undefined) return
const firstchar = newWord.charAt(0)
if (newWord === firstchar) {
this.suggestions = []
return
}
const matchedSuggestions = await this.suggest(
newWord,
this.maybeLocalizedEmojiNamesAndKeywords,
)
// Async: cancel if textAtCaret has changed during wait
if (this.textAtCaret !== newWord || matchedSuggestions.length <= 0) {
this.suggestions = []
return
}
this.suggestions = take(matchedSuggestions, 5).map(
({ imageUrl, ...rest }) => ({
...rest,
img: imageUrl || '',
}),
)
this.highlighted = this.defaultCandidateIndex
this.$refs.screenReaderNotice.announce(
this.$t(
'tool_tip.autocomplete_available',
{ number: this.suggestions.length },
this.suggestions.length,
),
)
},
},
methods: {
onInputScroll(e) {
this.$refs.hiddenOverlay.scrollTo({
top: this.input.scrollTop,
left: this.input.scrollLeft,
})
this.setCaret(e)
},
triggerShowPicker() {
this.$nextTick(() => {
this.$refs.picker.showPicker()
this.scrollIntoView()
})
// This temporarily disables "click outside" handler
// since external trigger also means click originates
// from outside, thus preventing picker from opening
this.disableClickOutside = true
setTimeout(() => {
this.disableClickOutside = false
}, 0)
},
togglePicker() {
this.input.focus()
if (!this.pickerShown) {
this.scrollIntoView()
this.$refs.picker.showPicker()
this.$refs.picker.startEmojiLoad()
} else {
this.$refs.picker.hidePicker()
}
},
replace(replacement) {
const newValue = Completion.replaceWord(
this.modelValue,
this.wordAtCaret,
replacement,
)
this.$emit('update:modelValue', newValue)
this.caret = 0
},
insert({ insertion, keepOpen, surroundingSpace = true }) {
const before = this.modelValue.substring(0, this.caret) || ''
const after = this.modelValue.substring(this.caret) || ''
/* Using a bit more smart approach to padding emojis with spaces:
* - put a space before cursor if there isn't one already, unless we
* are at the beginning of post or in spam mode
* - put a space after emoji if there isn't one already unless we are
* in spam mode
*
* The idea is that when you put a cursor somewhere in between sentence
* inserting just ' :emoji: ' will add more spaces to post which might
* break the flow/spacing, as well as the case where user ends sentence
* with a space before adding emoji.
*
* Spam mode is intended for creating multi-part emojis and overall spamming
* them, masto seem to be rendering :emoji::emoji: correctly now so why not
*/
const isSpaceRegex = /\s/
const spaceBefore =
surroundingSpace &&
!isSpaceRegex.exec(before.slice(-1)) &&
before.length &&
this.padEmoji > 0
? ' '
: ''
const spaceAfter =
surroundingSpace && !isSpaceRegex.exec(after[0]) && this.padEmoji
? ' '
: ''
const newValue = [before, spaceBefore, insertion, spaceAfter, after].join(
'',
)
this.$emit('update:modelValue', newValue)
const position =
this.caret + (insertion + spaceAfter + spaceBefore).length
if (!keepOpen) {
this.input.focus()
}
this.$nextTick(function () {
// Re-focus inputbox after clicking suggestion
// Set selection right after the replacement instead of the very end
this.input.setSelectionRange(position, position)
this.caret = position
})
},
replaceText(e, suggestion) {
const len = this.suggestions.length || 0
if (this.textAtCaret.length === 1) {
return
}
if (len > 0 || suggestion) {
const chosenSuggestion =
suggestion || this.suggestions[this.highlighted]
const replacement = chosenSuggestion.replacement
const newValue = Completion.replaceWord(
this.modelValue,
this.wordAtCaret,
replacement,
)
this.$emit('update:modelValue', newValue)
this.highlighted = 0
const position = this.wordAtCaret.start + replacement.length
this.$nextTick(function () {
// Re-focus inputbox after clicking suggestion
this.input.focus()
// Set selection right after the replacement instead of the very end
this.input.setSelectionRange(position, position)
this.caret = position
})
e.preventDefault()
}
},
cycleBackward(e) {
const len = this.suggestions.length || 0
this.highlighted -= 1
if (this.highlighted === -1) {
this.input.focus()
} else if (this.highlighted < -1) {
this.highlighted = len - 1
}
if (len > 0) {
e.preventDefault()
}
},
cycleForward(e) {
const len = this.suggestions.length || 0
this.highlighted += 1
if (this.highlighted >= len) {
this.highlighted = -1
this.input.focus()
}
if (len > 0) {
e.preventDefault()
}
},
scrollIntoView() {
const rootRef = this.$refs.picker.$el
/* Scroller is either `window` (replies in TL), sidebar (main post form,
* replies in notifs) or mobile post form. Note that getting and setting
* scroll is different for `Window` and `Element`s
*/
const scrollerRef =
this.$el.closest('.sidebar-scroller') ||
this.$el.closest('.post-form-modal-view') ||
window
const currentScroll =
scrollerRef === window ? scrollerRef.scrollY : scrollerRef.scrollTop
const scrollerHeight =
scrollerRef === window
? scrollerRef.innerHeight
: scrollerRef.offsetHeight
const scrollerBottomBorder = currentScroll + scrollerHeight
// We check where the bottom border of root element is, this uses findOffset
// to find offset relative to scrollable container (scroller)
const rootBottomBorder =
rootRef.offsetHeight + findOffset(rootRef, scrollerRef).top
const bottomDelta = Math.max(0, rootBottomBorder - scrollerBottomBorder)
// could also check top delta but there's no case for it
const targetScroll = currentScroll + bottomDelta
if (scrollerRef === window) {
scrollerRef.scroll(0, targetScroll)
} else {
scrollerRef.scrollTop = targetScroll
}
this.$nextTick(() => {
const { offsetHeight } = this.input
const { picker } = this.$refs
const pickerBottom = picker.$el.getBoundingClientRect().bottom
if (pickerBottom > window.innerHeight) {
picker.$el.style.top = 'auto'
picker.$el.style.bottom = offsetHeight + 'px'
}
})
},
onPickerShown() {
this.pickerShown = true
},
onPickerClosed() {
this.pickerShown = false
},
onBlur(e) {
// Clicking on any suggestion removes focus from autocomplete,
// preventing click handler ever executing.
this.blurTimeout = setTimeout(() => {
this.focused = false
this.setCaret(e)
}, 200)
},
onClick(e, suggestion) {
this.replaceText(e, suggestion)
},
onFocus(e) {
if (this.blurTimeout) {
clearTimeout(this.blurTimeout)
this.blurTimeout = null
}
this.focused = true
this.setCaret(e)
this.temporarilyHideSuggestions = false
},
onKeyUp(e) {
const { key } = e
this.setCaret(e)
// Setting hider in keyUp to prevent suggestions from blinking
// when moving away from suggested spot
if (key === 'Escape') {
this.temporarilyHideSuggestions = true
} else {
this.temporarilyHideSuggestions = false
}
},
onPaste(e) {
this.setCaret(e)
},
onKeyDown(e) {
const { ctrlKey, shiftKey, key } = e
if (this.newlineOnCtrlEnter && ctrlKey && key === 'Enter') {
this.insert({ insertion: '\n', surroundingSpace: false })
// Ensure only one new line is added on macos
e.stopPropagation()
e.preventDefault()
// Scroll the input element to the position of the cursor
this.$nextTick(() => {
this.input.blur()
this.input.focus()
})
}
// Disable suggestions hotkeys if suggestions are hidden
if (!this.temporarilyHideSuggestions) {
if (key === 'Tab') {
if (shiftKey) {
this.cycleBackward(e)
} else {
this.cycleForward(e)
}
}
if (key === 'ArrowUp') {
this.cycleBackward(e)
} else if (key === 'ArrowDown') {
this.cycleForward(e)
}
if (key === 'Enter') {
if (!ctrlKey) {
this.replaceText(e)
}
}
}
// Probably add optional keyboard controls for emoji picker?
// Escape hides suggestions, if suggestions are hidden it
// de-focuses the element (i.e. default browser behavior)
if (key === 'Escape') {
if (!this.temporarilyHideSuggestions) {
this.input.focus()
}
}
},
onInput(e) {
this.setCaret(e)
this.$emit('update:modelValue', e.target.value)
},
onStickerUploaded(e) {
this.$emit('sticker-uploaded', e)
},
onStickerUploadFailed(e) {
this.$emit('sticker-upload-Failed', e)
},
setCaret({ target: { selectionStart } }) {
this.caret = selectionStart
this.$nextTick(() => {
this.$refs.suggestorPopover.updateStyles()
})
},
autoCompleteItemLabel(suggestion) {
if (suggestion.user) {
return suggestion.displayText + ' ' + suggestion.detailText
} else {
return this.maybeLocalizedEmojiName(suggestion)
}
},
},
}
export default EmojiInput
diff --git a/src/components/emoji_input/suggestor.js b/src/components/emoji_input/suggestor.js
index c478fea67d..c0d8f7ca1b 100644
--- a/src/components/emoji_input/suggestor.js
+++ b/src/components/emoji_input/suggestor.js
@@ -1,148 +1,150 @@
+import { useEmojiStore } from 'src/stores/emoji.js'
+
/**
* suggest - generates a suggestor function to be used by emoji-input
* data: object providing source information for specific types of suggestions:
* data.emoji - optional, an array of all emoji available i.e.
* (useEmojiStore().standardEmojiList + state.instance.customEmoji)
* data.users - optional, an array of all known users
* updateUsersList - optional, a function to search and append to users
*
* Depending on data present one or both (or none) can be present, so if field
* doesn't support user linking you can just provide only emoji.
*/
export default (data) => {
const emojiCurry = suggestEmoji(data.emoji)
const usersCurry = data.store && suggestUsers(data.store)
return (input, nameKeywordLocalizer) => {
const firstChar = input[0]
if (firstChar === ':' && data.emoji) {
return emojiCurry(input, nameKeywordLocalizer)
}
if (firstChar === '@' && usersCurry) {
return usersCurry(input)
}
return []
}
}
export const suggestEmoji = (emojis) => (input, nameKeywordLocalizer) => {
const noPrefix = input.toLowerCase().substr(1)
return emojis
.map((emoji) => ({ ...emoji, ...nameKeywordLocalizer(emoji) }))
.filter(
(emoji) =>
emoji.names
.concat(emoji.keywords)
.filter((kw) => kw.toLowerCase().match(noPrefix)).length,
)
.map((k) => {
let score = 0
// An exact match always wins
score += Math.max(
...k.names.map((name) => (name.toLowerCase() === noPrefix ? 200 : 0)),
0,
)
// Prioritize custom emoji a lot
score += k.imageUrl ? 100 : 0
// Prioritize prefix matches somewhat
score += Math.max(
...k.names.map((kw) =>
kw.toLowerCase().startsWith(noPrefix) ? 10 : 0,
),
0,
)
// Sort by length
score -= k.displayText.length
k.score = score
return k
})
.sort((a, b) => {
// Break ties alphabetically
const alphabetically = a.displayText > b.displayText ? 0.5 : -0.5
return b.score - a.score + alphabetically
})
}
export const suggestUsers = ({ dispatch, state }) => {
// Keep some persistent values in closure, most importantly for the
// custom debounce to work. Lodash debounce does not return a promise.
let suggestions = []
let previousQuery = ''
let timeout = null
let cancelUserSearch = null
const userSearch = (query) => dispatch('searchUsers', { query })
const debounceUserSearch = (query) => {
cancelUserSearch && cancelUserSearch()
return new Promise((resolve, reject) => {
timeout = setTimeout(() => {
userSearch(query).then(resolve).catch(reject)
}, 300)
cancelUserSearch = () => {
clearTimeout(timeout)
resolve([])
}
})
}
return async (input) => {
const noPrefix = input.toLowerCase().substr(1)
if (previousQuery === noPrefix) return suggestions
suggestions = []
previousQuery = noPrefix
// Fetch more and wait, don't fetch if there's the 2nd @ because
// the backend user search can't deal with it.
// Reference semantics make it so that we get the updated data after
// the await.
if (!noPrefix.includes('@')) {
await debounceUserSearch(noPrefix)
}
const newSuggestions = state.users.users
.filter(
(user) =>
user.screen_name &&
user.name &&
(user.screen_name.toLowerCase().startsWith(noPrefix) ||
user.name.toLowerCase().startsWith(noPrefix)),
)
.slice(0, 20)
.sort((a, b) => {
let aScore = 0
let bScore = 0
// Matches on screen name (i.e. user@instance) makes a priority
aScore += a.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
bScore += b.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
// Matches on name takes second priority
aScore += a.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
bScore += b.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
const diff = (bScore - aScore) * 10
// Then sort alphabetically
const nameAlphabetically = a.name > b.name ? 1 : -1
const screenNameAlphabetically = a.screen_name > b.screen_name ? 1 : -1
return diff + nameAlphabetically + screenNameAlphabetically
})
.map((user) => ({
user,
displayText: user.screen_name_ui,
detailText: user.name,
imageUrl: user.profile_image_url_original,
replacement: '@' + user.screen_name + ' ',
}))
suggestions = newSuggestions || []
return suggestions
}
}
diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js
index 79c0ab47e4..1d19987ac3 100644
--- a/src/components/emoji_picker/emoji_picker.js
+++ b/src/components/emoji_picker/emoji_picker.js
@@ -1,436 +1,437 @@
import { chunk, debounce, trim } from 'lodash'
import { defineAsyncComponent } from 'vue'
import Popover from 'src/components/popover/popover.vue'
import { ensureFinalFallback } from '../../i18n/languages.js'
import Checkbox from '../checkbox/checkbox.vue'
import StillImage from '../still-image/still-image.vue'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceStore } from 'src/stores/instance.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBasketballBall,
faBoxOpen,
faBus,
faCode,
faFlag,
faIceCream,
faLightbulb,
faPaw,
faSmile,
faSmileBeam,
faStickyNote,
faUser,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faBoxOpen,
faStickyNote,
faSmileBeam,
faSmile,
faUser,
faPaw,
faIceCream,
faBus,
faBasketballBall,
faLightbulb,
faCode,
faFlag,
)
const UNICODE_EMOJI_GROUP_ICON = {
'smileys-and-emotion': 'smile',
'people-and-body': 'user',
'animals-and-nature': 'paw',
'food-and-drink': 'ice-cream',
'travel-and-places': 'bus',
activities: 'basketball-ball',
objects: 'lightbulb',
symbols: 'code',
flags: 'flag',
}
const maybeLocalizedKeywords = (emoji, languages, nameLocalizer) => {
const res = [emoji.displayText, nameLocalizer(emoji)]
if (emoji.annotations) {
languages.forEach((lang) => {
const keywords = emoji.annotations[lang]?.keywords || []
const name = emoji.annotations[lang]?.name
res.push(...keywords.concat([name]).filter((k) => k))
})
}
return res
}
const filterByKeyword = (list, keyword = '', languages, nameLocalizer) => {
if (keyword === '') return list
const keywordLowercase = keyword.toLowerCase()
const orderedEmojiList = []
for (const emoji of list) {
const indices = maybeLocalizedKeywords(emoji, languages, nameLocalizer)
.map((k) => k.toLowerCase().indexOf(keywordLowercase))
.filter((k) => k > -1)
const indexOfKeyword = indices.length ? Math.min(...indices) : -1
if (indexOfKeyword > -1) {
if (!Array.isArray(orderedEmojiList[indexOfKeyword])) {
orderedEmojiList[indexOfKeyword] = []
}
orderedEmojiList[indexOfKeyword].push(emoji)
}
}
return orderedEmojiList.flat()
}
const getOffset = (elem) => {
const style = elem.style.transform
const res = /translateY\((\d+)px\)/.exec(style)
if (!res) {
return 0
}
return res[1]
}
const toHeaderId = (id) => {
return id.replace(/^row-\d+-/, '')
}
const EmojiPicker = {
props: {
enableStickerPicker: {
required: false,
type: Boolean,
default: true,
},
hideCustomEmoji: {
required: false,
type: Boolean,
default: false,
},
},
inject: {
popoversZLayer: {
default: '',
},
},
data() {
return {
keyword: '',
activeGroup: 'custom',
showingStickers: false,
groupsScrolledClass: 'scrolled-top',
keepOpen: false,
customEmojiTimeout: null,
hideCustomEmojiInPicker: false,
// Lazy-load only after the first time `showing` becomes true.
contentLoaded: false,
groupRefs: {},
emojiRefs: {},
filteredEmojiGroups: [],
emojiSize: 0,
width: 0,
}
},
components: {
StickerPicker: defineAsyncComponent(
() => import('../sticker_picker/sticker_picker.vue'),
),
Checkbox,
StillImage,
Popover,
},
methods: {
groupScroll(e) {
e.currentTarget.scrollLeft += e.deltaY + e.deltaX
},
updateEmojiSize() {
const css = window.getComputedStyle(this.$refs.popover.$el)
const fontSize = css.getPropertyValue('font-size') || '1rem'
const emojiSize = css.getPropertyValue('--emojiSize') || '2.2rem'
const fontSizeUnit = fontSize.replace(/[0-9,.]+/, '').trim()
const fontSizeValue = Number(fontSize.replace(/[^0-9,.]+/, ''))
const emojiSizeUnit = emojiSize.replace(/[0-9,.]+/, '').trim()
const emojiSizeValue = Number(emojiSize.replace(/[^0-9,.]+/, ''))
let fontSizeMultiplier
if (fontSizeUnit.endsWith('em')) {
fontSizeMultiplier = fontSizeValue
} else {
fontSizeMultiplier = fontSizeValue / 14
}
let emojiSizeReal
if (emojiSizeUnit.endsWith('em')) {
emojiSizeReal = emojiSizeValue * fontSizeMultiplier * 14
} else {
emojiSizeReal = emojiSizeValue
}
const fullEmojiSize = emojiSizeReal + 2 * 0.2 * fontSizeMultiplier * 14
this.emojiSize = fullEmojiSize
},
showPicker() {
this.$refs.popover.showPopover()
this.$nextTick(() => {
this.onShowing()
})
},
hidePicker() {
this.$refs.popover.hidePopover()
},
setAnchorEl(el) {
this.$refs.popover.setAnchorEl(el)
},
setGroupRef(name) {
return (el) => {
this.groupRefs[name] = el
}
},
onPopoverShown() {
this.$emit('show')
},
onPopoverClosed() {
this.$emit('close')
},
onStickerUploaded(e) {
this.$emit('sticker-uploaded', e)
},
onStickerUploadFailed(e) {
this.$emit('sticker-upload-failed', e)
},
onEmoji(emoji) {
const value = emoji.imageUrl
? `:${emoji.displayText}:`
: emoji.replacement
if (!this.keepOpen) {
this.$refs.popover.hidePopover()
}
this.$emit('emoji', {
insertion: value,
insertionUrl: emoji.imageUrl,
keepOpen: this.keepOpen,
})
},
onScroll(startIndex, endIndex, visibleStartIndex, visibleEndIndex) {
const target = this.$refs['emoji-groups'].$el
this.scrolledGroup(target, visibleStartIndex, visibleEndIndex)
},
scrolledGroup(target, start, end) {
const top = target.scrollTop + 5
this.$nextTick(() => {
this.emojiItems.slice(start, end + 1).forEach((group) => {
const headerId = toHeaderId(group.id)
const ref = this.groupRefs['group-' + group.id]
if (!ref) {
return
}
const elem = ref.$el.parentElement
if (!elem) {
return
}
if (elem && getOffset(elem) <= top) {
this.activeGroup = headerId
}
})
this.scrollHeader()
})
},
scrollHeader() {
// Scroll the active tab's header into view
const headerRef = this.groupRefs['group-header-' + this.activeGroup]
const left = headerRef.offsetLeft
const right = left + headerRef.offsetWidth
const headerCont = this.$refs.header
const currentScroll = headerCont.scrollLeft
const currentScrollRight = currentScroll + headerCont.clientWidth
const setScroll = (s) => {
headerCont.scrollLeft = s
}
const margin = 7 // .emoji-tabs-item: padding
if (left - margin < currentScroll) {
setScroll(left - margin)
} else if (right + margin > currentScrollRight) {
setScroll(right + margin - headerCont.clientWidth)
}
},
highlight(groupId) {
this.setShowStickers(false)
const indexInList = this.emojiItems.findIndex((k) => k.id === groupId)
this.$refs['emoji-groups'].scrollToItem(indexInList)
},
updateScrolledClass(target) {
if (target.scrollTop <= 5) {
this.groupsScrolledClass = 'scrolled-top'
} else if (target.scrollTop >= target.scrollTopMax - 5) {
this.groupsScrolledClass = 'scrolled-bottom'
} else {
this.groupsScrolledClass = 'scrolled-middle'
}
},
toggleStickers() {
this.showingStickers = !this.showingStickers
},
setShowStickers(value) {
this.showingStickers = value
},
filterByKeyword(list, keyword) {
return filterByKeyword(
list,
keyword,
this.languages,
this.maybeLocalizedEmojiName,
)
},
onShowing() {
const oldContentLoaded = this.contentLoaded
this.updateEmojiSize()
this.recalculateItemPerRow()
this.$nextTick(() => {
this.$refs.search.focus()
})
this.contentLoaded = true
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
if (!oldContentLoaded) {
this.$nextTick(() => {
if (this.defaultGroup) {
this.highlight(this.defaultGroup)
}
})
}
},
getFilteredEmojiGroups() {
return this.allEmojiGroups
.map((group) => ({
...group,
emojis: this.filterByKeyword(group.emojis, trim(this.keyword)),
}))
.filter((group) => group.emojis.length > 0)
},
recalculateItemPerRow() {
this.$nextTick(() => {
if (!this.$refs['emoji-groups']) {
return
}
this.width = this.$refs['emoji-groups'].$el.clientWidth
})
},
},
watch: {
keyword() {
this.onScroll()
this.debouncedHandleKeywordChange()
},
allCustomGroups() {
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
},
},
computed: {
minItemSize() {
return this.emojiSize
},
// used to watch it
fontSize() {
this.$nextTick(() => {
this.updateEmojiSize()
})
- return this.$store.getters.mergedConfig.fontSize
+ return useMergedConfigStore().mergedConfig.fontSize
},
emojiHeight() {
return this.emojiSize
},
itemPerRow() {
return this.width ? Math.floor(this.width / this.emojiSize) : 6
},
activeGroupView() {
return this.showingStickers ? '' : this.activeGroup
},
stickersAvailable() {
if (useEmojiStore().stickers) {
return useEmojiStore().stickers.length > 0
}
return 0
},
allCustomGroups() {
if (this.hideCustomEmoji || this.hideCustomEmojiInPicker) {
return {}
}
const emojis = useEmojiStore().groupedCustomEmojis
if (emojis.unpacked) {
emojis.unpacked.text = this.$t('emoji.unpacked')
}
return emojis
},
defaultGroup() {
return Object.keys(this.allCustomGroups)[0]
},
unicodeEmojiGroups() {
return useEmojiStore().standardEmojiGroupList.map((group) => ({
id: `standard-${group.id}`,
text: this.$t(`emoji.unicode_groups.${group.id}`),
icon: UNICODE_EMOJI_GROUP_ICON[group.id],
emojis: group.emojis,
}))
},
allEmojiGroups() {
return Object.entries(this.allCustomGroups)
.map(([, v]) => v)
.concat(this.unicodeEmojiGroups)
},
stickerPickerEnabled() {
return (useEmojiStore().stickers || []).length !== 0
},
debouncedHandleKeywordChange() {
return debounce(() => {
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
}, 500)
},
emojiItems() {
return this.filteredEmojiGroups
.map((group) =>
chunk(group.emojis, this.itemPerRow).map((items, index) => ({
...group,
id: index === 0 ? group.id : `row-${index}-${group.id}`,
emojis: items,
isFirstRow: index === 0,
})),
)
.reduce((a, c) => a.concat(c), [])
},
languages() {
return ensureFinalFallback(
- this.$store.getters.mergedConfig.interfaceLanguage,
+ useMergedConfigStore().mergedConfig.interfaceLanguage,
)
},
maybeLocalizedEmojiName() {
return (emoji) => {
if (!emoji.annotations) {
return emoji.displayText
}
if (emoji.displayTextI18n) {
return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)
}
for (const lang of this.languages) {
if (emoji.annotations[lang]?.name) {
return emoji.annotations[lang].name
}
}
return emoji.displayText
}
},
isInModal() {
return this.popoversZLayer === 'modals'
},
},
}
export default EmojiPicker
diff --git a/src/components/emoji_reactions/emoji_reactions.js b/src/components/emoji_reactions/emoji_reactions.js
index 8a121befc5..e1a28a8f18 100644
--- a/src/components/emoji_reactions/emoji_reactions.js
+++ b/src/components/emoji_reactions/emoji_reactions.js
@@ -1,101 +1,103 @@
import StillImage from 'src/components/still-image/still-image.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserListPopover from '../user_list_popover/user_list_popover.vue'
+import { useInstanceStore } from 'src/stores/instance.js'
+
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCheck, faMinus, faPlus } from '@fortawesome/free-solid-svg-icons'
library.add(faPlus, faMinus, faCheck)
const EMOJI_REACTION_COUNT_CUTOFF = 12
const EmojiReactions = {
name: 'EmojiReactions',
components: {
UserAvatar,
UserListPopover,
StillImage,
},
props: ['status'],
data: () => ({
showAll: false,
}),
computed: {
tooManyReactions() {
return this.status.emoji_reactions.length > EMOJI_REACTION_COUNT_CUTOFF
},
emojiReactions() {
return this.showAll
? this.status.emoji_reactions
: this.status.emoji_reactions.slice(0, EMOJI_REACTION_COUNT_CUTOFF)
},
showMoreString() {
return `+${this.status.emoji_reactions.length - EMOJI_REACTION_COUNT_CUTOFF}`
},
accountsForEmoji() {
return this.status.emoji_reactions.reduce((acc, reaction) => {
acc[reaction.name] = reaction.accounts || []
return acc
}, {})
},
loggedIn() {
return !!this.$store.state.users.currentUser
},
remoteInteractionLink() {
- return this.$store.getters.remoteInteractionLink({
+ return useInstanceStore().getRemoteInteractionLink({
statusId: this.status.id,
})
},
},
methods: {
toggleShowAll() {
this.showAll = !this.showAll
},
reactedWith(emoji) {
return this.status.emoji_reactions.find((r) => r.name === emoji).me
},
async fetchEmojiReactionsByIfMissing() {
const hasNoAccounts = this.status.emoji_reactions.find((r) => !r.accounts)
if (hasNoAccounts) {
return await this.$store.dispatch(
'fetchEmojiReactionsBy',
this.status.id,
)
}
},
reactWith(emoji) {
this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji })
},
unreact(emoji) {
this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji })
},
async emojiOnClick(emoji) {
if (!this.loggedIn) return
await this.fetchEmojiReactionsByIfMissing()
if (this.reactedWith(emoji)) {
this.unreact(emoji)
} else {
this.reactWith(emoji)
}
},
counterTriggerAttrs(reaction) {
return {
class: [
'emoji-reaction-count-button',
{
'-picked-reaction': this.reactedWith(reaction.name),
toggled: this.reactedWith(reaction.name),
},
],
'aria-label': this.$t(
'status.reaction_count_label',
{ num: reaction.count },
reaction.count,
),
}
},
},
}
export default EmojiReactions
diff --git a/src/components/extra_notifications/extra_notifications.js b/src/components/extra_notifications/extra_notifications.js
index 85f4f72bd2..31ca57aaa1 100644
--- a/src/components/extra_notifications/extra_notifications.js
+++ b/src/components/extra_notifications/extra_notifications.js
@@ -1,72 +1,75 @@
import { mapState as mapPiniaState } from 'pinia'
import { mapGetters } from 'vuex'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
import { useInterfaceStore } from 'src/stores/interface.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBullhorn,
faComments,
faUserPlus,
} from '@fortawesome/free-solid-svg-icons'
library.add(faUserPlus, faComments, faBullhorn)
const ExtraNotifications = {
computed: {
shouldShowChats() {
return (
this.mergedConfig.showExtraNotifications &&
this.mergedConfig.showChatsInExtraNotifications &&
this.unreadChatCount
)
},
shouldShowAnnouncements() {
return (
this.mergedConfig.showExtraNotifications &&
this.mergedConfig.showAnnouncementsInExtraNotifications &&
this.unreadAnnouncementCount
)
},
shouldShowFollowRequests() {
return (
this.mergedConfig.showExtraNotifications &&
this.mergedConfig.showFollowRequestsInExtraNotifications &&
this.followRequestCount
)
},
hasAnythingToShow() {
return (
this.shouldShowChats ||
this.shouldShowAnnouncements ||
this.shouldShowFollowRequests
)
},
shouldShowCustomizationTip() {
return (
this.mergedConfig.showExtraNotificationsTip && this.hasAnythingToShow
)
},
currentUser() {
return this.$store.state.users.currentUser
},
- ...mapGetters(['unreadChatCount', 'followRequestCount', 'mergedConfig']),
+ ...mapGetters(['unreadChatCount', 'followRequestCount']),
...mapPiniaState(useAnnouncementsStore, {
unreadAnnouncementCount: 'unreadAnnouncementCount',
}),
+ ...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
},
methods: {
openNotificationSettings() {
return useInterfaceStore().openSettingsModalTab('notifications')
},
dismissConfigurationTip() {
- return this.$store.dispatch('setOption', {
- name: 'showExtraNotificationsTip',
+ return useSyncConfigStore().setSimplePrefAndSave({
+ path: 'showExtraNotificationsTip',
value: false,
})
},
},
}
export default ExtraNotifications
diff --git a/src/components/features_panel/features_panel.js b/src/components/features_panel/features_panel.js
index 106da1487a..10deda250a 100644
--- a/src/components/features_panel/features_panel.js
+++ b/src/components/features_panel/features_panel.js
@@ -1,25 +1,25 @@
import { mapState } from 'pinia'
import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
const FeaturesPanel = {
computed: {
...mapState(useInstanceCapabilitiesStore, [
'shoutAvailable',
'pleromaChatMessagesAvailable',
'gopherAvailable',
'suggestionsEnabled',
'mediaProxyAvailable',
]),
...mapState(useInstanceStore, {
- textlimit: (store) => store.limits.textlimit,
+ textLimit: (store) => store.limits.textLimit,
uploadlimit: (store) =>
fileSizeFormatService.fileSizeFormat(store.limits.uploadlimit),
}),
},
}
export default FeaturesPanel
diff --git a/src/components/features_panel/features_panel.vue b/src/components/features_panel/features_panel.vue
index 4270436faf..f15ad0edb0 100644
--- a/src/components/features_panel/features_panel.vue
+++ b/src/components/features_panel/features_panel.vue
@@ -1,41 +1,41 @@
<template>
<div class="features-panel">
<div class="panel panel-default base01-background">
<div class="panel-heading timeline-heading base02-background base04 -sticky">
<h1 class="title">
{{ $t('features_panel.title') }}
</h1>
</div>
<div class="panel-body">
<ul>
<li v-if="shoutAvailable">
{{ $t('features_panel.shout') }}
</li>
<li v-if="pleromaChatMessagesAvailable">
{{ $t('features_panel.pleroma_chat_messages') }}
</li>
<li v-if="gopherAvailable">
{{ $t('features_panel.gopher') }}
</li>
<li v-if="suggestionsEnabled">
{{ $t('features_panel.who_to_follow') }}
</li>
<li v-if="mediaProxyAvailable">
{{ $t('features_panel.media_proxy') }}
</li>
<li>{{ $t('features_panel.scope_options') }}</li>
- <li>{{ $t('features_panel.text_limit') }} = {{ textlimit }}</li>
+ <li>{{ $t('features_panel.text_limit') }} = {{ textLimit }}</li>
<li>{{ $t('features_panel.upload_limit') }} = {{ uploadlimit.num }} {{ $t('upload.file_size_units.' + uploadlimit.unit) }}</li>
</ul>
</div>
</div>
</div>
</template>
<script src="./features_panel.js"></script>
<style lang="scss">
.features-panel li {
line-height: 24px;
}
</style>
diff --git a/src/components/follow_button/follow_button.js b/src/components/follow_button/follow_button.js
index eb545b28d5..644f95f859 100644
--- a/src/components/follow_button/follow_button.js
+++ b/src/components/follow_button/follow_button.js
@@ -1,87 +1,89 @@
import {
requestFollow,
requestUnfollow,
} from '../../services/follow_manipulate/follow_manipulate'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
+
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
export default {
props: ['relationship', 'user', 'labelFollowing', 'buttonClass'],
components: {
ConfirmModal,
},
data() {
return {
inProgress: false,
showingConfirmUnfollow: false,
}
},
computed: {
shouldConfirmUnfollow() {
- return this.$store.getters.mergedConfig.modalOnUnfollow
+ return useMergedConfigStore().mergedConfig.modalOnUnfollow
},
isPressed() {
return this.inProgress || this.relationship.following
},
title() {
if (this.inProgress || this.relationship.following) {
return this.$t('user_card.follow_unfollow')
} else if (this.relationship.requested) {
return this.$t('user_card.follow_cancel')
} else {
return this.$t('user_card.follow')
}
},
label() {
if (this.inProgress) {
return this.$t('user_card.follow_progress')
} else if (this.relationship.following) {
return this.labelFollowing || this.$t('user_card.following')
} else if (this.relationship.requested) {
return this.$t('user_card.follow_sent')
} else {
return this.$t('user_card.follow')
}
},
disabled() {
return this.inProgress || this.user.deactivated
},
},
methods: {
showConfirmUnfollow() {
this.showingConfirmUnfollow = true
},
hideConfirmUnfollow() {
this.showingConfirmUnfollow = false
},
onClick() {
this.relationship.following || this.relationship.requested
? this.unfollow()
: this.follow()
},
follow() {
this.inProgress = true
requestFollow(this.relationship.id, this.$store).then(() => {
this.inProgress = false
})
},
unfollow() {
if (this.shouldConfirmUnfollow) {
this.showConfirmUnfollow()
} else {
this.doUnfollow()
}
},
doUnfollow() {
const store = this.$store
this.inProgress = true
requestUnfollow(this.relationship.id, store).then(() => {
this.inProgress = false
store.commit('removeStatus', {
timeline: 'friends',
userId: this.relationship.id,
})
})
this.hideConfirmUnfollow()
},
},
}
diff --git a/src/components/follow_request_card/follow_request_card.js b/src/components/follow_request_card/follow_request_card.js
index c037ddf429..a6ffcd28be 100644
--- a/src/components/follow_request_card/follow_request_card.js
+++ b/src/components/follow_request_card/follow_request_card.js
@@ -1,90 +1,92 @@
import { notificationsFromStore } from '../../services/notification_utils/notification_utils.js'
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+
const FollowRequestCard = {
props: ['user'],
components: {
BasicUserCard,
ConfirmModal,
},
data() {
return {
showingApproveConfirmDialog: false,
showingDenyConfirmDialog: false,
}
},
methods: {
findFollowRequestNotificationId() {
const notif = notificationsFromStore(this.$store).find(
(notif) =>
notif.from_profile.id === this.user.id &&
notif.type === 'follow_request',
)
return notif && notif.id
},
showApproveConfirmDialog() {
this.showingApproveConfirmDialog = true
},
hideApproveConfirmDialog() {
this.showingApproveConfirmDialog = false
},
showDenyConfirmDialog() {
this.showingDenyConfirmDialog = true
},
hideDenyConfirmDialog() {
this.showingDenyConfirmDialog = false
},
approveUser() {
if (this.shouldConfirmApprove) {
this.showApproveConfirmDialog()
} else {
this.doApprove()
}
},
doApprove() {
this.$store.state.api.backendInteractor.approveUser({ id: this.user.id })
this.$store.dispatch('removeFollowRequest', this.user)
const notifId = this.findFollowRequestNotificationId()
this.$store.dispatch('markSingleNotificationAsSeen', { id: notifId })
this.$store.dispatch('updateNotification', {
id: notifId,
updater: (notification) => {
notification.type = 'follow'
},
})
this.hideApproveConfirmDialog()
},
denyUser() {
if (this.shouldConfirmDeny) {
this.showDenyConfirmDialog()
} else {
this.doDeny()
}
},
doDeny() {
const notifId = this.findFollowRequestNotificationId()
this.$store.state.api.backendInteractor
.denyUser({ id: this.user.id })
.then(() => {
this.$store.dispatch('dismissNotificationLocal', { id: notifId })
this.$store.dispatch('removeFollowRequest', this.user)
})
this.hideDenyConfirmDialog()
},
},
computed: {
mergedConfig() {
- return this.$store.getters.mergedConfig
+ return useMergedConfigStore().mergedConfig
},
shouldConfirmApprove() {
return this.mergedConfig.modalOnApproveFollow
},
shouldConfirmDeny() {
return this.mergedConfig.modalOnDenyFollow
},
},
}
export default FollowRequestCard
diff --git a/src/components/font_control/font_control.js b/src/components/font_control/font_control.js
index 45e5fde81c..81c46ec89a 100644
--- a/src/components/font_control/font_control.js
+++ b/src/components/font_control/font_control.js
@@ -1,55 +1,57 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Popover from 'src/components/popover/popover.vue'
+import LocalSettingIndicator from 'src/components/settings_modal/helpers/local_setting_indicator.vue'
import Select from '../select/select.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faExclamationTriangle,
faFont,
faKeyboard,
} from '@fortawesome/free-solid-svg-icons'
library.add(faExclamationTriangle, faKeyboard, faFont)
export default {
components: {
Select,
Checkbox,
Popover,
+ LocalSettingIndicator,
},
props: ['name', 'label', 'modelValue', 'fallback', 'options', 'no-inherit'],
mounted() {
useInterfaceStore().queryLocalFonts()
},
emits: ['update:modelValue'],
data() {
return {
manualEntry: false,
availableOptions: [
this.noInherit ? '' : 'inherit',
'serif',
'sans-serif',
'monospace',
...(this.options || []),
].filter((_) => _),
}
},
methods: {
toggleManualEntry() {
this.manualEntry = !this.manualEntry
},
},
computed: {
present() {
- return typeof this.modelValue !== 'undefined'
+ return this.modelValue != null
},
localFontsList() {
return useInterfaceStore().localFonts
},
localFontsSize() {
return useInterfaceStore().localFonts?.length
},
},
}
diff --git a/src/components/font_control/font_control.vue b/src/components/font_control/font_control.vue
index 5124fd53a5..62f8d052d7 100644
--- a/src/components/font_control/font_control.vue
+++ b/src/components/font_control/font_control.vue
@@ -1,144 +1,146 @@
<template>
<div class="font-control">
<div class="setting-item">
<Checkbox
v-if="typeof fallback !== 'undefined'"
:id="name + '-o'"
class="font-checkbox setting-control setting-label"
:model-value="present"
- @change="$emit('update:modelValue', typeof modelValue === 'undefined' ? fallback : undefined)"
+ @change="$emit('update:modelValue', modelValue == null ? fallback : null)"
>
+ <LocalSettingIndicator />
+ {{ ' ' }}
<i18n-t
scope="global"
keypath="settings.style.fonts.override"
tag="span"
>
<span>
{{ label }}
</span>
</i18n-t>
</Checkbox>
</div>
{{ ' ' }}
<div
v-if="modelValue?.family"
class="font-input setting-item"
>
<label
v-if="manualEntry"
:id="name + '-label'"
:for="manualEntry ? name : name + '-font-switcher'"
class="label"
>
<i18n-t
keypath="settings.style.themes3.font.entry"
tag="span"
scope="global"
>
<template #fontFamily>
<code>font-family</code>
</template>
</i18n-t>
</label>
<label
v-else
:id="name + '-label'"
:for="manualEntry ? name : name + '-font-switcher'"
class="label"
>
{{ $t('settings.style.themes3.font.select') }}
</label>
{{ ' ' }}
<span
v-if="manualEntry"
class="btn-group"
>
<button
class="btn button-default"
:title="$t('settings.style.themes3.font.lookup_local_fonts')"
@click="toggleManualEntry"
>
<FAIcon
fixed-width
icon="font"
/>
</button>
<input
:id="name"
:model-value="modelValue.family"
class="input custom-font"
type="text"
@update:modelValue="$emit('update:modelValue', { ...(modelValue || {}), family: $event.target.value })"
>
</span>
<span
v-else
class="font-selector btn-group"
>
<button
class="btn button-default"
:title="$t('settings.style.themes3.font.enter_manually')"
@click="toggleManualEntry"
>
<FAIcon
fixed-width
icon="keyboard"
/>
</button>
<Select
:id="name + '-local-font-switcher'"
:model-value="modelValue?.family"
class="custom-font"
@update:model-value="v => $emit('update:modelValue', { ...(modelValue || {}), family: v })"
>
<optgroup
:label="$t('settings.style.themes3.font.group-builtin')"
>
<option
v-for="option in availableOptions"
:key="option"
:value="option"
:style="{ fontFamily: option === 'inherit' ? null : option }"
>
{{ $t('settings.style.themes3.font.builtin.' + option) }}
</option>
</optgroup>
<optgroup
v-if="localFontsSize > 0"
:label="$t('settings.style.themes3.font.group-local')"
>
<option
v-for="option in localFontsList"
:key="option"
:value="option"
:style="{ fontFamily: option }"
>
{{ option }}
</option>
</optgroup>
<optgroup
v-else
:label="$t('settings.style.themes3.font.group-local')"
>
<option disabled>
{{ $t('settings.style.themes3.font.local-unavailable1') }}
</option>
<option disabled>
{{ $t('settings.style.themes3.font.local-unavailable2') }}
</option>
</optgroup>
</Select>
</span>
</div>
</div>
</template>
<script src="./font_control.js"></script>
<style lang="scss">
.invalid-tooltip {
margin: 0.5em 1em;
min-width: 10em;
text-align: center;
}
</style>
diff --git a/src/components/gallery/gallery.js b/src/components/gallery/gallery.js
index 42cc6cfd6d..fafb66dbd2 100644
--- a/src/components/gallery/gallery.js
+++ b/src/components/gallery/gallery.js
@@ -1,137 +1,137 @@
import { set, sumBy } from 'lodash'
import Attachment from '../attachment/attachment.vue'
import { useMediaViewerStore } from 'src/stores/media_viewer.js'
-const displayTypes = new Set(["image", "video", "flash"])
+const displayTypes = new Set(['image', 'video', 'flash'])
const Gallery = {
props: [
'attachments',
'compact',
'limitRows',
'descriptions',
'limit',
'nsfw',
'setMedia',
'size',
'editable',
'removeAttachment',
'shiftUpAttachment',
'shiftDnAttachment',
'editAttachment',
'grid',
],
data() {
return {
sizes: {},
hidingLong: true,
}
},
components: { Attachment },
computed: {
rows() {
if (!this.attachments) {
return []
}
const attachments =
this.limit > 0
? this.attachments.slice(0, this.limit)
: this.attachments
if (this.size === 'hide') {
return attachments.map((item) => ({ minimal: true, items: [item] }))
}
const rows = this.grid
? [{ grid: true, items: attachments }]
: attachments
.reduce(
(acc, attachment, i) => {
if (attachment.type === 'audio') {
return [
...acc,
{ audio: true, items: [attachment] },
{ items: [] },
]
}
if (!displayTypes.has(attachment.type)) {
return [
...acc,
{ minimal: true, items: [attachment] },
{ items: [] },
]
}
const maxPerRow = 3
const attachmentsRemaining = this.attachments.length - i + 1
const currentRow = acc[acc.length - 1].items
currentRow.push(attachment)
if (
currentRow.length >= maxPerRow &&
attachmentsRemaining > maxPerRow
) {
return [...acc, { items: [] }]
} else {
return acc
}
},
[{ items: [] }],
)
.filter((_) => _.items.length > 0)
return rows
},
attachmentsDimensionalScore() {
return this.rows.reduce((acc, row) => {
let size = 0
if (row.minimal) {
size += 1 / 8
} else if (row.audio) {
size += 1 / 4
} else {
size += 1 / (row.items.length + 0.6)
}
return acc + size
}, 0)
},
tooManyAttachments() {
if (this.editable || this.size === 'small') {
return false
} else if (this.size === 'hide') {
return this.attachments.length > 8
} else {
return this.attachmentsDimensionalScore > 1
}
},
},
methods: {
onNaturalSizeLoad({ id, width, height }) {
set(this.sizes, id, { width, height })
},
rowStyle(row) {
if (row.audio) {
return { 'padding-bottom': '25%' } // fixed reduced height for audio
} else if (!row.minimal && !row.grid) {
return { 'padding-bottom': `${100 / (row.items.length + 0.6)}%` }
}
},
itemStyle(id, row) {
const total = sumBy(row, (item) => this.getAspectRatio(item.id))
return { flex: `${this.getAspectRatio(id) / total} 1 0%` }
},
getAspectRatio(id) {
const size = this.sizes[id]
return size ? size.width / size.height : 1
},
toggleHidingLong(event) {
this.hidingLong = event
},
openGallery() {
useMediaViewerStore().setMedia(this.attachments)
useMediaViewerStore().setCurrentMedia(this.attachments[0])
},
onMedia() {
useMediaViewerStore().setMedia(this.attachments)
},
},
}
export default Gallery
diff --git a/src/components/interface_language_switcher/interface_language_switcher.js b/src/components/interface_language_switcher/interface_language_switcher.js
index 1cdedadd3a..becf5677f1 100644
--- a/src/components/interface_language_switcher/interface_language_switcher.js
+++ b/src/components/interface_language_switcher/interface_language_switcher.js
@@ -1,63 +1,61 @@
import { v4 as uuidv4 } from 'uuid'
-import ProfileSettingIndicator from 'src/components/settings_modal/helpers/profile_setting_indicator.vue'
import localeService from '../../services/locale/locale.service.js'
import Select from '../select/select.vue'
export default {
components: {
Select,
- ProfileSettingIndicator,
},
props: {
// List of languages (or just one language)
modelValue: {
type: [Array, String],
required: true,
},
// Is this setting stored in user profile (true) or elsewhere (false)
// Doesn't affect storage, just shows an icon if true
profile: {
type: Boolean,
default: false,
},
},
emits: ['update:modelValue'],
computed: {
languages() {
return localeService.languages
},
uniqueId() {
return uuidv4()
},
controlledLanguage: {
get: function () {
return Array.isArray(this.modelValue)
? this.modelValue
: [this.modelValue]
},
set: function (val) {
this.$emit('update:modelValue', val)
},
},
},
methods: {
getLanguageName(code) {
return localeService.getLanguageName(code)
},
addLanguage() {
this.controlledLanguage = [...this.controlledLanguage, '']
},
setLanguageAt(index, val) {
const lang = [...this.controlledLanguage]
lang[index] = val
this.controlledLanguage = lang
},
removeLanguageAt(index) {
const lang = [...this.controlledLanguage]
lang.splice(index, 1)
this.controlledLanguage = lang
},
},
}
diff --git a/src/components/link-preview/link-preview.js b/src/components/link-preview/link-preview.js
index ac91f916da..808030fbbe 100644
--- a/src/components/link-preview/link-preview.js
+++ b/src/components/link-preview/link-preview.js
@@ -1,40 +1,42 @@
-import { mapGetters } from 'vuex'
+import { mapState } from 'pinia'
+
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
const LinkPreview = {
name: 'LinkPreview',
props: ['card', 'size', 'nsfw'],
data() {
return {
imageLoaded: false,
}
},
computed: {
useImage() {
// Currently BE shoudn't give cards if tagged NSFW, this is a bit paranoid
// as it makes sure to hide the image if somehow NSFW tagged preview can
// exist.
return this.card.image && !this.censored && this.size !== 'hide'
},
censored() {
return this.nsfw && this.hideNsfwConfig
},
useDescription() {
return this.card.description && /\S/.test(this.card.description)
},
hideNsfwConfig() {
return this.mergedConfig.hideNsfw
},
- ...mapGetters(['mergedConfig']),
+ ...mapState(useMergedConfigStore, ['mergedConfig']),
},
created() {
if (this.useImage) {
const newImg = new Image()
newImg.onload = () => {
this.imageLoaded = true
}
newImg.src = this.card.image
}
},
}
export default LinkPreview
diff --git a/src/components/lists_edit/lists_edit.js b/src/components/lists_edit/lists_edit.js
index bdadd4fcac..483a9c02a4 100644
--- a/src/components/lists_edit/lists_edit.js
+++ b/src/components/lists_edit/lists_edit.js
@@ -1,152 +1,151 @@
import { mapState as mapPiniaState } from 'pinia'
import { mapGetters, mapState } from 'vuex'
import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import ListsUserSearch from '../lists_user_search/lists_user_search.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useListsStore } from 'src/stores/lists.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronLeft, faSearch } from '@fortawesome/free-solid-svg-icons'
library.add(faSearch, faChevronLeft)
const ListsNew = {
components: {
BasicUserCard,
UserAvatar,
ListsUserSearch,
TabSwitcher,
PanelLoading,
},
data() {
return {
title: '',
titleDraft: '',
membersUserIds: [],
removedUserIds: new Set([]), // users we added for members, to undo
searchUserIds: [],
addedUserIds: new Set([]), // users we added from search, to undo
searchLoading: false,
reallyDelete: false,
}
},
created() {
if (!this.id) return
useListsStore()
.fetchList({ listId: this.id })
.then(() => {
this.title = this.findListTitle(this.id)
this.titleDraft = this.title
})
useListsStore()
.fetchListAccounts({ listId: this.id })
.then(() => {
this.membersUserIds = this.findListAccounts(this.id)
this.membersUserIds.forEach((userId) => {
this.$store.dispatch('fetchUserIfMissing', userId)
})
})
},
computed: {
id() {
return this.$route.params.id
},
membersUsers() {
return [...this.membersUserIds, ...this.addedUserIds]
.map((userId) => this.findUser(userId))
.filter((user) => user)
},
searchUsers() {
return this.searchUserIds
.map((userId) => this.findUser(userId))
.filter((user) => user)
},
...mapState({
currentUser: (state) => state.users.currentUser,
}),
...mapPiniaState(useListsStore, ['findListTitle', 'findListAccounts']),
...mapGetters(['findUser']),
},
methods: {
onInput() {
this.search(this.query)
},
toggleRemoveMember(user) {
if (this.removedUserIds.has(user.id)) {
this.id && this.addUser(user)
this.removedUserIds.delete(user.id)
} else {
this.id && this.removeUser(user.id)
this.removedUserIds.add(user.id)
}
},
toggleAddFromSearch(user) {
if (this.addedUserIds.has(user.id)) {
this.id && this.removeUser(user.id)
this.addedUserIds.delete(user.id)
} else {
this.id && this.addUser(user)
this.addedUserIds.add(user.id)
}
},
isRemoved(user) {
return this.removedUserIds.has(user.id)
},
isAdded(user) {
return this.addedUserIds.has(user.id)
},
addUser(user) {
useListsStore().addListAccount({ accountId: user.id, listId: this.id })
},
removeUser(userId) {
useListsStore().removeListAccount({ accountId: userId, listId: this.id })
},
onSearchLoading() {
this.searchLoading = true
},
onSearchLoadingDone() {
this.searchLoading = false
},
onSearchResults(results) {
this.searchLoading = false
this.searchUserIds = results
},
updateListTitle() {
useListsStore().setList({ listId: this.id, title: this.titleDraft })
this.title = this.findListTitle(this.id)
},
createList() {
useListsStore()
.createList({ title: this.titleDraft })
.then((list) => {
- useListsStore()
- .setListAccounts({
- listId: list.id,
- accountIds: [...this.addedUserIds],
- })
+ useListsStore().setListAccounts({
+ listId: list.id,
+ accountIds: [...this.addedUserIds],
+ })
return list.id
})
.then((listId) => {
this.$router.push({ name: 'lists-timeline', params: { id: listId } })
})
.catch((e) => {
useInterfaceStore().pushGlobalNotice({
messageKey: 'lists.error',
messageArgs: [e.message],
level: 'error',
})
})
},
deleteList() {
useListsStore().deleteList({ listId: this.id })
this.$router.push({ name: 'lists' })
},
},
}
export default ListsNew
diff --git a/src/components/media_upload/media_upload.js b/src/components/media_upload/media_upload.js
index ee61fa369e..2cb6a96e6d 100644
--- a/src/components/media_upload/media_upload.js
+++ b/src/components/media_upload/media_upload.js
@@ -1,198 +1,199 @@
import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
import statusPosterService from '../../services/status_poster/status_poster.service.js'
import { useInstanceStore } from 'src/stores/instance.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch, faUpload } from '@fortawesome/free-solid-svg-icons'
library.add(faUpload, faCircleNotch)
const mediaUpload = {
data() {
return {
uploadCount: 0,
uploadReady: true,
}
},
computed: {
uploading() {
return this.uploadCount > 0
},
},
methods: {
onClick() {
if (this.uploadReady) {
this.$refs.input.click()
}
},
async resizeImage(file) {
// Skip if not an image or if it's a GIF
if (!file.type.startsWith('image/') || file.type === 'image/gif') {
return file
}
// Skip if image compression is disabled
- if (!this.$store.getters.mergedConfig.imageCompression) {
+ if (!useMergedConfigStore().mergedConfig.imageCompression) {
return file
}
// For PNGs, check if animated
if (file.type === 'image/png') {
const isAnimated = await this.isAnimatedPng(file)
if (isAnimated) {
return file
}
}
return new Promise((resolve) => {
const img = new Image()
img.onload = () => {
// Calculate new dimensions
let width = img.width
let height = img.height
const maxSize = 2048
if (width > maxSize || height > maxSize) {
if (width > height) {
height = Math.round((height * maxSize) / width)
width = maxSize
} else {
width = Math.round((width * maxSize) / height)
height = maxSize
}
}
// Create canvas and resize
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0, width, height)
// Check WebP support by trying to create a WebP canvas
const testCanvas = document.createElement('canvas')
const supportsWebP = testCanvas
.toDataURL('image/webp')
.startsWith('data:image/webp')
// Convert to WebP if supported and alwaysUseJpeg is false, otherwise JPEG
const type =
- !this.$store.getters.mergedConfig.alwaysUseJpeg && supportsWebP
+ !useMergedConfigStore().mergedConfig.alwaysUseJpeg && supportsWebP
? 'image/webp'
: 'image/jpeg'
const extension = type === 'image/webp' ? '.webp' : '.jpg'
// Remove the original extension and add new one
const newFileName = file.name.replace(/\.[^/.]+$/, '') + extension
canvas.toBlob(
(blob) => {
resolve(
new File([blob], newFileName, {
type,
lastModified: Date.now(),
}),
)
},
type,
0.85,
)
}
img.src = URL.createObjectURL(file)
})
},
async isAnimatedPng(file) {
const buffer = await file.arrayBuffer()
const view = new Uint8Array(buffer)
// Look for animated PNG chunks (acTL)
for (let i = 0; i < view.length - 8; i++) {
if (
view[i] === 0x61 && // a
view[i + 1] === 0x63 && // c
view[i + 2] === 0x54 && // T
view[i + 3] === 0x4c
) {
// L
return true
}
}
return false
},
async uploadFile(file) {
const self = this
const store = this.$store
if (file.size > useInstanceStore().uploadlimit) {
const filesize = fileSizeFormatService.fileSizeFormat(file.size)
const allowedsize = fileSizeFormatService.fileSizeFormat(
useInstanceStore().uploadlimit,
)
self.$emit('upload-failed', 'file_too_big', {
filesize: filesize.num,
filesizeunit: filesize.unit,
allowedsize: allowedsize.num,
allowedsizeunit: allowedsize.unit,
})
return
}
// Resize image if needed
const processedFile = await this.resizeImage(file)
const formData = new FormData()
formData.append('file', processedFile)
self.$emit('uploading')
self.uploadCount++
statusPosterService.uploadMedia({ store, formData }).then(
(fileData) => {
self.$emit('uploaded', fileData)
self.decreaseUploadCount()
},
(error) => {
console.error('Error uploading file', error)
self.$emit('upload-failed', 'default')
self.decreaseUploadCount()
},
)
},
decreaseUploadCount() {
this.uploadCount--
if (this.uploadCount === 0) {
this.$emit('all-uploaded')
}
},
clearFile() {
this.uploadReady = false
this.$nextTick(() => {
this.uploadReady = true
})
},
multiUpload(files) {
for (const file of files) {
this.uploadFile(file)
}
},
change({ target }) {
this.multiUpload(target.files)
},
},
props: {
dropFiles: Object,
disabled: Boolean,
normalButton: Boolean,
acceptTypes: {
type: String,
default: '*/*',
},
},
watch: {
dropFiles: function (fileInfos) {
if (!this.uploading) {
this.multiUpload(fileInfos)
}
},
},
}
export default mediaUpload
diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js
index 37c30355cb..5edc89516a 100644
--- a/src/components/mention_link/mention_link.js
+++ b/src/components/mention_link/mention_link.js
@@ -1,166 +1,171 @@
+import { mapState as mapPiniaState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import { mapGetters, mapState } from 'vuex'
import {
highlightClass,
highlightStyle,
} from '../../services/user_highlighter/user_highlighter.js'
import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useUserHighlightStore } from 'src/stores/user_highlight.js'
+
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faAt } from '@fortawesome/free-solid-svg-icons'
library.add(faAt)
const MentionLink = {
name: 'MentionLink',
components: {
UserAvatar,
UnicodeDomainIndicator,
UserPopover: defineAsyncComponent(
() => import('../user_popover/user_popover.vue'),
),
},
props: {
url: {
required: true,
type: String,
},
content: {
required: true,
type: String,
},
userId: {
required: false,
type: String,
},
userScreenName: {
required: false,
type: String,
},
},
data() {
return {
hasSelection: false,
}
},
methods: {
onClick() {
if (this.shouldShowTooltip) return
const link = generateProfileLink(
this.userId || this.user.id,
this.userScreenName || this.user.screen_name,
)
this.$router.push(link)
},
handleSelection() {
if (this.$refs.full) {
this.hasSelection = document
.getSelection()
.containsNode(this.$refs.full, true)
}
},
},
mounted() {
document.addEventListener('selectionchange', this.handleSelection)
},
unmounted() {
document.removeEventListener('selectionchange', this.handleSelection)
},
computed: {
user() {
return (
this.url && this.$store && this.$store.getters.findUserByUrl(this.url)
)
},
isYou() {
// FIXME why user !== currentUser???
return this.user && this.user.id === this.currentUser.id
},
userName() {
return this.user && this.userNameFullUi.split('@')[0]
},
serverName() {
// XXX assumed that domain does not contain @
return (
this.user &&
(this.userNameFullUi.split('@')[1] ||
this.$store.getters.instanceDomain)
)
},
userNameFull() {
return this.user && this.user.screen_name
},
userNameFullUi() {
return this.user && this.user.screen_name_ui
},
- highlight() {
- return this.user && this.mergedConfig.highlight[this.user.screen_name]
+ highlightData() {
+ return this.highlight[this.user?.screen_name]
},
highlightType() {
- return this.highlight && '-' + this.highlight.type
+ return this.highlightData && '-' + this.highlightData.type
},
highlightClass() {
- if (this.highlight) return highlightClass(this.user)
+ return this.highlightData && highlightClass(this.user)
},
style() {
- if (this.highlight) {
+ if (this.highlightData) {
const {
backgroundColor,
backgroundPosition,
backgroundImage,
...rest
- } = highlightStyle(this.highlight)
+ } = highlightStyle(this.highlightData)
return rest
}
},
classnames() {
return [
{
'-you': this.isYou && this.shouldBoldenYou,
- '-highlighted': this.highlight,
+ '-highlighted': !!this.highlightData,
'-has-selection': this.hasSelection,
},
this.highlightType,
]
},
isRemote() {
return this.userName !== this.userNameFull
},
shouldShowFullUserName() {
const conf = this.mergedConfig.mentionLinkDisplay
if (conf === 'short') {
return false
} else if (conf === 'full') {
return true
} else {
// full_for_remote
return this.isRemote
}
},
shouldShowTooltip() {
return this.mergedConfig.mentionLinkShowTooltip
},
shouldShowAvatar() {
return this.mergedConfig.mentionLinkShowAvatar
},
shouldShowYous() {
return this.mergedConfig.mentionLinkShowYous
},
shouldBoldenYou() {
return this.mergedConfig.mentionLinkBoldenYou
},
shouldFadeDomain() {
return this.mergedConfig.mentionLinkFadeDomain
},
- ...mapGetters(['mergedConfig']),
+ ...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
+ ...mapPiniaState(useUserHighlightStore, ['highlight']),
...mapState({
currentUser: (state) => state.users.currentUser,
}),
},
}
export default MentionLink
diff --git a/src/components/mentions_line/mentions_line.js b/src/components/mentions_line/mentions_line.js
index e6aa392a06..88d2b32578 100644
--- a/src/components/mentions_line/mentions_line.js
+++ b/src/components/mentions_line/mentions_line.js
@@ -1,38 +1,40 @@
-import { mapGetters } from 'vuex'
+import { mapState } from 'pinia'
import MentionLink from 'src/components/mention_link/mention_link.vue'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+
export const MENTIONS_LIMIT = 5
const MentionsLine = {
name: 'MentionsLine',
props: {
mentions: {
required: true,
type: Array,
},
},
data: () => ({ expanded: false }),
components: {
MentionLink,
},
computed: {
mentionsComputed() {
return this.mentions.slice(0, MENTIONS_LIMIT)
},
extraMentions() {
return this.mentions.slice(MENTIONS_LIMIT)
},
manyMentions() {
return this.extraMentions.length > 0
},
- ...mapGetters(['mergedConfig']),
+ ...mapState(useMergedConfigStore, ['mergedConfig']),
},
methods: {
toggleShowMore() {
this.expanded = !this.expanded
},
},
}
export default MentionsLine
diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js
index c5b1d66f68..f47cef893a 100644
--- a/src/components/mobile_nav/mobile_nav.js
+++ b/src/components/mobile_nav/mobile_nav.js
@@ -1,156 +1,164 @@
import { mapState } from 'pinia'
import { mapGetters } from 'vuex'
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
import GestureService from '../../services/gesture_service/gesture_service'
import {
countExtraNotifications,
unseenNotificationsFromStore,
} from '../../services/notification_utils/notification_utils'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import Notifications from '../notifications/notifications.vue'
import SideDrawer from '../side_drawer/side_drawer.vue'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
import { useInstanceStore } from 'src/stores/instance.js'
-import { useServerSideStorageStore } from 'src/stores/serverSideStorage.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faArrowUp,
faBars,
faBell,
faCheckDouble,
faMinus,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(faTimes, faBell, faBars, faArrowUp, faMinus, faCheckDouble)
const MobileNav = {
components: {
SideDrawer,
Notifications,
NavigationPins,
ConfirmModal,
},
data: () => ({
notificationsCloseGesture: undefined,
notificationsOpen: false,
notificationsAtTop: true,
showingConfirmLogout: false,
}),
created() {
this.notificationsCloseGesture = GestureService.swipeGesture(
GestureService.DIRECTION_RIGHT,
() => this.closeMobileNotifications(true),
50,
)
},
computed: {
currentUser() {
return this.$store.state.users.currentUser
},
unseenNotifications() {
- return unseenNotificationsFromStore(this.$store)
+ return unseenNotificationsFromStore(
+ this.$store,
+ useMergedConfigStore().mergedConfig.notificationVisibility,
+ useMergedConfigStore().mergedConfig.ignoreInactionableSeen,
+ )
},
unseenNotificationsCount() {
return (
- this.unseenNotifications.length + countExtraNotifications(this.$store)
+ this.unseenNotifications.length +
+ countExtraNotifications(
+ this.$store,
+ useMergedConfigStore().mergedConfig,
+ )
)
},
unseenCount() {
return this.unseenNotifications.length
},
unseenCountBadgeText() {
return `${this.unseenCount ? this.unseenCount : ''}`
},
hideSitename() {
return useInstanceStore().hideSitename
},
sitename() {
return useInstanceStore().name
},
isChat() {
return this.$route.name === 'chat'
},
...mapState(useAnnouncementsStore, ['unreadAnnouncementCount']),
- ...mapState(useServerSideStorageStore, {
+ ...mapState(useMergedConfigStore, {
pinnedItems: (store) =>
new Set(store.prefsStorage.collections.pinnedNavItems).has('chats'),
}),
shouldConfirmLogout() {
- return this.$store.getters.mergedConfig.modalOnLogout
+ return useMergedConfigStore().mergedConfig.modalOnLogout
},
closingDrawerMarksAsSeen() {
- return this.$store.getters.mergedConfig.closingDrawerMarksAsSeen
+ return useMergedConfigStore().mergedConfig.closingDrawerMarksAsSeen
},
...mapGetters(['unreadChatCount']),
},
methods: {
toggleMobileSidebar() {
this.$refs.sideDrawer.toggleDrawer()
},
openMobileNotifications() {
this.notificationsOpen = true
},
closeMobileNotifications(markRead) {
if (this.notificationsOpen) {
// make sure to mark notifs seen only when the notifs were open and not
// from close-calls.
this.notificationsOpen = false
if (markRead && this.closingDrawerMarksAsSeen) {
this.markNotificationsAsSeen()
}
}
},
notificationsTouchStart(e) {
GestureService.beginSwipe(e, this.notificationsCloseGesture)
},
notificationsTouchMove(e) {
GestureService.updateSwipe(e, this.notificationsCloseGesture)
},
scrollToTop() {
window.scrollTo(0, 0)
},
scrollMobileNotificationsToTop() {
this.$refs.mobileNotifications.scrollTo(0, 0)
},
showConfirmLogout() {
this.showingConfirmLogout = true
},
hideConfirmLogout() {
this.showingConfirmLogout = false
},
logout() {
if (!this.shouldConfirmLogout) {
this.doLogout()
} else {
this.showConfirmLogout()
}
},
doLogout() {
this.$router.replace('/main/public')
this.$store.dispatch('logout')
this.hideConfirmLogout()
},
markNotificationsAsSeen() {
this.$store.dispatch('markNotificationsAsSeen')
},
onScroll({ target: { scrollTop, clientHeight, scrollHeight } }) {
this.notificationsAtTop = scrollTop > 0
if (scrollTop + clientHeight >= scrollHeight) {
this.$refs.notifications.fetchOlderNotifications()
}
},
},
watch: {
$route() {
// handles closing notificaitons when you press any router-link on the
// notifications.
this.closeMobileNotifications()
},
},
}
export default MobileNav
diff --git a/src/components/mobile_post_status_button/mobile_post_status_button.js b/src/components/mobile_post_status_button/mobile_post_status_button.js
index 83103c8279..4969352f67 100644
--- a/src/components/mobile_post_status_button/mobile_post_status_button.js
+++ b/src/components/mobile_post_status_button/mobile_post_status_button.js
@@ -1,119 +1,120 @@
import { debounce } from 'lodash'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { usePostStatusStore } from 'src/stores/post_status.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faPen } from '@fortawesome/free-solid-svg-icons'
library.add(faPen)
const HIDDEN_FOR_PAGES = new Set(['chats', 'chat', 'lists-edit'])
const MobilePostStatusButton = {
data() {
return {
hidden: false,
scrollingDown: false,
inputActive: false,
oldScrollPos: 0,
amountScrolled: 0,
}
},
created() {
if (this.autohideFloatingPostButton) {
this.activateFloatingPostButtonAutohide()
}
window.addEventListener('resize', this.handleOSK)
},
unmounted() {
if (this.autohideFloatingPostButton) {
this.deactivateFloatingPostButtonAutohide()
}
window.removeEventListener('resize', this.handleOSK)
},
computed: {
isLoggedIn() {
return !!this.$store.state.users.currentUser
},
isHidden() {
if (HIDDEN_FOR_PAGES.has(this.$route.name)) {
return true
}
return (
this.autohideFloatingPostButton && (this.hidden || this.inputActive)
)
},
isPersistent() {
- return !!this.$store.getters.mergedConfig.alwaysShowNewPostButton
+ return !!useMergedConfigStore().mergedConfig.alwaysShowNewPostButton
},
autohideFloatingPostButton() {
- return !!this.$store.getters.mergedConfig.autohideFloatingPostButton
+ return !!useMergedConfigStore().mergedConfig.autohideFloatingPostButton
},
},
watch: {
autohideFloatingPostButton: function (isEnabled) {
if (isEnabled) {
this.activateFloatingPostButtonAutohide()
} else {
this.deactivateFloatingPostButtonAutohide()
}
},
},
methods: {
activateFloatingPostButtonAutohide() {
window.addEventListener('scroll', this.handleScrollStart)
window.addEventListener('scroll', this.handleScrollEnd)
},
deactivateFloatingPostButtonAutohide() {
window.removeEventListener('scroll', this.handleScrollStart)
window.removeEventListener('scroll', this.handleScrollEnd)
},
openPostForm() {
usePostStatusStore().openPostStatusModal()
},
handleOSK() {
// This is a big hack: we're guessing from changed window sizes if the
// on-screen keyboard is active or not. This is only really important
// for phones in portrait mode and it's more important to show the button
// in normal scenarios on all phones, than it is to hide it when the
// keyboard is active.
// Guesswork based on https://www.mydevice.io/#compare-devices
// for example, iphone 4 and android phones from the same time period
const smallPhone = window.innerWidth < 350
const smallPhoneKbOpen = smallPhone && window.innerHeight < 345
const biggerPhone = !smallPhone && window.innerWidth < 450
const biggerPhoneKbOpen = biggerPhone && window.innerHeight < 560
if (smallPhoneKbOpen || biggerPhoneKbOpen) {
this.inputActive = true
} else {
this.inputActive = false
}
},
handleScrollStart: debounce(
function () {
if (window.scrollY > this.oldScrollPos) {
this.hidden = true
} else {
this.hidden = false
}
this.oldScrollPos = window.scrollY
},
100,
{ leading: true, trailing: false },
),
handleScrollEnd: debounce(
function () {
this.hidden = false
this.oldScrollPos = window.scrollY
},
100,
{ leading: false, trailing: true },
),
},
}
export default MobilePostStatusButton
diff --git a/src/components/mrf_transparency_panel/mrf_transparency_panel.js b/src/components/mrf_transparency_panel/mrf_transparency_panel.js
index 2bd82f486b..d77a0a839c 100644
--- a/src/components/mrf_transparency_panel/mrf_transparency_panel.js
+++ b/src/components/mrf_transparency_panel/mrf_transparency_panel.js
@@ -1,100 +1,101 @@
import { get } from 'lodash'
-import { mapState } from 'vuex'
+import { mapState } from 'pinia'
+import { useInstanceStore } from 'src/stores/instance.js'
/**
* This is for backwards compatibility. We originally didn't recieve
* extra info like a reason why an instance was rejected/quarantined/etc.
* Because we didn't want to break backwards compatibility it was decided
* to add an extra "info" key.
*/
const toInstanceReasonObject = (instances, info, key) => {
return instances.map((instance) => {
if (info[key] && info[key][instance] && info[key][instance].reason) {
return { instance, reason: info[key][instance].reason }
}
return { instance, reason: '' }
})
}
const MRFTransparencyPanel = {
computed: {
- ...mapState({
- federationPolicy: (state) => get(state, 'instance.federationPolicy'),
+ ...mapState(useInstanceStore, {
+ federationPolicy: (state) => state.federationPolicy,
mrfPolicies: (state) =>
- get(state, 'instance.federationPolicy.mrf_policies', []),
+ get(state, 'federationPolicy.mrf_policies', []),
quarantineInstances: (state) =>
toInstanceReasonObject(
- get(state, 'instance.federationPolicy.quarantined_instances', []),
+ get(state, 'federationPolicy.quarantined_instances', []),
get(
state,
- 'instance.federationPolicy.quarantined_instances_info',
+ 'federationPolicy.quarantined_instances_info',
[],
),
'quarantined_instances',
),
acceptInstances: (state) =>
toInstanceReasonObject(
- get(state, 'instance.federationPolicy.mrf_simple.accept', []),
- get(state, 'instance.federationPolicy.mrf_simple_info', []),
+ get(state, 'federationPolicy.mrf_simple.accept', []),
+ get(state, 'federationPolicy.mrf_simple_info', []),
'accept',
),
rejectInstances: (state) =>
toInstanceReasonObject(
- get(state, 'instance.federationPolicy.mrf_simple.reject', []),
- get(state, 'instance.federationPolicy.mrf_simple_info', []),
+ get(state, 'federationPolicy.mrf_simple.reject', []),
+ get(state, 'federationPolicy.mrf_simple_info', []),
'reject',
),
ftlRemovalInstances: (state) =>
toInstanceReasonObject(
get(
state,
- 'instance.federationPolicy.mrf_simple.federated_timeline_removal',
+ 'federationPolicy.mrf_simple.federated_timeline_removal',
[],
),
- get(state, 'instance.federationPolicy.mrf_simple_info', []),
+ get(state, 'federationPolicy.mrf_simple_info', []),
'federated_timeline_removal',
),
mediaNsfwInstances: (state) =>
toInstanceReasonObject(
- get(state, 'instance.federationPolicy.mrf_simple.media_nsfw', []),
- get(state, 'instance.federationPolicy.mrf_simple_info', []),
+ get(state, 'federationPolicy.mrf_simple.media_nsfw', []),
+ get(state, 'federationPolicy.mrf_simple_info', []),
'media_nsfw',
),
mediaRemovalInstances: (state) =>
toInstanceReasonObject(
- get(state, 'instance.federationPolicy.mrf_simple.media_removal', []),
- get(state, 'instance.federationPolicy.mrf_simple_info', []),
+ get(state, 'federationPolicy.mrf_simple.media_removal', []),
+ get(state, 'federationPolicy.mrf_simple_info', []),
'media_removal',
),
keywordsFtlRemoval: (state) =>
get(
state,
- 'instance.federationPolicy.mrf_keyword.federated_timeline_removal',
+ 'federationPolicy.mrf_keyword.federated_timeline_removal',
[],
),
keywordsReject: (state) =>
- get(state, 'instance.federationPolicy.mrf_keyword.reject', []),
+ get(state, 'federationPolicy.mrf_keyword.reject', []),
keywordsReplace: (state) =>
- get(state, 'instance.federationPolicy.mrf_keyword.replace', []),
+ get(state, 'federationPolicy.mrf_keyword.replace', []),
}),
hasInstanceSpecificPolicies() {
return (
this.quarantineInstances.length ||
this.acceptInstances.length ||
this.rejectInstances.length ||
this.ftlRemovalInstances.length ||
this.mediaNsfwInstances.length ||
this.mediaRemovalInstances.length
)
},
hasKeywordPolicies() {
return (
this.keywordsFtlRemoval.length ||
this.keywordsReject.length ||
this.keywordsReplace.length
)
},
},
}
export default MRFTransparencyPanel
diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js
index 8aff31f02b..ae6264217b 100644
--- a/src/components/nav_panel/nav_panel.js
+++ b/src/components/nav_panel/nav_panel.js
@@ -1,169 +1,169 @@
import { mapState as mapPiniaState } from 'pinia'
import { mapGetters, mapState } from 'vuex'
import BookmarkFoldersMenuContent from 'src/components/bookmark_folders_menu/bookmark_folders_menu_content.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ListsMenuContent from 'src/components/lists_menu/lists_menu_content.vue'
import { filterNavigation } from 'src/components/navigation/filter.js'
import { ROOT_ITEMS, TIMELINES } from 'src/components/navigation/navigation.js'
import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
-import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBell,
faBookmark,
faBullhorn,
faChevronDown,
faChevronUp,
faCity,
faComments,
faEnvelope,
faFilePen,
faGlobe,
faInfoCircle,
faList,
faStream,
faUsers,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faUsers,
faGlobe,
faCity,
faBookmark,
faEnvelope,
faChevronDown,
faChevronUp,
faComments,
faBell,
faInfoCircle,
faStream,
faList,
faBullhorn,
faFilePen,
)
const NavPanel = {
props: ['forceExpand', 'forceEditMode'],
components: {
BookmarkFoldersMenuContent,
ListsMenuContent,
NavigationEntry,
NavigationPins,
Checkbox,
},
data() {
return {
editMode: false,
showTimelines: false,
showLists: false,
showBookmarkFolders: false,
timelinesList: Object.entries(TIMELINES).map(([k, v]) => ({
...v,
name: k,
})),
rootList: Object.entries(ROOT_ITEMS).map(([k, v]) => ({ ...v, name: k })),
}
},
methods: {
toggleTimelines() {
this.showTimelines = !this.showTimelines
},
toggleLists() {
this.showLists = !this.showLists
},
toggleBookmarkFolders() {
this.showBookmarkFolders = !this.showBookmarkFolders
},
toggleEditMode() {
this.editMode = !this.editMode
},
toggleCollapse() {
- useServerSideStorageStore().setPreference({
- path: 'simple.collapseNav',
+ useSyncConfigStore().setSimplePrefAndSave({
+ path: 'collapseNav',
value: !this.collapsed,
})
- useServerSideStorageStore().pushServerSideStorage()
+ useSyncConfigStore().pushSyncConfig()
},
isPinned(item) {
return this.pinnedItems.has(item)
},
togglePin(item) {
if (this.isPinned(item)) {
- useServerSideStorageStore().removeCollectionPreference({
+ useSyncConfigStore().removeCollectionPreference({
path: 'collections.pinnedNavItems',
value: item,
})
} else {
- useServerSideStorageStore().addCollectionPreference({
+ useSyncConfigStore().addCollectionPreference({
path: 'collections.pinnedNavItems',
value: item,
})
}
- useServerSideStorageStore().pushServerSideStorage()
+ useSyncConfigStore().pushSyncConfig()
},
},
computed: {
...mapPiniaState(useAnnouncementsStore, {
unreadAnnouncementCount: 'unreadAnnouncementCount',
supportsAnnouncements: (store) => store.supportsAnnouncements,
}),
...mapPiniaState(useInstanceCapabilitiesStore, [
'pleromaChatMessagesAvailable',
'pleromaBookmarkFoldersAvailable',
'localBubble',
]),
...mapPiniaState(useInstanceStore, ['federating']),
...mapPiniaState(useInstanceStore, {
privateMode: (store) => store.private,
}),
- ...mapPiniaState(useServerSideStorageStore, {
+ ...mapPiniaState(useSyncConfigStore, {
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,
}),
timelinesItems() {
return filterNavigation(
Object.entries({ ...TIMELINES })
// do not show in timeliens list since it's in a better place now
.filter(([key]) => key !== 'bookmarks')
.map(([k, v]) => ({ ...v, name: k })),
{
hasChats: this.pleromaChatMessagesAvailable,
hasAnnouncements: this.supportsAnnouncements,
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser,
supportsBubbleTimeline: this.localBubble,
supportsBookmarkFolders: this.pleromaBookmarkFoldersAvailable,
},
)
},
rootItems() {
return filterNavigation(
Object.entries({ ...ROOT_ITEMS }).map(([k, v]) => ({ ...v, name: k })),
{
hasChats: this.pleromaChatMessagesAvailable,
hasAnnouncements: this.supportsAnnouncements,
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser,
supportsBubbleTimeline: this.localBubble,
supportsBookmarkFolders: this.pleromaBookmarkFoldersAvailable,
},
)
},
...mapGetters(['unreadChatCount']),
},
}
export default NavPanel
diff --git a/src/components/navigation/navigation_entry.js b/src/components/navigation/navigation_entry.js
index 3384534bee..7a43000cef 100644
--- a/src/components/navigation/navigation_entry.js
+++ b/src/components/navigation/navigation_entry.js
@@ -1,57 +1,57 @@
import { mapState as mapPiniaState, mapStores } from 'pinia'
import { mapState } from 'vuex'
import { routeTo } from 'src/components/navigation/navigation.js'
import OptionalRouterLink from 'src/components/optional_router_link/optional_router_link.vue'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
-import { useServerSideStorageStore } from 'src/stores/serverSideStorage.js'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faThumbtack } from '@fortawesome/free-solid-svg-icons'
library.add(faThumbtack)
const NavigationEntry = {
props: ['item', 'showPin'],
components: {
OptionalRouterLink,
},
methods: {
isPinned(value) {
return this.pinnedItems.has(value)
},
togglePin(value) {
if (this.isPinned(value)) {
- useServerSideStorageStore().removeCollectionPreference({
+ useSyncConfigStore().removeCollectionPreference({
path: 'collections.pinnedNavItems',
value,
})
} else {
- useServerSideStorageStore().addCollectionPreference({
+ useSyncConfigStore().addCollectionPreference({
path: 'collections.pinnedNavItems',
value,
})
}
- useServerSideStorageStore().pushServerSideStorage()
+ useSyncConfigStore().pushSyncConfig()
},
},
computed: {
routeTo() {
return routeTo(this.item, this.currentUser)
},
getters() {
return this.$store.getters
},
...mapStores(useAnnouncementsStore),
...mapState({
currentUser: (state) => state.users.currentUser,
}),
- ...mapPiniaState(useServerSideStorageStore, {
+ ...mapPiniaState(useSyncConfigStore, {
pinnedItems: (store) =>
new Set(store.prefsStorage.collections.pinnedNavItems),
}),
},
}
export default NavigationEntry
diff --git a/src/components/navigation/navigation_pins.js b/src/components/navigation/navigation_pins.js
index 698bf5d598..85f6fafeeb 100644
--- a/src/components/navigation/navigation_pins.js
+++ b/src/components/navigation/navigation_pins.js
@@ -1,130 +1,130 @@
import { mapState as mapPiniaState } from 'pinia'
import { mapState } from 'vuex'
import {
filterNavigation,
getBookmarkFolderEntries,
getListEntries,
} from 'src/components/navigation/filter.js'
import {
ROOT_ITEMS,
routeTo,
TIMELINES,
} from 'src/components/navigation/navigation.js'
import StillImage from 'src/components/still-image/still-image.vue'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useListsStore } from 'src/stores/lists'
-import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBell,
faBookmark,
faCity,
faComments,
faEnvelope,
faGlobe,
faInfoCircle,
faList,
faStream,
faUsers,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faUsers,
faGlobe,
faCity,
faBookmark,
faEnvelope,
faComments,
faBell,
faInfoCircle,
faStream,
faList,
)
const NavPanel = {
props: ['limit'],
methods: {
getRouteTo(item) {
return routeTo(item, this.currentUser)
},
},
components: {
StillImage,
},
computed: {
getters() {
return this.$store.getters
},
...mapPiniaState(useListsStore, {
lists: getListEntries,
}),
...mapPiniaState(useAnnouncementsStore, {
supportsAnnouncements: (store) => store.supportsAnnouncements,
}),
...mapPiniaState(useBookmarkFoldersStore, {
bookmarks: getBookmarkFolderEntries,
}),
- ...mapPiniaState(useServerSideStorageStore, {
+ ...mapPiniaState(useSyncConfigStore, {
pinnedItems: (store) =>
new Set(store.prefsStorage.collections.pinnedNavItems),
}),
...mapPiniaState(useInstanceStore, ['privateMode', 'federating']),
...mapPiniaState(useInstanceCapabilitiesStore, [
'pleromaChatMessagesAvailable',
'localBubble',
]),
...mapState({
currentUser: (state) => state.users.currentUser,
followRequestCount: (state) => state.api.followRequests.length,
}),
pinnedList() {
if (!this.currentUser) {
return filterNavigation(
[
{ ...TIMELINES.public, name: 'public' },
{ ...TIMELINES.twkn, name: 'twkn' },
{ ...ROOT_ITEMS.about, name: 'about' },
],
{
hasChats: this.pleromaChatMessagesAvailable,
hasAnnouncements: this.supportsAnnouncements,
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser,
supportsBubbleTimeline: this.localBubble,
supportsBookmarkFolders: this.bookmarks,
},
)
}
return filterNavigation(
[
...Object.entries({ ...TIMELINES })
.filter(([k]) => this.pinnedItems.has(k))
.map(([k, v]) => ({ ...v, name: k })),
...this.lists.filter((k) => this.pinnedItems.has(k.name)),
...this.bookmarks.filter((k) => this.pinnedItems.has(k.name)),
...Object.entries({ ...ROOT_ITEMS })
.filter(([k]) => this.pinnedItems.has(k))
.map(([k, v]) => ({ ...v, name: k })),
],
{
hasChats: this.pleromaChatMessagesAvailable,
hasAnnouncements: this.supportsAnnouncements,
supportsBubbleTimeline: this.localBubble,
supportsBookmarkFolders: this.bookmarks,
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser,
},
).slice(0, this.limit)
},
},
}
export default NavPanel
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index 16084dee4f..5f612cb9cc 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -1,226 +1,227 @@
import { mapState } from 'vuex'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
import {
highlightClass,
highlightStyle,
} from '../../services/user_highlighter/user_highlighter.js'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import Report from '../report/report.vue'
import Status from '../status/status.vue'
import StatusContent from '../status_content/status_content.vue'
import Timeago from '../timeago/timeago.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue'
import UserLink from '../user_link/user_link.vue'
import UserPopover from '../user_popover/user_popover.vue'
import { useInstanceStore } from 'src/stores/instance.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faCheck,
faCompressAlt,
faExpandAlt,
faEyeSlash,
faRetweet,
faStar,
faSuitcaseRolling,
faTimes,
faUser,
faUserPlus,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faCheck,
faTimes,
faStar,
faRetweet,
faUserPlus,
faUser,
faEyeSlash,
faSuitcaseRolling,
faExpandAlt,
faCompressAlt,
)
const Notification = {
data() {
return {
selecting: false,
statusExpanded: false,
unmuted: false,
showingApproveConfirmDialog: false,
showingDenyConfirmDialog: false,
}
},
props: ['notification'],
emits: ['interacted'],
components: {
StatusContent,
UserAvatar,
UserCard,
Timeago,
Status,
Report,
RichContent,
UserPopover,
UserLink,
ConfirmModal,
},
mounted() {
document.addEventListener('selectionchange', this.onContentSelect)
},
unmounted() {
document.removeEventListener('selectionchange', this.onContentSelect)
},
methods: {
toggleStatusExpanded() {
if (!this.expandable) return
this.statusExpanded = !this.statusExpanded
},
onContentSelect() {
const { isCollapsed, anchorNode, offsetNode } = document.getSelection()
if (isCollapsed) {
this.selecting = false
return
}
const within =
this.$refs.root.contains(anchorNode) ||
this.$refs.root.contains(offsetNode)
if (within) {
this.selecting = true
} else {
this.selecting = false
}
},
onContentClick(e) {
if (
!this.selecting &&
!e.target.closest('a') &&
!e.target.closest('button')
) {
this.toggleStatusExpanded()
}
},
generateUserProfileLink(user) {
return generateProfileLink(
user.id,
user.screen_name,
useInstanceStore().restrictedNicknames,
)
},
getUser(notification) {
return this.$store.state.users.usersObject[notification.from_profile.id]
},
interacted() {
this.$emit('interacted')
},
toggleMute() {
this.unmuted = !this.unmuted
},
showApproveConfirmDialog() {
this.showingApproveConfirmDialog = true
},
hideApproveConfirmDialog() {
this.showingApproveConfirmDialog = false
},
showDenyConfirmDialog() {
this.showingDenyConfirmDialog = true
},
hideDenyConfirmDialog() {
this.showingDenyConfirmDialog = false
},
approveUser() {
if (this.shouldConfirmApprove) {
this.showApproveConfirmDialog()
} else {
this.doApprove()
}
},
doApprove() {
this.$emit('interacted')
this.$store.state.api.backendInteractor.approveUser({ id: this.user.id })
this.$store.dispatch('removeFollowRequest', this.user)
this.$store.dispatch('markSingleNotificationAsSeen', {
id: this.notification.id,
})
this.$store.dispatch('updateNotification', {
id: this.notification.id,
updater: (notification) => {
notification.type = 'follow'
},
})
this.hideApproveConfirmDialog()
},
denyUser() {
if (this.shouldConfirmDeny) {
this.showDenyConfirmDialog()
} else {
this.doDeny()
}
},
doDeny() {
this.$emit('interacted')
this.$store.state.api.backendInteractor
.denyUser({ id: this.user.id })
.then(() => {
this.$store.dispatch('dismissNotificationLocal', {
id: this.notification.id,
})
this.$store.dispatch('removeFollowRequest', this.user)
})
this.hideDenyConfirmDialog()
},
},
computed: {
userClass() {
return highlightClass(this.notification.from_profile)
},
userStyle() {
- const highlight = this.$store.getters.mergedConfig.highlight
- const user = this.notification.from_profile
- return highlightStyle(highlight[user.screen_name])
+ const user = this.notification.from_profile.screen_name
+ return highlightStyle(useUserHighlightStore().get(user))
},
expandable() {
return new Set(['like', 'pleroma:emoji_reaction', 'repeat', 'poll']).has(
this.notification.type,
)
},
user() {
return this.$store.getters.findUser(this.notification.from_profile.id)
},
userProfileLink() {
return this.generateUserProfileLink(this.user)
},
targetUser() {
return this.$store.getters.findUser(this.notification.target.id)
},
targetUserProfileLink() {
return this.generateUserProfileLink(this.targetUser)
},
needMute() {
return this.$store.getters.relationship(this.user.id).muting
},
isStatusNotification() {
return isStatusNotification(this.notification.type)
},
mergedConfig() {
- return this.$store.getters.mergedConfig
+ return useMergedConfigStore().mergedConfig
},
shouldConfirmApprove() {
return this.mergedConfig.modalOnApproveFollow
},
shouldConfirmDeny() {
return this.mergedConfig.modalOnDenyFollow
},
...mapState({
currentUser: (state) => state.users.currentUser,
}),
},
}
export default Notification
diff --git a/src/components/notifications/notification_filters.vue b/src/components/notifications/notification_filters.vue
index 7be8eb76b9..9232930dfd 100644
--- a/src/components/notifications/notification_filters.vue
+++ b/src/components/notifications/notification_filters.vue
@@ -1,135 +1,138 @@
<template>
<Popover
trigger="click"
class="NotificationFilters"
placement="bottom"
:bound-to="{ x: 'container' }"
>
<template #content>
<div class="dropdown-menu">
<div class="menu-item dropdown-item -icon">
<button
class="main-button"
@click="toggleNotificationFilter('likes')"
>
<span
class="input menu-checkbox"
:class="{ 'menu-checkbox-checked': filters.likes }"
/>{{ $t('settings.notification_visibility_likes') }}
</button>
</div>
<div class="menu-item dropdown-item -icon">
<button
class="main-button"
@click="toggleNotificationFilter('repeats')"
>
<span
class="input menu-checkbox"
:class="{ 'menu-checkbox-checked': filters.repeats }"
/>{{ $t('settings.notification_visibility_repeats') }}
</button>
</div>
<div class="menu-item dropdown-item -icon">
<button
class="main-button"
@click="toggleNotificationFilter('follows')"
>
<span
class="input menu-checkbox"
:class="{ 'menu-checkbox-checked': filters.follows }"
/>{{ $t('settings.notification_visibility_follows') }}
</button>
</div>
<div class="menu-item dropdown-item -icon">
<button
class="main-button"
@click="toggleNotificationFilter('mentions')"
>
<span
class="input menu-checkbox"
:class="{ 'menu-checkbox-checked': filters.mentions }"
/>{{ $t('settings.notification_visibility_mentions') }}
</button>
</div>
<div class="menu-item dropdown-item -icon">
<button
class="main-button"
@click="toggleNotificationFilter('statuses')"
>
<span
class="input menu-checkbox"
:class="{ 'menu-checkbox-checked': filters.statuses }"
/>{{ $t('settings.notification_visibility_statuses') }}
</button>
</div>
<div class="menu-item dropdown-item -icon">
<button
class="main-button"
@click="toggleNotificationFilter('emojiReactions')"
>
<span
class="input menu-checkbox"
:class="{ 'menu-checkbox-checked': filters.emojiReactions }"
/>{{ $t('settings.notification_visibility_emoji_reactions') }}
</button>
</div>
<div class="menu-item dropdown-item -icon">
<button
class="main-button"
@click="toggleNotificationFilter('moves')"
>
<span
class="input menu-checkbox"
:class="{ 'menu-checkbox-checked': filters.moves }"
/>{{ $t('settings.notification_visibility_moves') }}
</button>
</div>
<div class="menu-item dropdown-item -icon">
<button
class="main-button"
@click="toggleNotificationFilter('polls')"
>
<span
class="input menu-checkbox"
:class="{ 'menu-checkbox-checked': filters.polls }"
/>{{ $t('settings.notification_visibility_polls') }}
</button>
</div>
</div>
</template>
<template #trigger>
<button class="filter-trigger-button button-unstyled">
<FAIcon icon="filter" />
</button>
</template>
</Popover>
</template>
<script>
import Popover from '../popover/popover.vue'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
+
import { library } from '@fortawesome/fontawesome-svg-core'
import { faFilter } from '@fortawesome/free-solid-svg-icons'
library.add(faFilter)
export default {
components: { Popover },
computed: {
filters() {
- return this.$store.getters.mergedConfig.notificationVisibility
+ return useMergedConfigStore().mergedConfig.notificationVisibility
},
},
methods: {
toggleNotificationFilter(type) {
- this.$store.dispatch('setOption', {
- name: 'notificationVisibility',
+ useSyncConfigStore().setSimplePrefAndSave({
+ path: 'notificationVisibility',
value: {
...this.filters,
[type]: !this.filters[type],
},
})
},
},
}
</script>
diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js
index 6483a256e8..564fae7f26 100644
--- a/src/components/notifications/notifications.js
+++ b/src/components/notifications/notifications.js
@@ -1,257 +1,270 @@
import { mapState } from 'pinia'
import { computed } from 'vue'
import { mapGetters } from 'vuex'
import FaviconService from '../../services/favicon_service/favicon_service.js'
import {
ACTIONABLE_NOTIFICATION_TYPES,
countExtraNotifications,
filteredNotificationsFromStore,
notificationsFromStore,
unseenNotificationsFromStore,
} from '../../services/notification_utils/notification_utils.js'
import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js'
import ExtraNotifications from '../extra_notifications/extra_notifications.vue'
import Notification from '../notification/notification.vue'
import NotificationFilters from './notification_filters.vue'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
import { useInterfaceStore } from 'src/stores/interface.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faArrowUp,
faCircleNotch,
faMinus,
} from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch, faArrowUp, faMinus)
const DEFAULT_SEEN_TO_DISPLAY_COUNT = 30
const Notifications = {
components: {
Notification,
NotificationFilters,
ExtraNotifications,
},
props: {
// Disables panel styles, unread mark, potentially other notification-related actions
// meant for "Interactions" timeline
minimalMode: Boolean,
// Custom filter mode, an array of strings, possible values 'mention', 'status', 'repeat', 'like', 'follow', used to override global filter for use in "Interactions" timeline
filterMode: Array,
// Do not show extra notifications
noExtra: {
type: Boolean,
default: false,
},
// Disable teleporting (i.e. for /users/user/notifications)
disableTeleport: Boolean,
},
data() {
return {
showScrollTop: false,
bottomedOut: false,
// How many seen notifications to display in the list. The more there are,
// the heavier the page becomes. This count is increased when loading
// older notifications, and cut back to default whenever hitting "Read!".
seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT,
}
},
provide() {
return {
popoversZLayer: computed(() => this.popoversZLayer),
}
},
computed: {
mainClass() {
return this.minimalMode ? '' : 'panel panel-default'
},
notifications() {
return notificationsFromStore(this.$store)
},
error() {
return this.$store.state.notifications.error
},
unseenNotifications() {
- return unseenNotificationsFromStore(this.$store)
+ return unseenNotificationsFromStore(
+ this.$store,
+ useMergedConfigStore().mergedConfig.notificationVisibility,
+ )
},
filteredNotifications() {
if (this.unseenAtTop) {
return [
- ...filteredNotificationsFromStore(this.$store).filter((n) =>
- this.shouldShowUnseen(n),
- ),
- ...filteredNotificationsFromStore(this.$store).filter(
- (n) => !this.shouldShowUnseen(n),
- ),
+ ...filteredNotificationsFromStore(
+ this.$store,
+ useMergedConfigStore().mergedConfig.notificationVisibility,
+ ).filter((n) => this.shouldShowUnseen(n)),
+ ...filteredNotificationsFromStore(
+ this.$store,
+ useMergedConfigStore().mergedConfig.notificationVisibility,
+ ).filter((n) => !this.shouldShowUnseen(n)),
]
} else {
- return filteredNotificationsFromStore(this.$store, this.filterMode)
+ return filteredNotificationsFromStore(
+ this.$store,
+ useMergedConfigStore().mergedConfig.notificationVisibility,
+ this.filterMode,
+ )
}
},
unseenCountBadgeText() {
return `${this.unseenCount ? this.unseenCount : ''}${this.extraNotificationsCount ? '*' : ''}`
},
unseenCount() {
return this.unseenNotifications.length
},
ignoreInactionableSeen() {
- return this.$store.getters.mergedConfig.ignoreInactionableSeen
+ return useMergedConfigStore().mergedConfig.ignoreInactionableSeen
},
extraNotificationsCount() {
- return countExtraNotifications(this.$store)
+ return countExtraNotifications(
+ this.$store,
+ useMergedConfigStore().mergedConfig,
+ )
},
unseenCountTitle() {
return (
this.unseenNotifications.length +
this.unreadChatCount +
this.unreadAnnouncementCount
)
},
loading() {
return this.$store.state.notifications.loading
},
noHeading() {
const { layoutType } = useInterfaceStore()
return this.minimalMode || layoutType === 'mobile'
},
teleportTarget() {
const { layoutType } = useInterfaceStore()
const map = {
wide: '#notifs-column',
mobile: '#mobile-notifications',
}
return map[layoutType] || '#notifs-sidebar'
},
popoversZLayer() {
const { layoutType } = useInterfaceStore()
return layoutType === 'mobile' ? 'navbar' : null
},
notificationsToDisplay() {
return this.filteredNotifications.slice(
0,
this.unseenCount + this.seenToDisplayCount,
)
},
noSticky() {
- return this.$store.getters.mergedConfig.disableStickyHeaders
+ return useMergedConfigStore().mergedConfig.disableStickyHeaders
},
unseenAtTop() {
- return this.$store.getters.mergedConfig.unseenAtTop
+ return useMergedConfigStore().mergedConfig.unseenAtTop
},
showExtraNotifications() {
return !this.noExtra
},
...mapState(useAnnouncementsStore, ['unreadAnnouncementCount']),
...mapGetters(['unreadChatCount']),
},
mounted() {
this.scrollerRef = this.$refs.root.closest('.column.-scrollable')
if (!this.scrollerRef) {
this.scrollerRef = this.$refs.root.closest('.mobile-notifications')
}
if (!this.scrollerRef) {
this.scrollerRef = this.$refs.root.closest('.column.main')
}
this.scrollerRef.addEventListener('scroll', this.updateScrollPosition)
},
unmounted() {
if (!this.scrollerRef) return
this.scrollerRef.removeEventListener('scroll', this.updateScrollPosition)
},
watch: {
unseenCountTitle(count) {
if (count > 0) {
FaviconService.drawFaviconBadge()
useInterfaceStore().setPageTitle(`(${count})`)
} else {
FaviconService.clearFaviconBadge()
useInterfaceStore().setPageTitle('')
}
},
teleportTarget() {
// handle scroller change
this.$nextTick(() => {
this.scrollerRef.removeEventListener(
'scroll',
this.updateScrollPosition,
)
this.scrollerRef = this.$refs.root.closest('.column.-scrollable')
if (!this.scrollerRef) {
this.scrollerRef = this.$refs.root.closest('.mobile-notifications')
}
this.scrollerRef.addEventListener('scroll', this.updateScrollPosition)
this.updateScrollPosition()
})
},
},
methods: {
scrollToTop() {
const scrollable = this.scrollerRef
scrollable.scrollTo({ top: this.$refs.root.offsetTop })
},
updateScrollPosition() {
this.showScrollTop =
this.$refs.root.offsetTop < this.scrollerRef.scrollTop
},
shouldShowUnseen(notification) {
if (notification.seen) return false
const actionable = ACTIONABLE_NOTIFICATION_TYPES.has(notification.type)
return this.ignoreInactionableSeen ? actionable : true
},
/* "Interacted" really refers to "actionable" notifications that require user input,
* everything else (likes/repeats/reacts) cannot be acted and therefore we just clear
* the "seen" status upon any clicks on them
*/
notificationClicked(notification) {
const { id } = notification
this.$store.dispatch('notificationClicked', { id })
},
notificationInteracted(notification) {
const { id } = notification
this.$store.dispatch('markSingleNotificationAsSeen', { id })
},
markAsSeen() {
this.$store.dispatch('markNotificationsAsSeen')
this.seenToDisplayCount = DEFAULT_SEEN_TO_DISPLAY_COUNT
},
fetchOlderNotifications() {
if (this.loading) {
return
}
const seenCount = this.filteredNotifications.length - this.unseenCount
if (this.seenToDisplayCount < seenCount) {
this.seenToDisplayCount = Math.min(
this.seenToDisplayCount + 20,
seenCount,
)
return
} else if (this.seenToDisplayCount > seenCount) {
this.seenToDisplayCount = seenCount
}
const store = this.$store
const credentials = store.state.users.currentUser.credentials
store.commit('setNotificationsLoading', { value: true })
notificationsFetcher
.fetchAndUpdate({
store,
credentials,
older: true,
})
.then((notifs) => {
store.commit('setNotificationsLoading', { value: false })
if (notifs.length === 0) {
this.bottomedOut = true
}
this.seenToDisplayCount += notifs.length
})
},
},
}
export default Notifications
diff --git a/src/components/password_reset/password_reset.vue b/src/components/password_reset/password_reset.vue
index 5392f487b5..ea86fb517a 100644
--- a/src/components/password_reset/password_reset.vue
+++ b/src/components/password_reset/password_reset.vue
@@ -1,129 +1,126 @@
<template>
<div class="settings panel panel-default">
<div class="panel-heading">
<h1 class="title">
{{ $t('password_reset.password_reset') }}
</h1>
</div>
<div class="panel-body">
<form
class="password-reset-form"
@submit.prevent="submit"
>
<div class="container">
<div v-if="!mailerEnabled">
<p v-if="passwordResetRequested">
{{ $t('password_reset.password_reset_required_but_mailer_is_disabled') }}
</p>
<p v-else>
{{ $t('password_reset.password_reset_disabled') }}
</p>
</div>
<div v-else-if="success || throttled">
<p v-if="success">
{{ $t('password_reset.check_email') }}
</p>
<div class="form-group text-center">
<router-link :to="{name: 'root'}">
{{ $t('password_reset.return_home') }}
</router-link>
</div>
</div>
<div v-else>
<p
v-if="passwordResetRequested"
class="alert password-reset-required error"
>
{{ $t('password_reset.password_reset_required') }}
</p>
<p>
{{ $t('password_reset.instruction') }}
</p>
<div class="form-group">
<input
ref="email"
v-model="user.email"
:disabled="isPending"
:placeholder="$t('password_reset.placeholder')"
class="input form-control"
type="input"
>
</div>
<div class="form-group">
<button
:disabled="isPending"
type="submit"
class="btn button-default btn-block"
>
{{ $t('settings.save') }}
</button>
</div>
</div>
<p
v-if="error"
- class="alert error notice-dismissible"
+ class="alert error"
>
<span>{{ error }}</span>
- <a
+ <button
class="fa-scale-110 fa-old-padding dismiss"
+ type="button"
@click.prevent="dismissError()"
>
<FAIcon icon="times" />
- </a>
+ </button>
</p>
</div>
</form>
</div>
</div>
</template>
<script src="./password_reset.js"></script>
<style lang="scss">
.password-reset-form {
display: flex;
flex-direction: column;
align-items: center;
margin: 0.6em;
.container {
display: flex;
flex: 1 0;
flex-direction: column;
margin-top: 0.6em;
max-width: 18rem;
> * {
min-width: 0;
}
}
.form-group {
display: flex;
flex-direction: column;
margin-bottom: 1em;
padding: 0.3em 0;
line-height: 1.85em;
}
.error {
text-align: center;
animation-name: shakeError;
animation-duration: 0.4s;
animation-timing-function: ease-in-out;
}
.alert {
padding: 0.5em;
margin: 0.3em 0 1em;
}
- .notice-dismissible {
- padding-right: 2rem;
- }
-
.dismiss {
cursor: pointer;
}
}
</style>
diff --git a/src/components/poll/poll.js b/src/components/poll/poll.js
index f8887e53f5..945b8b372e 100644
--- a/src/components/poll/poll.js
+++ b/src/components/poll/poll.js
@@ -1,120 +1,121 @@
import Checkbox from 'components/checkbox/checkbox.vue'
import RichContent from 'components/rich_content/rich_content.jsx'
import Timeago from 'components/timeago/timeago.vue'
import genRandomSeed from '../../services/random_seed/random_seed.service.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { usePollsStore } from 'src/stores/polls.js'
export default {
name: 'Poll',
props: ['basePoll', 'emoji'],
components: {
Timeago,
RichContent,
Checkbox,
},
data() {
return {
loading: false,
choices: [],
randomSeed: genRandomSeed(),
}
},
created() {
if (!usePollsStore().pollsObject[this.pollId]) {
usePollsStore().mergeOrAddPoll(this.basePoll)
}
usePollsStore().trackPoll(this.pollId)
},
unmounted() {
usePollsStore().untrackPoll(this.pollId)
},
computed: {
pollId() {
return this.basePoll.id
},
poll() {
const storePoll = usePollsStore().pollsObject[this.pollId]
return storePoll || {}
},
options() {
return (this.poll && this.poll.options) || []
},
expiresAt() {
return (this.poll && this.poll.expires_at) || null
},
expired() {
return (this.poll && this.poll.expired) || false
},
expirationLabel() {
- if (this.$store.getters.mergedConfig.useAbsoluteTimeFormat) {
+ if (useMergedConfigStore().mergedConfig.useAbsoluteTimeFormat) {
return this.expired ? 'polls.expired_at' : 'polls.expires_at'
} else {
return this.expired ? 'polls.expired' : 'polls.expires_in'
}
},
loggedIn() {
return this.$store.state.users.currentUser
},
showResults() {
return this.poll.voted || this.expired || !this.loggedIn
},
totalVotesCount() {
return this.poll.votes_count
},
containerClass() {
return {
loading: this.loading,
}
},
choiceIndices() {
// Convert array of booleans into an array of indices of the
// items that were 'true', so [true, false, false, true] becomes
// [0, 3].
return this.choices
.map((entry, index) => entry && index)
.filter((value) => typeof value === 'number')
},
isDisabled() {
const noChoice = this.choiceIndices.length === 0
return this.loading || noChoice
},
},
methods: {
percentageForOption(count) {
return this.totalVotesCount === 0
? 0
: Math.round((count / this.totalVotesCount) * 100)
},
resultTitle(option) {
return `${option.votes_count}/${this.totalVotesCount} ${this.$t('polls.votes')}`
},
activateOption(index, value) {
let result
if (this.poll.multiple) {
result = this.choices || this.options.map(() => false)
} else {
result = this.options.map(() => false)
}
result[index] = value
this.choices = result
},
optionId(index) {
return `poll${this.poll.id}-${index}`
},
vote() {
if (this.choiceIndices.length === 0) return
this.loading = true
usePollsStore()
.votePoll({
id: this.statusId,
pollId: this.poll.id,
choices: this.choiceIndices,
})
.then(() => {
this.loading = false
})
},
},
}
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index 0491835752..d08a8b9c2e 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -1,995 +1,998 @@
import { debounce, map, reject, uniqBy } from 'lodash'
import { mapActions, mapState } from 'pinia'
import { mapGetters } from 'vuex'
import DraftCloser from 'src/components/draft_closer/draft_closer.vue'
import Gallery from 'src/components/gallery/gallery.vue'
import Popover from 'src/components/popover/popover.vue'
import { propsToNative } from '../../services/attributes_helper/attributes_helper.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import genRandomSeed from '../../services/random_seed/random_seed.service.js'
import statusPoster from '../../services/status_poster/status_poster.service.js'
import Attachment from '../attachment/attachment.vue'
import Checkbox from '../checkbox/checkbox.vue'
import EmojiInput from '../emoji_input/emoji_input.vue'
import suggestor from '../emoji_input/suggestor.js'
import MediaUpload from '../media_upload/media_upload.vue'
import PollForm from '../poll/poll_form.vue'
import QuoteForm from '../quote/quote_form.vue'
import ScopeSelector from '../scope_selector/scope_selector.vue'
import Select from '../select/select.vue'
import StatusContent from '../status_content/status_content.vue'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMediaViewerStore } from 'src/stores/media_viewer.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { pollFormToMasto } from 'src/services/poll/poll.service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBan,
faChevronDown,
faChevronLeft,
faChevronRight,
faCircleNotch,
faPollH,
faQuoteRight,
faSmileBeam,
faTimes,
faUpload,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSmileBeam,
faPollH,
faUpload,
faQuoteRight,
faBan,
faTimes,
faCircleNotch,
faChevronDown,
faChevronLeft,
faChevronRight,
)
const buildMentionsString = ({ user, attentions = [] }, currentUser) => {
let allAttentions = [...attentions]
allAttentions.unshift(user)
allAttentions = uniqBy(allAttentions, 'id')
allAttentions = reject(allAttentions, { id: currentUser.id })
const mentions = map(allAttentions, (attention) => {
return `@${attention.screen_name}`
})
return mentions.length > 0 ? mentions.join(' ') + ' ' : ''
}
// Converts a string with px to a number like '2px' -> 2
const pxStringToNumber = (str) => {
return Number(str.substring(0, str.length - 2))
}
const typeAndRefId = ({ replyTo, profileMention, statusId }) => {
if (replyTo) {
return ['reply', replyTo]
} else if (profileMention) {
return ['mention', profileMention]
} else if (statusId) {
return ['edit', statusId]
} else {
return ['new', '']
}
}
const PostStatusForm = {
props: [
'statusId',
'statusText',
'statusIsSensitive',
'statusPoll',
'statusFiles',
'statusMediaDescriptions',
'statusScope',
'statusContentType',
'replyTo',
'repliedUser',
'attentions',
'copyMessageScope',
'subject',
'disableSubject',
'disableScopeSelector',
'disableVisibilitySelector',
'disableNotice',
'disableLockWarning',
'disablePolls',
'disableQuotes',
'disableSensitivityCheckbox',
'disableSubmit',
'disablePreview',
'disableDraft',
'hideDraft',
'closeable',
'placeholder',
'maxHeight',
'postHandler',
'preserveFocus',
'autoFocus',
'fileLimit',
'submitOnEnter',
'emojiPickerPlacement',
'optimisticPosting',
'profileMention',
'draftId',
],
emits: [
'posted',
'draft-done',
'resize',
'mediaplay',
'mediapause',
'can-close',
'update',
],
components: {
MediaUpload,
EmojiInput,
PollForm,
QuoteForm,
ScopeSelector,
Checkbox,
Select,
Attachment,
StatusContent,
Gallery,
DraftCloser,
Popover,
},
created() {
this.initQuote()
},
mounted() {
this.updateIdempotencyKey()
this.resize(this.$refs.textarea)
if (this.replyTo) {
const textLength = this.$refs.textarea.value.length
this.$refs.textarea.setSelectionRange(textLength, textLength)
}
if (this.replyTo || this.autoFocus) {
this.$refs.textarea.focus()
}
},
data() {
const preset = this.$route.query.message
let statusText = preset || ''
- const { scopeCopy } = this.$store.getters.mergedConfig
+ const { scopeCopy } = useMergedConfigStore().mergedConfig
const [statusType, refId] = typeAndRefId({
replyTo: this.replyTo,
profileMention: this.profileMention && this.repliedUser?.id,
statusId: this.statusId,
})
// If we are starting a new post, do not associate it with old drafts
let statusParams =
!this.disableDraft && (this.draftId || statusType !== 'new')
? this.getDraft(statusType, refId)
: null
if (!statusParams) {
if (statusType === 'reply' || statusType === 'mention') {
const currentUser = this.$store.state.users.currentUser
statusText = buildMentionsString(
{ user: this.repliedUser, attentions: this.attentions },
currentUser,
)
}
const scope =
(this.copyMessageScope && scopeCopy) ||
this.copyMessageScope === 'direct'
? this.copyMessageScope
: this.$store.state.users.currentUser.default_scope
const { postContentType: contentType, sensitiveByDefault } =
- this.$store.getters.mergedConfig
+ useMergedConfigStore().mergedConfig
statusParams = {
type: statusType,
refId,
spoilerText: this.subject || '',
status: statusText,
nsfw: !!sensitiveByDefault,
files: [],
poll: {},
hasPoll: false,
hasQuote: false,
quote: {},
mediaDescriptions: {},
visibility: scope,
contentType,
quoting: false,
}
if (statusType === 'edit') {
const statusContentType = this.statusContentType || contentType
statusParams = {
type: statusType,
refId,
spoilerText: this.subject || '',
status: this.statusText || '',
nsfw: this.statusIsSensitive || !!sensitiveByDefault,
files: this.statusFiles || [],
poll: this.statusPoll || {},
hasPoll: false,
hasQuote: false,
quote: {},
mediaDescriptions: this.statusMediaDescriptions || {},
visibility: this.statusScope || scope,
contentType: statusContentType,
}
}
}
return {
randomSeed: genRandomSeed(),
dropFiles: [],
uploadingFiles: false,
error: null,
posting: false,
highlighted: 0,
newStatus: statusParams,
caret: 0,
showDropIcon: 'hide',
dropStopTimeout: null,
preview: null,
previewLoading: false,
emojiInputShown: false,
idempotencyKey: '',
saveInhibited: true,
saveable: false,
}
},
computed: {
users() {
return this.$store.state.users.users
},
userDefaultScope() {
return this.$store.state.users.currentUser.default_scope
},
showAllScopes() {
return !this.mergedConfig.minimalScopesMode
},
hideExtraActions() {
return this.disableDraft || this.hideDraft
},
emojiUserSuggestor() {
return suggestor({
emoji: [
...useEmojiStore().standardEmojiList,
...useEmojiStore().customEmoji,
],
store: this.$store,
})
},
emojiSuggestor() {
return suggestor({
emoji: [
...useEmojiStore().standardEmojiList,
...useEmojiStore().customEmoji,
],
})
},
emoji() {
return useEmojiStore().standardEmojiList
},
customEmoji() {
return useEmojiStore().customEmoji
},
statusLength() {
return this.newStatus.status.length
},
spoilerTextLength() {
return this.newStatus.spoilerText.length
},
statusLengthLimit() {
- return useInstanceStore().textlimit
+ return useInstanceStore().limits.textLimit
},
hasStatusLengthLimit() {
return this.statusLengthLimit > 0
},
charactersLeft() {
return (
this.statusLengthLimit - (this.statusLength + this.spoilerTextLength)
)
},
isOverLengthLimit() {
return this.hasStatusLengthLimit && this.charactersLeft < 0
},
minimalScopesMode() {
return useInstanceStore().minimalScopesMode
},
alwaysShowSubject() {
return this.mergedConfig.alwaysShowSubjectInput
},
postFormats() {
return useInstanceCapabilitiesStore().postFormats || []
},
safeDMEnabled() {
return useInstanceCapabilitiesStore().safeDM
},
pollsAvailable() {
return (
useInstanceCapabilitiesStore().pollsAvailable &&
useInstanceStore().limits.pollLimits.max_options >= 2 &&
this.disablePolls !== true
)
},
hideScopeNotice() {
return (
- this.disableNotice || this.$store.getters.mergedConfig.hideScopeNotice
+ this.disableNotice ||
+ useMergedConfigStore().mergedConfig.hideScopeNotice
)
},
pollContentError() {
return (
this.pollFormVisible && this.newStatus.poll && this.newStatus.poll.error
)
},
showPreview() {
return !this.disablePreview && (!!this.preview || this.previewLoading)
},
emptyStatus() {
return (
this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
)
},
uploadFileLimitReached() {
return this.newStatus.files.length >= this.fileLimit
},
isEdit() {
return typeof this.statusId !== 'undefined' && this.statusId.trim() !== ''
},
quotingAvailable() {
if (!useInstanceCapabilitiesStore().quotingAvailable) {
return false
}
return this.disableQuotes !== true
},
isReply() {
return this.newStatus.type === 'reply'
},
quotable() {
return this.quotingAvailable && this.replyTo
},
quoteThreadToggled() {
return this.newStatus.hasQuote && this.newStatus.quote.thread
},
defaultQuotable() {
if (
!this.quotingAvailable ||
!this.isReply ||
!this.$store.getters.mergedConfig.quoteReply
) {
return false
}
const repliedStatus =
this.$store.state.statuses.allStatusesObject[this.replyTo]
if (!repliedStatus) {
return false
}
if (
repliedStatus.visibility === 'public' ||
repliedStatus.visibility === 'unlisted' ||
repliedStatus.visibility === 'local'
) {
return true
} else if (repliedStatus.visibility === 'private') {
return repliedStatus.user.id === this.$store.state.users.currentUser.id
}
return false
},
inReplyStatusId() {
return !this.newStatus.hasQuote ||
!this.newStatus.quote.thread ||
!this.newStatus.quote.id
? this.replyTo
: undefined
},
quoteId() {
return this.newStatus.hasQuote ? this.newStatus.quote.id : undefined
},
debouncedMaybeAutoSaveDraft() {
return debounce(this.maybeAutoSaveDraft, 3000)
},
pollFormVisible() {
return this.newStatus.hasPoll
},
quoteFormVisible() {
return this.newStatus.hasQuote && !this.newStatus.quote.thread
},
shouldAutoSaveDraft() {
- return this.$store.getters.mergedConfig.autoSaveDraft
+ return useMergedConfigStore().mergedConfig.autoSaveDraft
},
autoSaveState() {
if (this.saveable) {
return this.$t('post_status.auto_save_saving')
} else if (this.newStatus.id) {
return this.$t('post_status.auto_save_saved')
} else {
return this.$t('post_status.auto_save_nothing_new')
}
},
safeToSaveDraft() {
return (
(this.newStatus.status ||
this.newStatus.spoilerText ||
this.newStatus.files?.length ||
this.newStatus.hasPoll ||
this.newStatus.hasQuote) &&
this.saveable
)
},
hasEmptyDraft() {
return (
this.newStatus.id &&
!(
this.newStatus.status ||
this.newStatus.spoilerText ||
this.newStatus.files?.length ||
this.newStatus.hasPoll ||
this.newStatus.hasQuote
)
)
},
- ...mapGetters(['mergedConfig']),
+ ...mapState(useMergedConfigStore, ['mergedConfig']),
...mapState(useInterfaceStore, {
mobileLayout: (store) => store.mobileLayout,
}),
},
watch: {
newStatus: {
deep: true,
handler() {
this.statusChanged()
},
},
saveable(val) {
// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event#usage_notes
// MDN says we'd better add the beforeunload event listener only when needed, and remove it when it's no longer needed
if (val) {
this.addBeforeUnloadListener()
} else {
this.removeBeforeUnloadListener()
}
},
},
beforeUnmount() {
this.maybeAutoSaveDraft()
this.removeBeforeUnloadListener()
},
methods: {
...mapActions(useMediaViewerStore, ['increment']),
statusChanged() {
this.autoPreview()
this.updateIdempotencyKey()
this.debouncedMaybeAutoSaveDraft()
this.saveable = true
this.saveInhibited = false
},
clearStatus() {
const newStatus = this.newStatus
this.saveInhibited = true
this.newStatus = {
status: '',
spoilerText: '',
files: [],
visibility: newStatus.visibility,
contentType: newStatus.contentType,
poll: {},
hasPoll: false,
hasQuote: false,
quote: {},
mediaDescriptions: {},
}
this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
this.clearPollForm()
this.clearQuoteForm()
if (this.preserveFocus) {
this.$nextTick(() => {
this.$refs.textarea.focus()
})
}
const el = this.$el.querySelector('textarea')
el.style.height = 'auto'
el.style.height = undefined
this.error = null
if (this.preview) this.previewStatus()
this.saveable = false
},
async postStatus(event, newStatus) {
if (this.posting && !this.optimisticPosting) {
return
}
if (this.disableSubmit) {
return
}
if (this.emojiInputShown) {
return
}
if (this.submitOnEnter) {
event.stopPropagation()
event.preventDefault()
}
if (
this.optimisticPosting &&
(this.emptyStatus || this.isOverLengthLimit)
) {
return
}
if (this.emptyStatus) {
this.error = this.$t('post_status.empty_status_error')
return
}
const poll = newStatus.hasPoll ? pollFormToMasto(newStatus.poll) : {}
if (this.pollContentError) {
this.error = this.pollContentError
return
}
this.posting = true
try {
await this.setAllMediaDescriptions()
} catch {
this.error = this.$t('post_status.media_description_error')
this.posting = false
return
}
const postingOptions = {
status: newStatus.status,
spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility,
sensitive: newStatus.nsfw,
media: newStatus.files,
store: this.$store,
inReplyToStatusId: this.inReplyStatusId,
quoteId: this.quoteId,
contentType: newStatus.contentType,
poll,
idempotencyKey: this.idempotencyKey,
}
const postHandler = this.postHandler
? this.postHandler
: statusPoster.postStatus
postHandler(postingOptions).then((data) => {
if (!data.error) {
this.abandonDraft()
this.clearStatus()
this.$emit('posted', data)
} else {
this.error = data.error
}
this.posting = false
})
},
previewStatus() {
if (this.emptyStatus && this.newStatus.spoilerText.trim() === '') {
this.preview = { error: this.$t('post_status.preview_empty') }
this.previewLoading = false
return
}
const newStatus = this.newStatus
this.previewLoading = true
statusPoster
.postStatus({
status: newStatus.status,
spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility,
sensitive: newStatus.nsfw,
media: [],
store: this.$store,
inReplyToStatusId: this.inReplyStatusId,
quoteId: this.quoteId,
contentType: newStatus.contentType,
poll: {},
preview: true,
})
.then((data) => {
// Don't apply preview if not loading, because it means
// user has closed the preview manually.
if (!this.previewLoading) return
if (!data.error) {
this.preview = data
} else {
this.preview = { error: data.error }
}
})
.catch((error) => {
this.preview = { error }
})
.finally(() => {
this.previewLoading = false
})
},
debouncePreviewStatus: debounce(function () {
this.previewStatus()
}, 500),
autoPreview() {
if (!this.preview) return
this.previewLoading = true
this.debouncePreviewStatus()
},
closePreview() {
this.preview = null
this.previewLoading = false
},
togglePreview() {
if (this.showPreview) {
this.closePreview()
} else {
this.previewStatus()
}
},
addMediaFile(fileInfo) {
this.newStatus.files.push(fileInfo)
this.$emit('resize', { delayed: true })
},
removeMediaFile(fileInfo) {
const index = this.newStatus.files.indexOf(fileInfo)
this.newStatus.files.splice(index, 1)
this.$emit('resize')
},
editAttachment(fileInfo, newText) {
this.newStatus.mediaDescriptions[fileInfo.id] = newText
},
shiftUpMediaFile(fileInfo) {
const { files } = this.newStatus
const index = this.newStatus.files.indexOf(fileInfo)
files.splice(index, 1)
files.splice(index - 1, 0, fileInfo)
},
shiftDnMediaFile(fileInfo) {
const { files } = this.newStatus
const index = this.newStatus.files.indexOf(fileInfo)
files.splice(index, 1)
files.splice(index + 1, 0, fileInfo)
},
uploadFailed(errString, templateArgs) {
templateArgs = templateArgs || {}
this.error =
this.$t('upload.error.base') +
' ' +
this.$t('upload.error.' + errString, templateArgs)
},
startedUploadingFiles() {
this.uploadingFiles = true
},
finishedUploadingFiles() {
this.$emit('resize')
this.uploadingFiles = false
},
paste(e) {
this.autoPreview()
this.resize(e)
if (e.clipboardData.files.length > 0) {
// prevent pasting of file as text
e.preventDefault()
// Strangely, files property gets emptied after event propagation
// Trying to wrap it in array doesn't work. Plus I doubt it's possible
// to hold more than one file in clipboard.
this.dropFiles = [e.clipboardData.files[0]]
}
},
fileDrop(e) {
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
e.preventDefault() // allow dropping text like before
this.dropFiles = e.dataTransfer.files
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'hide'
}
},
fileDragStop() {
// The false-setting is done with delay because just using leave-events
// directly caused unwanted flickering, this is not perfect either but
// much less noticable.
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'fade'
this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500)
},
fileDrag(e) {
e.dataTransfer.dropEffect = this.uploadFileLimitReached ? 'none' : 'copy'
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'show'
}
},
onEmojiInputInput() {
this.$nextTick(() => {
this.resize(this.$refs.textarea)
})
},
resize(e) {
const target = e.target || e
if (!(target instanceof window.Element)) {
return
}
// Reset to default height for empty form, nothing else to do here.
if (target.value === '') {
target.style.height = null
this.$emit('resize')
return
}
const formRef = this.$refs.form
const bottomRef = this.$refs.bottom
/* Scroller is either `window` (replies in TL), sidebar (main post form,
* replies in notifs) or mobile post form. Note that getting and setting
* scroll is different for `Window` and `Element`s
*/
const bottomBottomPaddingStr =
window.getComputedStyle(bottomRef)['padding-bottom']
const bottomBottomPadding = pxStringToNumber(bottomBottomPaddingStr)
const scrollerRef =
this.$el.closest('.column.-scrollable') ||
this.$el.closest('.post-form-modal-view') ||
window
// Getting info about padding we have to account for, removing 'px' part
const topPaddingStr = window.getComputedStyle(target)['padding-top']
const bottomPaddingStr = window.getComputedStyle(target)['padding-bottom']
const topPadding = pxStringToNumber(topPaddingStr)
const bottomPadding = pxStringToNumber(bottomPaddingStr)
const vertPadding = topPadding + bottomPadding
const oldHeight = pxStringToNumber(target.style.height)
/* Explanation:
*
* https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight
* scrollHeight returns element's scrollable content height, i.e. visible
* element + overscrolled parts of it. We use it to determine when text
* inside the textarea exceeded its height, so we can set height to prevent
* overscroll, i.e. make textarea grow with the text. HOWEVER, since we
* explicitly set new height, scrollHeight won't go below that, so we can't
* SHRINK the textarea when there's extra space. To workaround that we set
* height to 'auto' which makes textarea tiny again, so that scrollHeight
* will match text height again. HOWEVER, shrinking textarea can screw with
* the scroll since there might be not enough padding around form-bottom to even
* warrant a scroll, so it will jump to 0 and refuse to move anywhere,
* so we check current scroll position before shrinking and then restore it
* with needed delta.
*/
// this part has to be BEFORE the content size update
const currentScroll =
scrollerRef === window ? scrollerRef.scrollY : scrollerRef.scrollTop
const scrollerHeight =
scrollerRef === window
? scrollerRef.innerHeight
: scrollerRef.offsetHeight
const scrollerBottomBorder = currentScroll + scrollerHeight
// BEGIN content size update
target.style.height = 'auto'
const heightWithoutPadding = Math.floor(target.scrollHeight - vertPadding)
let newHeight = this.maxHeight
? Math.min(heightWithoutPadding, this.maxHeight)
: heightWithoutPadding
// This is a bit of a hack to combat target.scrollHeight being different on every other input
// on some browsers for whatever reason. Don't change the height if difference is 1px or less.
if (Math.abs(newHeight - oldHeight) <= 1) {
newHeight = oldHeight
}
target.style.height = `${newHeight}px`
this.$emit('resize', newHeight)
// END content size update
// We check where the bottom border of form-bottom element is, this uses findOffset
// to find offset relative to scrollable container (scroller)
const bottomBottomBorder =
bottomRef.offsetHeight +
findOffset(bottomRef, scrollerRef).top +
bottomBottomPadding
const isBottomObstructed = scrollerBottomBorder < bottomBottomBorder
const isFormBiggerThanScroller = scrollerHeight < formRef.offsetHeight
const bottomChangeDelta = bottomBottomBorder - scrollerBottomBorder
// The intention is basically this;
// Keep form-bottom always visible so that submit button is in view EXCEPT
// if form element bigger than scroller and caret isn't at the end, so that
// if you scroll up and edit middle of text you won't get scrolled back to bottom
const shouldScrollToBottom =
isBottomObstructed &&
!(
isFormBiggerThanScroller &&
this.$refs.textarea.selectionStart !==
this.$refs.textarea.value.length
)
const totalDelta = shouldScrollToBottom ? bottomChangeDelta : 0
const targetScroll = Math.round(currentScroll + totalDelta)
if (scrollerRef === window) {
scrollerRef.scroll(0, targetScroll)
} else {
scrollerRef.scrollTop = targetScroll
}
},
clearError() {
this.error = null
},
changeVis(visibility) {
this.newStatus.visibility = visibility
},
togglePollForm() {
this.newStatus.hasPoll = !this.newStatus.hasPoll
},
setPoll(poll) {
this.newStatus.poll = poll
},
clearPollForm() {
if (this.$refs.pollForm) {
this.$refs.pollForm.clear()
}
},
initQuote() {
const quote = this.newStatus.quote
if (Object.keys(quote).length > 0) {
return
}
const quotable = this.defaultQuotable
quote.id = quotable ? this.replyTo : ''
quote.url = ''
quote.thread = quotable
},
setQuoteThread(v) {
this.newStatus.hasQuote = v
this.newStatus.quote.thread = v
this.newStatus.quote.id = v ? this.replyTo : ''
},
clearQuoteForm() {
if (this.$refs.quoteForm) {
this.$refs.quoteForm.clear()
}
},
toggleQuoteForm() {
this.newStatus.hasQuote = !this.newStatus.hasQuote
},
dismissScopeNotice() {
- this.$store.dispatch('setOption', {
- name: 'hideScopeNotice',
+ useSyncConfigStore().setSimplePrefAndSave({
+ path: 'hideScopeNotice',
value: true,
})
},
setMediaDescription(id) {
const description = this.newStatus.mediaDescriptions[id]
if (!description || description.trim() === '') return
return statusPoster.setMediaDescription({
store: this.$store,
id,
description,
})
},
setAllMediaDescriptions() {
const ids = this.newStatus.files.map((file) => file.id)
return Promise.all(ids.map((id) => this.setMediaDescription(id)))
},
handleEmojiInputShow(value) {
this.emojiInputShown = value
},
updateIdempotencyKey() {
this.idempotencyKey = Date.now().toString()
},
openProfileTab() {
useInterfaceStore().openSettingsModalTab('profile')
},
propsToNative(props) {
return propsToNative(props)
},
saveDraft() {
if (!this.disableDraft && !this.saveInhibited) {
if (this.safeToSaveDraft) {
return this.$store
.dispatch('addOrSaveDraft', { draft: this.newStatus })
.then((id) => {
if (this.newStatus.id !== id) {
this.newStatus.id = id
}
this.saveable = false
if (!this.shouldAutoSaveDraft) {
this.clearStatus()
this.$emit('draft-done')
}
})
} else if (this.hasEmptyDraft) {
// There is a draft, but there is nothing in it, clear it
return this.abandonDraft().then(() => {
this.saveable = false
if (!this.shouldAutoSaveDraft) {
this.clearStatus()
this.$emit('draft-done')
}
})
}
}
return Promise.resolve()
},
maybeAutoSaveDraft() {
if (this.shouldAutoSaveDraft) {
this.saveDraft(false)
}
},
abandonDraft() {
return this.$store.dispatch('abandonDraft', { id: this.newStatus.id })
},
getDraft(statusType, refId) {
const maybeDraft = this.$store.state.drafts.drafts[this.draftId]
if (this.draftId && maybeDraft) {
return maybeDraft
} else {
const existingDrafts = this.$store.getters.draftsByTypeAndRefId(
statusType,
refId,
)
if (existingDrafts.length) {
return existingDrafts[0]
}
}
// No draft available, fall back
},
requestClose() {
if (!this.saveable) {
this.$emit('can-close')
} else {
this.$refs.draftCloser.requestClose()
}
},
saveAndCloseDraft() {
this.saveDraft().then(() => {
this.$emit('can-close')
})
},
discardAndCloseDraft() {
this.abandonDraft().then(() => {
this.$emit('can-close')
})
},
addBeforeUnloadListener() {
this._beforeUnloadListener ||= () => {
this.saveDraft()
}
window.addEventListener('beforeunload', this._beforeUnloadListener)
},
removeBeforeUnloadListener() {
if (this._beforeUnloadListener) {
window.removeEventListener('beforeunload', this._beforeUnloadListener)
}
},
},
}
export default PostStatusForm
diff --git a/src/components/post_status_form/post_status_form.scss b/src/components/post_status_form/post_status_form.scss
index 976a23f0a5..684a92267e 100644
--- a/src/components/post_status_form/post_status_form.scss
+++ b/src/components/post_status_form/post_status_form.scss
@@ -1,282 +1,281 @@
.post-status-form {
position: relative;
.attachments {
margin-bottom: 0.5em;
}
.more-post-actions {
height: 100%;
.btn {
height: 100%;
}
}
.form-bottom {
display: flex;
justify-content: space-between;
padding: 0.5em;
height: 2.5em;
.post-button-group {
width: 10em;
display: flex;
.post-button {
flex: 1 0 auto;
}
.more-post-actions {
flex: 0 0 auto;
}
}
p {
margin: 0.35em;
padding: 0.35em;
display: flex;
}
}
.form-bottom-left {
display: flex;
gap: 1.5em;
button {
padding: 0.5em;
margin: -0.5em;
}
}
.preview-heading {
display: flex;
flex-wrap: wrap;
}
.preview-toggle {
flex: 10 0 auto;
cursor: pointer;
user-select: none;
padding-left: 0.5em;
&:hover {
text-decoration: underline;
}
svg,
i {
margin-left: 0.2em;
font-size: 0.8em;
transform: rotate(90deg);
}
}
.preview-container {
margin-bottom: 1em;
}
.preview-error {
font-style: italic;
color: var(--textFaint);
}
.preview-status {
border: 1px solid var(--border);
border-radius: var(--roundness);
padding: 0.5em;
margin: 0;
}
.reply-or-quote-selector {
flex: 1 0 auto;
margin-bottom: 0.5em;
display: grid;
grid-template-columns: 1fr 1fr;
}
.text-format {
.only-format {
color: var(--textFaint);
}
}
.visibility-tray {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-left: -0.5em;
}
.visibility-notice {
border: 1px solid var(--border);
border-radius: var(--roundness);
- padding: 0.5em 1em
}
.visibility-notice.edit-warning {
> :first-child {
margin-top: 0;
}
> :last-child {
margin-bottom: 0;
}
}
// Order is not necessary but a good indicator
.media-upload-icon {
order: 1;
justify-content: left;
}
.emoji-icon {
order: 2;
justify-content: center;
}
.poll-icon {
order: 3;
justify-content: center;
}
.quote-icon {
order: 4;
justify-content: right;
}
.media-upload-icon,
.poll-icon,
.quote-icon,
.emoji-icon {
font-size: 1.85em;
line-height: 1.1;
flex: 1;
padding: 0 0.1em;
display: flex;
align-items: center;
}
.error {
text-align: center;
}
.media-upload-wrapper {
margin-right: 0.2em;
margin-bottom: 0.5em;
width: 18em;
img,
video {
object-fit: contain;
max-height: 10em;
}
.video {
max-height: 10em;
}
input {
flex: 1;
width: 100%;
}
}
.status-input-wrapper {
display: flex;
position: relative;
width: 100%;
flex-direction: column;
}
.btn[disabled] {
cursor: not-allowed;
}
form {
display: flex;
flex-direction: column;
margin: 0.6em;
position: relative;
}
.form-group {
display: flex;
flex-direction: column;
padding: 0.25em 0.5em 0.5em;
line-height: 1.85;
}
.inputs-wrapper {
padding: 0;
}
.input.form-post-body {
// TODO: make a resizable textarea component?
box-sizing: content-box; // needed for easier computation of dynamic size
overflow: hidden;
transition: min-height 200ms 100ms;
// stock padding + 1 line of text (for counter)
padding-bottom: calc(var(--_padding) + var(--post-line-height) * 1em);
padding-right: 1.5em;
// two lines of text
height: calc(var(--post-line-height) * 1em);
min-height: calc(var(--post-line-height) * 1em);
resize: none;
background: transparent;
text-wrap: stable;
&.scrollable-form {
overflow-y: auto;
}
}
.main-input {
position: relative;
}
.subject-input {
border-bottom: 1px solid var(--border);
}
.character-counter {
position: absolute;
bottom: 0;
right: 0;
padding: 0;
margin: 0 0.5em;
&.error {
color: var(--cRed);
}
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 0.6; }
}
@keyframes fade-out {
from { opacity: 0.6; }
to { opacity: 0; }
}
.drop-indicator {
position: absolute;
width: 100%;
height: 100%;
font-size: 5em;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.6;
color: var(--text);
background-color: var(--bg);
border-radius: var(--roundness);
border: 2px dashed var(--text);
}
.auto-save-status {
align-self: center;
}
}
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index 0eedea360c..5fdde4784f 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -1,436 +1,440 @@
<template>
<div
ref="form"
class="post-status-form"
>
<form
autocomplete="off"
@submit.prevent
@dragover.prevent="fileDrag"
>
<div class="form-group">
- <i18n-t
+ <div
v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private' && !disableLockWarning"
- keypath="post_status.account_not_locked_warning"
- tag="p"
- class="visibility-notice"
- scope="global"
+ class="visibility-notice notice-dismissible"
>
- <button
- class="button-unstyled -link"
- @click="openProfileTab"
+ <i18n-t
+ keypath="post_status.account_not_locked_warning"
+ tag="p"
+ class=""
+ scope="global"
>
- {{ $t('post_status.account_not_locked_warning_link') }}
- </button>
- </i18n-t>
+ <button
+ class="button-unstyled -link"
+ @click="openProfileTab"
+ >
+ {{ $t('post_status.account_not_locked_warning_link') }}
+ </button>
+ </i18n-t>
+ </div>
<p
v-if="!hideScopeNotice && newStatus.visibility === 'public'"
class="visibility-notice notice-dismissible"
>
<span>{{ $t('post_status.scope_notice.public') }}</span>
<a
class="fa-scale-110 fa-old-padding dismiss"
:title="$t('post_status.scope_notice_dismiss')"
role="button"
tabindex="0"
@click.prevent="dismissScopeNotice()"
>
<FAIcon icon="times" />
</a>
</p>
<p
v-else-if="!hideScopeNotice && newStatus.visibility === 'unlisted'"
class="visibility-notice notice-dismissible"
>
<span>{{ $t('post_status.scope_notice.unlisted') }}</span>
<a
class="fa-scale-110 fa-old-padding dismiss"
:title="$t('post_status.scope_notice_dismiss')"
role="button"
tabindex="0"
@click.prevent="dismissScopeNotice()"
>
<FAIcon icon="times" />
</a>
</p>
<p
v-else-if="!hideScopeNotice && newStatus.visibility === 'private' && $store.state.users.currentUser.locked"
class="visibility-notice notice-dismissible"
>
<span>{{ $t('post_status.scope_notice.private') }}</span>
<a
class="fa-scale-110 fa-old-padding dismiss"
:title="$t('post_status.scope_notice_dismiss')"
role="button"
tabindex="0"
@click.prevent="dismissScopeNotice()"
>
<FAIcon icon="times" />
</a>
</p>
<p
v-else-if="newStatus.visibility === 'direct'"
- class="visibility-notice"
+ class="visibility-notice notice-dismissible"
>
<span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span>
<span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
</p>
<div
v-if="isEdit"
class="visibility-notice edit-warning"
>
<p>{{ $t('post_status.edit_remote_warning') }}</p>
<p>{{ $t('post_status.edit_unsupported_warning') }}</p>
</div>
<div
v-if="!disablePreview"
class="preview-heading faint"
>
<a
class="preview-toggle faint"
@click.stop.prevent="togglePreview"
>
{{ $t('post_status.preview') }}
<FAIcon :icon="showPreview ? 'chevron-left' : 'chevron-right'" />
</a>
<div
v-show="previewLoading"
class="preview-spinner"
>
<FAIcon
class="fa-old-padding"
spin
icon="circle-notch"
/>
</div>
<div
v-if="quotable"
role="radiogroup"
class="btn-group reply-or-quote-selector"
>
<button
:id="`reply-or-quote-option-${randomSeed}-reply`"
class="btn button-default reply-or-quote-option"
:class="{ toggled: !quoteThreadToggled }"
tabindex="0"
role="radio"
:disabled="quoteFormVisible"
:aria-labelledby="`reply-or-quote-option-${randomSeed}-reply`"
:aria-checked="!newStatus.quote.thread"
@click="setQuoteThread(false)"
>
{{ $t('post_status.reply_option') }}
</button>
<button
:id="`reply-or-quote-option-${randomSeed}-quote`"
class="btn button-default reply-or-quote-option"
:class="{ toggled: quoteThreadToggled }"
tabindex="0"
role="radio"
:disabled="quoteFormVisible"
:aria-labelledby="`reply-or-quote-option-${randomSeed}-quote`"
:aria-checked="newStatus.quote.thread"
@click="setQuoteThread(true)"
>
{{ $t('post_status.quote_option') }}
</button>
</div>
</div>
<div
v-if="showPreview"
class="preview-container"
>
<div
v-if="!preview"
class="preview-status"
>
{{ $t('general.loading') }}
</div>
<div
v-else-if="preview.error"
class="preview-status preview-error"
>
{{ preview.error }}
</div>
<StatusContent
v-else
:status="preview"
class="preview-status"
/>
</div>
<div class="input inputs-wrapper">
<EmojiInput
v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)"
v-model="newStatus.spoilerText"
enable-emoji-picker
:suggest="emojiSuggestor"
class="input form-control subject-input unstyled"
>
<template #default="inputProps">
<input
v-model="newStatus.spoilerText"
type="text"
:placeholder="$t('post_status.content_warning')"
:disabled="posting && !optimisticPosting"
v-bind="propsToNative(inputProps)"
size="1"
class="input form-post-subject unstyled"
>
</template>
</EmojiInput>
<EmojiInput
ref="emoji-input"
v-model="newStatus.status"
:suggest="emojiUserSuggestor"
:placement="emojiPickerPlacement"
class="input form-control main-input unstyled"
enable-sticker-picker
enable-emoji-picker
:newline-on-ctrl-enter="submitOnEnter"
@input="onEmojiInputInput"
@sticker-uploaded="addMediaFile"
@sticker-upload-failed="uploadFailed"
@shown="handleEmojiInputShow"
>
<template #default="inputProps">
<textarea
ref="textarea"
v-model="newStatus.status"
:placeholder="placeholder || $t('post_status.default')"
rows="1"
cols="1"
:disabled="posting && !optimisticPosting"
class="input form-post-body"
:class="{ 'scrollable-form': !!maxHeight }"
v-bind="propsToNative(inputProps)"
@keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
@keydown.meta.enter="postStatus($event, newStatus)"
@keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)"
@input="resize"
@compositionupdate="resize"
@paste="paste"
/>
<p
v-if="hasStatusLengthLimit"
class="character-counter faint"
:class="{ error: isOverLengthLimit }"
>
{{ charactersLeft }}
</p>
</template>
</EmojiInput>
</div>
<div
v-if="!disableScopeSelector"
class="visibility-tray"
>
<scope-selector
v-if="!disableVisibilitySelector"
:show-all="showAllScopes"
:user-default="userDefaultScope"
:original-scope="copyMessageScope"
:initial-scope="newStatus.visibility"
:on-scope-change="changeVis"
/>
<div
v-if="postFormats.length > 1"
class="text-format"
>
<Select
v-model="newStatus.contentType"
class="input form-control unstyled"
:attrs="{ 'aria-label': $t('post_status.content_type_selection') }"
unstyled="true"
>
<option
v-for="postFormat in postFormats"
:key="postFormat"
:value="postFormat"
>
{{ $t(`post_status.content_type["${postFormat}"]`) }}
</option>
</Select>
</div>
<div
v-if="postFormats.length === 1 && postFormats[0] !== 'text/plain'"
class="text-format"
>
<span class="only-format">
{{ $t(`post_status.content_type["${postFormats[0]}"]`) }}
</span>
</div>
</div>
</div>
<poll-form
v-if="pollsAvailable"
ref="pollForm"
:visible="pollFormVisible"
:params="newStatus.poll"
/>
<quote-form
v-if="quotingAvailable"
ref="quoteForm"
:visible="quoteFormVisible"
:reply="isReply"
:params="newStatus.quote"
/>
<span
v-if="!disableDraft && shouldAutoSaveDraft"
class="auto-save-status"
>
{{ autoSaveState }}
</span>
<div
ref="bottom"
class="form-bottom"
>
<div class="form-bottom-left">
<media-upload
ref="mediaUpload"
class="media-upload-icon"
:drop-files="dropFiles"
:disabled="uploadFileLimitReached"
@uploading="startedUploadingFiles"
@uploaded="addMediaFile"
@upload-failed="uploadFailed"
@all-uploaded="finishedUploadingFiles"
/>
<button
v-if="pollsAvailable"
class="poll-icon button-unstyled"
:class="{ selected: pollFormVisible }"
:title="$t('polls.add_poll')"
@click="togglePollForm"
>
<FAIcon icon="poll-h" />
</button>
<button
v-if="quotingAvailable"
class="quote-icon button-unstyled"
:disabled="newStatus.quote.thread"
:class="{ selected: quoteFormVisible }"
:title="$t('tool_tip.add_quote')"
@click="toggleQuoteForm"
>
<FAIcon icon="quote-right" />
</button>
</div>
<div class="btn-group post-button-group">
<button
class="btn button-default post-button"
:disabled="isOverLengthLimit || posting || uploadingFiles || disableSubmit"
@click.stop.prevent="postStatus($event, newStatus)"
>
<template v-if="posting">
{{ $t('post_status.posting') }}
</template>
<template v-else>
{{ $t('post_status.post') }}
</template>
</button>
<Popover
v-if="!hideExtraActions"
class="more-post-actions"
:normal-button="true"
trigger="click"
placement="bottom"
:offset="{ y: 5 }"
:trigger-attrs="{ 'aria-label': $t('post_status.more_post_actions') }"
>
<template #trigger>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="chevron-down"
/>
</template>
<template #content="{close}">
<div
class="dropdown-menu"
role="menu"
>
<div
class="menu-item dropdown-item"
:class="{ disabled: !safeToSaveDraft }"
>
<button
v-if="!hideDraft || !disableDraft"
class="main-button"
role="menu"
:disabled="!safeToSaveDraft"
@click.prevent="saveDraft"
@click="close"
>
<template v-if="closeable">
{{ $t('post_status.save_to_drafts_and_close_button') }}
</template>
<template v-else>
{{ $t('post_status.save_to_drafts_button') }}
</template>
</button>
</div>
</div>
</template>
</Popover>
</div>
</div>
<div
v-show="showDropIcon !== 'hide'"
:style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }"
class="drop-indicator"
@dragleave="fileDragStop"
@drop.stop="fileDrop"
>
<FAIcon :icon="uploadFileLimitReached ? 'ban' : 'upload'" />
</div>
<div
v-if="error"
class="alert error"
>
Error: {{ error }}
<button
class="button-unstyled"
@click="clearError"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
/>
</button>
</div>
<gallery
v-if="newStatus.files && newStatus.files.length > 0"
class="attachments"
:grid="true"
:nsfw="false"
:attachments="newStatus.files"
:descriptions="newStatus.mediaDescriptions"
:set-media="() => setMedia()"
:editable="true"
:edit-attachment="editAttachment"
:remove-attachment="removeMediaFile"
:shift-up-attachment="newStatus.files.length > 1 && shiftUpMediaFile"
:shift-dn-attachment="newStatus.files.length > 1 && shiftDnMediaFile"
@play="$emit('mediaplay', attachment.id)"
@pause="$emit('mediapause', attachment.id)"
/>
<div
v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox"
class="upload_settings"
>
<Checkbox v-model="newStatus.nsfw">
{{ $t('post_status.attachments_sensitive') }}
</Checkbox>
</div>
</form>
<DraftCloser
ref="draftCloser"
@save="saveAndCloseDraft"
@discard="discardAndCloseDraft"
/>
</div>
</template>
<script src="./post_status_form.js"></script>
<style src="./post_status_form.scss" lang="scss"></style>
diff --git a/src/components/quick_filter_settings/quick_filter_settings.js b/src/components/quick_filter_settings/quick_filter_settings.js
index 84abeec724..4127f3f309 100644
--- a/src/components/quick_filter_settings/quick_filter_settings.js
+++ b/src/components/quick_filter_settings/quick_filter_settings.js
@@ -1,133 +1,137 @@
import { mapState } from 'pinia'
-import { mapGetters } from 'vuex'
import Popover from '../popover/popover.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
+import { useLocalConfigStore } from 'src/stores/local_config.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faFilter, faFont, faWrench } from '@fortawesome/free-solid-svg-icons'
library.add(faFilter, faFont, faWrench)
const QuickFilterSettings = {
props: {
conversation: Boolean,
nested: Boolean,
},
components: {
Popover,
},
methods: {
setReplyVisibility(visibility) {
- this.$store.dispatch('setOption', {
- name: 'replyVisibility',
+ useSyncConfigStore().setSimplePrefAndSave({
+ path: 'replyVisibility',
value: visibility,
})
this.$store.dispatch('queueFlushAll')
},
openTab(tab) {
useInterfaceStore().openSettingsModalTab(tab)
},
},
computed: {
- ...mapGetters(['mergedConfig']),
+ ...mapState(useMergedConfigStore, ['mergedConfig']),
...mapState(useInterfaceStore, {
mobileLayout: (state) => state.layoutType === 'mobile',
}),
triggerAttrs() {
if (this.mobileLayout) {
return {}
} else {
return {
title: this.$t('timeline.quick_filter_settings'),
}
}
},
mainClass() {
if (this.mobileLayout) {
return 'main-button'
} else {
return 'dropdown-item'
}
},
loggedIn() {
return !!this.$store.state.users.currentUser
},
replyVisibilitySelf: {
get() {
return this.mergedConfig.replyVisibility === 'self'
},
set() {
this.setReplyVisibility('self')
},
},
replyVisibilityFollowing: {
get() {
return this.mergedConfig.replyVisibility === 'following'
},
set() {
this.setReplyVisibility('following')
},
},
replyVisibilityAll: {
get() {
return this.mergedConfig.replyVisibility === 'all'
},
set() {
this.setReplyVisibility('all')
},
},
hideMedia: {
get() {
return (
this.mergedConfig.hideAttachments ||
this.mergedConfig.hideAttachmentsInConv
)
},
- set() {
- const value = !this.hideMedia
- this.$store.dispatch('setOption', { name: 'hideAttachments', value })
- this.$store.dispatch('setOption', {
- name: 'hideAttachmentsInConv',
+ set(value) {
+ useLocalConfigStore().set({
+ path: 'hideAttachments',
+ value,
+ })
+ useLocalConfigStore().set({
+ path: 'hideAttachmentsInConv',
value,
})
},
},
hideMutedPosts: {
get() {
return this.mergedConfig.hideFilteredStatuses
},
- set() {
- const value = !this.hideMutedPosts
- this.$store.dispatch('setOption', {
- name: 'hideFilteredStatuses',
+ set(value) {
+ useSyncConfigStore().setSimplePrefAndSave({
+ path: 'hideFilteredStatuses',
value,
})
},
},
muteBotStatuses: {
get() {
return this.mergedConfig.muteBotStatuses
},
- set() {
- const value = !this.muteBotStatuses
- this.$store.dispatch('setOption', { name: 'muteBotStatuses', value })
+ set(value) {
+ useSyncConfigStore().setSimplePrefAndSave({
+ path: 'muteBotStatuses',
+ value,
+ })
},
},
muteSensitiveStatuses: {
get() {
return this.mergedConfig.muteSensitiveStatuses
},
- set() {
- const value = !this.muteSensitiveStatuses
- this.$store.dispatch('setOption', {
- name: 'muteSensitiveStatuses',
+ set(value) {
+ useSyncConfigStore().setSimplePrefAndSave({
+ path: 'muteSensitiveStatuses',
value,
})
},
},
},
}
export default QuickFilterSettings
diff --git a/src/components/quick_view_settings/quick_view_settings.js b/src/components/quick_view_settings/quick_view_settings.js
index 438d1405bc..f809d241c9 100644
--- a/src/components/quick_view_settings/quick_view_settings.js
+++ b/src/components/quick_view_settings/quick_view_settings.js
@@ -1,111 +1,112 @@
import { mapState } from 'pinia'
-import { mapGetters } from 'vuex'
import Popover from 'src/components/popover/popover.vue'
import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBars,
faFolderTree,
faList,
faWrench,
} from '@fortawesome/free-solid-svg-icons'
library.add(faList, faFolderTree, faBars, faWrench)
const QuickViewSettings = {
props: {
conversation: Boolean,
},
components: {
Popover,
QuickFilterSettings,
},
methods: {
- setConversationDisplay(visibility) {
- this.$store.dispatch('setOption', {
- name: 'conversationDisplay',
- value: visibility,
- })
- },
openTab(tab) {
useInterfaceStore().openSettingsModalTab(tab)
},
},
computed: {
- ...mapGetters(['mergedConfig']),
+ ...mapState(useMergedConfigStore, ['mergedConfig']),
...mapState(useInterfaceStore, {
mobileLayout: (state) => state.layoutType === 'mobile',
}),
loggedIn() {
return !!this.$store.state.users.currentUser
},
conversationDisplay: {
get() {
return this.mergedConfig.conversationDisplay
},
- set(newVal) {
- this.setConversationDisplay(newVal)
+ set(value) {
+ useSyncConfigStore().setSimplePrefAndSave({
+ path: 'conversationDisplay',
+ value,
+ })
},
},
autoUpdate: {
get() {
return this.mergedConfig.streaming
},
set() {
const value = !this.autoUpdate
- this.$store.dispatch('setOption', { name: 'streaming', value })
+ useSyncConfigStore().setSimplePrefAndSave({ path: 'streaming', value })
},
},
collapseWithSubjects: {
get() {
return this.mergedConfig.collapseMessageWithSubject
},
set() {
const value = !this.collapseWithSubjects
- this.$store.dispatch('setOption', {
- name: 'collapseMessageWithSubject',
+ useSyncConfigStore().setSimplePrefAndSave({
+ path: 'collapseMessageWithSubject',
value,
})
},
},
showUserAvatars: {
get() {
return this.mergedConfig.mentionLinkShowAvatar
},
set() {
const value = !this.showUserAvatars
- this.$store.dispatch('setOption', {
- name: 'mentionLinkShowAvatar',
+ useSyncConfigStore().setSimplePrefAndSave({
+ path: 'mentionLinkShowAvatar',
value,
})
},
},
muteBotStatuses: {
get() {
return this.mergedConfig.muteBotStatuses
},
set() {
const value = !this.muteBotStatuses
- this.$store.dispatch('setOption', { name: 'muteBotStatuses', value })
+ useSyncConfigStore().setSimplePrefAndSave({
+ path: 'muteBotStatuses',
+ value,
+ })
},
},
muteSensitiveStatuses: {
get() {
return this.mergedConfig.muteSensitiveStatuses
},
set() {
const value = !this.muteSensitiveStatuses
- this.$store.dispatch('setOption', {
- name: 'muteSensitiveStatuses',
+ useSyncConfigStore().setSimplePrefAndSave({
+ path: 'muteSensitiveStatuses',
value,
})
},
},
},
}
export default QuickViewSettings
diff --git a/src/components/remove_follower_button/remove_follower_button.js b/src/components/remove_follower_button/remove_follower_button.js
index b8f195815f..bd5aa6d5b4 100644
--- a/src/components/remove_follower_button/remove_follower_button.js
+++ b/src/components/remove_follower_button/remove_follower_button.js
@@ -1,50 +1,52 @@
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+
export default {
props: ['user', 'relationship'],
data() {
return {
inProgress: false,
showingConfirmRemoveFollower: false,
}
},
components: {
ConfirmModal,
},
computed: {
label() {
if (this.inProgress) {
return this.$t('user_card.follow_progress')
} else {
return this.$t('user_card.remove_follower')
}
},
shouldConfirmRemoveUserFromFollowers() {
- return this.$store.getters.mergedConfig.modalOnRemoveUserFromFollowers
+ return useMergedConfigStore().mergedConfig.modalOnRemoveUserFromFollowers
},
},
methods: {
showConfirmRemoveUserFromFollowers() {
this.showingConfirmRemoveFollower = true
},
hideConfirmRemoveUserFromFollowers() {
this.showingConfirmRemoveFollower = false
},
onClick() {
if (!this.shouldConfirmRemoveUserFromFollowers) {
this.doRemoveUserFromFollowers()
} else {
this.showConfirmRemoveUserFromFollowers()
}
},
doRemoveUserFromFollowers() {
this.inProgress = true
this.$store
.dispatch('removeUserFromFollowers', this.relationship.id)
.then(() => {
this.inProgress = false
})
this.hideConfirmRemoveUserFromFollowers()
},
},
}
diff --git a/src/components/settings_modal/helpers/attachment_setting.vue b/src/components/settings_modal/helpers/attachment_setting.vue
index a7804d78fb..c8e0e4ea02 100644
--- a/src/components/settings_modal/helpers/attachment_setting.vue
+++ b/src/components/settings_modal/helpers/attachment_setting.vue
@@ -1,123 +1,123 @@
<template>
<span
v-if="matchesExpertLevel"
class="AttachmentSetting setting-item"
:class="{ '-compact': compact }"
>
<label
class="setting-label"
:for="path"
:class="{ 'faint': shouldBeDisabled }"
>
<template v-if="backendDescriptionLabel">
{{ backendDescriptionLabel + ' ' }}
</template>
<template v-else-if="source === 'admin'">
MISSING LABEL FOR {{ path }}
</template>
<slot v-else />
</label>
<p
v-if="backendDescriptionDescription"
class="setting-description"
:class="{ 'faint': shouldBeDisabled }"
>
{{ backendDescriptionDescription + ' ' }}
</p>
<div class="attachment-input">
<div class="controls control-field">
<label for="path">{{ $t('settings.url') }}</label>
<input
:id="path"
class="input string-input"
:disabled="shouldBeDisabled"
:value="realDraftMode ? draft : state"
@change="update"
>
{{ ' ' }}
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
- <ProfileSettingIndicator :is-profile="isProfileSetting" />
+ <LocalSettingIndicator :is-local="isLocalSetting" />
</div>
<div v-if="!compact">{{ $t('settings.preview') }}</div>
<Attachment
class="attachment"
:compact="compact"
:attachment="attachment"
size="small"
hide-description
/>
<div class="controls control-upload">
<MediaUpload
ref="mediaUpload"
class="media-upload-icon"
normal-button
:accept-types="acceptTypes"
@uploaded="setMediaFile"
/>
</div>
</div>
<DraftButtons />
</span>
</template>
<script src="./attachment_setting.js"></script>
<style lang="scss">
.AttachmentSetting {
.attachment {
display: block;
width: 100%;
height: 15em;
margin-bottom: 0.5em;
}
.attachment-input {
margin-left: 1em;
display: flex;
flex-direction: column;
width: 20em;
}
&.-compact {
.attachment-input {
flex-direction: row;
align-items: flex-end;
}
.attachment {
flex: 0;
order: 0;
display: block;
min-width: 4em;
height: 4em;
align-self: center;
margin-bottom: 0;
}
.control-field {
order: 1;
min-width: 12em;
margin-left: 0.5em;
}
.control-upload {
order: 2;
min-width: 12em;
padding: 0 0.5em;
}
}
.controls {
margin-bottom: 0.5em;
input,
button {
width: 100%;
}
}
}
</style>
diff --git a/src/components/settings_modal/helpers/boolean_setting.vue b/src/components/settings_modal/helpers/boolean_setting.vue
index 2134ad7ce6..4fb37fab7b 100644
--- a/src/components/settings_modal/helpers/boolean_setting.vue
+++ b/src/components/settings_modal/helpers/boolean_setting.vue
@@ -1,45 +1,45 @@
<template>
<label
v-if="matchesExpertLevel"
class="BooleanSetting setting-item"
>
<Checkbox
class="setting-control setting-label"
:model-value="visibleState"
:disabled="shouldBeDisabled"
:indeterminate="isIndeterminate"
@update:model-value="update"
>
<span
class="label"
:class="{ 'faint': shouldBeDisabled }"
>
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
- <ProfileSettingIndicator :is-profile="isProfileSetting" />
+ <LocalSettingIndicator :is-local="isLocalSetting" />
{{ ' ' }}
<template v-if="backendDescriptionLabel">
{{ backendDescriptionLabel }}
</template>
<template v-else-if="source === 'admin'">
MISSING LABEL FOR {{ path }}
</template>
<slot v-else />
</span>
</Checkbox>
<p
v-if="backendDescriptionDescription || showDescription"
class="setting-description"
:class="{ 'faint': shouldBeDisabled }"
>
<slot name="description">
{{ backendDescriptionDescription + ' ' }}
</slot>
</p>
<DraftButtons />
</label>
</template>
<script src="./boolean_setting.js"></script>
diff --git a/src/components/settings_modal/helpers/choice_setting.vue b/src/components/settings_modal/helpers/choice_setting.vue
index 3189510262..a162177ce1 100644
--- a/src/components/settings_modal/helpers/choice_setting.vue
+++ b/src/components/settings_modal/helpers/choice_setting.vue
@@ -1,59 +1,59 @@
<template>
<label
v-if="matchesExpertLevel"
class="ChoiceSetting setting-item"
:class="{ 'faint': shouldBeDisabled }"
>
<span class="setting-label">
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
- <ProfileSettingIndicator :is-profile="isProfileSetting" />
+ <LocalSettingIndicator :is-local="isLocalSetting" />
{{ ' ' }}
<template v-if="backendDescriptionLabel">
{{ backendDescriptionLabel }}
</template>
<template v-else>
<slot />
</template>
</span>
<Select
class="setting-control"
:model-value="realDraftMode ? draft : state"
:disabled="shouldBeDisabled"
@update:model-value="update"
>
<option
v-for="option in realOptions"
:key="option.key"
:value="option.value"
>
{{ option.label }}
{{ option.value === defaultState ? $t('settings.instance_default_simple') : '' }}
</option>
</Select>
<DraftButtons />
<p
v-if="backendDescriptionDescription"
class="setting-description"
>
{{ backendDescriptionDescription + ' ' }}
</p>
</label>
</template>
<script src="./choice_setting.js"></script>
<style lang="scss">
.ChoiceSetting.setting-item {
.-mobile & {
display: block;
.setting-label {
display: block;
margin-bottom: 0.5em
}
}
}
</style>
diff --git a/src/components/settings_modal/helpers/color_setting.vue b/src/components/settings_modal/helpers/color_setting.vue
index c996dc02f3..f8f3b9427f 100644
--- a/src/components/settings_modal/helpers/color_setting.vue
+++ b/src/components/settings_modal/helpers/color_setting.vue
@@ -1,72 +1,72 @@
<template>
<label
v-if="matchesExpertLevel"
class="ColorSetting setting-item"
>
<label
v-if="!hideLabel"
:for="path"
class="setting-label"
:class="{ 'faint': shouldBeDisabled }"
>
<template v-if="backendDescriptionLabel">
{{ backendDescriptionLabel + ' ' }}
</template>
<template v-else-if="source === 'admin'">
MISSING LABEL FOR {{ path }}
</template>
<slot v-else />
</label>
{{ ' ' }}
<ColorInput
:id="path"
:name="path"
class="setting-control color-setting-input"
:class="{ disabled: shouldBeDisabled }"
:disabled="shouldBeDisabled"
:placeholder="backendDescriptionSuggestions"
:model-value="realDraftMode ? draft : state"
@update:model-value="update"
/>
{{ ' ' }}
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
- <ProfileSettingIndicator :is-profile="isProfileSetting" />
+ <LocalSettingIndicator :is-local="isLocalSetting" />
<DraftButtons v-if="!hideDraftButtons" />
<p
v-if="backendDescriptionDescription"
class="setting-description"
:class="{ 'faint': shouldBeDisabled }"
>
{{ backendDescriptionDescription + ' ' }}
</p>
</label>
</template>
<script src="./color_setting.js"></script>
<style lang="scss">
.ColorSetting {
&.setting-item {
display: grid;
grid-template-areas:
"label control"
". desc"
". draft";
.setting-label {
text-align: right;
align-self: center;
}
.setting-control {
align-self: end;
}
}
.color-setting-input {
align-self: baseline;
}
}
</style>
diff --git a/src/components/settings_modal/helpers/group_setting.vue b/src/components/settings_modal/helpers/group_setting.vue
index 68e571f7d3..7040c62140 100644
--- a/src/components/settings_modal/helpers/group_setting.vue
+++ b/src/components/settings_modal/helpers/group_setting.vue
@@ -1,15 +1,15 @@
<template>
<span
v-if="matchesExpertLevel"
class="GroupSetting setting-item"
>
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
- <ProfileSettingIndicator :is-profile="isProfileSetting" />
+ <LocalSettingIndicator :is-local="isLocalSetting" />
<DraftButtons />
</span>
</template>
<script src="./group_setting.js"></script>
diff --git a/src/components/settings_modal/helpers/list_setting.js b/src/components/settings_modal/helpers/list_setting.js
index c1d504cd55..ecbdb3669a 100644
--- a/src/components/settings_modal/helpers/list_setting.js
+++ b/src/components/settings_modal/helpers/list_setting.js
@@ -1,133 +1,135 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Setting from './setting.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+
export default {
...Setting,
data() {
return {
newValue: '',
}
},
components: {
...Setting.components,
Checkbox,
},
props: {
...Setting.props,
ignoreSuggestions: {
required: false,
type: Boolean,
},
overrideAvailableOptions: {
required: false,
type: Boolean,
},
options: {
required: false,
type: Set,
},
allowNew: {
required: false,
type: Boolean,
default: true,
},
forceNew: {
required: false,
type: Boolean,
default: false,
},
},
computed: {
...Setting.computed,
showNew() {
if (this.forceNew) return true
if (!this.allowNew) return false
- const isExpert = this.$store.state.config.expertLevel > 0
+ const isExpert = useMergedConfigStore().mergedConfig.expertLevel > 0
const hasBuiltins = this.builtinEntries.length > 0
if (hasBuiltins) {
return isExpert
} else {
return true
}
},
valueSet() {
return new Set(this.visibleState)
},
suggestionsSet() {
const suggestions = this.backendDescriptionSuggestions
if (suggestions) {
return new Set(suggestions)
} else {
return new Set()
}
},
extraEntries() {
if (this.ignoreSuggestions) return [...this.valueSet.values()]
if (!this.suggestionsSet) return []
return [...this.valueSet.values()].filter((x) => {
return !this.builtinEntriesValueSet.has(x)
})
},
builtinEntries() {
if (this.ignoreSuggestions) return []
if (this.overrideAvailableOptions) {
return [...this.options]
}
if (!this.suggestionsSet) return []
const builtins = [...this.suggestionsSet.values()]
return builtins.map((option) => ({
label: option,
value: option,
}))
},
builtinEntriesValueSet() {
return new Set(this.builtinEntries.map((x) => x.value))
},
},
methods: {
...Setting.methods,
optionPresent(option) {
return this.valueSet.has(option)
},
getValue({ event, value, index, eventType }) {
switch (eventType) {
case 'toggle': {
this.newValue = ''
const newSet = new Set(this.valueSet.values())
if (event) {
newSet.add(value)
} else {
newSet.delete(value)
}
return [...newSet.values()]
}
case 'add': {
if (!this.newValue) return this.valueSet.values()
const res = [...this.valueSet.values(), this.newValue]
this.newValue = ''
return res
}
case 'remove': {
const pre = [...this.valueSet.values()].slice(0, index)
const post = [...this.valueSet.values()].slice(index + 1)
return [...pre, ...post]
}
case 'edit': {
const pre = [...this.valueSet.values()].slice(0, index)
const post = [...this.valueSet.values()].slice(index + 1)
const string = event.target.value
if (!string) return [...this.valueSet.values()]
return [...pre, string, ...post]
}
}
},
},
}
diff --git a/src/components/settings_modal/helpers/list_setting.vue b/src/components/settings_modal/helpers/list_setting.vue
index 1dacba4103..4b62be3062 100644
--- a/src/components/settings_modal/helpers/list_setting.vue
+++ b/src/components/settings_modal/helpers/list_setting.vue
@@ -1,92 +1,92 @@
<template>
<div
v-if="matchesExpertLevel"
class="ListSetting setting-item"
>
<label
class="setting-label"
:class="{ 'faint': shouldBeDisabled }"
>
<template v-if="backendDescriptionLabel">
{{ backendDescriptionLabel + ' ' }}
</template>
<template v-else-if="source === 'admin'">
MISSING LABEL FOR {{ path }}
</template>
<slot v-else />
</label>
<p
v-if="backendDescriptionDescription"
class="setting-description"
:class="{ 'faint': shouldBeDisabled }"
>
{{ backendDescriptionDescription + ' ' }}
</p>
<ul class="setting-list">
<li
v-for="(item, i) in builtinEntries"
:key="i"
>
<Checkbox
:disabled="shouldBeDisabled"
:model-value="optionPresent(item.value)"
@update:model-value="event => update({ event, value: item.value, eventType: 'toggle' })"
>
{{ item.label }}
</Checkbox>
</li>
<li
v-for="(item, index) in extraEntries"
:key="index"
>
<div class="btn-group">
<input
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:value="item"
@change="e => update({ event: e, index, eventType: 'edit' })"
>
<button
class="button-default"
@click="e => update({ index, eventType: 'remove' })"
>
<FAIcon icon="times" />
</button>
</div>
</li>
<li v-if="showNew">
<div class="btn-group">
<input
v-model="newValue"
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:disabled="shouldBeDisabled"
>
<button
class="button-default"
@click="e => update({ eventType: 'add' })"
>
<FAIcon icon="plus" />
</button>
</div>
</li>
</ul>
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
- <ProfileSettingIndicator :is-profile="isProfileSetting" />
+ <LocalSettingIndicator :is-local="isLocalSetting" />
<DraftButtons />
</div>
</template>
<script src="./list_setting.js"></script>
<style lang="scss">
.ListSetting {
.setting-list {
.checkbox {
padding: 0.5em 0;
}
}
}
</style>
diff --git a/src/components/settings_modal/helpers/list_tuple_setting.vue b/src/components/settings_modal/helpers/list_tuple_setting.vue
index eb2f6e16eb..6719c213ea 100644
--- a/src/components/settings_modal/helpers/list_tuple_setting.vue
+++ b/src/components/settings_modal/helpers/list_tuple_setting.vue
@@ -1,113 +1,112 @@
<template>
<div
v-if="matchesExpertLevel"
class="ListTupleSetting"
>
<label
class="setting-label"
:class="{ 'faint': shouldBeDisabled }"
>
<template v-if="backendDescriptionLabel">
{{ backendDescriptionLabel + ' ' }}
</template>
<template v-else-if="source === 'admin'">
MISSING LABEL FOR {{ path }}
</template>
<slot v-else />
</label>
<p
v-if="backendDescriptionDescription"
class="setting-description"
:class="{ 'faint': shouldBeDisabled }"
>
{{ backendDescriptionDescription + ' ' }}
</p>
<ul class="setting-list">
<li
v-for="(item, index) in visibleState"
:key="index"
>
<div class="btn-group">
<input
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:value="item.tuple[0]"
@change="e => update({ event: e, index, eventType: 'edit', tuple: 0 })"
>
<input
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:value="item.tuple[1]"
@change="e => update({ event: e, index, eventType: 'edit', tuple: 1 })"
>
<button
class="button-default"
@click="e => update({ index, eventType: 'remove' })"
>
<FAIcon icon="times" />
</button>
</div>
</li>
<li>
<div class="btn-group">
<input
v-model="newValue[0]"
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:disabled="shouldBeDisabled"
:placeholder="backendDescriptionSuggestions[0][0]"
>
<input
v-model="newValue[1]"
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:disabled="shouldBeDisabled"
:placeholder="backendDescriptionSuggestions[0][1]"
>
<button
class="button-default"
@click="e => update({ eventType: 'add' })"
>
<FAIcon icon="plus" />
</button>
</div>
</li>
</ul>
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
- <ProfileSettingIndicator :is-profile="isProfileSetting" />
<DraftButtons />
</div>
</template>
<script src="./list_tuple_setting.js"></script>
<style lang="scss">
.ListTupleSetting {
.btn-group {
display: flex
}
dl {
display: inline-grid;
grid-template-columns: auto auto;
gap: 0.5em;
align-items: baseline;
dt {
display: inline;
font-weight: 800;
&::after {
content: ':'
}
}
dd {
display: inline;
margin: 0
}
}
}
</style>
diff --git a/src/components/settings_modal/helpers/profile_setting_indicator.vue b/src/components/settings_modal/helpers/local_setting_indicator.vue
similarity index 66%
rename from src/components/settings_modal/helpers/profile_setting_indicator.vue
rename to src/components/settings_modal/helpers/local_setting_indicator.vue
index 3b45b125d7..e22e36fb26 100644
--- a/src/components/settings_modal/helpers/profile_setting_indicator.vue
+++ b/src/components/settings_modal/helpers/local_setting_indicator.vue
@@ -1,50 +1,50 @@
<template>
<span
- v-if="isProfile"
- class="ProfileSettingIndicator"
+ v-if="isLocal"
+ class="LocalSettingIndicator"
>
<Popover
trigger="hover"
>
<template #trigger>
&nbsp;
<FAIcon
- icon="server"
- :aria-label="$t('settings.setting_server_side')"
+ icon="desktop"
+ :aria-label="$t('settings.setting_local_side')"
/>
</template>
<template #content>
<div class="profilesetting-tooltip">
- {{ $t('settings.setting_server_side') }}
+ {{ $t('settings.setting_local_side') }}
</div>
</template>
</Popover>
</span>
</template>
<script>
import Popover from 'src/components/popover/popover.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
-import { faServer } from '@fortawesome/free-solid-svg-icons'
+import { faDesktop } from '@fortawesome/free-solid-svg-icons'
-library.add(faServer)
+library.add(faDesktop)
export default {
components: { Popover },
- props: ['isProfile'],
+ props: ['isLocal'],
}
</script>
<style lang="scss">
-.ProfileSettingIndicator {
+.LocalSettingIndicator {
display: inline-block;
position: relative;
}
.profilesetting-tooltip {
margin: 0.5em 1em;
min-width: 10em;
text-align: center;
}
</style>
diff --git a/src/components/settings_modal/helpers/map_setting.vue b/src/components/settings_modal/helpers/map_setting.vue
index 95adcb3149..011003a51e 100644
--- a/src/components/settings_modal/helpers/map_setting.vue
+++ b/src/components/settings_modal/helpers/map_setting.vue
@@ -1,112 +1,112 @@
<template>
<div
v-if="matchesExpertLevel"
class="MapSetting setting-item"
>
<label
class="setting-label"
:class="{ 'faint': shouldBeDisabled }"
>
<template v-if="backendDescriptionLabel">
{{ backendDescriptionLabel + ' ' }}
</template>
<template v-else-if="source === 'admin'">
MISSING LABEL FOR {{ path }}
</template>
<slot v-else />
</label>
<p
v-if="backendDescriptionDescription"
class="setting-description"
:class="{ 'faint': shouldBeDisabled }"
>
{{ backendDescriptionDescription + ' ' }}
</p>
<ul class="setting-list">
<li
v-for="(item, i) in displayState"
:key="i"
>
<div class="btn-group">
<input
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:value="item[0]"
@change="e => update({ event: e, key: item[0], eventType: 'edit', isKey: true })"
>
<input
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:value="item[1]"
@change="e => update({ event: e, key: item[0], eventType: 'edit', isKey: false })"
>
<button
class="button-default"
@click="e => update({ key: item[0], eventType: 'remove' })"
>
<FAIcon icon="times" />
</button>
</div>
</li>
<li v-if="allowNew">
<div class="btn-group">
<input
v-model="newValue[0]"
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:disabled="shouldBeDisabled"
:placeholder="backendDescriptionSuggestions[0][0]"
>
<input
v-model="newValue[1]"
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:disabled="shouldBeDisabled"
:placeholder="backendDescriptionSuggestions[0][1]"
>
<button
class="button-default"
@click="e => update({ eventType: 'add' })"
>
<FAIcon icon="plus" />
</button>
</div>
</li>
</ul>
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
- <ProfileSettingIndicator :is-profile="isProfileSetting" />
+ <LocalSettingIndicator :is-local="isLocalSetting" />
<DraftButtons />
</div>
</template>
<script src="./map_setting.js"></script>
<style lang="scss">
.MapSetting {
.btn-group {
display: flex
}
dl {
display: inline-grid;
grid-template-columns: auto auto;
gap: 0.5em;
dt {
display: inline;
font-weight: 800;
&::after {
content: ':'
}
}
dd {
display: inline;
margin: 0
}
}
}
</style>
diff --git a/src/components/settings_modal/helpers/number_setting.vue b/src/components/settings_modal/helpers/number_setting.vue
index 26c18f928f..3a6434e9e4 100644
--- a/src/components/settings_modal/helpers/number_setting.vue
+++ b/src/components/settings_modal/helpers/number_setting.vue
@@ -1,49 +1,49 @@
<template>
<span
v-if="matchesExpertLevel"
class="NumberSetting setting-item"
>
<label
v-if="!hideLabel"
:for="path"
class="setting-label"
:class="{ 'faint': shouldBeDisabled }"
>
+ <ModifiedIndicator
+ :changed="isChanged"
+ :onclick="reset"
+ />
+ <LocalSettingIndicator :is-local="isLocalSetting" />
+ {{ ' ' }}
+ <DraftButtons v-if="!hideDraftButtons" />
<template v-if="backendDescriptionLabel">
{{ backendDescriptionLabel + ' ' }}
</template>
<template v-else-if="source === 'admin'">
MISSING LABEL FOR {{ path }}
</template>
<slot v-else />
</label>
{{ ' ' }}
<input
:id="path"
class="input number-input setting-control"
type="number"
:step="step || 1"
:disabled="shouldBeDisabled"
:placeholder="backendDescriptionSuggestions"
:min="min || 0"
:value="realDraftMode ? draft :state"
@change="update"
>
- {{ ' ' }}
- <ModifiedIndicator
- :changed="isChanged"
- :onclick="reset"
- />
- <ProfileSettingIndicator :is-profile="isProfileSetting" />
- <DraftButtons v-if="!hideDraftButtons" />
<p
v-if="backendDescriptionDescription"
class="setting-description"
:class="{ 'faint': shouldBeDisabled }"
>
{{ backendDescriptionDescription + ' ' }}
</p>
</span>
</template>
<script src="./number_setting.js"></script>
diff --git a/src/components/settings_modal/helpers/proxy_setting.vue b/src/components/settings_modal/helpers/proxy_setting.vue
index 869cbcdcc8..00552f5116 100644
--- a/src/components/settings_modal/helpers/proxy_setting.vue
+++ b/src/components/settings_modal/helpers/proxy_setting.vue
@@ -1,59 +1,58 @@
<template>
<label
v-if="matchesExpertLevel"
class="ProxySetting setting-item"
>
<label
v-if="!hideLabel"
:for="path"
class="setting-label"
:class="{ 'faint': shouldBeDisabled }"
>
<template v-if="backendDescriptionLabel">
{{ backendDescriptionLabel + ' ' }}
</template>
<template v-else-if="source === 'admin'">
MISSING LABEL FOR {{ path }}
</template>
<slot v-else />
</label>
{{ ' ' }}
<div class="setting-control">
<input
:id="path"
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:disabled="shouldBeDisabled"
:placeholder="backendDescriptionSuggestions[0]"
:value="displayState"
@change="event => update({ event })"
>
{{ ' ' }}
<Checkbox
:model-value="socksState"
:disabled="shouldBeDisabled"
:indeterminate="isIndeterminate"
@update:model-value="event => update({ event, isProxy: true})"
>
SOCKS5
{{ ' ' }}
</Checkbox>
</div>
{{ ' ' }}
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
- <ProfileSettingIndicator :is-profile="isProfileSetting" />
<DraftButtons v-if="!hideDraftButtons" />
<p
v-if="backendDescriptionDescription"
class="setting-description"
:class="{ 'faint': shouldBeDisabled }"
>
{{ backendDescriptionDescription + ' ' }}
</p>
</label>
</template>
<script src="./proxy_setting.js"></script>
diff --git a/src/components/settings_modal/helpers/pwa_manifest_icons_setting.vue b/src/components/settings_modal/helpers/pwa_manifest_icons_setting.vue
index 6dc8ac3278..a55b5d7936 100644
--- a/src/components/settings_modal/helpers/pwa_manifest_icons_setting.vue
+++ b/src/components/settings_modal/helpers/pwa_manifest_icons_setting.vue
@@ -1,226 +1,225 @@
<template>
<div
v-if="matchesExpertLevel"
class="PWAManifestIconsSetting setting-item"
>
<label
class="pwa-label setting-label"
:class="{ 'faint': shouldBeDisabled }"
>
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
- <ProfileSettingIndicator :is-profile="isProfileSetting" />
{{ ' ' }}
<template v-if="backendDescriptionLabel">
{{ backendDescriptionLabel + ' ' }}
</template>
<template v-else-if="source === 'admin'">
MISSING LABEL FOR {{ path }}
</template>
<slot v-else />
</label>
<p
v-if="backendDescriptionDescription"
class="setting-description"
:class="{ 'faint': shouldBeDisabled }"
>
{{ backendDescriptionDescription + ' ' }}
</p>
<div class="setting-control">
<ul class="item-list">
<li
v-if="visibleState.length === 0"
class="no_items"
>
{{ $t('admin_dash.instance.pwa.no_icons') }}
<button
v-if="visibleState.length === 0"
class="button-default add-button"
@click="e => update({ eventType: 'add' })"
>
<FAIcon icon="plus" />
</button>
</li>
<li
v-for="(item, index) in visibleState"
:key="index"
>
<div class="icon-element">
<div class="src-field">
<Attachment
class="src-attachment"
:compact="true"
:attachment="attachment(item)"
size="small"
hide-description
/>
<div class="src-url">
<label for="path">{{ $t('settings.url') }}</label>
<input
:id="path"
class="input string-input"
:disabled="shouldBeDisabled"
:value="item[':src']"
@change="event => update({ event, index, eventType: 'edit', field: ':src' })"
>
</div>
<MediaUpload
ref="mediaUpload"
class="src-upload media-upload-icon"
:class="{ disabled: shouldBeDisabled }"
normal-button
accept-types="image"
@uploaded="event => setMediaFile({ event, index })"
/>
</div>
<dl>
<dt>{{ $t('admin_dash.instance.pwa.icon.purpose') }}</dt>
<dd>
<Select
:class="{ disabled: shouldBeDisabled }"
:disabled="shouldBeDisabled"
:model-value="item[':purpose']"
@update:model-value="event => setPurpose({ event, index })"
>
<option
v-for="(purpose, index2) in purposeOptions"
:key="index2"
:value="purpose.value"
>
{{ purpose.label }}
</option>
</Select>
</dd>
<dt><code>sizes</code>{{ $t('admin_dash.instance.pwa.optional') }}</dt>
<dd>
<input
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:value="item[':sizes']"
@change="e => update({ event: e, index, eventType: 'edit', field: ':sizes' })"
>
</dd>
<dt><code>type</code>{{ $t('admin_dash.instance.pwa.optional') }}</dt>
<dd>
<input
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:value="item[':type']"
@change="e => update({ event: e, index, eventType: 'edit', field: ':type' })"
>
</dd>
</dl>
<div class="buttons">
<button
v-if="index === visibleState.length - 1"
class="button-default add-button"
@click="e => update({ eventType: 'add' })"
>
<FAIcon icon="plus" />
</button>
<button
class="button-default delete-button"
@click="e => update({ index, eventType: 'remove' })"
>
<FAIcon icon="times" />
</button>
</div>
</div>
</li>
</ul>
</div>
</div>
</template>
<script src="./pwa_manifest_icons_setting.js"></script>
<style lang="scss">
div.PWAManifestIconsSetting {
margin-left: 3em;
&.setting-item {
display: grid;
grid-template-areas:
"label"
"desc"
"control"
"draft";
grid-template-rows: 2em auto 1fr;
grid-template-columns: 1fr;
}
.pwa-label.setting-label {
align-self: end;
text-align: left;
}
.buttons {
display: flex;
gap: 0.5em;
justify-content: right;
margin-top: 0.5em;
button {
line-height: 2;
}
}
ul {
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fit, 12em);
grid-gap: 2em;
}
dl {
display: grid;
grid-template-columns: 1fr;
margin-top: 0.5em;
gap: 0.25em;
align-items: baseline;
dt {
font-weight: 800;
&::after {
content: ':'
}
}
dd {
margin: 0
}
}
.src-field {
display: grid;
grid-template-columns: auto;
justify-items: center;
gap: 0.5em;
.src-attachment {
width: 10em;
height: 10em;
display: block;
margin-bottom: 0.5em;
}
.src-upload {
display: block;
width: 100%;
}
.src-url {
display: flex;
flex-direction: column;
gap: 0.25em;
width: 100%;
label {
display: block;
}
}
}
}
</style>
diff --git a/src/components/settings_modal/helpers/rate_setting.vue b/src/components/settings_modal/helpers/rate_setting.vue
index 49e09e046e..bb361dfb93 100644
--- a/src/components/settings_modal/helpers/rate_setting.vue
+++ b/src/components/settings_modal/helpers/rate_setting.vue
@@ -1,140 +1,140 @@
<template>
<div
v-if="matchesExpertLevel"
class="RateSetting setting-item"
>
<label
class="setting-label"
:class="{ 'faint': shouldBeDisabled }"
>
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
- <ProfileSettingIndicator :is-profile="isProfileSetting" />
+ <LocalSettingIndicator :is-local="isLocalSetting" />
{{ ' ' }}
<template v-if="backendDescriptionLabel">
{{ backendDescriptionLabel + ' ' }}
</template>
<template v-else-if="source === 'admin'">
MISSING LABEL FOR {{ path }}
</template>
<slot v-else />
</label>
<p
v-if="backendDescriptionDescription"
class="setting-description"
:class="{ 'faint': shouldBeDisabled }"
>
{{ backendDescriptionDescription + ' ' }}
</p>
<div class="setting-control">
<table>
<thead>
<tr>
<th>&nbsp;</th>
<th>
{{ $t('admin_dash.rate_limit.period') }}
</th>
<th>
{{ $t('admin_dash.rate_limit.amount') }}
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
{{ isSeparate ? $t('admin_dash.rate_limit.unauthenticated') : $t('admin_dash.rate_limit.rate_limit') }}
</td>
<td>
<input
class="input string-input"
type="number"
:value="normalizedState[0][0]"
@change="e => update({ event: e, index: 0, side: 0, eventType: 'edit' })"
>
</td>
<td>
<input
class="input string-input"
type="number"
:value="normalizedState[0][1]"
@change="e => update({ event: e, index: 1, side: 0, eventType: 'edit' })"
>
</td>
</tr>
<tr v-if="isSeparate">
<td>
{{ $t('admin_dash.rate_limit.authenticated') }}
</td>
<td>
<input
class="input string-input"
type="number"
:value="normalizedState[1][0]"
@change="e => update({ event: e, index: 0, side: 1, eventType: 'edit' })"
>
</td>
<td>
<input
class="input string-input"
type="number"
:value="normalizedState[1][1]"
@change="e => update({ event: e, index: 1, side: 1, eventType: 'edit' })"
>
</td>
</tr>
</tbody>
</table>
<Checkbox
:model-value="isSeparate"
@update:model-value="event => update({ event: event ? 'join' : 'split', eventType: 'toggleMode' })"
>
{{ $t('admin_dash.rate_limit.separate') }}
</Checkbox>
</div>
<DraftButtons />
</div>
</template>
<script src="./rate_setting.js"></script>
<style lang="scss">
.RateSetting {
&.setting-item {
display: grid;
grid-template-areas:
"label control"
"desc control"
". draft";
.setting-label {
text-align: right;
align-self: center;
}
.setting-description {
text-align: right;
}
.setting-control {
align-self: end;
}
}
table {
margin-top: 0.5em;
}
th {
font-weight: normal;
}
td {
input {
width: 15ch;
}
}
margin-bottom: 2em;
}
</style>
diff --git a/src/components/settings_modal/helpers/setting.js b/src/components/settings_modal/helpers/setting.js
index 2884d92b49..af8ebecbd0 100644
--- a/src/components/settings_modal/helpers/setting.js
+++ b/src/components/settings_modal/helpers/setting.js
@@ -1,366 +1,428 @@
import { cloneDeep, get, isEqual, set } from 'lodash'
import DraftButtons from './draft_buttons.vue'
+import LocalSettingIndicator from './local_setting_indicator.vue'
import ModifiedIndicator from './modified_indicator.vue'
-import ProfileSettingIndicator from './profile_setting_indicator.vue'
+
+import { useInstanceStore } from 'src/stores/instance.js'
+import { useInterfaceStore } from 'src/stores/interface.js'
+import { useLocalConfigStore } from 'src/stores/local_config.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
export default {
components: {
ModifiedIndicator,
DraftButtons,
- ProfileSettingIndicator,
+ LocalSettingIndicator,
},
props: {
modelValue: {
type: String,
default: null,
},
description: {
type: String,
default: null,
},
path: {
type: [String, Array],
required: false,
},
showDescription: {
type: Boolean,
required: false,
},
descriptionPathOverride: {
type: [String, Array],
required: false,
},
suggestions: {
type: [String, Array],
required: false,
},
subgroup: {
type: String,
required: false,
},
disabled: {
type: Boolean,
default: false,
},
+ local: {
+ type: Boolean,
+ default: false,
+ },
parentPath: {
type: [String, Array],
},
parentInvert: {
type: Boolean,
default: false,
},
expert: {
type: [Number, String],
default: 0,
},
source: {
type: String,
default: undefined,
},
hideDraftButtons: {
// this is for the weird backend hybrid (Boolean|String or Boolean|Number) settings
required: false,
type: Boolean,
},
hideLabel: {
type: Boolean,
},
hideDescription: {
type: Boolean,
},
swapDescriptionAndLabel: {
type: Boolean,
},
backendDescriptionPath: {
type: [String, Array],
},
overrideBackendDescription: {
type: Boolean,
},
overrideBackendDescriptionLabel: {
type: [Boolean, String],
},
draftMode: {
type: Boolean,
default: undefined,
},
timedApplyMode: {
type: Boolean,
default: false,
},
},
inject: {
defaultSource: {
default: 'default',
},
defaultDraftMode: {
default: false,
},
},
data() {
return {
localDraft: null,
}
},
created() {
if (
this.realDraftMode &&
(this.realSource !== 'admin' || this.path == null)
) {
this.draft = cloneDeep(this.state)
}
},
computed: {
draft: {
get() {
if (this.realSource === 'admin' || this.path == null) {
return get(this.$store.state.adminSettings.draft, this.canonPath)
} else {
return this.localDraft
}
},
set(value) {
if (this.realSource === 'admin' || this.path == null) {
this.$store.commit('updateAdminDraft', {
path: this.canonPath,
value,
})
} else {
this.localDraft = value
}
},
},
state() {
if (this.path == null) {
return this.modelValue
}
const value = get(this.configSource, this.canonPath)
if (value === undefined) {
return this.defaultState
} else {
return value
}
},
visibleState() {
return this.realDraftMode ? this.draft : this.state
},
realSource() {
return this.source || this.defaultSource
},
realDraftMode() {
return typeof this.draftMode === 'undefined'
? this.defaultDraftMode
: this.draftMode
},
backendDescription() {
return get(
this.$store.state.adminSettings.descriptions,
this.descriptionPath,
)
},
backendDescriptionLabel() {
if (this.realSource !== 'admin') return ''
if (
this.overrideBackendDescriptionLabel !== '' &&
typeof this.overrideBackendDescriptionLabel === 'string'
) {
return this.overrideBackendDescriptionLabel
}
if (!this.backendDescription || this.overrideBackendDescriptionLabel) {
return this.$t(
[
'admin_dash',
'temp_overrides',
...this.canonPath.map((p) => p.replace(/\./g, '_DOT_')),
'label',
].join('.'),
)
} else {
return this.swapDescriptionAndLabel
? this.backendDescription?.description
: this.backendDescription?.label
}
},
backendDescriptionDescription() {
if (this.description) return this.description
if (this.realSource !== 'admin') return ''
if (this.hideDescription) return null
if (!this.backendDescription || this.overrideBackendDescription) {
return this.$t(
[
'admin_dash',
'temp_overrides',
...this.canonPath.map((p) => p.replace(/\./g, '_DOT_')),
'description',
].join('.'),
)
} else {
return this.swapDescriptionAndLabel
? this.backendDescription?.label
: this.backendDescription?.description
}
},
backendDescriptionSuggestions() {
return this.backendDescription?.suggestions || this.suggestions
},
shouldBeDisabled() {
if (this.path == null) {
return this.disabled
}
let parentValue = null
if (this.parentPath !== undefined && this.realSource === 'admin') {
if (this.realDraftMode) {
parentValue = get(
this.$store.state.adminSettings.draft,
this.parentPath,
)
} else {
parentValue = get(this.configSource, this.parentPath)
}
}
return (
this.disabled ||
(parentValue !== null
? this.parentInvert
? parentValue
: !parentValue
: false)
)
},
configSource() {
switch (this.realSource) {
case 'profile':
return this.$store.state.profileConfig
case 'admin':
return this.$store.state.adminSettings.config
default:
- return this.$store.getters.mergedConfig
+ return useMergedConfigStore().mergedConfig
}
},
configSink() {
if (this.path == null) {
return (k, v) => this.$emit('update:modelValue', v)
}
+
switch (this.realSource) {
case 'profile':
return (k, v) =>
this.$store.dispatch('setProfileOption', { name: k, value: v })
case 'admin':
return (k, v) =>
this.$store.dispatch('pushAdminSetting', { path: k, value: v })
default:
- if (this.timedApplyMode) {
- return (k, v) =>
- this.$store.dispatch('setOptionTemporarily', {
- name: k,
- value: v,
- })
- } else {
- return (k, v) =>
- this.$store.dispatch('setOption', { name: k, value: v })
+ return (readPath, value) => {
+ const writePath = `${readPath}`
+
+ if (!this.timedApplyMode) {
+ if (this.local) {
+ useLocalConfigStore().set({
+ path: writePath,
+ value,
+ })
+ } else {
+ useSyncConfigStore().setSimplePrefAndSave({
+ path: writePath,
+ value,
+ })
+ }
+ } else {
+ if (useInterfaceStore().temporaryChangesTimeoutId !== null) {
+ console.error("Can't track more than one temporary change")
+ return
+ }
+
+ const oldValue = get(this.configSource, readPath)
+
+ if (this.local) {
+ useLocalConfigStore().setTemporarily({ path: writePath, value })
+ } else {
+ useSyncConfigStore().setPreference({ path: writePath, value })
+ }
+
+ const confirm = () => {
+ if (this.local) {
+ useLocalConfigStore().set({ path: writePath, value })
+ } else {
+ useSyncConfigStore().pushSyncConfig()
+ }
+ useInterfaceStore().clearTemporaryChanges()
+ }
+
+ const revert = () => {
+ if (this.local) {
+ useLocalConfigStore().unsetTemporarily({
+ path: writePath,
+ value,
+ })
+ } else {
+ useSyncConfigStore().setPreference({
+ path: writePath,
+ value: oldValue,
+ })
+ }
+ useInterfaceStore().clearTemporaryChanges()
+ }
+
+ useInterfaceStore().setTemporaryChanges({ confirm, revert })
+ }
}
}
},
defaultState() {
switch (this.realSource) {
case 'profile':
return {}
- default:
- return get(this.$store.getters.defaultConfig, this.path)
+ default: {
+ return get(useMergedConfigStore().mergedConfigDefault, this.path)
+ }
}
},
isProfileSetting() {
return this.realSource === 'profile'
},
+ isLocalSetting() {
+ return this.local
+ },
isChanged() {
if (this.path == null) return false
switch (this.realSource) {
case 'profile':
case 'admin':
return false
default:
return this.state !== this.defaultState
}
},
canonPath() {
if (this.path == null) return null
return Array.isArray(this.path) ? this.path : this.path.split('.')
},
descriptionPath() {
if (this.path == null) return null
if (this.descriptionPathOverride) return this.descriptionPathOverride
const path = Array.isArray(this.path) ? this.path : this.path.split('.')
if (this.subgroup) {
return [
...path.slice(0, path.length - 1),
':subgroup,' + this.subgroup,
...path.slice(path.length - 1),
]
}
return path
},
isDirty() {
if (this.path == null) return false
if (this.realSource === 'admin' && this.canonPath.length > 3) {
return false // should not show draft buttons for "grouped" values
} else {
return this.realDraftMode && !isEqual(this.draft, this.state)
}
},
canHardReset() {
return (
this.realSource === 'admin' &&
this.$store.state.adminSettings.modifiedPaths?.has(
this.canonPath.join(' -> '),
)
)
},
matchesExpertLevel() {
const settingExpertLevel = this.expert || 0
- const userToggleExpert = this.$store.state.config.expertLevel || 0
+ const userToggleExpert =
+ useMergedConfigStore().mergedConfig.expertLevel || 0
return settingExpertLevel <= userToggleExpert
},
},
methods: {
getValue(e) {
return e.target.value
},
update(e) {
if (this.realDraftMode) {
this.draft = this.getValue(e)
} else {
this.configSink(this.path, this.getValue(e))
}
},
commitDraft() {
if (this.realDraftMode) {
this.configSink(this.path, this.draft)
}
},
reset() {
if (this.realDraftMode) {
this.draft = cloneDeep(this.state)
} else {
set(
- this.$store.getters.mergedConfig,
+ useMergedConfigStore().mergedConfig,
this.path,
cloneDeep(this.defaultState),
)
}
},
hardReset() {
switch (this.realSource) {
case 'admin':
return this.$store
.dispatch('resetAdminSetting', { path: this.path })
.then(() => {
this.draft = this.state
})
default:
console.warn('Hard reset not implemented yet!')
}
},
},
}
diff --git a/src/components/settings_modal/helpers/shared_computed_object.js b/src/components/settings_modal/helpers/shared_computed_object.js
index b8197b6396..39e0b5b9c4 100644
--- a/src/components/settings_modal/helpers/shared_computed_object.js
+++ b/src/components/settings_modal/helpers/shared_computed_object.js
@@ -1,19 +1,18 @@
+import { mapState as mapPiniaState } from 'pinia'
+import { mapState } from 'vuex'
+
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+
const SharedComputedObject = () => ({
- user() {
- return this.$store.state.users.currentUser
- },
- expertLevel() {
- return this.$store.getters.mergedConfig.expertLevel > 0
- },
- mergedConfig() {
- return this.$store.getters.mergedConfig
- },
- adminConfig() {
- return this.$store.state.adminSettings.config
- },
- adminDraft() {
- return this.$store.state.adminSettings.draft
- },
+ ...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
+ ...mapPiniaState(useMergedConfigStore, {
+ 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/helpers/string_setting.vue b/src/components/settings_modal/helpers/string_setting.vue
index 2ab205db1e..af84590b25 100644
--- a/src/components/settings_modal/helpers/string_setting.vue
+++ b/src/components/settings_modal/helpers/string_setting.vue
@@ -1,47 +1,47 @@
<template>
<span
v-if="matchesExpertLevel"
class="StringSetting setting-item"
>
<label
v-if="!hideLabel"
:for="path"
class="setting-label"
:class="{ 'faint': shouldBeDisabled }"
>
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
- <ProfileSettingIndicator :is-profile="isProfileSetting" />
+ <LocalSettingIndicator :is-local="isLocalSetting" />
{{ ' ' }}
<template v-if="backendDescriptionLabel">
{{ backendDescriptionLabel + ' ' }}
</template>
<template v-else-if="source === 'admin'">
MISSING LABEL FOR {{ path }}
</template>
<slot v-else />
</label>
<input
:id="path"
class="setting-control input string-input"
:class="{ disabled: shouldBeDisabled }"
:disabled="shouldBeDisabled"
:placeholder="backendDescriptionSuggestions"
:value="realDraftMode ? draft : state"
@change="update"
>
{{ ' ' }}
<DraftButtons v-if="!hideDraftButtons" />
<p
v-if="backendDescriptionDescription"
class="setting-description"
:class="{ 'faint': shouldBeDisabled }"
>
{{ backendDescriptionDescription + ' ' }}
</p>
</span>
</template>
<script src="./string_setting.js"></script>
diff --git a/src/components/settings_modal/helpers/tuple_setting.vue b/src/components/settings_modal/helpers/tuple_setting.vue
index e39e233442..7835b92189 100644
--- a/src/components/settings_modal/helpers/tuple_setting.vue
+++ b/src/components/settings_modal/helpers/tuple_setting.vue
@@ -1,58 +1,58 @@
<template>
<span
v-if="matchesExpertLevel"
class="setting-item"
>
<label
v-if="!hideLabel"
:for="path"
class="setting-label"
:class="{ 'faint': shouldBeDisabled }"
>
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
- <ProfileSettingIndicator :is-profile="isProfileSetting" />
+ <LocalSettingIndicator :is-local="isLocalSetting" />
{{ ' ' }}
<template v-if="backendDescriptionLabel">
{{ backendDescriptionLabel + ' ' }}
</template>
<template v-else-if="source === 'admin'">
MISSING LABEL FOR {{ path }}
</template>
<slot v-else />
</label>
<span class="setting-control">
<input
:id="path"
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:disabled="shouldBeDisabled"
:placeholder="backendDescriptionSuggestions?.[0]?.[0]"
:value="visibleState?.tuple?.[0]"
@change="e => update({ e, side: 0 })"
>
{{ ' ' }}
<input
:id="path"
class="input string-input"
:class="{ disabled: shouldBeDisabled }"
:disabled="shouldBeDisabled"
:placeholder="backendDescriptionSuggestions?.[0]?.[1]"
:value="visibleState?.tuple?.[1]"
@change="e => update({ e, side: 1 })"
>
</span>
<DraftButtons v-if="!hideDraftButtons" />
<p
v-if="backendDescriptionDescription"
class="setting-description"
:class="{ 'faint': shouldBeDisabled }"
>
{{ backendDescriptionDescription + ' ' }}
</p>
</span>
</template>
<script src="./tuple_setting.js"></script>
diff --git a/src/components/settings_modal/helpers/unit_setting.vue b/src/components/settings_modal/helpers/unit_setting.vue
index d23745fa49..54fb841115 100644
--- a/src/components/settings_modal/helpers/unit_setting.vue
+++ b/src/components/settings_modal/helpers/unit_setting.vue
@@ -1,68 +1,69 @@
<template>
<span
v-if="matchesExpertLevel"
class="UnitSetting setting-item"
>
<label
:for="path"
class="setting-label size-label"
>
+ <LocalSettingIndicator :is-local="isLocalSetting" />
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
{{ ' ' }}
<slot />
</label>
<span class="no-break setting-control">
<input
:id="path"
class="input number-input"
type="number"
:step="step"
:disabled="disabled"
:min="min || 0"
:value="stateValue"
@change="updateValue"
>
<Select
:id="path"
:model-value="stateUnit"
:disabled="disabled"
class="unit-input unstyled"
@change="updateUnit"
>
<option
v-for="option in units"
:key="option"
:value="option"
>
{{ getUnitString(option) }}
</option>
</Select>
</span>
{{ ' ' }}
</span>
</template>
<script src="./unit_setting.js"></script>
<style lang="scss">
.UnitSetting {
.no-break {
display: inline-flex;
}
.number-input {
max-width: 6.5em;
text-align: right;
}
.unit-input,
.unit-input select {
min-width: 4em;
width: auto;
}
}
</style>
diff --git a/src/components/settings_modal/settings_modal.js b/src/components/settings_modal/settings_modal.js
index f2c071045e..5c157e068b 100644
--- a/src/components/settings_modal/settings_modal.js
+++ b/src/components/settings_modal/settings_modal.js
@@ -1,212 +1,272 @@
import { cloneDeep, isEqual } from 'lodash'
import { mapActions, mapState } from 'pinia'
import AsyncComponentError from 'src/components/async_component_error/async_component_error.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ConfirmModal from 'src/components/confirm_modal/confirm_modal.vue'
import Modal from 'src/components/modal/modal.vue'
import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
import Popover from '../popover/popover.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
+import { useLocalConfigStore } from 'src/stores/local_config.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
+import {
+ LOCAL_ONLY_KEYS,
+ ROOT_CONFIG,
+ ROOT_CONFIG_DEFINITIONS,
+ validateSetting,
+} from 'src/modules/default_config_state.js'
import {
newExporter,
newImporter,
} from 'src/services/export_import/export_import.js'
import getResettableAsyncComponent from 'src/services/resettable_async_component.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faWindowMinimize } from '@fortawesome/free-regular-svg-icons'
import {
faChevronDown,
faFileDownload,
faFileUpload,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
const PLEROMAFE_SETTINGS_MAJOR_VERSION = 1
const PLEROMAFE_SETTINGS_MINOR_VERSION = 0
library.add(
faTimes,
faWindowMinimize,
faFileUpload,
faFileDownload,
faChevronDown,
)
const SettingsModal = {
data() {
return {
dataImporter: newImporter({
validator: this.importValidator,
onImport: this.onImport,
onImportFailure: this.onImportFailure,
}),
dataThemeExporter: newExporter({
filename: 'pleromafe_settings.full',
getExportedObject: () => this.generateExport(true),
}),
dataExporter: newExporter({
filename: 'pleromafe_settings',
getExportedObject: () => this.generateExport(),
}),
}
},
components: {
Modal,
Popover,
Checkbox,
ConfirmModal,
SettingsModalUserContent: getResettableAsyncComponent(
() => import('./settings_modal_user_content.vue'),
{
loadingComponent: PanelLoading,
errorComponent: AsyncComponentError,
delay: 0,
},
),
SettingsModalAdminContent: getResettableAsyncComponent(
() => import('./settings_modal_admin_content.vue'),
{
loadingComponent: PanelLoading,
errorComponent: AsyncComponentError,
delay: 0,
},
),
},
methods: {
closeModal() {
useInterfaceStore().closeSettingsModal()
},
peekModal() {
useInterfaceStore().togglePeekSettingsModal()
},
importValidator(data) {
if (!Array.isArray(data._pleroma_settings_version)) {
return {
messageKey: 'settings.file_import_export.invalid_file',
}
}
const [major, minor] = data._pleroma_settings_version
if (major > PLEROMAFE_SETTINGS_MAJOR_VERSION) {
return {
messageKey: 'settings.file_export_import.errors.file_too_new',
messageArgs: {
fileMajor: major,
feMajor: PLEROMAFE_SETTINGS_MAJOR_VERSION,
},
}
}
if (major < PLEROMAFE_SETTINGS_MAJOR_VERSION) {
return {
messageKey: 'settings.file_export_import.errors.file_too_old',
messageArgs: {
fileMajor: major,
feMajor: PLEROMAFE_SETTINGS_MAJOR_VERSION,
},
}
}
if (minor > PLEROMAFE_SETTINGS_MINOR_VERSION) {
useInterfaceStore().pushGlobalNotice({
level: 'warning',
messageKey: 'settings.file_export_import.errors.file_slightly_new',
})
}
return true
},
onImportFailure(result) {
if (result.error) {
useInterfaceStore().pushGlobalNotice({
messageKey: 'settings.invalid_settings_imported',
level: 'error',
})
} else {
useInterfaceStore().pushGlobalNotice({
...result.validationResult,
level: 'error',
})
}
},
- onImport(data) {
- if (data) {
- this.$store.dispatch('loadSettings', data)
- }
+ onImport(input) {
+ if (!input) return
+ const { _pleroma_settings_version, ...data } = input
+
+ Object.entries(data).forEach(([path, value]) => {
+ const definition = ROOT_CONFIG_DEFINITIONS[path]
+
+ const finalValue = validateSetting({
+ path,
+ value,
+ definition,
+ throwError: false,
+ defaultState: ROOT_CONFIG,
+ })
+
+ if (finalValue === undefined) return
+
+ if (LOCAL_ONLY_KEYS.has(path)) {
+ useLocalConfigStore().set({ path, value: finalValue })
+ } else {
+ if (path.startsWith('muteFilters')) {
+ Object.keys(
+ useMergedConfigStore().mergedConfig.muteFilters,
+ ).forEach((key) => {
+ useSyncConfigStore().unsetPreference({
+ path: `simple.${path}.${key}`,
+ })
+ })
+
+ Object.entries(value).forEach(([key, filter]) => {
+ useSyncConfigStore().setPreference({
+ path: `simple.${path}.${key}`,
+ value: filter,
+ })
+ })
+ } else {
+ if (finalValue !== undefined) {
+ useSyncConfigStore().setPreference({
+ path: `simple.${path}`,
+ value: finalValue,
+ })
+ }
+ }
+ }
+ })
+ useSyncConfigStore().pushSyncConfig()
},
restore() {
this.dataImporter.importData()
},
backup() {
this.dataExporter.exportData()
},
backupWithTheme() {
this.dataThemeExporter.exportData()
},
generateExport(theme = false) {
- const { config } = this.$store.state
+ const config = useMergedConfigStore().mergedConfigWithoutDefaults
let sample = config
if (!theme) {
const ignoreList = new Set([
+ 'theme',
'customTheme',
'customThemeSource',
'colors',
+ 'style',
+ 'styleCustomData',
+ 'palette',
+ 'paletteCustomData',
+ 'themeChecksum',
])
+
sample = Object.fromEntries(
- Object.entries(sample).filter(([key]) => !ignoreList.has(key)),
+ Object.entries(sample).filter(
+ ([key, value]) => !ignoreList.has(key) && value !== undefined,
+ ),
)
}
const clone = cloneDeep(sample)
clone._pleroma_settings_version = [
PLEROMAFE_SETTINGS_MAJOR_VERSION,
PLEROMAFE_SETTINGS_MINOR_VERSION,
]
return clone
},
resetAdminDraft() {
this.$store.commit('resetAdminDraft')
},
pushAdminDraft() {
this.$store.dispatch('pushAdminDraft')
},
...mapActions(useInterfaceStore, [
'temporaryChangesRevert',
'temporaryChangesConfirm',
]),
},
computed: {
...mapState(useInterfaceStore, {
temporaryChangesCountdown: (store) => store.temporaryChangesCountdown,
currentSaveStateNotice: (store) => store.settings.currentSaveStateNotice,
modalActivated: (store) => store.settingsModalState !== 'hidden',
modalMode: (store) => store.settingsModalMode,
modalOpenedOnceUser: (store) => store.settingsModalLoadedUser,
modalOpenedOnceAdmin: (store) => store.settingsModalLoadedAdmin,
modalPeeked: (store) => store.settingsModalState === 'minimized',
}),
expertLevel: {
get() {
- return this.$store.state.config.expertLevel > 0
+ return useMergedConfigStore().mergedConfig.expertLevel > 0
},
set(value) {
- this.$store.dispatch('setOption', {
- name: 'expertLevel',
+ useSyncConfigStore().setSimplePrefAndSave({
+ path: 'expertLevel',
value: value ? 1 : 0,
})
},
},
adminDraftAny() {
return !isEqual(
this.$store.state.adminSettings.config,
this.$store.state.adminSettings.draft,
)
},
},
}
export default SettingsModal
diff --git a/src/components/settings_modal/settings_modal.scss b/src/components/settings_modal/settings_modal.scss
index 032b603fa9..371498a967 100644
--- a/src/components/settings_modal/settings_modal.scss
+++ b/src/components/settings_modal/settings_modal.scss
@@ -1,270 +1,270 @@
.settings-modal {
overflow: hidden;
h2 {
font-size: 1.3rem;
font-weight: 500;
margin-top: 1em;
margin-bottom: 1em;
margin-right: 1em;
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-top: 1em;
margin-bottom: 0.5em;
margin-right: 1em;
border-bottom: 1px solid var(--border);
padding-bottom: 0.25em;
box-sizing: border-box;
padding-left: 0.5em;
}
h4 {
font-size: 1.1rem;
margin-top: 1em;
margin-right: 1em;
margin-bottom: 0.5em;
margin-left: 1em;
}
h5 {
font-size: 1rem;
margin-left: 1em;
margin-bottom: 0.25em;
margin-top: 0.75em;
}
p {
line-height: 1.5;
}
.suboptions {
margin-left: 1em;
}
.sidenote {
margin-left: 5em;
padding: 0.25em 1em;
margin-top: 0.25em;
}
.setting-description {
margin-top: 0.2em;
margin-bottom: 0;
font-size: 80%;
}
.setting-item {
display: grid;
grid-template-areas:
"label control"
"label desc"
". draft";
grid-template-columns: 4fr 5fr;
column-gap: 0.5em;
align-items: baseline;
padding: 0.5em 0;
.setting-label {
grid-area: label;
text-align: right;
}
.ModifiedIndicator,
- .ProfileSettingIndicator {
+ .LocalSettingIndicator {
grid-area: indicator;
}
.setting-control {
grid-area: control;
&.textarea {
align-self: baseline;
overflow: auto;
}
&.checkbox {
display: grid;
grid-template-columns: subgrid;
.label {
grid-area: label;
text-align: right;
}
.checkbox-indicator {
grid-area: control;
}
.-mobile & {
.label {
text-align: left;
}
}
}
}
.setting-control.setting-label {
grid-column: 1 / 3;
text-align: left;
}
.setting-description {
grid-area: desc;
}
.DraftButtons {
grid-area: draft;
}
}
.vertical-tab-switcher {
height: 100%;
}
.setting-list,
.option-list {
list-style-type: none;
padding-left: 0;
margin: 0;
.btn:not(.dropdown-button) {
padding: 0 2em;
}
.btn-group {
.button-default {
flex: 0 1 auto;
}
}
&.two-column {
display: grid;
grid-template-columns: 1fr 1fr;
margin-left: 2em;
border-bottom: 1px solid var(--border);
padding-bottom: 0.5em;
margin-bottom: 1em;
.setting-item {
grid-template-columns: 3fr 1fr;
}
> li {
margin: 0;
break-inside: avoid;
}
}
}
.settings-modal-panel {
overflow: hidden;
transition: transform;
transition-timing-function: ease-in-out;
transition-duration: 300ms;
width: 70em;
max-width: 90vw;
height: 90vh;
@media all and (width <= 800px) {
max-width: 100vw;
height: 100%;
}
>.panel-body {
height: 100%;
overflow-y: hidden;
.btn {
min-height: 2em;
}
}
}
.settings-footer {
display: flex;
flex-wrap: wrap;
line-height: 2;
>* {
margin-right: 0.5em;
}
.extra-content {
display: flex;
flex-grow: 1;
}
}
&.-mobile {
.tabs {
.menu-item {
font-size: 1.2em;
padding-top: 0.75em;
padding-bottom: 0.75em;
}
}
/* stylelint-disable no-descending-specificity */
.setting-item {
grid-template-columns: 1fr min-content;
column-gap: 0.5em;
padding: 1em;
align-items: center;
.setting-label {
text-align: left;
}
.checkbox {
.label {
text-align: left;
margin-left: 0;
}
}
}
ul {
padding: 0;
li:not(:first-child) {
.setting-item {
border-top: 1px solid var(--border);
}
}
}
.setting-list:not(.suboptions),
.option-list {
&.two-column {
grid-template-columns: 1fr;
}
}
}
&.peek {
.settings-modal-panel {
/* Explanation:
* Modal is positioned vertically centered.
* 100vh - 100% = Distance between modal's top+bottom boundaries and screen
* (100vh - 100%) / 2 = Distance between bottom (or top) boundary and screen
* + 100% - we move modal completely off-screen, it's top boundary touches
* bottom of the screen
* - 50px - leaving tiny amount of space so that titlebar + tiny amount of modal is visible
*/
transform: translateY(calc(((100vh - 100%) / 2 + 100%) - 50px));
@media all and (width <= 800px) {
/* For mobile, the modal takes 100% of the available screen.
This ensures the minimized modal is always 50px above the browser bottom
bar regardless of whether or not it is visible.
*/
transform: translateY(calc(100% - 50px));
}
}
}
}
diff --git a/src/components/settings_modal/settings_modal_user_content.js b/src/components/settings_modal/settings_modal_user_content.js
index ddee812cae..6e5d2addc7 100644
--- a/src/components/settings_modal/settings_modal_user_content.js
+++ b/src/components/settings_modal/settings_modal_user_content.js
@@ -1,124 +1,125 @@
import VerticalTabSwitcher from './helpers/vertical_tab_switcher.jsx'
import AppearanceTab from './tabs/appearance_tab.vue'
import ClutterTab from './tabs/clutter_tab.vue'
import ComposingTab from './tabs/composing_tab.vue'
import DataImportExportTab from './tabs/data_import_export_tab.vue'
import DeveloperTab from './tabs/developer_tab.vue'
import FilteringTab from './tabs/filtering_tab.vue'
import GeneralTab from './tabs/general_tab.vue'
import LayoutTab from './tabs/layout_tab.vue'
import MutesAndBlocksTab from './tabs/mutes_and_blocks_tab.vue'
import NotificationsTab from './tabs/notifications_tab.vue'
import OldThemeTab from './tabs/old_theme_tab/old_theme_tab.vue'
import PostsTab from './tabs/posts_tab.vue'
import ProfileTab from './tabs/profile_tab.vue'
import SecurityTab from './tabs/security_tab/security_tab.vue'
import StyleTab from './tabs/style_tab/style_tab.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBell,
faBroom,
faCode,
faColumns,
faDownload,
faEyeSlash,
faFilter,
faLock,
faMessage,
faPaintBrush,
faPalette,
faUser,
faWindowRestore,
faWrench,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faWrench,
faUser,
faMessage,
faWindowRestore,
faColumns,
faBell,
faFilter,
faEyeSlash,
faBroom,
faLock,
faDownload,
faPalette,
faPaintBrush,
faCode,
)
const SettingsModalContent = {
components: {
VerticalTabSwitcher,
DataImportExportTab,
MutesAndBlocksTab,
NotificationsTab,
FilteringTab,
SecurityTab,
ProfileTab,
GeneralTab,
PostsTab,
ComposingTab,
ClutterTab,
LayoutTab,
AppearanceTab,
StyleTab,
DeveloperTab,
OldThemeTab,
},
computed: {
isLoggedIn() {
return !!this.$store.state.users.currentUser
},
open() {
return useInterfaceStore().settingsModalState !== 'hidden'
},
bodyLock() {
return useInterfaceStore().settingsModalState === 'visible'
},
expertLevel() {
- return this.$store.state.config.expertLevel
+ return useMergedConfigStore().mergedConfig.expertLevel
},
},
data() {
return {
navCollapsed: false,
navHideHeader: false,
}
},
methods: {
onOpen() {
const targetTab = useInterfaceStore().settingsModalTargetTab
// We're being told to open in specific tab
if (targetTab) {
const tabIndex = this.$refs.tabSwitcher.$slots
.default()
.findIndex((elm) => {
return elm.props && elm.props['data-tab-name'] === targetTab
})
if (tabIndex >= 0) {
this.$refs.tabSwitcher.setTab(tabIndex)
}
}
// Clear the state of target tab, so that next time settings is opened
// it doesn't force it.
useInterfaceStore().clearSettingsModalTargetTab()
},
},
mounted() {
this.onOpen()
},
watch: {
open: function (value) {
if (value) this.onOpen()
},
},
}
export default SettingsModalContent
diff --git a/src/components/settings_modal/tabs/appearance_tab.js b/src/components/settings_modal/tabs/appearance_tab.js
index 0a217a2162..99e267d8bd 100644
--- a/src/components/settings_modal/tabs/appearance_tab.js
+++ b/src/components/settings_modal/tabs/appearance_tab.js
@@ -1,500 +1,498 @@
import { mapActions } from 'pinia'
import fileSizeFormatService from 'src/components/../services/file_size_format/file_size_format.js'
import PaletteEditor from 'src/components/palette_editor/palette_editor.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import FloatSetting from '../helpers/float_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
-import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import UnitSetting from '../helpers/unit_setting.vue'
import Preview from './old_theme_tab/theme_preview.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { normalizeThemeData, useInterfaceStore } from 'src/stores/interface.js'
import { 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 } from 'src/services/theme_data/iss_deserializer.js'
import { init } from 'src/services/theme_data/theme_data_3.service.js'
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js'
const AppearanceTab = {
data() {
return {
availableThemesV3: [],
availableThemesV2: [],
bundledPalettes: [],
compilationCache: {},
fileImporter: newImporter({
accept: '.json, .iss',
validator: this.importValidator,
onImport: this.onImport,
parser: this.importParser,
onImportFailure: this.onImportFailure,
}),
palettesKeys: [
'bg',
'fg',
'link',
'text',
'cRed',
'cGreen',
'cBlue',
'cOrange',
],
userPalette: {},
intersectionObserver: null,
forcedRoundnessOptions: ['disabled', 'sharp', 'nonsharp', 'round'].map(
(mode, i) => ({
key: mode,
value: i - 1,
label: this.$t(
`settings.style.themes3.hacks.forced_roundness_mode_${mode}`,
),
}),
),
underlayOverrideModes: ['none', 'opaque', 'transparent'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(
`settings.style.themes3.hacks.underlay_override_mode_${mode}`,
),
})),
backgroundUploading: false,
background: null,
backgroundPreview: null,
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
FloatSetting,
UnitSetting,
- ProfileSettingIndicator,
Preview,
PaletteEditor,
},
mounted() {
useInterfaceStore().getThemeData()
const updateIndex = (resource) => {
const capitalizedResource = resource[0].toUpperCase() + resource.slice(1)
const currentIndex = useInstanceStore()[`${resource}sIndex`]
let promise
if (currentIndex) {
promise = Promise.resolve(currentIndex)
} else {
promise = useInterfaceStore()[`fetch${capitalizedResource}sIndex`]()
}
return promise.then((index) => {
return Object.entries(index).map(([k, func]) => [k, func()])
})
}
updateIndex('style').then((styles) => {
styles.forEach(([key, stylePromise]) =>
stylePromise.then((data) => {
const meta = data.find((x) => x.component === '@meta')
this.availableThemesV3.push({
key,
data,
name: meta.directives.name,
version: 'v3',
})
}),
)
})
updateIndex('theme').then((themes) => {
themes.forEach(([key, themePromise]) =>
themePromise.then((data) => {
if (!data) {
console.warn(`Theme with key ${key} is empty or malformed`)
} else if (Array.isArray(data)) {
console.warn(
`Theme with key ${key} is a v1 theme and should be moved to static/palettes/index.json`,
)
} else if (!data.source && !data.theme) {
console.warn(`Theme with key ${key} is malformed`)
} else {
this.availableThemesV2.push({
key,
data,
name: data.name,
version: 'v2',
})
}
}),
)
})
this.userPalette = useInterfaceStore().paletteDataUsed || {}
updateIndex('palette').then((bundledPalettes) => {
bundledPalettes.forEach(([key, palettePromise]) =>
palettePromise.then((v) => {
let palette
if (Array.isArray(v)) {
const [
name,
bg,
fg,
text,
link,
cRed = '#FF0000',
cGreen = '#00FF00',
cBlue = '#0000FF',
cOrange = '#E3FF00',
] = v
palette = {
key,
name,
bg,
fg,
text,
link,
cRed,
cBlue,
cGreen,
cOrange,
}
} else {
palette = { key, ...v }
}
if (!palette.key.startsWith('style.')) {
this.bundledPalettes.push(palette)
}
}),
)
})
this.previewTheme('stock', 'v3')
if (window.IntersectionObserver) {
this.intersectionObserver = new IntersectionObserver(
(entries, observer) => {
entries.forEach(({ target, isIntersecting }) => {
if (!isIntersecting) return
const theme = this.availableStyles.find(
(x) => x.key === target.dataset.themeKey,
)
this.$nextTick(() => {
if (theme) this.previewTheme(theme.key, theme.version, theme.data)
})
observer.unobserve(target)
})
},
{
root: this.$refs.themeList,
},
)
} else {
this.availableStyles.forEach((theme) =>
this.previewTheme(theme.key, theme.version, theme.data),
)
}
},
updated() {
this.$nextTick(() => {
this.$refs.themeList
.querySelectorAll('.theme-preview')
.forEach((node) => {
this.intersectionObserver.observe(node)
})
})
},
watch: {
paletteDataUsed() {
this.userPalette = this.paletteDataUsed || {}
},
},
computed: {
isDefaultBackground() {
return !this.$store.state.users.currentUser.background_image
},
switchInProgress() {
return useInterfaceStore().themeChangeInProgress
},
paletteDataUsed() {
return useInterfaceStore().paletteDataUsed
},
availableStyles() {
return [...this.availableThemesV3, ...this.availableThemesV2]
},
availablePalettes() {
return [...this.bundledPalettes, ...this.stylePalettes]
},
stylePalettes() {
const ruleset = useInterfaceStore().styleDataUsed || []
if (!ruleset && ruleset.length === 0) return
const meta = ruleset.find((x) => x.component === '@meta')
const result = ruleset
.filter((x) => x.component.startsWith('@palette'))
.map((x) => {
const { variant, directives } = x
const {
bg,
fg,
text,
link,
accent,
cRed,
cBlue,
cGreen,
cOrange,
wallpaper,
} = directives
const result = {
name: `${meta.directives.name || this.$t('settings.style.themes3.palette.imported')}: ${variant}`,
key: `style.${variant.toLowerCase().replace(/ /g, '_')}`,
bg,
fg,
text,
link,
accent,
cRed,
cBlue,
cGreen,
cOrange,
wallpaper,
}
return Object.fromEntries(Object.entries(result).filter(([, v]) => v))
})
return result
},
noIntersectionObserver() {
return !window.IntersectionObserver
},
instanceWallpaper() {
useInstanceStore().instanceIdentity.background
},
instanceWallpaperUsed() {
return (
useInstanceStore().instanceIdentity.background &&
!this.$store.state.users.currentUser.background_image
)
},
customThemeVersion() {
const { themeVersion } = useInterfaceStore()
return themeVersion
},
isCustomThemeUsed() {
const { customTheme, customThemeSource } = this.mergedConfig
return customTheme != null || customThemeSource != null
},
isCustomStyleUsed() {
const { styleCustomData } = this.mergedConfig
return styleCustomData != null
},
...SharedComputedObject(),
},
methods: {
importFile() {
this.fileImporter.importData()
},
importParser(file, filename) {
if (filename.endsWith('.json')) {
return JSON.parse(file)
} else if (filename.endsWith('.iss')) {
return deserialize(file)
}
},
onImport(parsed, filename) {
if (filename.endsWith('.json')) {
useInterfaceStore().setThemeCustom(parsed.source || parsed.theme)
} else if (filename.endsWith('.iss')) {
useInterfaceStore().setStyleCustom(parsed)
}
},
onImportFailure(result) {
console.error('Failure importing theme:', result)
useInterfaceStore().pushGlobalNotice({
messageKey: 'settings.invalid_theme_imported',
level: 'error',
})
},
importValidator(parsed, filename) {
if (filename.endsWith('.json')) {
const version = parsed._pleroma_theme_version
return version >= 1 || version <= 2
} else if (filename.endsWith('.iss')) {
if (!Array.isArray(parsed)) return false
if (parsed.length < 1) return false
if (parsed.find((x) => x.component === '@meta') == null) return false
return true
}
},
isThemeActive(key) {
return (
key ===
(this.mergedConfig.theme || useInstanceStore().instanceIdentity.theme)
)
},
isStyleActive(key) {
return (
key ===
(this.mergedConfig.style || useInstanceStore().instanceIdentity.style)
)
},
isPaletteActive(key) {
return (
key ===
(this.mergedConfig.palette ||
useInstanceStore().instanceIdentity.palette)
)
},
...mapActions(useInterfaceStore, ['setStyle', 'setTheme']),
setPalette(name, data) {
useInterfaceStore().setPalette(name)
this.userPalette = data
},
setPaletteCustom(data) {
useInterfaceStore().setPaletteCustom(data)
this.userPalette = data
},
resetTheming() {
useInterfaceStore().setStyle('stock')
},
previewTheme(key, version, input) {
let theme3
if (this.compilationCache[key]) {
theme3 = this.compilationCache[key]
} else if (input) {
if (version === 'v2') {
const style = normalizeThemeData(input)
const theme2 = convertTheme2To3(style)
theme3 = init({
inputRuleset: theme2,
ultimateBackgroundColor: '#000000',
liteMode: true,
debug: true,
onlyNormalState: true,
})
} else if (version === 'v3') {
const palette = input.find((x) => x.component === '@palette')
let paletteRule
if (palette) {
const { directives } = palette
directives.link = directives.link || directives.accent
directives.accent = directives.accent || directives.link
paletteRule = {
component: 'Root',
directives: Object.fromEntries(
Object.entries(directives)
.filter(([k]) => k && k !== 'name')
.map(([k, v]) => ['--' + k, 'color | ' + v]),
),
}
} else {
paletteRule = null
}
theme3 = init({
inputRuleset: [...input, paletteRule].filter((x) => x),
ultimateBackgroundColor: '#000000',
liteMode: true,
onlyNormalState: true,
})
}
} else {
theme3 = init({
inputRuleset: [],
ultimateBackgroundColor: '#000000',
liteMode: true,
onlyNormalState: true,
})
}
if (!this.compilationCache[key]) {
this.compilationCache[key] = theme3
}
const sheet = createStyleSheet('appearance-tab-previews', 90)
sheet.addRule(
[
'#theme-preview-',
key,
' {\n',
getCssRules(theme3.eager).join('\n'),
'\n}',
].join(''),
)
sheet.ready = true
adoptStyleSheets()
},
uploadFile(slot, e) {
const file = e.target.files[0]
if (!file) {
return
}
if (file.size > useInstanceStore()[slot + 'limit']) {
const filesize = fileSizeFormatService.fileSizeFormat(file.size)
const allowedsize = fileSizeFormatService.fileSizeFormat(
useInstanceStore()[slot + 'limit'],
)
useInterfaceStore().pushGlobalNotice({
messageKey: 'upload.error.message',
messageArgs: [
this.$t('upload.error.file_too_big', {
filesize: filesize.num,
filesizeunit: filesize.unit,
allowedsize: allowedsize.num,
allowedsizeunit: allowedsize.unit,
}),
],
level: 'error',
})
return
}
const reader = new FileReader()
reader.onload = ({ target }) => {
const img = target.result
this[slot + 'Preview'] = img
this[slot] = file
}
reader.readAsDataURL(file)
},
resetBackground() {
const confirmed = window.confirm(
this.$t('settings.reset_background_confirm'),
)
if (confirmed) {
this.submitBackground('')
}
},
resetUploadedBackground() {
this.backgroundPreview = null
},
submitBackground(background) {
if (!this.backgroundPreview && background !== '') {
return
}
this.backgroundUploading = true
this.$store.state.api.backendInteractor
.updateProfileImages({ background })
.then((data) => {
this.$store.commit('addNewUsers', [data])
this.$store.commit('setCurrentUser', data)
this.backgroundPreview = null
})
.catch(this.displayUploadError)
.finally(() => {
this.backgroundUploading = false
})
},
},
}
export default AppearanceTab
diff --git a/src/components/settings_modal/tabs/appearance_tab.vue b/src/components/settings_modal/tabs/appearance_tab.vue
index 75a06388d0..a0439ed4a1 100644
--- a/src/components/settings_modal/tabs/appearance_tab.vue
+++ b/src/components/settings_modal/tabs/appearance_tab.vue
@@ -1,256 +1,256 @@
<template>
<div
class="appearance-tab"
:label="$t('settings.interface')"
icon="table-columns"
>
<div
class="setting-section"
:label="$t('settings.theme')"
icon="paintbrush"
>
<h3>{{ $t('settings.style.style_section') }}</h3>
<ul
ref="themeList"
class="theme-list"
>
<button
class="button-default theme-preview"
data-theme-key="stock"
:class="{ toggled: isStyleActive('stock'), disabled: switchInProgress }"
:disabled="switchInProgress"
@click="resetTheming"
>
<preview id="theme-preview-stock" />
<span class="theme-name">
{{ $t('settings.style.stock_theme_used') }}
<span class="alert neutral version">v3</span>
</span>
</button>
<button
v-if="isCustomThemeUsed"
disabled
class="button-default theme-preview toggled"
>
<preview />
<span class="theme-name">
{{ $t('settings.style.custom_theme_used') }}
<span class="alert neutral version">v2</span>
</span>
</button>
<button
v-if="isCustomStyleUsed"
disabled
class="button-default theme-preview toggled"
>
<preview />
<span class="theme-name">
{{ $t('settings.style.custom_style_used') }}
<span class="alert neutral version">v3</span>
</span>
</button>
<button
v-for="style in availableStyles"
:key="style.key"
:data-theme-key="style.key"
class="button-default theme-preview"
- :class="{ toggled: isThemeActive(style.key), disabled: switchInProgress }"
+ :class="{ toggled: isStyleActive(style.key), disabled: switchInProgress }"
:disabled="switchInProgress"
@click="style.version === 'v2' ? setTheme(style.key) : setStyle(style.key)"
>
<preview :id="'theme-preview-' + style.key" />
<span class="theme-name">
{{ style.name }}
<span class="alert neutral version">{{ style.version }}</span>
</span>
</button>
</ul>
<div class="import-file-container">
<button
class="btn button-default"
:class="{ disabled: switchInProgress }"
:disabled="switchInProgress"
@click="importFile"
>
<FAIcon icon="folder-open" />
{{ $t('settings.style.themes3.editor.load_style') }}
</button>
<h4>{{ $t('settings.style.themes3.palette.label') }}</h4>
<div
v-if="customThemeVersion === 'v3'"
class="palettes-container"
>
<h5 v-if="stylePalettes?.length > 0">
{{ $t('settings.style.themes3.palette.style') }}
</h5>
<div class="palettes">
<button
v-for="p in stylePalettes || []"
:key="p.name"
class="btn button-default palette-entry"
:class="{ toggled: isPaletteActive(p.key), disabled: switchInProgress }"
:disabled="switchInProgress"
@click="() => setPalette(p.key, p)"
>
<div class="palette-label">
<label>
{{ p.name ?? $t('settings.style.themes3.palette.user') }}
</label>
</div>
<div class="palette-preview">
<span
v-for="c in palettesKeys"
:key="c"
class="palette-square"
:style="{ backgroundColor: p[c], border: '1px solid ' + (p[c] ?? 'var(--text)') }"
/>
</div>
</button>
<h5>{{ $t('settings.style.themes3.palette.bundled') }}</h5>
<button
v-for="p in bundledPalettes"
:key="p.name"
class="btn button-default palette-entry"
:class="{ toggled: isPaletteActive(p.key), disabled: switchInProgress }"
:disabled="switchInProgress"
@click="() => setPalette(p.key, p)"
>
<div class="palette-label">
<label>
{{ p.name }}
</label>
</div>
<div class="palette-preview">
<span
v-for="c in palettesKeys"
:key="c"
class="palette-square"
:style="{ backgroundColor: p[c], border: '1px solid ' + (p[c] ?? 'var(--text)') }"
/>
</div>
</button>
</div>
</div>
<div>
<template v-if="customThemeVersion === 'v3'">
<h5 v-if="expertLevel > 0">
{{ $t('settings.style.themes3.palette.user') }}
</h5>
<PaletteEditor
v-if="expertLevel > 0"
v-model="userPalette"
class="userPalette"
:compact="true"
:apply="true"
:disabled="switchInProgress"
@apply-palette="data => setPaletteCustom(data)"
/>
</template>
<template v-else-if="customThemeVersion === 'v2'">
<div class="alert neutral theme-notice unsupported-theme-v2">
{{ $t('settings.style.themes3.palette.v2_unsupported') }}
</div>
</template>
</div>
</div>
<h3>{{ $t('settings.background') }}</h3>
<div class="banner-background">
<div class="banner-background-preview">
<div class="fun-monitor">
<div class="fun-monitor-stand button-default" />
<div class="fun-monitor-neck button-default" />
<div class="fun-monitor-display-bezel button-default">
<div class="fun-monitor-display-screen input">
<img
v-if="backgroundPreview || user.background_image || instanceWallpaper"
class="fun-monitor-display-screen-image"
:src="backgroundPreview || user.background_image || instanceWallpaper"
>
<div
v-else
class="wallpaper"
/>
<div class="fun-monitor-display-screen-overlay input" />
<div
v-if="backgroundUploading"
class="fun-monitor-display-uploading"
>
<FAIcon
class="fun-monitor-display-screen-uploading"
spin
icon="circle-notch"
/>
</div>
</div>
</div>
</div>
</div>
<div class="banner-background-input">
<h4>{{ $t('settings.set_new_background') }}</h4>
<input
type="file"
class="input"
@change="uploadFile('background', $event)"
>
<div class="custom-bg-control">
<button
:disabled="!backgroundPreview"
class="btn button-default"
@click="submitBackground(background)"
>
{{ $t('settings.save') }}
</button>
<button
:disabled="!backgroundPreview"
class="btn button-default"
@click="resetUploadedBackground"
>
{{ $t('settings.reset') }}
</button>
</div>
<button
v-if="!isDefaultBackground"
class="btn button-default reset-button"
:title="$t('settings.reset_profile_background')"
@click="resetBackground"
>
{{ $t('settings.reset_profile_background') }}
</button>
</div>
</div>
<h3>{{ $t('settings.visual_tweaks') }}</h3>
<div class="alert neutral theme-notice">
{{ $t("settings.style.visual_tweaks_section_note") }}
</div>
<ul class="setting-list">
<li>
<ChoiceSetting
id="forcedRoundness"
path="forcedRoundness"
:options="forcedRoundnessOptions"
>
{{ $t('settings.style.themes3.hacks.force_interface_roundness') }}
</ChoiceSetting>
</li>
<li>
<ChoiceSetting
id="underlayOverride"
- path="theme3hacks.underlay"
+ path="underlay"
:options="underlayOverrideModes"
>
{{ $t('settings.style.themes3.hacks.underlay_overrides') }}
</ChoiceSetting>
</li>
<li v-if="instanceWallpaperUsed">
<BooleanSetting path="hideInstanceWallpaper">
{{ $t('settings.hide_wallpaper') }}
</BooleanSetting>
</li>
</ul>
</div>
</div>
</template>
<script src="./appearance_tab.js"></script>
<style lang="scss" src="./appearance_tab.scss"></style>
diff --git a/src/components/settings_modal/tabs/clutter_tab.js b/src/components/settings_modal/tabs/clutter_tab.js
index 23e069d21d..85749ab3f1 100644
--- a/src/components/settings_modal/tabs/clutter_tab.js
+++ b/src/components/settings_modal/tabs/clutter_tab.js
@@ -1,208 +1,159 @@
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 { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
-import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
const ClutterTab = {
components: {
BooleanSetting,
ChoiceSetting,
UnitSetting,
IntegerSetting,
Checkbox,
Select,
HelpIndicator,
},
computed: {
...SharedComputedObject(),
...mapState(useInstanceCapabilitiesStore, ['shoutAvailable']),
...mapState(useInstanceStore, {
showFeaturesPanel: (store) => store.instanceIdentity.showFeaturesPanel,
instanceSpecificPanelPresent: (store) =>
store.instanceIdentity.showInstanceSpecificPanel &&
store.instanceIdentity.instanceSpecificPanelContent,
}),
- ...mapState(useServerSideStorageStore, {
+ ...mapState(useSyncConfigStore, {
muteFilters: (store) =>
Object.entries(store.prefsStorage.simple.muteFilters),
muteFiltersObject: (store) => store.prefsStorage.simple.muteFilters,
}),
- 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,
- )
- },
},
methods: {
- ...mapActions(useServerSideStorageStore, [
- 'setPreference',
- 'unsetPreference',
- 'pushServerSideStorage',
+ ...mapActions(useSyncConfigStore, [
+ 'setSimplePrefAndSave',
+ 'unsetSimplePrefAndSave',
+ '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.pushServerSideStorage()
+ this.setSimplePrefAndSave({ path: '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.pushServerSideStorage()
+ this.setSimplePrefAndSave({ path: 'muteFilters.' + newId, value: filter })
+ this.pushSyncConfig()
},
deleteFilter(id) {
delete this.muteFiltersDraftObject[id]
- this.unsetPreference({ path: 'simple.muteFilters.' + id, value: null })
- this.pushServerSideStorage()
+ this.unsetSimplePrefAndSave({ path: 'muteFilters.' + id, value: null })
+ this.pushSyncConfig()
},
purgeExpiredFilters() {
this.muteFiltersExpired.forEach(([id]) => {
delete this.muteFiltersDraftObject[id]
- this.unsetPreference({ path: 'simple.muteFilters.' + id, value: null })
+ this.unsetSimplePrefAndSave({ path: 'muteFilters.' + id, value: null })
})
- this.pushServerSideStorage()
+ 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,
+ this.setSimplePrefAndSave({
+ path: 'muteFilters.' + id,
value: this.muteFiltersDraftObject[id],
})
- this.pushServerSideStorage()
+ this.pushSyncConfig()
this.muteFiltersDraftDirty[id] = false
},
},
// Updating nested properties
watch: {
replyVisibility() {
this.$store.dispatch('queueFlushAll')
},
},
}
export default ClutterTab
diff --git a/src/components/settings_modal/tabs/clutter_tab.vue b/src/components/settings_modal/tabs/clutter_tab.vue
index d14d94818b..533cd85c6b 100644
--- a/src/components/settings_modal/tabs/clutter_tab.vue
+++ b/src/components/settings_modal/tabs/clutter_tab.vue
@@ -1,93 +1,95 @@
<template>
<div class="clutter-tab">
<div class="setting-section">
<h3>{{ $t('settings.interface') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting path="alwaysShowSubjectInput">
{{ $t('settings.subject_input_always_show') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="minimalScopesMode">
{{ $t('settings.minimal_scopes_mode') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="hidePostStats">
{{ $t('settings.hide_post_stats') }}
</BooleanSetting>
</li>
<li>
- <BooleanSetting
- path="hideUserStats"
- >
+ <BooleanSetting 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"
>
{{ $t('settings.hide_scrobbles_after') }}
</UnitSetting>
</li>
<li v-if="instanceSpecificPanelPresent">
<BooleanSetting path="hideISP">
{{ $t('settings.hide_isp') }}
</BooleanSetting>
</li>
</ul>
</li>
</ul>
<h3>{{ $t('settings.attachments') }}</h3>
<ul class="setting-list">
<li>
<IntegerSetting
path="maxThumbnails"
:min="0"
>
{{ $t('settings.max_thumbnails') }}
</IntegerSetting>
</li>
<li>
- <BooleanSetting path="hideAttachments">
+ <BooleanSetting
+ :local="true"
+ path="hideAttachments"
+ >
{{ $t('settings.hide_attachments_in_tl') }}
</BooleanSetting>
</li>
<li>
- <BooleanSetting path="hideAttachmentsInConv">
+ <BooleanSetting
+ :local="true"
+ path="hideAttachmentsInConv"
+ >
{{ $t('settings.hide_attachments_in_convo') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="userCardHidePersonalMarks">
{{ $t('settings.user_card_hide_personal_marks') }}
</BooleanSetting>
</li>
<li v-if="shoutAvailable">
- <BooleanSetting
- path="hideShoutbox"
- >
+ <BooleanSetting path="hideShoutbox">
{{ $t('settings.hide_shoutbox') }}
</BooleanSetting>
</li>
</ul>
</div>
</div>
</template>
<script src="./clutter_tab.js"></script>
diff --git a/src/components/settings_modal/tabs/composing_tab.js b/src/components/settings_modal/tabs/composing_tab.js
index 9d55d1f4fa..ab8d0101d2 100644
--- a/src/components/settings_modal/tabs/composing_tab.js
+++ b/src/components/settings_modal/tabs/composing_tab.js
@@ -1,188 +1,189 @@
import { mapState } from 'pinia'
import FontControl from 'src/components/font_control/font_control.vue'
import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
import Select from 'src/components/select/select.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import FloatSetting from '../helpers/float_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
-import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import UnitSetting from '../helpers/unit_setting.vue'
import { useInstanceStore } from 'src/stores/instance.js'
+import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
import localeService from 'src/services/locale/locale.service.js'
import { cacheKey, clearCache, emojiCacheKey } from 'src/services/sw/sw.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faDatabase,
faGlobe,
faMessage,
faPenAlt,
faSliders,
} from '@fortawesome/free-solid-svg-icons'
library.add(faGlobe, faMessage, faPenAlt, faDatabase, faSliders)
const ComposingTab = {
data() {
return {
subjectLineOptions: ['email', 'noop', 'masto'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(
`settings.subject_line_${mode === 'masto' ? 'mastodon' : mode}`,
),
})),
conversationDisplayOptions: ['tree', 'linear'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.conversation_display_${mode}`),
})),
absoluteTime12hOptions: ['24h', '12h'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.absolute_time_format_12h_${mode}`),
})),
conversationOtherRepliesButtonOptions: ['below', 'inside'].map(
(mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.conversation_other_replies_button_${mode}`),
}),
),
mentionLinkDisplayOptions: ['short', 'full_for_remote', 'full'].map(
(mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.mention_link_display_${mode}`),
}),
),
userPopoverAvatarActionOptions: ['close', 'zoom', 'open'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.user_popover_avatar_action_${mode}`),
})),
unsavedPostActionOptions: ['save', 'discard', 'confirm'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.unsaved_post_action_${mode}`),
})),
loopSilentAvailable:
// Firefox
Object.getOwnPropertyDescriptor(
HTMLVideoElement.prototype,
'mozHasAudio',
) ||
// Chrome-likes
Object.getOwnPropertyDescriptor(
HTMLMediaElement.prototype,
'webkitAudioDecodedByteCount',
) ||
// Future spec, still not supported in Nightly 63 as of 08/2018
Object.getOwnPropertyDescriptor(
HTMLMediaElement.prototype,
'audioTracks',
),
emailLanguage: this.$store.state.users.currentUser.language || [''],
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
FloatSetting,
UnitSetting,
InterfaceLanguageSwitcher,
- ProfileSettingIndicator,
ScopeSelector,
Select,
FontControl,
},
computed: {
postFormats() {
- return useInstanceStore().postFormats || []
+ return useInstanceCapabilitiesStore().postFormats
},
postContentOptions() {
return this.postFormats.map((format) => ({
key: format,
value: format,
label: this.$t(`post_status.content_type["${format}"]`),
}))
},
language: {
get: function () {
- return this.$store.getters.mergedConfig.interfaceLanguage
+ return useMergedConfigStore().mergedConfig.interfaceLanguage
},
set: function (val) {
- this.$store.dispatch('setOption', {
- name: 'interfaceLanguage',
+ useSyncConfigStore().setSimplePrefAndSave({
+ path: 'interfaceLanguage',
value: val,
})
},
},
...SharedComputedObject(),
...mapState(useInstanceStore, ['blockExpiration']),
},
methods: {
changeDefaultScope(value) {
this.$store.dispatch('setProfileOption', { name: 'defaultScope', value })
},
clearCache(key) {
clearCache(key)
.then(() => {
this.$store.dispatch('settingsSaved', { success: true })
})
.catch((error) => {
this.$store.dispatch('settingsSaved', { error })
})
},
tooSmall() {
this.$emit('tooSmall')
},
tooBig() {
this.$emit('tooBig')
},
getNavMode() {
return this.$refs.tabSwitcher.getNavMode()
},
clearAssetCache() {
this.clearCache(cacheKey)
},
clearEmojiCache() {
this.clearCache(emojiCacheKey)
},
updateProfile() {
const params = {
language: localeService.internalToBackendLocaleMulti(
this.emailLanguage,
),
}
this.$store.state.api.backendInteractor
.updateProfile({ params })
.then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
})
},
updateFont(key, value) {
- this.$store.dispatch('setOption', {
- name: 'theme3hacks',
+ useSyncConfigStore().setSimplePrefAndSave({
+ path: 'theme3hacks',
value: {
...this.mergedConfig.theme3hacks,
fonts: {
...this.mergedConfig.theme3hacks.fonts,
[key]: value,
},
},
})
},
},
}
export default ComposingTab
diff --git a/src/components/settings_modal/tabs/composing_tab.vue b/src/components/settings_modal/tabs/composing_tab.vue
index f71d64600b..755539096b 100644
--- a/src/components/settings_modal/tabs/composing_tab.vue
+++ b/src/components/settings_modal/tabs/composing_tab.vue
@@ -1,119 +1,117 @@
<template>
<div :label="$t('settings.posts')">
<div class="setting-section">
<h3>{{ $t('settings.general') }}</h3>
<ul class="setting-list">
<li>
<label
class="setting-item "
for="default-vis"
>
- <span class="setting-label">
- <ProfileSettingIndicator :is-profile="true" />
- {{ $t('settings.default_vis') }}
- </span>
<ScopeSelector
class="scope-selector setting-control"
:show-all="true"
:user-default="$store.state.profileConfig.defaultScope"
:initial-scope="$store.state.profileConfig.defaultScope"
:on-scope-change="changeDefaultScope"
:unstyled="false"
- uns
/>
</label>
</li>
<li>
<!-- <BooleanSetting source="profile" path="defaultNSFW"> -->
<BooleanSetting path="sensitiveByDefault">
{{ $t('settings.sensitive_by_default') }}
</BooleanSetting>
</li>
<li v-if="postFormats.length > 0">
<ChoiceSetting
id="postContentType"
path="postContentType"
:options="postContentOptions"
+ :local="true"
>
{{ $t('settings.default_post_status_content_type') }}
</ChoiceSetting>
</li>
<li>
<BooleanSetting path="padEmoji">
{{ $t('settings.pad_emoji') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="autocompleteSelect"
expert="1"
>
{{ $t('settings.autocomplete_select_first') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="autoSaveDraft"
>
{{ $t('settings.auto_save_draft') }}
</BooleanSetting>
</li>
<li v-if="!mergedConfig.autoSaveDraft">
<ChoiceSetting
id="unsavedPostAction"
path="unsavedPostAction"
:options="unsavedPostActionOptions"
expert="1"
>
{{ $t('settings.unsaved_post_action') }}
</ChoiceSetting>
</li>
</ul>
<h3>{{ $t('settings.replies') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting
path="scopeCopy"
>
{{ $t('settings.scope_copy') }}
</BooleanSetting>
</li>
<li>
<ChoiceSetting
id="subjectLineBehavior"
path="subjectLineBehavior"
:options="subjectLineOptions"
>
{{ $t('settings.subject_line_behavior') }}
</ChoiceSetting>
</li>
</ul>
<h3 v-if="expertLevel > 0">
{{ $t('settings.attachments') }}
</h3>
<ul class="setting-list">
<li>
<BooleanSetting
path="imageCompression"
+ :local="true"
expert="1"
>
{{ $t('settings.image_compression') }}
</BooleanSetting>
<ul class="setting-list suboptions">
<li>
<BooleanSetting
path="alwaysUseJpeg"
+ :local="true"
expert="1"
parent-path="imageCompression"
>
{{ $t('settings.always_use_jpeg') }}
</BooleanSetting>
</li>
</ul>
</li>
</ul>
</div>
</div>
</template>
<script src="./composing_tab.js"></script>
diff --git a/src/components/settings_modal/tabs/developer_tab.vue b/src/components/settings_modal/tabs/developer_tab.vue
index 1544c02a96..3e0fe71cb9 100644
--- a/src/components/settings_modal/tabs/developer_tab.vue
+++ b/src/components/settings_modal/tabs/developer_tab.vue
@@ -1,75 +1,80 @@
<template>
<div
:label="$t('settings.developer')"
class="developer-tab"
>
<div class="setting-section">
<h3>{{ $t('settings.version.title') }}</h3>
<dl class="setting-list">
<dt>{{ $t('settings.version.backend_version') }}</dt>
<dd>
<a
:href="backendRepository"
target="_blank"
>
{{ backendVersion }}
</a>
</dd>
<dt>{{ $t('settings.version.frontend_version') }}</dt>
<dd>
<a
:href="frontendVersionLink"
target="_blank"
>
{{ frontendVersion }}
</a>
</dd>
</dl>
<h3>{{ $t('settings.debug') }}</h3>
<ul class="setting-list">
<li>
- <BooleanSetting path="virtualScrolling">
+ <BooleanSetting
+ :local="true"
+ path="virtualScrolling"
+ >
{{ $t('settings.virtual_scrolling') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="themeDebug"
+ :local="true"
:expert="1"
>
{{ $t('settings.theme_debug') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="forceThemeRecompilation"
+ :local="true"
:expert="1"
>
{{ $t('settings.force_theme_recompilation_debug') }}
</BooleanSetting>
</li>
<h4>{{ $t('settings.cache') }}</h4>
<li>
<div class="setting-item">
<div class="cache-buttons">
<button
class="btn button-default"
@click="clearAssetCache"
>
{{ $t('settings.clear_asset_cache') }}
</button>
<button
class="btn button-default"
@click="clearEmojiCache"
>
{{ $t('settings.clear_emoji_cache') }}
</button>
</div>
</div>
</li>
</ul>
</div>
</div>
</template>
<script src="./developer_tab.js" />
<style lang="scss" src="./developer_tab.scss"></style>
diff --git a/src/components/settings_modal/tabs/filtering_tab.js b/src/components/settings_modal/tabs/filtering_tab.js
index 68efbe762d..ef3d8835bd 100644
--- a/src/components/settings_modal/tabs/filtering_tab.js
+++ b/src/components/settings_modal/tabs/filtering_tab.js
@@ -1,268 +1,273 @@
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 { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface'
-import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+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(
- useServerSideStorageStore().prefsStorage.simple.muteFilters,
+ useSyncConfigStore().prefsStorage.simple.muteFilters,
),
muteFiltersDraftDirty: Object.fromEntries(
Object.entries(
- useServerSideStorageStore().prefsStorage.simple.muteFilters,
+ 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: {
...SharedComputedObject(),
- ...mapState(useServerSideStorageStore, {
+ ...mapState(useSyncConfigStore, {
muteFilters: (store) =>
Object.entries(store.prefsStorage.simple.muteFilters),
muteFiltersObject: (store) => store.prefsStorage.simple.muteFilters,
}),
...mapState(useInstanceCapabilitiesStore, ['blockExpiration']),
onMuteDefaultActionLv1: {
get() {
- const value = this.$store.state.config.onMuteDefaultAction
+ const value = useMergedConfigStore().mergedConfig.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',
+ this.setPreference({
+ path: 'simple.onMuteDefaultAction',
value: realValue,
})
},
},
onBlockDefaultActionLv1: {
get() {
- const value = this.$store.state.config.onBlockDefaultAction
+ const value = useMergedConfigStore().mergedConfig.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',
+ this.setPreference({
+ path: 'simple.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,
)
},
},
methods: {
- ...mapActions(useServerSideStorageStore, [
+ ...mapActions(useSyncConfigStore, [
'setPreference',
+ 'setSimplePrefAndSave',
+ 'unsetSimplePrefAndSave',
'unsetPreference',
- 'pushServerSideStorage',
+ 'unsetPrefAndSave',
+ '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.pushServerSideStorage()
+ this.setSimplePrefAndSave({ path: 'muteFilters.' + newId, value: filter })
},
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()
+ this.setSimplePrefAndSave({ path: 'muteFilters.' + newId, value: filter })
},
deleteFilter(id) {
delete this.muteFiltersDraftObject[id]
- this.unsetPreference({ path: 'simple.muteFilters.' + id, value: null })
- this.pushServerSideStorage()
+ this.unsetSimplePrefAndSave({ path: 'muteFilters.' + id, value: null })
},
purgeExpiredFilters() {
this.muteFiltersExpired.forEach(([id]) => {
delete this.muteFiltersDraftObject[id]
this.unsetPreference({ path: 'simple.muteFilters.' + id, value: null })
})
- this.pushServerSideStorage()
+ 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,
+ this.setSimplePrefAndSave({
+ path: 'muteFilters.' + id,
value: this.muteFiltersDraftObject[id],
})
- this.pushServerSideStorage()
this.muteFiltersDraftDirty[id] = false
},
},
// Updating nested properties
watch: {
replyVisibility() {
this.$store.dispatch('queueFlushAll')
},
+ muteFiltersObject() {
+ this.muteFiltersDraftObject = cloneDeep(
+ useMergedConfigStore().mergedConfig.muteFilters,
+ )
+ },
},
}
export default FilteringTab
diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js
index 0d8f9a7862..db1177cb2a 100644
--- a/src/components/settings_modal/tabs/general_tab.js
+++ b/src/components/settings_modal/tabs/general_tab.js
@@ -1,82 +1,74 @@
import { mapState } from 'pinia'
import FontControl from 'src/components/font_control/font_control.vue'
import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import FloatSetting from '../helpers/float_setting.vue'
-import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import UnitSetting from '../helpers/unit_setting.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
+import { useLocalConfigStore } from 'src/stores/local_config.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
import localeService from 'src/services/locale/locale.service.js'
const GeneralTab = {
data() {
return {
absoluteTime12hOptions: ['24h', '12h'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.absolute_time_format_12h_${mode}`),
})),
emailLanguage: this.$store.state.users.currentUser.language || [''],
}
},
components: {
BooleanSetting,
ChoiceSetting,
UnitSetting,
FloatSetting,
FontControl,
InterfaceLanguageSwitcher,
- ProfileSettingIndicator,
},
computed: {
language: {
get: function () {
- return this.$store.getters.mergedConfig.interfaceLanguage
+ return useMergedConfigStore().mergedConfig.interfaceLanguage
},
set: function (val) {
- this.$store.dispatch('setOption', {
- name: 'interfaceLanguage',
+ useSyncConfigStore().setSimplePrefAndSave({
+ path: 'interfaceLanguage',
value: val,
})
},
},
...SharedComputedObject(),
...mapState(useInstanceCapabilitiesStore, ['blockExpiration']),
},
methods: {
updateProfile() {
const params = {
language: localeService.internalToBackendLocaleMulti(
this.emailLanguage,
),
}
this.$store.state.api.backendInteractor
.updateProfile({ params })
.then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
})
},
- updateFont(key, value) {
- this.$store.dispatch('setOption', {
- name: 'theme3hacks',
- value: {
- ...this.mergedConfig.theme3hacks,
- fonts: {
- ...this.mergedConfig.theme3hacks.fonts,
- [key]: value,
- },
- },
- })
+ updateFont(path, value) {
+ useLocalConfigStore().set({ path, value })
},
},
}
export default GeneralTab
diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue
index c44e558b54..19800e0dd4 100644
--- a/src/components/settings_modal/tabs/general_tab.vue
+++ b/src/components/settings_modal/tabs/general_tab.vue
@@ -1,210 +1,209 @@
<template>
<div>
<div class="setting-section">
<h3>{{ $t('settings.format_and_language') }}</h3>
<ul class="setting-list">
<h4>{{ $t('settings.interfaceLanguage') }}</h4>
<interface-language-switcher
v-model="language"
class="lang-selector"
@update="val => language = val"
/>
- <h4>
- {{ $t('settings.email_language') }}
- {{ ' ' }}
- <ProfileSettingIndicator :is-profile="true" />
- </h4>
<interface-language-switcher
v-model="emailLanguage"
class="lang-selector"
:profile="true"
@update:model-value="updateProfile()"
/>
<li>
<BooleanSetting path="useAbsoluteTimeFormat">
{{ $t('settings.absolute_time_format') }}
</BooleanSetting>
</li>
<li>
<ChoiceSetting
id="absoluteTime12h"
path="absoluteTime12h"
:options="absoluteTime12hOptions"
>
{{ $t('settings.absolute_time_format_12h') }}
</ChoiceSetting>
</li>
</ul>
<h3>{{ $t('settings.scale_and_font') }}</h3>
<ul class="setting-list">
<li>
<UnitSetting
path="textSize"
:step="0.1"
:units="['px', 'rem']"
:reset-default="{ 'px': 14, 'rem': 1 }"
timed-apply-mode
+ :local="true"
>
{{ $t('settings.text_size') }}
</UnitSetting>
<p class="sidenote">
<i18n-t
scope="global"
keypath="settings.text_size_tip"
tag="span"
>
<code>px</code>
<code>rem</code>
</i18n-t>
<br>
<i18n-t
scope="global"
keypath="settings.text_size_tip2"
tag="span"
>
<code>14px</code>
</i18n-t>
</p>
</li>
<li>
<FontControl
- :model-value="mergedConfig.theme3hacks.fonts.interface"
+ :model-value="mergedConfig.fontInterface"
name="ui"
:label="$t('settings.style.fonts.components_inline.interface')"
:fallback="{ family: 'sans-serif' }"
no-inherit="1"
- @update:model-value="v => updateFont('interface', v)"
+ @update:model-value="v => updateFont('fontInterface', v)"
/>
</li>
<li>
<FontControl
- :model-value="mergedConfig.theme3hacks.fonts.input"
+ :model-value="mergedConfig.fontInput"
name="input"
:fallback="{ family: 'inherit' }"
:label="$t('settings.style.fonts.components_inline.input')"
- @update:model-value="v => updateFont('input', v)"
+ @update:model-value="v => updateFont('fontInput', v)"
/>
</li>
<li>
<UnitSetting
path="emojiSize"
:step="0.1"
+ :local="true"
:units="['px', 'rem']"
:reset-default="{ 'px': 32, 'rem': 2.2 }"
>
{{ $t('settings.emoji_size') }}
</UnitSetting>
<ul
class="setting-list suboptions"
>
<li>
<FloatSetting
v-if="user"
path="emojiReactionsScale"
+ :local="true"
>
{{ $t('settings.emoji_reactions_scale') }}
</FloatSetting>
</li>
</ul>
</li>
</ul>
<h3>{{ $t('settings.timelines') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting path="streaming">
{{ $t('settings.streaming') }}
</BooleanSetting>
<ul class="setting-list suboptions">
<li>
<BooleanSetting
path="pauseOnUnfocused"
parent-path="streaming"
>
{{ $t('settings.pause_on_unfocused') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
<BooleanSetting
path="useStreamingApi"
+ :local="true"
expert="1"
>
{{ $t('settings.useStreamingApi') }}
</BooleanSetting>
</li>
</ul>
<h3 v-if="expertLevel > 0">
{{ $t('settings.confirmations') }}
</h3>
<ul
v-if="expertLevel > 0"
class="setting-list"
>
<li class="select-multiple">
<h4 class="label">
{{ $t('settings.confirm_dialogs') }}
</h4>
<ul class="option-list">
<li>
<BooleanSetting path="modalOnRepeat">
{{ $t('settings.confirm_dialogs_repeat') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="modalOnUnfollow">
{{ $t('settings.confirm_dialogs_unfollow') }}
</BooleanSetting>
</li>
<li
v-if="!blockExpiration"
>
<BooleanSetting
path="modalOnBlock"
>
{{ $t('settings.confirm_dialogs_block') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="modalOnMuteConversation">
{{ $t('settings.confirm_dialogs_mute_conversation') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="modalOnMuteDomain">
{{ $t('settings.confirm_dialogs_mute_domain') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="modalOnDelete">
{{ $t('settings.confirm_dialogs_delete') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="modalOnLogout">
{{ $t('settings.confirm_dialogs_logout') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="modalOnApproveFollow">
{{ $t('settings.confirm_dialogs_approve_follow') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="modalOnDenyFollow">
{{ $t('settings.confirm_dialogs_deny_follow') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="modalOnRemoveUserFromFollowers">
{{ $t('settings.confirm_dialogs_remove_follower') }}
</BooleanSetting>
</li>
</ul>
</li>
</ul>
</div>
</div>
</template>
<script src="./general_tab.js"></script>
diff --git a/src/components/settings_modal/tabs/layout_tab.js b/src/components/settings_modal/tabs/layout_tab.js
index 8259820fcf..fc56e1392e 100644
--- a/src/components/settings_modal/tabs/layout_tab.js
+++ b/src/components/settings_modal/tabs/layout_tab.js
@@ -1,52 +1,51 @@
import { mapState } from 'pinia'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
-import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import UnitSetting from '../helpers/unit_setting.vue'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
const GeneralTab = {
data() {
return {
thirdColumnModeOptions: ['none', 'notifications', 'postform'].map(
(mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.third_column_mode_${mode}`),
}),
),
}
},
components: {
BooleanSetting,
ChoiceSetting,
UnitSetting,
- ProfileSettingIndicator,
},
computed: {
...mapState(useInstanceCapabilitiesStore, [
'postFormats',
'suggestionsEnabled',
]),
columns() {
- const mode = this.$store.getters.mergedConfig.thirdColumnMode
+ const mode = useMergedConfigStore().mergedConfig.thirdColumnMode
const notif = mode === 'none' ? [] : ['notifs']
if (
- this.$store.getters.mergedConfig.sidebarRight ||
+ useMergedConfigStore().mergedConfig.sidebarRight ||
mode === 'postform'
) {
return [...notif, 'content', 'sidebar']
} else {
return ['sidebar', 'content', ...notif]
}
},
...SharedComputedObject(),
},
}
export default GeneralTab
diff --git a/src/components/settings_modal/tabs/layout_tab.vue b/src/components/settings_modal/tabs/layout_tab.vue
index b52c106b99..ca514e0cb3 100644
--- a/src/components/settings_modal/tabs/layout_tab.vue
+++ b/src/components/settings_modal/tabs/layout_tab.vue
@@ -1,122 +1,132 @@
<template>
<div :label="$t('settings.layout')">
<div class="setting-section">
<h3>{{ $t('settings.general') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting path="modalMobileCenter">
{{ $t('settings.mobile_center_dialog') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="alwaysShowNewPostButton"
expert="1"
>
{{ $t('settings.always_show_post_button') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="autohideFloatingPostButton"
expert="1"
>
{{ $t('settings.autohide_floating_post_button') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="userPopoverOverlay"
expert="1"
>
{{ $t('settings.user_popover_avatar_overlay') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="userCardLeftJustify">
{{ $t('settings.user_card_left_justify') }}
</BooleanSetting>
</li>
<li>
<UnitSetting
path="themeEditorMinWidth"
+ :local="true"
:units="['px', 'rem']"
expert="1"
>
{{ $t('settings.theme_editor_min_width') }}
</UnitSetting>
</li>
<li>
<UnitSetting
path="navbarSize"
:step="0.1"
+ :local="true"
:units="['px', 'rem']"
:reset-default="{ 'px': 55, 'rem': 3.5 }"
>
{{ $t('settings.navbar_size') }}
</UnitSetting>
</li>
</ul>
<h3>{{ $t('settings.columns') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting path="disableStickyHeaders">
{{ $t('settings.disable_sticky_headers') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="showScrollbars">
{{ $t('settings.show_scrollbars') }}
</BooleanSetting>
</li>
<li>
<UnitSetting
path="panelHeaderSize"
+ :local="true"
:step="0.1"
:units="['px', 'rem']"
:reset-default="{ 'px': 52, 'rem': 3.2 }"
timed-apply-mode
>
{{ $t('settings.panel_header_size') }}
</UnitSetting>
</li>
<li>
- <BooleanSetting path="sidebarRight">
+ <BooleanSetting
+ :local="true"
+ path="sidebarRight"
+ >
{{ $t('settings.right_sidebar') }}
</BooleanSetting>
</li>
<li>
- <BooleanSetting path="navbarColumnStretch">
+ <BooleanSetting
+ :local="true"
+ path="navbarColumnStretch"
+ >
{{ $t('settings.navbar_column_stretch') }}
</BooleanSetting>
</li>
<li>
<ChoiceSetting
v-if="user"
id="thirdColumnMode"
path="thirdColumnMode"
:options="thirdColumnModeOptions"
>
{{ $t('settings.third_column_mode') }}
</ChoiceSetting>
</li>
<li v-if="expertLevel > 0">
<h4> {{ $t('settings.column_sizes') }} </h4>
<div class="column-settings">
<UnitSetting
v-for="column in columns"
:key="column"
+ :local="true"
:path="column + 'ColumnWidth'"
:units="horizontalUnits"
expert="1"
>
{{ $t('settings.column_sizes_' + column) }}
</UnitSetting>
</div>
</li>
</ul>
</div>
</div>
</template>
<script src="./layout_tab.js"></script>
diff --git a/src/components/settings_modal/tabs/notifications_tab.vue b/src/components/settings_modal/tabs/notifications_tab.vue
index d08f07b68c..ba50f5b21d 100644
--- a/src/components/settings_modal/tabs/notifications_tab.vue
+++ b/src/components/settings_modal/tabs/notifications_tab.vue
@@ -1,294 +1,297 @@
<template>
<div :label="$t('settings.notifications')">
<div class="setting-section">
<h3>{{ $t('settings.notification_setting_annoyance') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting path="closingDrawerMarksAsSeen">
{{ $t('settings.notification_setting_drawer_marks_as_seen') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="ignoreInactionableSeen">
{{ $t('settings.notification_setting_ignore_inactionable_seen') }}
</BooleanSetting>
<p class="sidenote">
{{ $t('settings.notification_setting_ignore_inactionable_seen_tip') }}
</p>
</li>
<li>
<BooleanSetting
path="unseenAtTop"
expert="1"
>
{{ $t('settings.notification_setting_unseen_at_top') }}
</BooleanSetting>
</li>
</ul>
</div>
<div class="setting-section">
<h3>{{ $t('settings.notification_setting_filters') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting
source="profile"
path="blockNotificationsFromStrangers"
>
{{ $t('settings.notification_setting_block_from_strangers') }}
</BooleanSetting>
</li>
<li>
<h4> {{ $t('settings.notification_visibility') }}</h4>
<p
v-if="expertLevel > 0"
class="sidenote"
>
{{ $t('settings.notification_setting_filters_chrome_push') }}
</p>
<ul class="setting-list two-column">
<li>
<h5> {{ $t('settings.notification_visibility_mentions') }}</h5>
<ul class="setting-list">
<li>
<BooleanSetting path="notificationVisibility.mentions">
{{ $t('settings.notification_visibility_in_column') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationNative.mentions">
{{ $t('settings.notification_visibility_native_notifications') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
<h5> {{ $t('settings.notification_visibility_statuses') }}</h5>
<ul class="setting-list">
<li>
<BooleanSetting path="notificationVisibility.statuses">
{{ $t('settings.notification_visibility_in_column') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationNative.statuses">
{{ $t('settings.notification_visibility_native_notifications') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
<h5> {{ $t('settings.notification_visibility_likes') }}</h5>
<ul class="setting-list">
<li>
<BooleanSetting path="notificationVisibility.likes">
{{ $t('settings.notification_visibility_in_column') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationNative.likes">
{{ $t('settings.notification_visibility_native_notifications') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
<h5> {{ $t('settings.notification_visibility_repeats') }}</h5>
<ul class="setting-list">
<li>
<BooleanSetting path="notificationVisibility.repeats">
{{ $t('settings.notification_visibility_in_column') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationNative.repeats">
{{ $t('settings.notification_visibility_native_notifications') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
<h5> {{ $t('settings.notification_visibility_emoji_reactions') }}</h5>
<ul class="setting-list">
<li>
<BooleanSetting path="notificationVisibility.emojiReactions">
{{ $t('settings.notification_visibility_in_column') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationNative.emojiReactions">
{{ $t('settings.notification_visibility_native_notifications') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
<h5> {{ $t('settings.notification_visibility_follows') }}</h5>
<ul class="setting-list">
<li>
<BooleanSetting path="notificationVisibility.follows">
{{ $t('settings.notification_visibility_in_column') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationNative.follows">
{{ $t('settings.notification_visibility_native_notifications') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
<h5> {{ $t('settings.notification_visibility_follow_requests') }}</h5>
<ul class="setting-list">
<li>
<BooleanSetting path="notificationVisibility.followRequest">
{{ $t('settings.notification_visibility_in_column') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationNative.followRequest">
{{ $t('settings.notification_visibility_native_notifications') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
<h5> {{ $t('settings.notification_visibility_moves') }}</h5>
<ul class="setting-list">
<li>
<BooleanSetting path="notificationVisibility.moves">
{{ $t('settings.notification_visibility_in_column') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationNative.moves">
{{ $t('settings.notification_visibility_native_notifications') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
<h5> {{ $t('settings.notification_visibility_polls') }}</h5>
<ul class="setting-list">
<li>
<BooleanSetting path="notificationVisibility.polls">
{{ $t('settings.notification_visibility_in_column') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationNative.polls">
{{ $t('settings.notification_visibility_native_notifications') }}
</BooleanSetting>
</li>
</ul>
</li>
<li v-if="canReceiveReports">
<h5> {{ $t('settings.notification_visibility_reports') }}</h5>
<ul class="setting-list">
<li>
<BooleanSetting path="notificationVisibility.reports">
{{ $t('settings.notification_visibility_in_column') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationNative.reports">
{{ $t('settings.notification_visibility_native_notifications') }}
</BooleanSetting>
</li>
</ul>
</li>
</ul>
</li>
<li>
<BooleanSetting path="showExtraNotifications">
{{ $t('settings.notification_show_extra') }}
</BooleanSetting>
<ul class="setting-list suboptions">
<li>
<BooleanSetting
path="showChatsInExtraNotifications"
:disabled="!mergedConfig.showExtraNotifications"
>
{{ $t('settings.notification_extra_chats') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="showAnnouncementsInExtraNotifications"
:disabled="!mergedConfig.showExtraNotifications"
>
{{ $t('settings.notification_extra_announcements') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="showFollowRequestsInExtraNotifications"
:disabled="!mergedConfig.showExtraNotifications"
>
{{ $t('settings.notification_extra_follow_requests') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="showExtraNotificationsTip"
:disabled="!mergedConfig.showExtraNotifications"
>
{{ $t('settings.notification_extra_tip') }}
</BooleanSetting>
</li>
</ul>
</li>
</ul>
</div>
<div
v-if="expertLevel > 0"
class="setting-section"
>
<h3>{{ $t('settings.notification_setting_privacy') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting
path="webPushNotifications"
+ :local="true"
expert="1"
>
{{ $t('settings.enable_web_push_notifications') }}
</BooleanSetting>
<ul class="setting-list suboptions">
<li>
<BooleanSetting
path="webPushAlwaysShowNotifications"
+ :local="true"
:disabled="!mergedConfig.webPushNotifications"
>
{{ $t('settings.enable_web_push_always_show') }}
</BooleanSetting>
<p
:class="{ faint: !mergedConfig.webPushNotifications }"
class="sidenote"
>
{{ $t('settings.enable_web_push_always_show_tip') }}
</p>
</li>
</ul>
</li>
<li>
<BooleanSetting
source="profile"
+ :local="true"
path="webPushHideContents"
expert="1"
>
{{ $t('settings.notification_setting_hide_notification_contents') }}
</BooleanSetting>
</li>
</ul>
</div>
<p class="sidenote">
<ul>
<li>{{ $t('settings.notification_mutes') }}</li>
<li>{{ $t('settings.notification_blocks') }}</li>
</ul>
</p>
</div>
</template>
<script src="./notifications_tab.js"></script>
<!-- <style lang="scss" src="./profile.scss"></style> -->
diff --git a/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.js b/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.js
index 549803265a..d46ddcb522 100644
--- a/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.js
+++ b/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.js
@@ -1,841 +1,842 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ColorInput from 'src/components/color_input/color_input.vue'
import ContrastRatio from 'src/components/contrast_ratio/contrast_ratio.vue'
import FontControl from 'src/components/font_control/font_control.vue'
import OpacityInput from 'src/components/opacity_input/opacity_input.vue'
import RangeInput from 'src/components/range_input/range_input.vue'
import Select from 'src/components/select/select.vue'
import ShadowControl from 'src/components/shadow_control/shadow_control.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import Preview from './theme_preview.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
import {
getContrastRatioLayers,
hex2rgb,
relativeLuminance,
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,
getScopedVersion,
} from 'src/services/theme_data/css_utils.js'
import { SLOT_INHERITANCE } from 'src/services/theme_data/pleromafe.js'
import {
CURRENT_VERSION,
colors2to3,
DEFAULT_SHADOWS,
generateColors,
generateFonts,
generateRadii,
generateShadows,
getLayers,
getOpacitySlot,
OPACITIES,
shadows2to3,
} from 'src/services/theme_data/theme_data.service.js'
import { init } from 'src/services/theme_data/theme_data_3.service.js'
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js'
// List of color values used in v1
const v1OnlyNames = [
'bg',
'fg',
'text',
'link',
'cRed',
'cGreen',
'cBlue',
'cOrange',
].map((_) => _ + 'ColorLocal')
const colorConvert = (color) => {
if (color.startsWith('--') || color === 'transparent') {
return color
} else {
return hex2rgb(color)
}
}
export default {
data() {
return {
themeImporter: newImporter({
validator: this.importValidator,
onImport: this.onImport,
onImportFailure: this.onImportFailure,
}),
themeExporter: newExporter({
filename: 'pleroma_theme',
getExportedObject: () => this.exportedTheme,
}),
availableStyles: [],
selected: '',
- selectedTheme: this.$store.getters.mergedConfig.theme,
+ selectedTheme: useMergedConfigStore().mergedConfig.theme,
themeWarning: undefined,
tempImportFile: undefined,
engineVersion: 0,
previewTheme: {},
shadowsInvalid: true,
colorsInvalid: true,
radiiInvalid: true,
keepColor: false,
keepShadows: false,
keepOpacity: false,
keepRoundness: false,
keepFonts: false,
...Object.keys(SLOT_INHERITANCE)
.map((key) => [key, ''])
.reduce(
(acc, [key, val]) => ({ ...acc, [key + 'ColorLocal']: val }),
{},
),
...Object.keys(OPACITIES)
.map((key) => [key, ''])
.reduce(
(acc, [key, val]) => ({ ...acc, [key + 'OpacityLocal']: val }),
{},
),
shadowSelected: undefined,
shadowsLocal: {},
fontsLocal: {},
btnRadiusLocal: '',
inputRadiusLocal: '',
checkboxRadiusLocal: '',
panelRadiusLocal: '',
avatarRadiusLocal: '',
avatarAltRadiusLocal: '',
attachmentRadiusLocal: '',
tooltipRadiusLocal: '',
chatMessageRadiusLocal: '',
}
},
created() {
const currentIndex = useInstanceStore().themesIndex
let promise
if (currentIndex) {
promise = Promise.resolve(currentIndex)
} else {
promise = useInterfaceStore().fetchThemesIndex()
}
promise.then((themesIndex) => {
Object.values(themesIndex).forEach((themeFunc) => {
themeFunc().then(
(themeData) => themeData && this.availableStyles.push(themeData),
)
})
})
},
mounted() {
if (typeof this.shadowSelected === 'undefined') {
this.shadowSelected = this.shadowsAvailable[0]
}
},
computed: {
themeWarningHelp() {
if (!this.themeWarning) return
const t = this.$t
const pre = 'settings.style.switcher.help.'
const { origin, themeEngineVersion, type, noActionsPossible } =
this.themeWarning
if (origin === 'file') {
// Loaded v2 theme from file
if (themeEngineVersion === 2 && type === 'wrong_version') {
return t(pre + 'v2_imported')
}
if (themeEngineVersion > CURRENT_VERSION) {
return (
t(pre + 'future_version_imported') +
' ' +
(noActionsPossible
? t(pre + 'snapshot_missing')
: t(pre + 'snapshot_present'))
)
}
if (themeEngineVersion < CURRENT_VERSION) {
return (
t(pre + 'future_version_imported') +
' ' +
(noActionsPossible
? t(pre + 'snapshot_missing')
: t(pre + 'snapshot_present'))
)
}
} else if (origin === 'localStorage') {
if (type === 'snapshot_source_mismatch') {
return t(pre + 'snapshot_source_mismatch')
}
// FE upgraded from v2
if (themeEngineVersion === 2) {
return t(pre + 'upgraded_from_v2')
}
// Admin downgraded FE
if (themeEngineVersion > CURRENT_VERSION) {
return (
t(pre + 'fe_downgraded') +
' ' +
(noActionsPossible
? t(pre + 'migration_snapshot_ok')
: t(pre + 'migration_snapshot_gone'))
)
}
// Admin upgraded FE
if (themeEngineVersion < CURRENT_VERSION) {
return (
t(pre + 'fe_upgraded') +
' ' +
(noActionsPossible
? t(pre + 'migration_snapshot_ok')
: t(pre + 'migration_snapshot_gone'))
)
}
}
},
selectedVersion() {
return Array.isArray(this.selectedTheme) ? 1 : 2
},
currentColors() {
return Object.keys(SLOT_INHERITANCE)
.map((key) => [key, this[key + 'ColorLocal']])
.reduce((acc, [key, val]) => ({ ...acc, [key]: val }), {})
},
currentOpacity() {
return Object.keys(OPACITIES)
.map((key) => [key, this[key + 'OpacityLocal']])
.reduce((acc, [key, val]) => ({ ...acc, [key]: val }), {})
},
currentRadii() {
return {
btn: this.btnRadiusLocal,
input: this.inputRadiusLocal,
checkbox: this.checkboxRadiusLocal,
panel: this.panelRadiusLocal,
avatar: this.avatarRadiusLocal,
avatarAlt: this.avatarAltRadiusLocal,
tooltip: this.tooltipRadiusLocal,
attachment: this.attachmentRadiusLocal,
chatMessage: this.chatMessageRadiusLocal,
}
},
// This needs optimization maybe
previewContrast() {
try {
if (!this.previewTheme.colors.bg) return {}
const colors = this.previewTheme.colors
const opacity = this.previewTheme.opacity
if (!colors.bg) return {}
const hints = (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,
})
const colorsConverted = Object.entries(colors).reduce(
(acc, [key, value]) => ({ ...acc, [key]: colorConvert(value) }),
{},
)
const ratios = Object.entries(SLOT_INHERITANCE).reduce(
(acc, [key, value]) => {
const slotIsBaseText = key === 'text' || key === 'link'
const slotIsText =
slotIsBaseText ||
(typeof value === 'object' && value !== null && value.textColor)
if (!slotIsText) return acc
const { layer, variant } = slotIsBaseText ? { layer: 'bg' } : value
const background = variant || layer
const opacitySlot = getOpacitySlot(background)
const textColors = [
key,
...(background === 'bg'
? ['cRed', 'cGreen', 'cBlue', 'cOrange']
: []),
]
const layers = getLayers(
layer,
variant || layer,
opacitySlot,
colorsConverted,
opacity,
)
// Temporary patch for null-y value errors
if (layers.flat().some((v) => v == null)) return acc
return {
...acc,
...textColors.reduce((acc, textColorKey) => {
const newKey = slotIsBaseText
? 'bg' + textColorKey[0].toUpperCase() + textColorKey.slice(1)
: textColorKey
return {
...acc,
[newKey]: getContrastRatioLayers(
colorsConverted[textColorKey],
layers,
colorsConverted[textColorKey],
),
}
}, {}),
}
},
{},
)
return Object.entries(ratios).reduce((acc, [k, v]) => {
acc[k] = hints(v)
return acc
}, {})
} catch (e) {
console.warn('Failure computing contrasts', e)
return {}
}
},
themeDataUsed() {
return useInterfaceStore().themeDataUsed
},
shadowsAvailable() {
return Object.keys(DEFAULT_SHADOWS).sort()
},
currentShadowOverriden: {
get() {
return !!this.currentShadow
},
set(val) {
if (val) {
this.shadowsLocal[this.shadowSelected] = (
this.currentShadowFallback || []
).map((s) => ({
name: null,
x: 0,
y: 0,
blur: 0,
spread: 0,
inset: false,
color: '#000000',
alpha: 1,
...s,
}))
} else {
delete this.shadowsLocal[this.shadowSelected]
}
},
},
currentShadowFallback() {
return (this.previewTheme.shadows || {})[this.shadowSelected]
},
currentShadow: {
get() {
return this.shadowsLocal[this.shadowSelected]
},
set(v) {
this.shadowsLocal[this.shadowSelected] = v
},
},
themeValid() {
return !this.shadowsInvalid && !this.colorsInvalid && !this.radiiInvalid
},
exportedTheme() {
const saveEverything =
!this.keepFonts &&
!this.keepShadows &&
!this.keepOpacity &&
!this.keepRoundness &&
!this.keepColor
const source = {
themeEngineVersion: CURRENT_VERSION,
}
if (this.keepFonts || saveEverything) {
source.fonts = this.fontsLocal
}
if (this.keepShadows || saveEverything) {
source.shadows = this.shadowsLocal
}
if (this.keepOpacity || saveEverything) {
source.opacity = this.currentOpacity
}
if (this.keepColor || saveEverything) {
source.colors = this.currentColors
}
if (this.keepRoundness || saveEverything) {
source.radii = this.currentRadii
}
const theme = {
themeEngineVersion: CURRENT_VERSION,
...this.previewTheme,
}
return {
// To separate from other random JSON files and possible future source formats
_pleroma_theme_version: 2,
theme,
source,
}
},
isActive() {
const tabSwitcher = this.$parent
return tabSwitcher ? tabSwitcher.isActive('theme') : false
},
},
components: {
ColorInput,
OpacityInput,
RangeInput,
ContrastRatio,
ShadowControl,
FontControl,
TabSwitcher,
Preview,
Checkbox,
Select,
},
methods: {
loadTheme(
{ theme, source, _pleroma_theme_version: fileVersion },
origin,
forceUseSource = false,
) {
this.dismissWarning()
const version =
origin === 'localStorage' && !theme.colors ? 'l1' : fileVersion
const snapshotEngineVersion = (theme || {}).themeEngineVersion
const themeEngineVersion = (source || {}).themeEngineVersion || 2
const versionsMatch = themeEngineVersion === CURRENT_VERSION
const sourceSnapshotMismatch =
theme !== undefined &&
source !== undefined &&
themeEngineVersion !== snapshotEngineVersion
// Force loading of source if user requested it or if snapshot
// is unavailable
const forcedSourceLoad = (source && forceUseSource) || !theme
if (
!(versionsMatch && !sourceSnapshotMismatch) &&
!forcedSourceLoad &&
version !== 'l1' &&
origin !== 'defaults'
) {
if (sourceSnapshotMismatch && origin === 'localStorage') {
this.themeWarning = {
origin,
themeEngineVersion,
type: 'snapshot_source_mismatch',
}
} else if (!theme) {
this.themeWarning = {
origin,
noActionsPossible: true,
themeEngineVersion,
type: 'no_snapshot_old_version',
}
} else if (!versionsMatch) {
this.themeWarning = {
origin,
noActionsPossible: !source,
themeEngineVersion,
type: 'wrong_version',
}
}
}
this.normalizeLocalState(theme, version, source, forcedSourceLoad)
},
forceLoadLocalStorage() {
this.loadThemeFromLocalStorage(true)
},
dismissWarning() {
this.themeWarning = undefined
this.tempImportFile = undefined
},
forceLoad() {
const { origin } = this.themeWarning
switch (origin) {
case 'localStorage':
this.loadThemeFromLocalStorage(true)
break
case 'file':
this.onImport(this.tempImportFile, true)
break
}
this.dismissWarning()
},
forceSnapshot() {
const { origin } = this.themeWarning
switch (origin) {
case 'localStorage':
this.loadThemeFromLocalStorage(false, true)
break
case 'file':
console.error('Forcing snapshot from file is not supported yet')
break
}
this.dismissWarning()
},
loadThemeFromLocalStorage(confirmLoadSource = false) {
const theme = this.themeDataUsed?.source
if (theme) {
this.loadTheme(
{
theme,
},
'localStorage',
confirmLoadSource,
)
}
},
setCustomTheme() {
useInterfaceStore().setThemeV2({
customTheme: {
ignore: true,
themeFileVersion: this.selectedVersion,
themeEngineVersion: CURRENT_VERSION,
...this.previewTheme,
},
customThemeSource: {
themeFileVersion: this.selectedVersion,
themeEngineVersion: CURRENT_VERSION,
shadows: this.shadowsLocal,
fonts: this.fontsLocal,
opacity: this.currentOpacity,
colors: this.currentColors,
radii: this.currentRadii,
},
})
},
updatePreviewColors() {
const result = generateColors({
opacity: this.currentOpacity,
colors: this.currentColors,
})
this.previewTheme.colors = result.theme.colors
this.previewTheme.opacity = result.theme.opacity
},
updatePreviewShadows() {
this.previewTheme.shadows = generateShadows(
{
shadows: this.shadowsLocal,
opacity: this.previewTheme.opacity,
themeEngineVersion: this.engineVersion,
},
this.previewTheme.colors,
relativeLuminance(this.previewTheme.colors.bg) < 0.5 ? 1 : -1,
).theme.shadows
},
importTheme() {
this.themeImporter.importData()
},
exportTheme() {
this.themeExporter.exportData()
},
onImport(parsed, forceSource = false) {
this.tempImportFile = parsed
this.loadTheme(parsed, 'file', forceSource)
},
onImportFailure() {
useInterfaceStore().pushGlobalNotice({
messageKey: 'settings.invalid_theme_imported',
level: 'error',
})
},
importValidator(parsed) {
const version = parsed._pleroma_theme_version
return version >= 1 || version <= 2
},
clearAll() {
this.loadThemeFromLocalStorage()
},
// Clears all the extra stuff when loading V1 theme
clearV1() {
Object.keys(this.$data)
.filter((_) => _.endsWith('ColorLocal') || _.endsWith('OpacityLocal'))
.filter((_) => !v1OnlyNames.includes(_))
.forEach((key) => {
this.$data[key] = undefined
})
},
clearRoundness() {
Object.keys(this.$data)
.filter((_) => _.endsWith('RadiusLocal'))
.forEach((key) => {
this.$data[key] = undefined
})
},
clearOpacity() {
Object.keys(this.$data)
.filter((_) => _.endsWith('OpacityLocal'))
.forEach((key) => {
this.$data[key] = undefined
})
},
clearShadows() {
this.shadowsLocal = {}
},
clearFonts() {
this.fontsLocal = {}
},
/**
* This applies stored theme data onto form. Supports three versions of data:
* v3 (version >= 3) - newest version of themes which supports snapshots for better compatiblity
* v2 (version = 2) - newer version of themes.
* v1 (version = 1) - older version of themes (import from file)
* v1l (version = l1) - older version of theme (load from local storage)
* v1 and v1l differ because of way themes were stored/exported.
* @param {Object} theme - theme data (snapshot)
* @param {Number} version - version of data. 0 means try to guess based on data. "l1" means v1, locastorage type
* @param {Object} source - theme source - this will be used if compatible
* @param {Boolean} source - by default source won't be used if version doesn't match since it might render differently
* this allows importing source anyway
*/
normalizeLocalState(theme, version = 0, source, forceSource = false) {
let input
if (typeof source !== 'undefined') {
if (forceSource || source?.themeEngineVersion === CURRENT_VERSION) {
input = source
version = source.themeEngineVersion
} else {
input = theme
}
} else {
input = theme
}
const radii = input.radii || input
const opacity = input.opacity
const shadows = input.shadows || {}
const fonts = input.fonts || {}
const colors = !input.themeEngineVersion
? colors2to3(input.colors || input)
: input.colors || input
if (version === 0) {
if (input.version) version = input.version
// Old v1 naming: fg is text, btn is foreground
if (
typeof colors.text === 'undefined' &&
typeof colors.fg !== 'undefined'
) {
version = 1
}
// New v2 naming: text is text, fg is foreground
if (
typeof colors.text !== 'undefined' &&
typeof colors.fg !== 'undefined'
) {
version = 2
}
}
this.engineVersion = version
// Stuff that differs between V1 and V2
if (version === 1) {
this.fgColorLocal = rgb2hex(colors.btn)
this.textColorLocal = rgb2hex(colors.fg)
}
if (!this.keepColor) {
this.clearV1()
const keys = new Set(version !== 1 ? Object.keys(SLOT_INHERITANCE) : [])
if (version === 1 || version === 'l1') {
keys
.add('bg')
.add('link')
.add('cRed')
.add('cBlue')
.add('cGreen')
.add('cOrange')
}
keys.forEach((key) => {
const color = colors[key]
const hex = rgb2hex(colors[key])
this[key + 'ColorLocal'] = hex === '#aN' ? color : hex
})
}
if (opacity && !this.keepOpacity) {
this.clearOpacity()
Object.entries(opacity).forEach(([k, v]) => {
if (typeof v === 'undefined' || v === null || Number.isNaN(v)) return
this[k + 'OpacityLocal'] = v
})
}
if (!this.keepRoundness) {
this.clearRoundness()
Object.entries(radii).forEach(([k, v]) => {
// 'Radius' is kept mostly for v1->v2 localstorage transition
const key = k.endsWith('Radius') ? k.split('Radius')[0] : k
this[key + 'RadiusLocal'] = v
})
}
if (!this.keepShadows) {
this.clearShadows()
if (version === 2) {
this.shadowsLocal = shadows2to3(shadows, this.previewTheme.opacity)
} else {
this.shadowsLocal = shadows
}
this.updatePreviewColors()
this.updatePreviewShadows()
this.shadowSelected = this.shadowsAvailable[0]
}
if (!this.keepFonts) {
this.clearFonts()
this.fontsLocal = fonts
}
},
updateTheme3Preview() {
const theme2 = convertTheme2To3(this.previewTheme)
const theme3 = init({
inputRuleset: theme2,
ultimateBackgroundColor: '#000000',
liteMode: true,
})
const sheet = createStyleSheet('theme-tab-overall-preview', 90)
const rule = getScopedVersion(getCssRules(theme3.eager), '&').join('\n')
sheet.clear()
sheet.addRule('#theme-preview {\n' + rule + '\n}')
sheet.ready = true
adoptStyleSheets()
},
},
watch: {
themeDataUsed() {
this.loadThemeFromLocalStorage()
},
currentRadii() {
try {
this.previewTheme.radii = generateRadii({
radii: this.currentRadii,
}).theme.radii
this.radiiInvalid = false
} catch (e) {
this.radiiInvalid = true
console.warn(e)
}
},
shadowsLocal: {
handler() {
try {
this.updatePreviewShadows()
this.shadowsInvalid = false
} catch (e) {
this.shadowsInvalid = true
console.warn(e)
}
},
deep: true,
},
fontsLocal: {
handler() {
try {
this.previewTheme.fonts = generateFonts({
fonts: this.fontsLocal,
}).theme.fonts
this.fontsInvalid = false
} catch (e) {
this.fontsInvalid = true
console.warn(e)
}
},
deep: true,
},
currentColors() {
try {
this.updatePreviewColors()
this.colorsInvalid = false
} catch (e) {
this.colorsInvalid = true
console.warn(e)
}
},
currentOpacity() {
try {
this.updatePreviewColors()
} catch (e) {
console.warn(e)
}
},
selected() {
this.selectedTheme = Object.entries(this.availableStyles).find(
([, s]) => {
if (Array.isArray(s)) {
return s[0] === this.selected
} else {
return s.name === this.selected
}
},
)[1]
},
selectedTheme() {
this.dismissWarning()
if (this.selectedVersion === 1) {
if (!this.keepRoundness) {
this.clearRoundness()
}
if (!this.keepShadows) {
this.clearShadows()
}
if (!this.keepOpacity) {
this.clearOpacity()
}
if (!this.keepColor) {
this.clearV1()
this.bgColorLocal = this.selectedTheme[1]
this.fgColorLocal = this.selectedTheme[2]
this.textColorLocal = this.selectedTheme[3]
this.linkColorLocal = this.selectedTheme[4]
this.cRedColorLocal = this.selectedTheme[5]
this.cGreenColorLocal = this.selectedTheme[6]
this.cBlueColorLocal = this.selectedTheme[7]
this.cOrangeColorLocal = this.selectedTheme[8]
}
} else if (this.selectedVersion >= 2) {
this.normalizeLocalState(
this.selectedTheme.theme,
2,
this.selectedTheme.source,
)
}
},
},
}
diff --git a/src/components/settings_modal/tabs/posts_tab.js b/src/components/settings_modal/tabs/posts_tab.js
index 268a0a56fb..ab00e52e77 100644
--- a/src/components/settings_modal/tabs/posts_tab.js
+++ b/src/components/settings_modal/tabs/posts_tab.js
@@ -1,84 +1,76 @@
import FontControl from 'src/components/font_control/font_control.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
-import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
-const GeneralTab = {
+import { useLocalConfigStore } from 'src/stores/local_config.js'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
+
+const PostsTab = {
data() {
return {
conversationDisplayOptions: ['tree', 'linear'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.conversation_display_${mode}`),
})),
conversationOtherRepliesButtonOptions: ['below', 'inside'].map(
(mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.conversation_other_replies_button_${mode}`),
}),
),
mentionLinkDisplayOptions: ['short', 'full_for_remote', 'full'].map(
(mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.mention_link_display_${mode}`),
}),
),
userPopoverAvatarActionOptions: ['close', 'zoom', 'open'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.user_popover_avatar_action_${mode}`),
})),
unsavedPostActionOptions: ['save', 'discard', 'confirm'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.unsaved_post_action_${mode}`),
})),
loopSilentAvailable:
// Firefox
Object.getOwnPropertyDescriptor(
HTMLVideoElement.prototype,
'mozHasAudio',
) ||
// Chrome-likes
Object.getOwnPropertyDescriptor(
HTMLMediaElement.prototype,
'webkitAudioDecodedByteCount',
) ||
// Future spec, still not supported in Nightly 63 as of 08/2018
Object.getOwnPropertyDescriptor(
HTMLMediaElement.prototype,
'audioTracks',
),
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
FontControl,
- ProfileSettingIndicator,
},
computed: {
...SharedComputedObject(),
},
methods: {
- updateFont(key, value) {
- this.$store.dispatch('setOption', {
- name: 'theme3hacks',
- value: {
- ...this.mergedConfig.theme3hacks,
- fonts: {
- ...this.mergedConfig.theme3hacks.fonts,
- [key]: value,
- },
- },
- })
+ updateFont(path, value) {
+ useLocalConfigStore().set({ path, value })
},
},
}
-export default GeneralTab
+export default PostsTab
diff --git a/src/components/settings_modal/tabs/posts_tab.vue b/src/components/settings_modal/tabs/posts_tab.vue
index 0a864d1794..1e684246d4 100644
--- a/src/components/settings_modal/tabs/posts_tab.vue
+++ b/src/components/settings_modal/tabs/posts_tab.vue
@@ -1,257 +1,263 @@
<template>
<div class="posts-tab">
<div class="setting-section">
<h3>{{ $t('settings.posts_appearance') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting path="collapseMessageWithSubject">
{{ $t('settings.collapse_subject') }}
</BooleanSetting>
</li>
<li>
<ChoiceSetting
id="conversationDisplay"
path="conversationDisplay"
:options="conversationDisplayOptions"
>
{{ $t('settings.conversation_display') }}
</ChoiceSetting>
<ul
v-if="mergedConfig.conversationDisplay !== 'linear'"
class="setting-list suboptions"
>
<li>
<BooleanSetting path="conversationTreeAdvanced">
{{ $t('settings.tree_advanced') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="conversationTreeFadeAncestors"
:expert="1"
>
{{ $t('settings.tree_fade_ancestors') }}
</BooleanSetting>
</li>
<li>
<IntegerSetting
path="maxDepthInThread"
:min="3"
:expert="1"
>
{{ $t('settings.max_depth_in_thread') }}
</IntegerSetting>
</li>
<li>
<ChoiceSetting
id="conversationOtherRepliesButton"
path="conversationOtherRepliesButton"
:options="conversationOtherRepliesButtonOptions"
:expert="1"
>
{{ $t('settings.conversation_other_replies_button') }}
</ChoiceSetting>
</li>
</ul>
</li>
<li>
<FontControl
- :model-value="mergedConfig.theme3hacks.fonts.post"
+ :model-value="mergedConfig.fontPosts"
name="post"
:fallback="{ family: 'inherit' }"
:label="$t('settings.style.fonts.components.post')"
- @update:model-value="v => updateFont('post', v)"
+ @update:model-value="v => updateFont('fontPosts', v)"
/>
</li>
<li>
<FontControl
- :model-value="mergedConfig.theme3hacks.fonts.monospace"
+ :model-value="mergedConfig.fontMonospace"
name="postCode"
:fallback="{ family: 'monospace' }"
:label="$t('settings.style.fonts.components.monospace')"
- @update:model-value="v => updateFont('monospace', v)"
+ @update:model-value="v => updateFont('fontMonospace', v)"
/>
</li>
<li>
<BooleanSetting path="greentext">
<i18n-t
keypath="settings.plaintext_quotes"
tag="span"
>
<span class="greentext">
{{ $t('settings.greentext_quotes') }}
</span>
</i18n-t>
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="emojiReactionsOnTimeline"
expert="1"
>
{{ $t('settings.emoji_reactions_on_timeline') }}
</BooleanSetting>
</li>
</ul>
<h3>{{ $t('settings.mention_links') }}</h3>
<ul class="setting-list">
<li>
<ChoiceSetting
id="mentionLinkDisplay"
path="mentionLinkDisplay"
+ :local="true"
:options="mentionLinkDisplayOptions"
>
{{ $t('settings.mention_link_display') }}
</ChoiceSetting>
<ul class="setting-list suboptions">
<li>
<BooleanSetting
v-if="mergedConfig.mentionLinkDisplay !== 'short'"
path="mentionLinkFadeDomain"
>
{{ $t('settings.mention_link_fade_domain') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
<BooleanSetting
path="mentionLinkShowTooltip"
expert="1"
>
{{ $t('settings.mention_link_use_tooltip') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="mentionLinkShowAvatar">
{{ $t('settings.mention_link_show_avatar') }}
</BooleanSetting>
</li>
<li v-if="user">
<BooleanSetting
path="mentionLinkBoldenYou"
expert="1"
>
{{ $t('settings.mention_link_bolden_you') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
v-if="user"
source="profile"
path="stripRichContent"
expert="1"
>
{{ $t('settings.no_rich_text_description') }}
</BooleanSetting>
<ul
v-if="mergedConfig.useAbsoluteTimeFormat"
class="setting-list suboptions"
>
<li>
<UnitSetting
path="absoluteTimeFormatMinAge"
unit-set="time"
:units="['s', 'm', 'h', 'd']"
:min="0"
>
{{ $t('settings.absolute_time_format_min_age') }}
</UnitSetting>
</li>
</ul>
</li>
</ul>
<h3>{{ $t('settings.attachments') }}</h3>
<ul class="setting-list">
<li>
<BooleanSetting path="stopGifs">
{{ $t('settings.stop_gifs') }}
</BooleanSetting>
</li>
<li>
- <BooleanSetting path="hideNsfw">
+ <BooleanSetting
+ :local="true"
+ path="hideNsfw"
+ >
{{ $t('settings.nsfw_clickthrough') }}
</BooleanSetting>
<ul class="setting-list suboptions">
<li>
<BooleanSetting
path="preloadImage"
expert="1"
+ :local="true"
parent-path="hideNsfw"
>
{{ $t('settings.preload_images') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="useOneClickNsfw"
expert="1"
+ :local="true"
parent-path="hideNsfw"
>
{{ $t('settings.use_one_click_nsfw') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
<BooleanSetting
path="loopVideo"
expert="1"
>
{{ $t('settings.loop_video') }}
</BooleanSetting>
<ul class="setting-list suboptions">
<li>
<BooleanSetting
path="loopVideoSilentOnly"
expert="1"
parent-path="loopVideo"
:disabled="!loopSilentAvailable"
>
{{ $t('settings.loop_video_silent_only') }}
</BooleanSetting>
<div
v-if="!loopSilentAvailable"
class="unavailable"
>
<FAIcon icon="globe" />! {{ $t('settings.limited_availability') }}
</div>
</li>
</ul>
</li>
<li>
<BooleanSetting
path="playVideosInModal"
expert="1"
>
{{ $t('settings.play_videos_in_modal') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="useContainFit"
expert="1"
>
{{ $t('settings.use_contain_fit') }}
</BooleanSetting>
</li>
</ul>
<h3 v-if="expertLevel > 0">
{{ $t('settings.fun') }}
</h3>
<ul
v-if="expertLevel > 0"
class="setting-list"
>
<li v-if="user">
<BooleanSetting path="mentionLinkShowYous">
{{ $t('settings.show_yous') }}
</BooleanSetting>
</li>
</ul>
</div>
</div>
</template>
<script src="./posts_tab.js"></script>
<style src="./posts_tab.scss"></style>
diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js
index acfc0c674b..3e12f265fa 100644
--- a/src/components/settings_modal/tabs/profile_tab.js
+++ b/src/components/settings_modal/tabs/profile_tab.js
@@ -1,59 +1,57 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import UserCard from 'src/components/user_card/user_card.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
-import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faCircleNotch,
faPlus,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(faTimes, faPlus, faCircleNotch)
const ProfileTab = {
data() {
return {
// Whether user is locked or not
locked: this.$store.state.users.currentUser.locked,
}
},
components: {
UserCard,
Checkbox,
BooleanSetting,
- ProfileSettingIndicator,
},
computed: {
user() {
return this.$store.state.users.currentUser
},
...SharedComputedObject(),
},
methods: {
updateProfile() {
const params = {
locked: this.locked,
}
this.$store.state.api.backendInteractor
.updateProfile({ params })
.then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
})
.catch((error) => {
this.displayUploadError(error)
})
},
},
watch: {
locked() {
this.updateProfile()
},
},
}
export default ProfileTab
diff --git a/src/components/settings_modal/tabs/profile_tab.vue b/src/components/settings_modal/tabs/profile_tab.vue
index 80064daf8c..2bdf1e8feb 100644
--- a/src/components/settings_modal/tabs/profile_tab.vue
+++ b/src/components/settings_modal/tabs/profile_tab.vue
@@ -1,92 +1,91 @@
<template>
<div class="profile-tab">
<div class="setting-section profile-edit">
<UserCard
:user-id="user.id"
:editable="true"
:switcher="false"
/>
</div>
<div class="setting-section">
<h3>{{ $t('settings.account_privacy') }}</h3>
<ul class="setting-list">
<li>
<div class="setting-item">
<Checkbox
v-model="locked"
class="setting-label setting-control custom-boolean-setting"
>
- <ProfileSettingIndicator :is-profile="true" />
{{ $t('settings.lock_account_description') }}
</Checkbox>
</div>
</li>
<li>
<BooleanSetting
source="profile"
path="discoverable"
>
{{ $t('settings.discoverable') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
source="profile"
path="allowFollowingMove"
>
{{ $t('settings.allow_following_move') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
source="profile"
path="hideFavorites"
>
{{ $t('settings.hide_favorites_description') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
source="profile"
path="hideFollowers"
>
{{ $t('settings.hide_followers_description') }}
</BooleanSetting>
<ul class="setting-list suboptions">
<li>
<BooleanSetting
source="profile"
path="hideFollowersCount"
parent-path="hideFollowers"
>
{{ $t('settings.hide_followers_count_description') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
<BooleanSetting
source="profile"
path="hideFollows"
>
{{ $t('settings.hide_follows_description') }}
</BooleanSetting>
<ul class="setting-list suboptions">
<li>
<BooleanSetting
source="profile"
path="hideFollowsCount"
parent-path="hideFollows"
>
{{ $t('settings.hide_follows_count_description') }}
</BooleanSetting>
</li>
</ul>
</li>
</ul>
</div>
</div>
</template>
<script src="./profile_tab.js"></script>
<style lang="scss" src="./profile_tab.scss"></style>
diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js
index 05d8e92f5c..fd2157e291 100644
--- a/src/components/side_drawer/side_drawer.js
+++ b/src/components/side_drawer/side_drawer.js
@@ -1,129 +1,133 @@
import { mapActions, mapState } from 'pinia'
import { mapGetters } from 'vuex'
import { USERNAME_ROUTES } from 'src/components/navigation/navigation.js'
import GestureService from '../../services/gesture_service/gesture_service'
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
import UserCard from '../user_card/user_card.vue'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useShoutStore } from 'src/stores/shout'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBell,
faBullhorn,
faCog,
faComments,
faCompass,
faFilePen,
faHome,
faInfoCircle,
faList,
faSearch,
faSignInAlt,
faSignOutAlt,
faTachometerAlt,
faUserPlus,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSignInAlt,
faSignOutAlt,
faHome,
faComments,
faBell,
faUserPlus,
faBullhorn,
faSearch,
faTachometerAlt,
faCog,
faInfoCircle,
faCompass,
faList,
faFilePen,
)
const SideDrawer = {
props: ['logout'],
data: () => ({
closed: true,
closeGesture: undefined,
}),
created() {
this.closeGesture = GestureService.swipeGesture(
GestureService.DIRECTION_LEFT,
this.toggleDrawer,
)
if (this.currentUser && this.currentUser.locked) {
this.$store.dispatch('startFetchingFollowRequests')
}
},
components: { UserCard },
computed: {
currentUser() {
return this.$store.state.users.currentUser
},
shout() {
return useShoutStore().joined
},
unseenNotifications() {
- return unseenNotificationsFromStore(this.$store)
+ return unseenNotificationsFromStore(
+ this.$store,
+ useMergedConfigStore().mergedConfig.notificationVisibility,
+ )
},
unseenNotificationsCount() {
return this.unseenNotifications.length
},
followRequestCount() {
return this.$store.state.api.followRequests.length
},
timelinesRoute() {
let name
if (useInterfaceStore().lastTimeline) {
name = useInterfaceStore().lastTimeline
}
name = this.currentUser ? 'friends' : 'public-timeline'
if (USERNAME_ROUTES.has(name)) {
return { name, params: { username: this.currentUser.screen_name } }
} else {
return { name }
}
},
...mapState(useAnnouncementsStore, [
'supportsAnnouncements',
'unreadAnnouncementCount',
]),
...mapState(useInstanceCapabilitiesStore, [
'pleromaChatMessagesAvailable',
'suggestionsEnabled',
]),
...mapState(useInstanceStore, ['privateMode', 'federating']),
...mapState(useInstanceStore, {
logo: (store) => store.instanceIdentity.logo,
sitename: (store) => store.instanceIdentity.name,
hideSitename: (store) => store.instanceIdentity.hideSitename,
}),
...mapGetters(['unreadChatCount', 'draftCount']),
},
methods: {
toggleDrawer() {
this.closed = !this.closed
},
doLogout() {
this.logout()
this.toggleDrawer()
},
touchStart(e) {
GestureService.beginSwipe(e, this.closeGesture)
},
touchMove(e) {
GestureService.updateSwipe(e, this.closeGesture)
},
...mapActions(useInterfaceStore, ['openSettingsModal']),
},
}
export default SideDrawer
diff --git a/src/components/status/status.js b/src/components/status/status.js
index ddc49b851d..7b4a315611 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -1,665 +1,663 @@
import { unescape as ldUnescape, uniqBy } from 'lodash'
import MentionLink from 'src/components/mention_link/mention_link.vue'
import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import StatusActionButtons from 'src/components/status_action_buttons/status_action_buttons.vue'
import { muteFilterHits } from '../../services/status_parser/status_parser.js'
import {
highlightClass,
highlightStyle,
} from '../../services/user_highlighter/user_highlighter.js'
import AvatarList from '../avatar_list/avatar_list.vue'
import EmojiReactions from '../emoji_reactions/emoji_reactions.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue'
import Quote from '../quote/quote.vue'
import StatusContent from '../status_content/status_content.vue'
import StatusPopover from '../status_popover/status_popover.vue'
import Timeago from '../timeago/timeago.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserLink from '../user_link/user_link.vue'
import UserListPopover from '../user_list_popover/user_list_popover.vue'
import UserPopover from '../user_popover/user_popover.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
-import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
+import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleRight,
faChevronDown,
faChevronUp,
faEllipsisH,
faEnvelope,
faEye,
faEyeSlash,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faPlay,
faPlusSquare,
faReply,
faRetweet,
faSmileBeam,
faStar,
faThumbtack,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faEnvelope,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faTimes,
faRetweet,
faReply,
faPlusSquare,
faStar,
faSmileBeam,
faEllipsisH,
faEyeSlash,
faEye,
faThumbtack,
faChevronUp,
faChevronDown,
faAngleDoubleRight,
faPlay,
)
const camelCase = (name) => name.charAt(0).toUpperCase() + name.slice(1)
const controlledOrUncontrolledGetters = (list) =>
list.reduce((res, name) => {
const camelized = camelCase(name)
const toggle = `controlledToggle${camelized}`
const controlledName = `controlled${camelized}`
const uncontrolledName = `uncontrolled${camelized}`
res[name] = function () {
return (this.$data[toggle] !== undefined ||
this.$props[toggle] !== undefined) &&
this[toggle]
? this[controlledName]
: this[uncontrolledName]
}
return res
}, {})
const controlledOrUncontrolledToggle = (obj, name) => {
const camelized = camelCase(name)
const toggle = `controlledToggle${camelized}`
const uncontrolledName = `uncontrolled${camelized}`
if (obj[toggle]) {
obj[toggle]()
} else {
obj[uncontrolledName] = !obj[uncontrolledName]
}
}
const controlledOrUncontrolledSet = (obj, name, val) => {
const camelized = camelCase(name)
const set = `controlledSet${camelized}`
const uncontrolledName = `uncontrolled${camelized}`
if (obj[set]) {
obj[set](val)
} else {
obj[uncontrolledName] = val
}
}
const Status = {
name: 'Status',
components: {
PostStatusForm,
UserAvatar,
AvatarList,
Timeago,
StatusPopover,
UserListPopover,
EmojiReactions,
StatusContent,
RichContent,
MentionLink,
MentionsLine,
UserPopover,
UserLink,
Quote,
StatusActionButtons,
},
props: [
'statusoid',
+ 'replies',
+
'expandable',
- 'inConversation',
'focused',
'highlight',
'compact',
- 'replies',
'isPreview',
'noHeading',
'inlineExpanded',
'showPinned',
'inProfile',
- 'profileUserId',
+ 'inConversation',
'inQuote',
-
+ 'profileUserId',
'simpleTree',
- 'controlledThreadDisplayStatus',
- 'controlledToggleThreadDisplay',
'showOtherRepliesAsButton',
+ 'dive',
+ 'controlledThreadDisplayStatus',
+ 'controlledToggleThreadDisplay',
'controlledShowingTall',
'controlledToggleShowingTall',
'controlledExpandingSubject',
'controlledToggleExpandingSubject',
'controlledShowingLongSubject',
'controlledToggleShowingLongSubject',
'controlledReplying',
'controlledToggleReplying',
'controlledMediaPlaying',
'controlledSetMediaPlaying',
- 'dive',
],
- emits: ['interacted'],
+ emits: ['interacted', 'goto', 'toggleExpanded'],
data() {
return {
uncontrolledReplying: false,
unmuted: false,
userExpanded: false,
uncontrolledMediaPlaying: [],
suspendable: true,
error: null,
headTailLinks: null,
}
},
computed: {
...controlledOrUncontrolledGetters(['replying', 'mediaPlaying']),
showReasonMutedThread() {
return (
(this.status.thread_muted ||
(this.status.reblog && this.status.reblog.thread_muted)) &&
!this.inConversation
)
},
repeaterClass() {
const user = this.statusoid.user
return highlightClass(user)
},
userClass() {
const user = this.retweet
? this.statusoid.retweeted_status.user
: this.statusoid.user
return highlightClass(user)
},
deleted() {
return this.statusoid.deleted
},
repeaterStyle() {
const user = this.statusoid.user
- const highlight = this.mergedConfig.highlight
- return highlightStyle(highlight[user.screen_name])
+ return highlightStyle(useUserHighlightStore().get(user.screen_name))
},
userStyle() {
if (this.noHeading) return
const user = this.retweet
? this.statusoid.retweeted_status.user
: this.statusoid.user
- const highlight = this.mergedConfig.highlight
- return highlightStyle(highlight[user.screen_name])
+ return highlightStyle(useUserHighlightStore().get(user.screen_name))
},
userProfileLink() {
return this.generateUserProfileLink(
this.status.user.id,
this.status.user.screen_name,
)
},
replyProfileLink() {
if (this.isReply) {
const user = this.$store.getters.findUser(
this.status.in_reply_to_user_id,
)
// FIXME Why user not found sometimes???
return user ? user.statusnet_profile_url : 'NOT_FOUND'
}
},
retweet() {
return !!this.statusoid.retweeted_status
},
retweeterUser() {
return this.statusoid.user
},
retweeter() {
return this.statusoid.user.name || this.statusoid.user.screen_name_ui
},
retweeterHtml() {
return this.statusoid.user.name
},
retweeterProfileLink() {
return this.generateUserProfileLink(
this.statusoid.user.id,
this.statusoid.user.screen_name,
)
},
status() {
if (this.retweet) {
return this.statusoid.retweeted_status
} else {
return this.statusoid
}
},
statusFromGlobalRepository() {
// NOTE: Consider to replace status with statusFromGlobalRepository
return this.$store.state.statuses.allStatusesObject[this.status.id]
},
loggedIn() {
return !!this.currentUser
},
muteFilterHits() {
return muteFilterHits(
- Object.values(
- useServerSideStorageStore().prefsStorage.simple.muteFilters,
- ),
+ Object.values(useSyncConfigStore().prefsStorage.simple.muteFilters),
this.status,
)
},
botStatus() {
return this.status.user.actor_type === 'Service'
},
showActorTypeIndicator() {
return !this.hideBotIndication
},
sensitiveStatus() {
return this.status.nsfw
},
mentionsLine() {
if (!this.headTailLinks) return []
const writtenSet = new Set(
this.headTailLinks.writtenMentions.map((_) => _.url),
)
return this.status.attentions
.filter((attn) => {
// no reply user
return (
attn.id !== this.status.in_reply_to_user_id &&
// no self-replies
attn.statusnet_profile_url !==
this.status.user.statusnet_profile_url &&
// don't include if mentions is written
!writtenSet.has(attn.statusnet_profile_url)
)
})
.map((attn) => ({
url: attn.statusnet_profile_url,
content: attn.screen_name,
userId: attn.id,
}))
},
hasMentionsLine() {
return this.mentionsLine.length > 0
},
muteReasons() {
return [
this.userIsMuted ? 'user' : null,
this.status.thread_muted ? 'thread' : null,
this.muteFilterHits.length > 0 ? 'filtered' : null,
this.muteBotStatuses && this.botStatus ? 'bot' : null,
this.muteSensitiveStatuses && this.sensitiveStatus ? 'nsfw' : null,
].filter((_) => _)
},
muteLocalized() {
if (this.muteReasons.length === 0) return null
const mainReason = () => {
switch (this.muteReasons[0]) {
case 'user':
return this.$t('status.muted_user')
case 'thread':
return this.$t('status.thread_muted')
case 'filtered':
return this.$t(
'status.muted_filters',
{
name: this.muteFilterHits[0].name,
filterMore: this.muteFilterHits.length - 1,
},
this.muteFilterHits.length,
)
case 'bot':
return this.$t('status.bot_muted')
case 'nsfw':
return this.$t('status.sensitive_muted')
}
}
if (this.muteReasons.length > 1) {
return this.$t(
'status.multi_reason_mute',
{
main: mainReason(),
numReasonsMore: this.muteReasons.length - 1,
},
this.muteReasons.length - 1,
)
} else {
return mainReason()
}
},
muted() {
if (this.statusoid.user.id === this.currentUser.id) return false
return !this.unmuted && !this.shouldNotMute && this.muteReasons.length > 0
},
userIsMuted() {
if (this.statusoid.user.id === this.currentUser.id) return false
const { status } = this
const { reblog } = status
const relationship = this.$store.getters.relationship(status.user.id)
const relationshipReblog =
reblog && this.$store.getters.relationship(reblog.user.id)
return (
(status.muted && !status.thread_muted) ||
// Reprööt of a muted post according to BE
(reblog && reblog.muted && !reblog.thread_muted) ||
// Muted user
relationship.muting ||
// Muted user of a reprööt
(relationshipReblog && relationshipReblog.muting)
)
},
shouldNotMute() {
if (this.isFocused) return true
const { status } = this
const { reblog } = status
return (
((this.inProfile &&
// Don't mute user's posts on user timeline (except reblogs)
((!reblog && status.user.id === this.profileUserId) ||
// Same as above but also allow self-reblogs
(reblog && reblog.user.id === this.profileUserId))) ||
// 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.muteFilterHits.length > 0
)
},
hideMutedUsers() {
return this.mergedConfig.hideMutedPosts
},
hideMutedThreads() {
return this.mergedConfig.hideMutedThreads
},
hideFilteredStatuses() {
return this.mergedConfig.hideFilteredStatuses
},
hideWordFilteredPosts() {
return this.mergedConfig.hideWordFilteredPosts
},
hideStatus() {
return (
!this.shouldNotMute &&
((this.muted && this.hideFilteredStatuses) ||
(this.userIsMuted && this.hideMutedUsers) ||
(this.status.thread_muted && this.hideMutedThreads) ||
(this.muteFilterHits.length > 0 && this.hideWordFilteredPosts) ||
this.muteFilterHits.some((x) => x.hide))
)
},
isFocused() {
// retweet or root of an expanded conversation
if (this.focused) {
return true
} else if (!this.inConversation) {
return false
}
// use conversation highlight only when in conversation
return this.status.id === this.highlight
},
isReply() {
return !!(
this.status.in_reply_to_status_id && this.status.in_reply_to_user_id
)
},
replyToName() {
if (this.status.in_reply_to_screen_name) {
return this.status.in_reply_to_screen_name
} else {
const user = this.$store.getters.findUser(
this.status.in_reply_to_user_id,
)
return user && user.screen_name_ui
}
},
replySubject() {
if (!this.status.summary) return ''
const decodedSummary = ldUnescape(this.status.summary)
const behavior = this.mergedConfig.subjectLineBehavior
const startsWithRe = decodedSummary.match(/^re[: ]/i)
if ((behavior !== 'noop' && startsWithRe) || behavior === 'masto') {
return decodedSummary
} else if (behavior === 'email') {
return 're: '.concat(decodedSummary)
} else if (behavior === 'noop') {
return ''
}
},
combinedFavsAndRepeatsUsers() {
// Use the status from the global status repository since favs and repeats are saved in it
const combinedUsers = [].concat(
this.statusFromGlobalRepository.favoritedBy,
this.statusFromGlobalRepository.rebloggedBy,
)
return uniqBy(combinedUsers, 'id')
},
tags() {
return this.status.tags
.filter((tagObj) => Object.hasOwn(tagObj, 'name'))
.map((tagObj) => tagObj.name)
.join(' ')
},
hidePostStats() {
return this.mergedConfig.hidePostStats
},
shouldDisplayFavsAndRepeats() {
return (
!this.hidePostStats &&
this.isFocused &&
(this.combinedFavsAndRepeatsUsers.length > 0 ||
this.statusFromGlobalRepository.quotes_count)
)
},
muteBotStatuses() {
return this.mergedConfig.muteBotStatuses
},
muteSensitiveStatuses() {
return this.mergedConfig.muteSensitiveStatuses
},
hideBotIndication() {
return this.mergedConfig.hideBotIndication
},
currentUser() {
return this.$store.state.users.currentUser
},
mergedConfig() {
- return this.$store.getters.mergedConfig
+ return useMergedConfigStore().mergedConfig
},
isSuspendable() {
return !this.replying && this.mediaPlaying.length === 0
},
inThreadForest() {
return !!this.controlledThreadDisplayStatus
},
threadShowing() {
return this.controlledThreadDisplayStatus === 'showing'
},
visibilityLocalized() {
return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility)
},
isEdited() {
return this.status.edited_at !== null
},
editingAvailable() {
return useInstanceCapabilitiesStore().editingAvailable
},
quoteId() {
return this.status.quote_id
},
quoteUrl() {
return this.status.quote_url
},
quoteVisible() {
return this.status.quote_visible
},
quoteExpanded() {
return !this.inQuote
},
scrobblePresent() {
if (this.mergedConfig.hideScrobbles) return false
if (!this.status.user?.latestScrobble) return false
const value = this.mergedConfig.hideScrobblesAfter.match(/\d+/gs)[0]
const unit = this.mergedConfig.hideScrobblesAfter.match(/\D+/gs)[0]
let multiplier = 60 * 1000 // minutes is smallest unit
switch (unit) {
case 'm':
break
case 'h':
multiplier *= 60 // hour
break
case 'd':
multiplier *= 60 // hour
multiplier *= 24 // day
break
}
const maxAge = Number(value) * multiplier
const createdAt = Date.parse(this.status.user.latestScrobble.created_at)
const age = Date.now() - createdAt
if (age > maxAge) return false
return this.status.user.latestScrobble.artist
},
scrobble() {
return this.status.user?.latestScrobble
},
},
methods: {
visibilityIcon(visibility) {
switch (visibility) {
case 'private':
return 'lock'
case 'unlisted':
return 'lock-open'
case 'direct':
return 'envelope'
case 'local':
return 'igloo'
default:
return 'globe'
}
},
showError(error) {
this.error = error
},
clearError() {
this.$emit('interacted')
this.error = undefined
},
toggleReplying() {
this.$emit('interacted')
if (this.replying) {
this.$refs.postStatusForm.requestClose()
} else {
this.doToggleReplying()
}
},
doToggleReplying() {
controlledOrUncontrolledToggle(this, 'replying')
},
gotoOriginal(id) {
if (this.inConversation) {
this.$emit('goto', id)
}
},
toggleExpanded() {
this.$emit('toggleExpanded')
},
toggleMute() {
this.unmuted = !this.unmuted
},
toggleUserExpanded() {
this.userExpanded = !this.userExpanded
},
generateUserProfileLink(id, name) {
return generateProfileLink(
id,
name,
useInstanceStore().restrictedNicknames,
)
},
addMediaPlaying(id) {
controlledOrUncontrolledSet(
this,
'mediaPlaying',
this.mediaPlaying.concat(id),
)
},
removeMediaPlaying(id) {
controlledOrUncontrolledSet(
this,
'mediaPlaying',
this.mediaPlaying.filter((mediaId) => mediaId !== id),
)
},
setHeadTailLinks(headTailLinks) {
this.headTailLinks = headTailLinks
},
toggleThreadDisplay() {
this.controlledToggleThreadDisplay()
},
scrollIfHighlighted(highlightId) {
if (this.$el.getBoundingClientRect == null) return
const id = highlightId
if (this.status.id === id) {
const rect = this.$el.getBoundingClientRect()
if (rect.top < 100) {
// Post is above screen, match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.height >= window.innerHeight - 50) {
// Post we want to see is taller than screen so match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.bottom > window.innerHeight - 50) {
// Post is below screen, match its bottom to screen bottom
window.scrollBy(0, rect.bottom - window.innerHeight + 50)
}
}
},
},
watch: {
highlight: function (id) {
this.scrollIfHighlighted(id)
},
'status.repeat_num': function (num) {
// refetch repeats when repeat_num is changed in any way
if (
this.isFocused &&
this.statusFromGlobalRepository.rebloggedBy &&
this.statusFromGlobalRepository.rebloggedBy.length !== num
) {
this.$store.dispatch('fetchRepeats', this.status.id)
}
},
'status.fave_num': function (num) {
// refetch favs when fave_num is changed in any way
if (
this.isFocused &&
this.statusFromGlobalRepository.favoritedBy &&
this.statusFromGlobalRepository.favoritedBy.length !== num
) {
this.$store.dispatch('fetchFavs', this.status.id)
}
},
isSuspendable: function (val) {
this.suspendable = val
},
},
}
export default Status
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 1b65b453aa..015a7017c0 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -1,566 +1,567 @@
<template>
<div
v-if="!hideStatus"
ref="root"
class="Status"
:class="[{ '-focused': isFocused }, { '-conversation': inlineExpanded }]"
>
<div
v-if="error"
class="alert error"
>
{{ error }}
- <span
+ <button
class="fa-scale-110 fa-old-padding"
+ type="button"
@click="clearError"
>
<FAIcon icon="times" />
- </span>
+ </button>
</div>
<template v-if="muted && !isPreview">
<div class="status-container muted">
<small class="status-username">
<FAIcon
v-if="muted && retweet"
class="fa-scale-110 fa-old-padding repeat-icon"
icon="retweet"
/>
<user-link
:user="status.user"
:at="false"
/>
</small>
<small class="mute-reason">
{{ muteLocalized }}
</small>
<button
class="unmute button-unstyled"
@click.prevent="toggleMute"
>
<FAIcon
icon="eye-slash"
class="fa-scale-110 fa-old-padding"
/>
</button>
</div>
</template>
<template v-else>
<div
v-if="showPinned"
class="pin"
>
<FAIcon
icon="thumbtack"
class="faint"
/>
<span class="faint">{{ $t('status.pinned') }}</span>
</div>
<div
v-if="retweet && !noHeading && !inConversation"
:class="[repeaterClass, { highlighted: repeaterStyle }]"
:style="[repeaterStyle]"
class="status-container repeat-info"
>
<UserAvatar
v-if="retweet"
class="left-side repeater-avatar"
:show-actor-type-indicator="showActorTypeIndicator"
:user="statusoid.user"
/>
<div class="right-side faint">
<bdi
class="status-username repeater-name"
:title="retweeter"
>
<router-link
v-if="retweeterHtml"
:to="retweeterProfileLink"
>
<RichContent
:html="retweeterHtml"
:emoji="retweeterUser.emoji"
:is-local="retweeterUser.is_local"
/>
</router-link>
<router-link
v-else
:to="retweeterProfileLink"
>{{ retweeter }}</router-link>
</bdi>
{{ ' ' }}
<FAIcon
icon="retweet"
class="repeat-icon"
:title="$t('tool_tip.repeat')"
/>
{{ $t('timeline.repeated') }}
</div>
</div>
<div
v-if="!deleted"
:class="[userClass, { highlighted: userStyle, '-repeat': retweet && !inConversation }]"
:style="[ userStyle ]"
class="status-container"
:data-tags="tags"
>
<div
v-if="!noHeading"
class="left-side"
>
<a
v-if="status.user?.name"
:href="$router.resolve(userProfileLink).href"
@click.prevent
>
<UserPopover
:user-id="status.user.id"
:overlay-centers="true"
>
<UserAvatar
class="post-avatar"
:show-actor-type-indicator="showActorTypeIndicator"
:compact="compact"
:user="status?.user"
/>
</UserPopover>
</a>
<UserAvatar
v-else
:user="status?.user"
class="post-avatar"
:compact="compact"
:title="$t('status.unknown_user_info')"
/>
</div>
<div class="right-side">
<div
v-if="!noHeading"
class="status-heading"
>
<div class="heading-name-row">
<div
v-if="status.user"
class="heading-left"
>
<h4
v-if="status.user.name_html"
class="status-username"
:title="status.user.name"
>
<RichContent
:html="status.user.name"
:emoji="status.user.emoji"
:is-local="status.user.is_local"
/>
</h4>
<h4
v-else
class="status-username"
:title="status.user.name"
>
{{ status.user.name }}
</h4>
<user-link
class="account-name"
:title="status.user.screen_name_ui"
:user="status.user"
:at="false"
/>
<img
v-if="!!(status.user && status.user.favicon)"
class="status-favicon"
:src="status.user.favicon"
>
</div>
<span class="heading-right">
<router-link
class="timeago faint"
:to="{ name: 'conversation', params: { id: status.id } }"
>
<Timeago
:time="status.created_at"
:auto-update="60"
/>
</router-link>
<span
v-if="status.visibility"
class="visibility-icon"
:title="visibilityLocalized"
>
<FAIcon
fixed-width
class="fa-scale-110"
:icon="visibilityIcon(status.visibility)"
/>
</span>
<button
v-if="expandable && !isPreview"
class="button-unstyled"
:title="$t('status.expand')"
@click.prevent="toggleExpanded"
>
<FAIcon
fixed-width
class="fa-scale-110"
icon="plus-square"
/>
</button>
<button
v-if="unmuted"
class="button-unstyled"
@click.prevent="toggleMute"
>
<FAIcon
fixed-width
icon="eye-slash"
class="fa-scale-110"
/>
</button>
<button
v-if="inThreadForest && replies && replies.length && !simpleTree"
class="button-unstyled"
:title="threadShowing ? $t('status.thread_hide') : $t('status.thread_show')"
:aria-expanded="threadShowing ? 'true' : 'false'"
@click.prevent="toggleThreadDisplay"
>
<FAIcon
fixed-width
class="fa-scale-110"
:icon="threadShowing ? 'chevron-up' : 'chevron-down'"
/>
</button>
<button
v-if="dive && !simpleTree"
class="button-unstyled"
:title="$t('status.show_only_conversation_under_this')"
@click.prevent="dive"
>
<FAIcon
fixed-width
class="fa-scale-110"
:icon="'angle-double-right'"
/>
</button>
</span>
</div>
<div
v-if="scrobblePresent"
class="status-rich-presence"
>
<a
v-if="scrobble.externalLink"
:href="scrobble.externalLink"
target="_blank"
>
{{ scrobble.artist }} — {{ scrobble.title }}
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="play"
/>
<span class="status-rich-presence-time">
<Timeago
template-key="time.in_past"
:time="scrobble.created_at"
:auto-update="60"
/>
</span>
</a>
<span v-if="!scrobble.externalLink">
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="music"
/>
{{ scrobble.artist }} — {{ scrobble.title }}
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="play"
/>
<span class="status-rich-presence-time">
<Timeago
template-key="time.in_past"
:time="scrobble.created_at"
:auto-update="60"
/>
</span>
</span>
</div>
<div
v-if="isReply || hasMentionsLine"
class="heading-reply-row"
>
<span
v-if="isReply"
class="glued-label reply-glued-label"
>
<i18n-t
keypath="status.reply_to_with_arg"
scope="global"
>
<template #replyToWithIcon>
<StatusPopover
v-if="!isPreview"
:status-id="status.parent_visible && status.in_reply_to_status_id"
class="reply-to-popover"
style="min-width: 0;"
:class="{ '-strikethrough': !status.parent_visible }"
>
<button
class="button-unstyled reply-to"
:aria-label="$t('tool_tip.reply')"
@click.prevent="gotoOriginal(status.in_reply_to_status_id)"
>
<i18n-t
keypath="status.reply_to_with_icon"
scope="global"
>
<template #icon>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="reply"
flip="horizontal"
/>
</template>
<template #replyTo>
<span
class="reply-to-text"
>
{{ $t('status.reply_to') }}
</span>
</template>
</i18n-t>
</button>
</StatusPopover>
<span
v-else
class="reply-to-no-popover"
>
<span class="reply-to-text">{{ $t('status.reply_to') }}</span>
</span>
</template>
<template #user>
<MentionLink
:content="replyToName"
:url="replyProfileLink"
:user-id="status.in_reply_to_user_id"
:user-screen-name="status.in_reply_to_screen_name"
/>
</template>
</i18n-t>
</span>
<!-- This little wrapper is made for sole purpose of "gluing" -->
<!-- "Mentions" label to the first mention -->
<span
v-if="hasMentionsLine"
class="glued-label"
>
<span
class="mentions"
:aria-label="$t('tool_tip.mentions')"
@click.prevent="gotoOriginal(status.in_reply_to_status_id)"
>
<span
class="mentions-text"
>
{{ $t('status.mentions') }}
</span>
</span>
<MentionsLine
v-if="hasMentionsLine"
:mentions="mentionsLine.slice(0, 1)"
class="mentions-line-first"
/>
</span>
{{ ' ' }}
<MentionsLine
v-if="hasMentionsLine"
:mentions="mentionsLine.slice(1)"
class="mentions-line"
/>
</div>
<div
v-if="isEdited && editingAvailable && !isPreview"
class="heading-edited-row"
>
<i18n-t
scope="global"
keypath="status.edited_at"
tag="span"
>
<template #time>
<Timeago
template-key="time.in_past"
:time="status.edited_at"
:auto-update="60"
:long-format="true"
/>
</template>
</i18n-t>
</div>
</div>
<StatusContent
ref="content"
:status="status"
:no-heading="noHeading"
:highlight="highlight"
:focused="isFocused"
:controlled-showing-tall="controlledShowingTall"
:controlled-expanding-subject="controlledExpandingSubject"
:controlled-showing-long-subject="controlledShowingLongSubject"
:controlled-toggle-showing-tall="controlledToggleShowingTall"
:controlled-toggle-expanding-subject="controlledToggleExpandingSubject"
:controlled-toggle-showing-long-subject="controlledToggleShowingLongSubject"
@mediaplay="addMediaPlaying($event)"
@mediapause="removeMediaPlaying($event)"
@parse-ready="setHeadTailLinks"
/>
<Quote
:status-id="quoteId"
:status-url="quoteUrl"
:status-visible="quoteVisible"
:initially-expanded="quoteExpanded"
/>
<div
v-if="inConversation && !isPreview && replies && replies.length"
class="replies"
>
<button
v-if="showOtherRepliesAsButton && replies.length > 1"
class="button-unstyled -link"
:title="$t('status.ancestor_follow', { numReplies: replies.length - 1 }, replies.length - 1)"
@click.prevent="dive"
>
{{ $t('status.replies_list_with_others', { numReplies: replies.length - 1 }, replies.length - 1) }}
</button>
<span
v-else
class="faint"
>
{{ $t('status.replies_list') }}
</span>
<StatusPopover
v-for="reply in replies"
:key="reply.id"
:status-id="reply.id"
>
<button
class="button-unstyled -link reply-link"
@click.prevent="gotoOriginal(reply.id)"
>
{{ reply.name }}
</button>
</StatusPopover>
</div>
<transition name="fade">
<div
v-if="shouldDisplayFavsAndRepeats"
class="favs-repeated-users"
>
<div class="stats">
<UserListPopover
v-if="statusFromGlobalRepository.rebloggedBy && statusFromGlobalRepository.rebloggedBy.length > 0"
:users="statusFromGlobalRepository.rebloggedBy"
>
<div class="stat-count">
<a class="stat-title">{{ $t('status.repeats') }}</a>
<div class="stat-number">
{{ statusFromGlobalRepository.rebloggedBy.length }}
</div>
</div>
</UserListPopover>
<UserListPopover
v-if="statusFromGlobalRepository.favoritedBy && statusFromGlobalRepository.favoritedBy.length > 0"
:users="statusFromGlobalRepository.favoritedBy"
>
<div
class="stat-count"
>
<a class="stat-title">{{ $t('status.favorites') }}</a>
<div class="stat-number">
{{ statusFromGlobalRepository.favoritedBy.length }}
</div>
</div>
</UserListPopover>
<router-link
v-if="statusFromGlobalRepository.quotes_count > 0"
:to="{ name: 'quotes', params: { id: status.id } }"
>
<div
class="stat-count"
>
<a class="stat-title">{{ $t('status.quotes') }}</a>
<div class="stat-number">
{{ statusFromGlobalRepository.quotes_count }}
</div>
</div>
</router-link>
<div class="avatar-row">
<AvatarList :users="combinedFavsAndRepeatsUsers" />
</div>
</div>
</div>
</transition>
<EmojiReactions
v-if="(mergedConfig.emojiReactionsOnTimeline || isFocused) && (!noHeading && !isPreview)"
:status="status"
/>
<StatusActionButtons
v-if="!noHeading && !isPreview"
:status="status"
:replying="replying"
@toggle-replying="toggleReplying"
@interacted="e => $emit('interacted')"
/>
</div>
</div>
<div
v-else
class="gravestone"
>
<div class="left-side">
<UserAvatar
class="post-avatar"
:compact="compact"
:show-actor-type-indicator="showActorTypeIndicator"
/>
</div>
<div class="right-side">
<div class="deleted-text">
{{ $t('status.status_deleted') }}
</div>
</div>
</div>
<div
v-if="replying"
class="status-container reply-form"
>
<PostStatusForm
ref="postStatusForm"
class="reply-body"
:closeable="true"
:reply-to="status.id"
:attentions="status.attentions"
:replied-user="status.user"
:copy-message-scope="status.visibility"
:subject="replySubject"
@posted="doToggleReplying"
@draft-done="doToggleReplying"
@can-close="doToggleReplying"
/>
</div>
</template>
</div>
</template>
<script src="./status.js"></script>
<style src="./status.scss" lang="scss"></style>
diff --git a/src/components/status_action_buttons/action_button.vue b/src/components/status_action_buttons/action_button.vue
index a9aedb9e3c..cab055e64f 100644
--- a/src/components/status_action_buttons/action_button.vue
+++ b/src/components/status_action_buttons/action_button.vue
@@ -1,109 +1,109 @@
<template>
<div
class="action-button"
:class="buttonClass"
>
<component
:is="getComponent(button)"
class="action-button-inner"
:class="buttonInnerClass"
role="menuitem"
type="button"
:title="$t(button.label(funcArg))"
target="_blank"
:tabindex="0"
:disabled="button.interactive ? !button.interactive(funcArg) : false"
:href="getComponent(button) == 'a' ? button.link?.(funcArg) || remoteInteractionLink : undefined"
@click="doActionWrap(button, outerClose)"
>
<FALayers>
<FAIcon
class="fa-scale-110"
:icon="button.icon(funcArg)"
:spin="!extra && getComponent(button) == 'button' && button.animated?.() && animationState"
:style="{ '--fa-animation-duration': '750ms' }"
fixed-width
/>
<template v-if="!buttonClass.disabled && (!button.interactive || button?.interactive(funcArg)) && button.toggleable?.(funcArg) && button.active">
<FAIcon
v-if="button.active(funcArg)"
class="active-marker"
transform="shrink-6 up-9 right-15"
:icon="button.activeIndicator?.(funcArg) || 'check'"
/>
<FAIcon
v-if="!button.active(funcArg)"
class="focus-marker"
transform="shrink-6 up-9 right-15"
:icon="button.openIndicator?.(funcArg) || 'plus'"
/>
<FAIcon
v-else
class="focus-marker"
transform="shrink-6 up-9 right-15"
:icon="button.closeIndicator?.(funcArg) || 'minus'"
/>
</template>
</FALayers>
<span
v-if="extra"
class="action-label"
>
{{ $t(button.label(funcArg)) }}
</span>
<FAIcon
v-if="button.dropdown?.()"
class="chevron-icon"
size="lg"
:icon="extra ? 'chevron-right' : 'chevron-up'"
fixed-width
/>
</component>
<span
v-if="button.counter?.(funcArg) > 0"
class="action-counter"
>
{{ button.counter?.(funcArg) }}
</span>
<span
v-if="!extra && button.name === 'bookmark'"
class="separator"
/>
<Popover
v-if="button.name === 'bookmark'"
:trigger="extra ? 'hover' : 'click'"
:placement="extra ? 'right' : 'top'"
:offset="extra ? { x: 10 } : { y: 10 }"
:trigger-attrs="{ class: 'extra-button' }"
>
<template #trigger>
<FAIcon
class="chevron-icon"
size="lg"
:icon="extra ? 'chevron-right' : 'chevron-up'"
fixed-width
/>
</template>
<template #content="{close}">
<StatusBookmarkFolderMenu
v-if="button.name === 'bookmark'"
:status="status"
- :close="() => { close(); outerClose() }"
+ @close="() => { close(); outerClose?.() }"
/>
</template>
</Popover>
<EmojiPicker
v-if="button.name === 'emoji'"
ref="picker"
:enable-sticker-picker="false"
:hide-custom-emoji="hideCustomEmoji"
class="emoji-picker-panel"
@emoji="addReaction"
/>
</div>
</template>
<script src="./action_button.js" />
<style lang="scss" src="./action_button.scss" />
diff --git a/src/components/status_action_buttons/buttons_definitions.js b/src/components/status_action_buttons/buttons_definitions.js
index bf03a9ab92..294293a401 100644
--- a/src/components/status_action_buttons/buttons_definitions.js
+++ b/src/components/status_action_buttons/buttons_definitions.js
@@ -1,291 +1,292 @@
import { useEditStatusStore } from 'src/stores/editStatus.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useReportsStore } from 'src/stores/reports.js'
import { useStatusHistoryStore } from 'src/stores/statusHistory.js'
const PRIVATE_SCOPES = new Set(['private', 'direct'])
const PUBLIC_SCOPES = new Set(['public', 'unlisted'])
export const BUTTONS = [
{
// =========
// REPLY
// =========
name: 'reply',
label: 'tool_tip.reply',
icon: 'reply',
active: ({ replying }) => replying,
counter: ({ status }) => status.replies_count,
anon: true,
anonLink: true,
toggleable: true,
closeIndicator: 'times',
activeIndicator: 'none',
action({ emit }) {
emit('toggleReplying')
return Promise.resolve()
},
},
{
// =========
// REPEAT
// =========
name: 'retweet',
label: ({ status }) =>
status.repeated ? 'tool_tip.unrepeat' : 'tool_tip.repeat',
icon({ status, currentUser }) {
if (
currentUser.id !== status.user.id &&
PRIVATE_SCOPES.has(status.visibility)
) {
return 'lock'
}
return 'retweet'
},
animated: true,
active: ({ status }) => status.repeated,
counter: ({ status }) => status.repeat_num,
anonLink: true,
interactive: ({ status, currentUser }) =>
!!currentUser &&
(currentUser.id === status.user.id ||
!PRIVATE_SCOPES.has(status.visibility)),
toggleable: true,
confirm: ({ status, getters }) =>
- !status.repeated && getters.mergedConfig.modalOnRepeat,
+ !status.repeated && useMergedConfigStore().mergedConfig.modalOnRepeat,
confirmStrings: {
title: 'status.repeat_confirm_title',
body: 'status.repeat_confirm',
confirm: 'status.repeat_confirm_accept_button',
cancel: 'status.repeat_confirm_cancel_button',
},
action({ status, dispatch }) {
if (!status.repeated) {
return dispatch('retweet', { id: status.id })
} else {
return dispatch('unretweet', { id: status.id })
}
},
},
{
// =========
// FAVORITE
// =========
name: 'favorite',
label: ({ status }) =>
status.favorited ? 'tool_tip.unfavorite' : 'tool_tip.favorite',
icon: ({ status }) =>
status.favorited ? ['fas', 'star'] : ['far', 'star'],
animated: true,
active: ({ status }) => status.favorited,
counter: ({ status }) => status.fave_num,
anonLink: true,
toggleable: true,
action({ status, dispatch }) {
if (!status.favorited) {
return dispatch('favorite', { id: status.id })
} else {
return dispatch('unfavorite', { id: status.id })
}
},
},
{
// =========
// EMOJI REACTIONS
// =========
name: 'emoji',
label: 'tool_tip.add_reaction',
icon: ['far', 'smile-beam'],
anonLink: true,
},
{
// =========
// MUTE
// =========
name: 'mute',
icon: 'eye-slash',
label: 'status.mute_ellipsis',
if: ({ loggedIn }) => loggedIn,
toggleable: false,
dropdown: true,
action({ status, dispatch, emit }) {
/* prevent hiding */
},
},
{
// =========
// PIN STATUS
// =========
name: 'pin',
icon: 'thumbtack',
label: ({ status }) => (status.pinned ? 'status.unpin' : 'status.pin'),
if({ status, loggedIn, currentUser }) {
return (
loggedIn &&
status.user.id === currentUser.id &&
PUBLIC_SCOPES.has(status.visibility)
)
},
action({ status, dispatch }) {
if (status.pinned) {
return dispatch('unpinStatus', status.id)
} else {
return dispatch('pinStatus', status.id)
}
},
},
{
// =========
// BOOKMARK
// =========
name: 'bookmark',
icon: ({ status }) =>
status.bookmarked ? ['fas', 'bookmark'] : ['far', 'bookmark'],
toggleable: true,
active: ({ status }) => status.bookmarked,
label: ({ status }) =>
status.bookmarked ? 'status.unbookmark' : 'status.bookmark',
if: ({ loggedIn }) => loggedIn,
action({ status, dispatch }) {
if (status.bookmarked) {
return dispatch('unbookmark', { id: status.id })
} else {
return dispatch('bookmark', { id: status.id })
}
},
},
{
// =========
// EDIT HISTORY
// =========
name: 'editHistory',
icon: 'history',
label: 'status.status_history',
if({ status, state }) {
return (
useInstanceCapabilitiesStore().editingAvailable &&
status.edited_at !== null
)
},
action({ status }) {
const originalStatus = { ...status }
const stripFieldsList = [
'attachments',
'created_at',
'emojis',
'text',
'raw_html',
'nsfw',
'poll',
'summary',
'summary_raw_html',
]
stripFieldsList.forEach((p) => delete originalStatus[p])
useStatusHistoryStore().openStatusHistoryModal(originalStatus)
return Promise.resolve()
},
},
{
// =========
// EDIT
// =========
name: 'edit',
icon: 'pen',
label: 'status.edit',
if({ status, loggedIn, currentUser, state }) {
return (
loggedIn &&
useInstanceCapabilitiesStore().editingAvailable &&
status.user.id === currentUser.id
)
},
action({ dispatch, status }) {
return dispatch('fetchStatusSource', { id: status.id }).then((data) =>
useEditStatusStore().openEditStatusModal({
statusId: status.id,
subject: data.spoiler_text,
statusText: data.text,
statusIsSensitive: status.nsfw,
statusPoll: status.poll,
statusFiles: [...status.attachments],
visibility: status.visibility,
statusContentType: data.content_type,
}),
)
},
},
{
// =========
// DELETE
// =========
name: 'delete',
icon: 'times',
label: 'status.delete',
if({ status, loggedIn, currentUser }) {
return (
loggedIn &&
(status.user.id === currentUser.id ||
currentUser.privileges.includes('messages_delete'))
)
},
- confirm: ({ getters }) => getters.mergedConfig.modalOnDelete,
+ confirm: ({ getters }) => useMergedConfigStore().mergedConfig.modalOnDelete,
confirmStrings: {
title: 'status.delete_confirm_title',
body: 'status.delete_confirm',
confirm: 'status.delete_confirm_accept_button',
cancel: 'status.delete_confirm_cancel_button',
},
action({ dispatch, status }) {
return dispatch('deleteStatus', { id: status.id })
},
},
{
// =========
// SHARE/COPY
// =========
name: 'share',
icon: 'share-alt',
label: 'status.copy_link',
action({ state, status, router }) {
navigator.clipboard.writeText(
[
useInstanceStore().server,
router.resolve({ name: 'conversation', params: { id: status.id } })
.href,
].join(''),
)
return Promise.resolve()
},
},
{
// =========
// EXTERNAL
// =========
name: 'external',
icon: 'external-link-alt',
label: 'status.external_source',
link: ({ status }) => status.external_url,
},
{
// =========
// REPORT
// =========
name: 'report',
icon: 'flag',
label: 'user_card.report',
if: ({ loggedIn }) => loggedIn,
action({ status }) {
return useReportsStore().openUserReportingModal({
userId: status.user.id,
statusIds: [status.id],
})
},
},
].map((button) => {
return Object.fromEntries(
Object.entries(button).map(([k, v]) => [
k,
typeof v === 'function' || k === 'name' ? v : () => v,
]),
)
})
diff --git a/src/components/status_action_buttons/status_action_buttons.js b/src/components/status_action_buttons/status_action_buttons.js
index 2e08797c80..9938e63823 100644
--- a/src/components/status_action_buttons/status_action_buttons.js
+++ b/src/components/status_action_buttons/status_action_buttons.js
@@ -1,150 +1,150 @@
import { mapState } from 'pinia'
import ConfirmModal from 'src/components/confirm_modal/confirm_modal.vue'
import Popover from 'src/components/popover/popover.vue'
import ActionButtonContainer from './action_button_container.vue'
import { BUTTONS } from './buttons_definitions.js'
-import { useServerSideStorageStore } from 'src/stores/serverSideStorage.js'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
import genRandomSeed from 'src/services/random_seed/random_seed.service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faEllipsisH } from '@fortawesome/free-solid-svg-icons'
library.add(faEllipsisH)
const StatusActionButtons = {
props: ['status', 'replying'],
emits: ['toggleReplying', 'interacted'],
data() {
return {
showPin: false,
showingConfirmDialog: false,
currentConfirmTitle: '',
currentConfirmOkText: '',
currentConfirmCancelText: '',
currentConfirmAction: () => {
/* no-op */
},
randomSeed: genRandomSeed(),
}
},
components: {
Popover,
ConfirmModal,
ActionButtonContainer,
},
computed: {
- ...mapState(useServerSideStorageStore, {
+ ...mapState(useSyncConfigStore, {
pinnedItems: (store) =>
new Set(store.prefsStorage.collections.pinnedStatusActions),
}),
buttons() {
return BUTTONS.filter((x) => (x.if ? x.if(this.funcArg) : true))
},
quickButtons() {
return this.buttons.filter((x) => this.pinnedItems.has(x.name))
},
extraButtons() {
return this.buttons.filter((x) => !this.pinnedItems.has(x.name))
},
currentUser() {
return this.$store.state.users.currentUser
},
funcArg() {
return {
status: this.status,
replying: this.replying,
emit: this.$emit,
dispatch: this.$store.dispatch,
state: this.$store.state,
getters: this.$store.getters,
router: this.$router,
currentUser: this.currentUser,
loggedIn: !!this.currentUser,
}
},
triggerAttrs() {
return {
title: this.$t('status.more_actions'),
'aria-controls': `popup-menu-${this.randomSeed}`,
'aria-haspopup': 'menu',
}
},
},
methods: {
doAction(button) {
if (button.confirm?.(this.funcArg)) {
// TODO move to action_button
this.currentConfirmTitle = this.$t(
button.confirmStrings(this.funcArg).title,
)
this.currentConfirmOkText = this.$t(
button.confirmStrings(this.funcArg).confirm,
)
this.currentConfirmCancelText = this.$t(
button.confirmStrings(this.funcArg).cancel,
)
this.currentConfirmBody = this.$t(
button.confirmStrings(this.funcArg).body,
)
this.currentConfirmAction = () => {
this.showingConfirmDialog = false
this.doActionReal(button)
}
this.showingConfirmDialog = true
} else {
this.doActionReal(button)
}
},
doActionReal(button) {
button
.action?.(this.funcArg)
.then(() => this.$emit('onSuccess'))
.catch((err) => this.$emit('onError', err.error.error))
},
onExtraClose() {
this.showPin = false
},
isPinned(button) {
return this.pinnedItems.has(button.name)
},
unpin(button) {
- useServerSideStorageStore().removeCollectionPreference({
+ useSyncConfigStore().removeCollectionPreference({
path: 'collections.pinnedStatusActions',
value: button.name,
})
- useServerSideStorageStore().pushServerSideStorage()
+ useSyncConfigStore().pushSyncConfig()
},
pin(button) {
- useServerSideStorageStore().addCollectionPreference({
+ useSyncConfigStore().addCollectionPreference({
path: 'collections.pinnedStatusActions',
value: button.name,
})
- useServerSideStorageStore().pushServerSideStorage()
+ useSyncConfigStore().pushSyncConfig()
},
getComponent(button) {
if (!this.$store.state.users.currentUser && button.anonLink) {
return 'a'
} else if (button.action == null && button.link != null) {
return 'a'
} else {
return 'button'
}
},
getClass(button) {
return {
[button.name + '-button']: true,
disabled: button.interactive
? !button.interactive(this.funcArg)
: false,
'-pin-edit': this.showPin,
'-dropdown': button.dropdown?.(),
'-active': button.active?.(this.funcArg),
}
},
},
}
export default StatusActionButtons
diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js
index 731fcc3902..5250acb9f2 100644
--- a/src/components/status_body/status_body.js
+++ b/src/components/status_body/status_body.js
@@ -1,162 +1,162 @@
-import { mapGetters } from 'vuex'
+import { mapState } from 'pinia'
import RichContent from 'src/components/rich_content/rich_content.jsx'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faFile,
faImage,
faLink,
faMusic,
faPollH,
} from '@fortawesome/free-solid-svg-icons'
library.add(faFile, faMusic, faImage, faLink, faPollH)
const StatusBody = {
name: 'StatusBody',
props: [
'compact',
'collapse', // replaces newlines with spaces
'status',
'focused',
'noHeading',
'fullContent',
'singleLine',
'showingTall',
'expandingSubject',
'showingLongSubject',
'toggleShowingTall',
'toggleExpandingSubject',
'toggleShowingLongSubject',
],
data() {
return {
postLength: this.status.text.length,
parseReadyDone: false,
}
},
emits: ['parseReady'],
computed: {
localCollapseSubjectDefault() {
return this.mergedConfig.collapseMessageWithSubject
},
// This is a bit hacky, but we want to approximate post height before rendering
// so we count newlines (masto uses <p> for paragraphs, GS uses <br> between them)
// as well as approximate line count by counting characters and approximating ~80
// per line.
//
// Using max-height + overflow: auto for status components resulted in false positives
// very often with japanese characters, and it was very annoying.
tallStatus() {
if (this.singleLine || this.compact) return false
const lengthScore =
this.status.raw_html.split(/<p|<br/).length + this.postLength / 80
return lengthScore > 20
},
longSubject() {
return this.status.summary.length > 240
},
// When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status.
mightHideBecauseSubject() {
return !!this.status.summary && this.localCollapseSubjectDefault
},
mightHideBecauseTall() {
return (
this.tallStatus &&
!(this.status.summary && this.localCollapseSubjectDefault)
)
},
hideSubjectStatus() {
return this.mightHideBecauseSubject && !this.expandingSubject
},
hideTallStatus() {
return this.mightHideBecauseTall && !this.showingTall
},
shouldShowToggle() {
return this.mightHideBecauseSubject || this.mightHideBecauseTall
},
toggleButtonClasses() {
return {
'cw-status-hider': !this.showingMore && this.mightHideBecauseSubject,
'tall-status-hider': !this.showingMore && this.mightHideBecauseTall,
'status-unhider': this.showingMore,
}
},
toggleText() {
if (this.showingMore) {
return this.mightHideBecauseSubject
? this.$t('status.hide_content')
: this.$t('general.show_less')
} else {
return this.mightHideBecauseSubject
? this.$t('status.show_content')
: this.$t('general.show_more')
}
},
showingMore() {
return (
(this.mightHideBecauseTall && this.showingTall) ||
(this.mightHideBecauseSubject && this.expandingSubject)
)
},
attachmentTypes() {
- return this.status.attachments.map((file) =>
- file.type,
- )
+ return this.status.attachments.map((file) => file.type)
},
collapsedStatus() {
return this.status.raw_html.replace(/(\n|<br\s?\/?>)/g, ' ')
},
- ...mapGetters(['mergedConfig']),
+ ...mapState(useMergedConfigStore, ['mergedConfig']),
},
components: {
RichContent,
},
mounted() {
this.status.attentions &&
this.status.attentions.forEach((attn) => {
const { id } = attn
this.$store.dispatch('fetchUserIfMissing', id)
})
},
methods: {
onParseReady(event) {
if (this.parseReadyDone) return
this.parseReadyDone = true
this.$emit('parseReady', event)
const { writtenMentions, invisibleMentions } = event
writtenMentions
.filter((mention) => !mention.notifying)
.forEach((mention) => {
const { content, url } = mention
const cleanedString = content.replace(/<[^>]+?>/gi, '') // remove all tags
if (!cleanedString.startsWith('@')) return
const handle = cleanedString.slice(1)
const host = url.replace(/^https?:\/\//, '').replace(/\/.+?$/, '')
this.$store.dispatch('fetchUserIfMissing', `${handle}@${host}`)
})
/* This is a bit of a hack to make current tall status detector work
* with rich mentions. Invisible mentions are detected at RichContent level
* and also we generate plaintext version of mentions by stripping tags
* so here we subtract from post length by each mention that became invisible
* via MentionsLine
*/
this.postLength = invisibleMentions.reduce((acc, mention) => {
return acc - mention.textContent.length - 1
}, this.postLength)
},
toggleShowMore() {
if (this.mightHideBecauseTall) {
this.toggleShowingTall()
} else if (this.mightHideBecauseSubject) {
this.toggleExpandingSubject()
}
},
generateTagLink(tag) {
return `/tag/${tag}`
},
},
}
export default StatusBody
diff --git a/src/components/status_bookmark_folder_menu/status_bookmark_folder_menu.js b/src/components/status_bookmark_folder_menu/status_bookmark_folder_menu.js
index a3c97ffe6a..7e92a02878 100644
--- a/src/components/status_bookmark_folder_menu/status_bookmark_folder_menu.js
+++ b/src/components/status_bookmark_folder_menu/status_bookmark_folder_menu.js
@@ -1,42 +1,43 @@
import { mapState } from 'pinia'
import Popover from 'src/components/popover/popover.vue'
import StillImage from 'src/components/still-image/still-image.vue'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronRight, faFolder } from '@fortawesome/free-solid-svg-icons'
library.add(faChevronRight, faFolder)
const StatusBookmarkFolderMenu = {
- props: ['status', 'close'],
+ props: ['status'],
+ emits: ['success', 'error', 'close'],
data() {
return {}
},
components: {
Popover,
StillImage,
},
computed: {
...mapState(useBookmarkFoldersStore, {
folders: (store) => store.allFolders,
}),
folderId() {
return this.status.bookmark_folder_id
},
},
methods: {
toggleFolder(id) {
const value = id === this.folderId ? null : id
this.$store
.dispatch('bookmark', { id: this.status.id, bookmark_folder_id: value })
- .then(() => this.$emit('onSuccess'))
- .catch((err) => this.$emit('onError', err.error.error))
+ .then(() => this.$emit('success'))
+ .catch((err) => this.$emit('error', err.error.error))
},
},
}
export default StatusBookmarkFolderMenu
diff --git a/src/components/status_bookmark_folder_menu/status_bookmark_folder_menu.vue b/src/components/status_bookmark_folder_menu/status_bookmark_folder_menu.vue
index 006edbb809..818de095e9 100644
--- a/src/components/status_bookmark_folder_menu/status_bookmark_folder_menu.vue
+++ b/src/components/status_bookmark_folder_menu/status_bookmark_folder_menu.vue
@@ -1,35 +1,35 @@
<template>
<div class="dropdown-menu bookmark-folder-menu">
<div
v-for="folder in folders"
:key="folder.id"
class="menu-item dropdown-item -icon-double"
>
<button
class="main-button"
@click="toggleFolder(folder.id)"
- @click.stop="close"
+ @click.stop="$emit('close')"
>
<span
class="input menu-checkbox -radio"
:class="{ 'menu-checkbox-checked': status.bookmark_folder_id == folder.id }"
/>
<StillImage
v-if="folder.emoji_url"
:src="folder.emoji_url"
class="emoji"
/>
<span
v-else
class="iconLetter fa-scale-110 fa-old-padding"
>
{{ folder.name[0] }}
</span>
{{ ' ' + folder.name }}
</button>
</div>
</div>
</template>
<script src="./status_bookmark_folder_menu.js"></script>
<style src="./status_bookmark_folder_menu.scss" />
diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js
index a00792826d..a72db5b96a 100644
--- a/src/components/status_content/status_content.js
+++ b/src/components/status_content/status_content.js
@@ -1,157 +1,159 @@
+import { mapState as mapPiniaState } from 'pinia'
import { mapGetters, mapState } from 'vuex'
import StatusBody from 'src/components/status_body/status_body.vue'
import Attachment from '../attachment/attachment.vue'
import Gallery from '../gallery/gallery.vue'
import LinkPreview from '../link-preview/link-preview.vue'
import Poll from '../poll/poll.vue'
import { useMediaViewerStore } from 'src/stores/media_viewer.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faCircleNotch,
faFile,
faImage,
faLink,
faMusic,
faPollH,
} from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch, faFile, faMusic, faImage, faLink, faPollH)
const camelCase = (name) => name.charAt(0).toUpperCase() + name.slice(1)
const controlledOrUncontrolledGetters = (list) =>
list.reduce((res, name) => {
const camelized = camelCase(name)
const toggle = `controlledToggle${camelized}`
const controlledName = `controlled${camelized}`
const uncontrolledName = `uncontrolled${camelized}`
res[name] = function () {
return (this.$data[toggle] !== undefined ||
this.$props[toggle] !== undefined) &&
this[toggle]
? this[controlledName]
: this[uncontrolledName]
}
return res
}, {})
const controlledOrUncontrolledToggle = (obj, name) => {
const camelized = camelCase(name)
const toggle = `controlledToggle${camelized}`
const uncontrolledName = `uncontrolled${camelized}`
if (obj[toggle]) {
obj[toggle]()
} else {
obj[uncontrolledName] = !obj[uncontrolledName]
}
}
const StatusContent = {
name: 'StatusContent',
props: [
'status',
'compact',
'collapse',
'focused',
'noHeading',
'fullContent',
'singleLine',
'controlledShowingTall',
'controlledExpandingSubject',
'controlledToggleShowingTall',
'controlledToggleExpandingSubject',
'controlledShowingLongSubject',
'controlledToggleShowingLongSubject',
],
emits: ['parseReady', 'mediaplay', 'mediapause'],
data() {
return {
uncontrolledShowingTall:
this.fullContent || (this.inConversation && this.focused),
uncontrolledShowingLongSubject: false,
// not as computed because it sets the initial state which will be changed later
uncontrolledExpandingSubject:
- !this.$store.getters.mergedConfig.collapseMessageWithSubject,
+ !useMergedConfigStore().mergedConfig.collapseMessageWithSubject,
}
},
computed: {
...controlledOrUncontrolledGetters([
'showingTall',
'expandingSubject',
'showingLongSubject',
]),
statusCard() {
if (!this.status.card) return null
return this.status.card.url === this.status.quote_url
? null
: this.status.card
},
hideAttachments() {
return (
(this.mergedConfig.hideAttachments && !this.inConversation) ||
(this.mergedConfig.hideAttachmentsInConv && this.inConversation)
)
},
nsfwClickthrough() {
if (!this.status.nsfw) {
return false
}
if (this.status.summary && this.localCollapseSubjectDefault) {
return false
}
return true
},
localCollapseSubjectDefault() {
return this.mergedConfig.collapseMessageWithSubject
},
attachmentSize() {
if (this.compact) {
return 'small'
} else if (
(this.mergedConfig.hideAttachments && !this.inConversation) ||
(this.mergedConfig.hideAttachmentsInConv && this.inConversation) ||
this.status.attachments.length > this.maxThumbnails
) {
return 'hide'
}
return 'normal'
},
maxThumbnails() {
return this.mergedConfig.maxThumbnails
},
- ...mapGetters(['mergedConfig']),
+ ...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
...mapState({
currentUser: (state) => state.users.currentUser,
}),
},
components: {
Attachment,
Poll,
Gallery,
LinkPreview,
StatusBody,
},
methods: {
toggleShowingTall() {
controlledOrUncontrolledToggle(this, 'showingTall')
},
toggleExpandingSubject() {
controlledOrUncontrolledToggle(this, 'expandingSubject')
},
toggleShowingLongSubject() {
controlledOrUncontrolledToggle(this, 'showingLongSubject')
},
setMedia() {
const attachments =
this.attachmentSize === 'hide'
? this.status.attachments
: this.galleryAttachments
return () => useMediaViewerStore().setMedia(attachments)
},
},
}
export default StatusContent
diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js
index e61aa572fe..29a4ed1ded 100644
--- a/src/components/still-image/still-image.js
+++ b/src/components/still-image/still-image.js
@@ -1,75 +1,77 @@
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+
const StillImage = {
props: [
'src',
'referrerpolicy',
'mimetype',
'imageLoadError',
'imageLoadHandler',
'alt',
'height',
'width',
'dataSrc',
'loading',
],
data() {
return {
// for lazy loading, see loadLazy()
realSrc: this.src,
- stopGifs: this.$store.getters.mergedConfig.stopGifs,
+ stopGifs: useMergedConfigStore().mergedConfig.stopGifs,
}
},
computed: {
animated() {
if (!this.realSrc) {
return false
}
return (
this.stopGifs &&
(this.mimetype === 'image/gif' || this.realSrc.endsWith('.gif'))
)
},
style() {
const appendPx = (str) => (/\d$/.test(str) ? str + 'px' : str)
return {
height: this.height ? appendPx(this.height) : null,
width: this.width ? appendPx(this.width) : null,
}
},
},
methods: {
loadLazy() {
if (this.dataSrc) {
this.realSrc = this.dataSrc
}
},
onLoad() {
if (!this.realSrc) {
return
}
const image = this.$refs.src
if (!image) return
this.imageLoadHandler && this.imageLoadHandler(image)
const canvas = this.$refs.canvas
if (!canvas) return
const width = image.naturalWidth
const height = image.naturalHeight
canvas.width = width
canvas.height = height
canvas.getContext('2d').drawImage(image, 0, 0, width, height)
},
onError() {
this.imageLoadError && this.imageLoadError()
},
},
watch: {
src() {
this.realSrc = this.src
},
dataSrc() {
this.$el.removeAttribute('data-loaded')
},
},
}
export default StillImage
diff --git a/src/components/timeago/timeago.js b/src/components/timeago/timeago.js
index efb92d4cd7..c09cf96194 100644
--- a/src/components/timeago/timeago.js
+++ b/src/components/timeago/timeago.js
@@ -1,127 +1,129 @@
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+
import * as DateUtils from 'src/services/date_utils/date_utils.js'
import localeService from 'src/services/locale/locale.service.js'
export default {
name: 'Timeago',
props: ['time', 'autoUpdate', 'longFormat', 'nowThreshold', 'templateKey'],
data() {
return {
relativeTimeMs: 0,
relativeTime: { key: 'time.now', num: 0 },
interval: null,
}
},
computed: {
shouldUseAbsoluteTimeFormat() {
- if (!this.$store.getters.mergedConfig.useAbsoluteTimeFormat) {
+ if (!useMergedConfigStore().mergedConfig.useAbsoluteTimeFormat) {
return false
}
return (
DateUtils.durationStrToMs(
- this.$store.getters.mergedConfig.absoluteTimeFormatMinAge,
+ useMergedConfigStore().mergedConfig.absoluteTimeFormatMinAge,
) <= this.relativeTimeMs
)
},
time12hFormat() {
- return this.$store.getters.mergedConfig.absoluteTimeFormat12h === '12h'
+ return useMergedConfigStore().mergedConfig.absoluteTimeFormat12h === '12h'
},
browserLocale() {
return localeService.internalToBrowserLocale(this.$i18n.locale)
},
timeAsDate() {
return typeof this.time === 'string'
? new Date(Date.parse(this.time))
: this.time
},
localeDateString() {
return this.timeAsDate.toLocaleString(this.browserLocale)
},
relativeTimeString() {
const timeString = this.$i18n.t(
this.relativeTime.key,
[this.relativeTime.num],
this.relativeTime.num,
)
if (
typeof this.templateKey === 'string' &&
this.relativeTime.key !== 'time.now'
) {
return this.$i18n.t(this.templateKey, [timeString])
}
return timeString
},
absoluteTimeString() {
if (this.longFormat) {
return this.localeDateString
}
const now = new Date()
const formatter = (() => {
if (DateUtils.isSameDay(this.timeAsDate, now)) {
return new Intl.DateTimeFormat(this.browserLocale, {
minute: 'numeric',
hour: 'numeric',
hour12: this.time12hFormat,
})
} else if (DateUtils.isSameMonth(this.timeAsDate, now)) {
return new Intl.DateTimeFormat(this.browserLocale, {
month: 'short',
day: 'numeric',
hour12: this.time12hFormat,
})
} else if (DateUtils.isSameYear(this.timeAsDate, now)) {
return new Intl.DateTimeFormat(this.browserLocale, {
month: 'short',
day: 'numeric',
hour12: this.time12hFormat,
})
} else {
return new Intl.DateTimeFormat(this.browserLocale, {
year: 'numeric',
month: 'short',
hour12: this.time12hFormat,
})
}
})()
return formatter.format(this.timeAsDate)
},
relativeOrAbsoluteTimeString() {
return this.shouldUseAbsoluteTimeFormat
? this.absoluteTimeString
: this.relativeTimeString
},
},
watch: {
time(newVal, oldVal) {
if (oldVal !== newVal) {
clearTimeout(this.interval)
this.refreshRelativeTimeObject()
}
},
},
created() {
this.refreshRelativeTimeObject()
},
unmounted() {
clearTimeout(this.interval)
},
methods: {
refreshRelativeTimeObject() {
const nowThreshold =
typeof this.nowThreshold === 'number' ? this.nowThreshold : 1
this.relativeTimeMs = DateUtils.relativeTimeMs(this.time)
this.relativeTime = this.longFormat
? DateUtils.relativeTime(this.time, nowThreshold)
: DateUtils.relativeTimeShort(this.time, nowThreshold)
if (this.autoUpdate) {
this.interval = setTimeout(
this.refreshRelativeTimeObject,
1000 * this.autoUpdate,
)
}
},
},
}
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index 4616a41117..2153afc443 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -1,337 +1,341 @@
import { debounce, keyBy, throttle } from 'lodash'
import { mapState } from 'pinia'
import Conversation from '../conversation/conversation.vue'
import QuickFilterSettings from '../quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from '../quick_view_settings/quick_view_settings.vue'
import ScrollTopButton from '../scroll_top_button/scroll_top_button.vue'
import Status from '../status/status.vue'
import TimelineMenu from '../timeline_menu/timeline_menu.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
import timelineFetcher from 'src/services/timeline_fetcher/timeline_fetcher.service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faArrowUp,
faCheck,
faCircleNotch,
faCirclePlus,
faCog,
faMinus,
} from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch, faCog, faMinus, faArrowUp, faCirclePlus, faCheck)
const Timeline = {
props: [
'timeline',
'timelineName',
'title',
'userId',
'listId',
'statusId',
'bookmarkFolderId',
'tag',
'embedded',
'count',
'pinnedStatusIds',
'inProfile',
'footerSlipgate', // reference to an element where we should put our footer
],
data() {
return {
showScrollTop: false,
paused: false,
unfocused: false,
bottomedOut: false,
virtualScrollIndex: 0,
blockingClicks: false,
}
},
components: {
Status,
ScrollTopButton,
Conversation,
TimelineMenu,
QuickFilterSettings,
QuickViewSettings,
},
computed: {
filteredVisibleStatuses() {
return this.timeline.visibleStatuses.filter(
(status) =>
this.timelineName !== 'user' ||
(status.id >= this.timeline.minId &&
status.id <= this.timeline.maxId),
)
},
filteredPinnedStatusIds() {
return (this.pinnedStatusIds || []).filter(
(statusId) => this.timeline.statusesObject[statusId],
)
},
newStatusCount() {
return this.timeline.newStatusCount
},
showLoadButton() {
return this.timeline.newStatusCount > 0 || this.timeline.flushMarker !== 0
},
loadButtonString() {
if (this.timeline.flushMarker !== 0) {
return this.$t('timeline.reload')
} else {
return `${this.$t('timeline.show_new')} (${this.newStatusCount})`
}
},
mobileLoadButtonString() {
if (this.timeline.flushMarker !== 0) {
return '+'
} else {
return this.newStatusCount > 99 ? '∞' : this.newStatusCount
}
},
classes() {
let rootClasses = !this.embedded
? ['panel', 'panel-default']
: ['-embedded']
if (this.blockingClicks)
rootClasses = rootClasses.concat(['-blocked', '_misclick-prevention'])
return {
root: rootClasses,
header: ['timeline-heading'].concat(
!this.embedded ? ['panel-heading', '-sticky'] : ['panel-body'],
),
body: ['timeline-body'].concat(
!this.embedded ? ['panel-body'] : ['panel-body'],
),
footer: ['timeline-footer'].concat(
!this.embedded ? ['panel-footer'] : ['panel-body'],
),
}
},
// id map of statuses which need to be hidden in the main list due to pinning logic
pinnedStatusIdsObject() {
return keyBy(this.pinnedStatusIds)
},
statusesToDisplay() {
const amount = this.timeline.visibleStatuses.length
const statusesPerSide = Math.ceil(Math.max(3, window.innerHeight / 80))
const nonPinnedIndex =
this.virtualScrollIndex - this.filteredPinnedStatusIds.length
const min = Math.max(0, nonPinnedIndex - statusesPerSide)
const max = Math.min(amount, nonPinnedIndex + statusesPerSide)
return this.timeline.visibleStatuses.slice(min, max).map((_) => _.id)
},
virtualScrollingEnabled() {
- return this.$store.getters.mergedConfig.virtualScrolling
+ return useMergedConfigStore().mergedConfig.virtualScrolling
},
...mapState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile',
}),
},
created() {
const store = this.$store
const credentials = store.state.users.currentUser.credentials
const showImmediately = this.timeline.visibleStatuses.length === 0
window.addEventListener('scroll', this.handleScroll)
if (store.state.api.fetchers[this.timelineName]) {
return false
}
timelineFetcher.fetchAndUpdate({
store,
credentials,
timeline: this.timelineName,
showImmediately,
userId: this.userId,
listId: this.listId,
statusId: this.statusId,
bookmarkFolderId: this.bookmarkFolderId,
tag: this.tag,
})
},
mounted() {
if (typeof document.hidden !== 'undefined') {
document.addEventListener(
'visibilitychange',
this.handleVisibilityChange,
false,
)
this.unfocused = document.hidden
}
window.addEventListener('keydown', this.handleShortKey)
setTimeout(this.determineVisibleStatuses, 250)
},
unmounted() {
window.removeEventListener('scroll', this.handleScroll)
window.removeEventListener('keydown', this.handleShortKey)
if (typeof document.hidden !== 'undefined')
document.removeEventListener(
'visibilitychange',
this.handleVisibilityChange,
false,
)
this.$store.commit('setLoading', {
timeline: this.timelineName,
value: false,
})
},
methods: {
stopBlockingClicks: debounce(function () {
this.blockingClicks = false
}, 1000),
blockClicksTemporarily() {
if (!this.blockingClicks) {
this.blockingClicks = true
}
this.stopBlockingClicks()
},
handleShortKey(e) {
// Ignore when input fields are focused
if (['textarea', 'input'].includes(e.target.tagName.toLowerCase())) return
if (e.key === '.') this.showNewStatuses()
},
showNewStatuses() {
if (this.timeline.flushMarker !== 0) {
this.$store.commit('clearTimeline', {
timeline: this.timelineName,
excludeUserId: true,
})
this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 })
if (this.timelineName === 'user') {
this.$store.dispatch('fetchPinnedStatuses', this.userId)
}
this.fetchOlderStatuses()
} else {
this.blockClicksTemporarily()
this.$store.commit('showNewStatuses', { timeline: this.timelineName })
this.paused = false
}
window.scrollTo({ top: 0 })
},
fetchOlderStatuses: throttle(
function () {
const store = this.$store
const credentials = store.state.users.currentUser.credentials
store.commit('setLoading', { timeline: this.timelineName, value: true })
timelineFetcher
.fetchAndUpdate({
store,
credentials,
timeline: this.timelineName,
older: true,
showImmediately: true,
userId: this.userId,
listId: this.listId,
statusId: this.statusId,
bookmarkFolderId: this.bookmarkFolderId,
tag: this.tag,
})
.then(({ statuses }) => {
if (statuses && statuses.length === 0) {
this.bottomedOut = true
}
})
.finally(() =>
store.commit('setLoading', {
timeline: this.timelineName,
value: false,
}),
)
},
1000,
this,
),
determineVisibleStatuses() {
if (!this.$refs.timeline) return
if (!this.virtualScrollingEnabled) return
const statuses = this.$refs.timeline.children
const cappedScrollIndex = Math.max(
0,
Math.min(this.virtualScrollIndex, statuses.length - 1),
)
if (statuses.length === 0) return
const height = Math.max(document.body.offsetHeight, window.pageYOffset)
const centerOfScreen = window.pageYOffset + window.innerHeight * 0.5
// Start from approximating the index of some visible status by using the
// the center of the screen on the timeline.
let approxIndex = Math.floor(statuses.length * (centerOfScreen / height))
let err = statuses[approxIndex].getBoundingClientRect().y
// if we have a previous scroll index that can be used, test if it's
// closer than the previous approximation, use it if so
const virtualScrollIndexY =
statuses[cappedScrollIndex].getBoundingClientRect().y
if (Math.abs(err) > virtualScrollIndexY) {
approxIndex = cappedScrollIndex
err = virtualScrollIndexY
}
// if the status is too far from viewport, check the next/previous ones if
// they happen to be better
while (err < -20 && approxIndex < statuses.length - 1) {
err += statuses[approxIndex].offsetHeight
approxIndex++
}
while (err > window.innerHeight + 100 && approxIndex > 0) {
approxIndex--
err -= statuses[approxIndex].offsetHeight
}
// this status is now the center point for virtual scrolling and visible
// statuses will be nearby statuses before and after it
this.virtualScrollIndex = approxIndex
},
scrollLoad() {
const bodyBRect = document.body.getBoundingClientRect()
const height = Math.max(bodyBRect.height, -bodyBRect.y)
if (
this.timeline.loading === false &&
this.$el.offsetHeight > 0 &&
window.innerHeight + window.pageYOffset >= height - 750
) {
this.fetchOlderStatuses()
}
},
handleScroll: throttle(function (e) {
this.determineVisibleStatuses()
this.scrollLoad(e)
}, 200),
handleVisibilityChange() {
this.unfocused = document.hidden
},
},
watch: {
newStatusCount(count) {
- if (!this.$store.getters.mergedConfig.streaming) {
+ if (!useMergedConfigStore().mergedConfig.streaming) {
return
}
if (count > 0) {
// only 'stream' them when you're scrolled to the top
const doc = document.documentElement
const top = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0)
if (
top < 15 &&
!this.paused &&
- !(this.unfocused && this.$store.getters.mergedConfig.pauseOnUnfocused)
+ !(
+ this.unfocused &&
+ useMergedConfigStore().mergedConfig.pauseOnUnfocused
+ )
) {
this.showNewStatuses()
} else {
this.paused = true
}
}
},
},
}
export default Timeline
diff --git a/src/components/update_notification/update_notification.js b/src/components/update_notification/update_notification.js
index a8bc606769..2a9fb7f60b 100644
--- a/src/components/update_notification/update_notification.js
+++ b/src/components/update_notification/update_notification.js
@@ -1,89 +1,90 @@
import Modal from 'src/components/modal/modal.vue'
import { useInstanceStore } from 'src/stores/instance.js'
-import { useServerSideStorageStore } from 'src/stores/serverSideStorage.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
import pleromaTanFoxMask from 'src/assets/pleromatan_apology_fox_mask.png'
import pleromaTanMask from 'src/assets/pleromatan_apology_mask.png'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faTimes } from '@fortawesome/free-solid-svg-icons'
library.add(faTimes)
-export const CURRENT_UPDATE_COUNTER = 1
+export const CURRENT_UPDATE_COUNTER = 2
const pleromaTan = '/static/pleromatan_apology.png'
const pleromaTanFox = '/static/pleromatan_apology_fox.png'
const UpdateNotification = {
data() {
return {
showingImage: false,
pleromaTanVariant: Math.random() > 0.5 ? pleromaTan : pleromaTanFox,
showingMore: false,
}
},
components: {
Modal,
},
computed: {
pleromaTanStyles() {
const mask =
this.pleromaTanVariant === pleromaTan
? pleromaTanMask
: pleromaTanFoxMask
return {
'shape-outside': 'url(' + mask + ')',
}
},
shouldShow() {
return (
!useInstanceStore().disableUpdateNotification &&
this.$store.state.users.currentUser &&
- useServerSideStorageStore().flagStorage.updateCounter <
+ useSyncConfigStore().flagStorage.updateCounter <
CURRENT_UPDATE_COUNTER &&
- !useServerSideStorageStore().prefsStorage.simple.dontShowUpdateNotifs
+ !useMergedConfigStore().mergedConfig.dontShowUpdateNotifs
)
},
},
methods: {
toggleShow() {
this.showingMore = !this.showingMore
},
neverShowAgain() {
this.toggleShow()
- useServerSideStorageStore().setFlag({
+ useSyncConfigStore().setFlag({
flag: 'updateCounter',
value: CURRENT_UPDATE_COUNTER,
})
- useServerSideStorageStore().setPreference({
- path: 'simple.dontShowUpdateNotifs',
+ useSyncConfigStore().setSimplePrefAndSave({
+ path: 'dontShowUpdateNotifs',
value: true,
})
- useServerSideStorageStore().pushServerSideStorage()
+ useSyncConfigStore().pushSyncConfig()
},
dismiss() {
- useServerSideStorageStore().setFlag({
+ useSyncConfigStore().setFlag({
flag: 'updateCounter',
value: CURRENT_UPDATE_COUNTER,
})
- useServerSideStorageStore().pushServerSideStorage()
+ useSyncConfigStore().pushSyncConfig()
},
},
mounted() {
this.contentHeightNoImage = this.$refs.animatedText.scrollHeight
// Workaround to get the text height only after mask loaded. A bit hacky.
const newImg = new Image()
newImg.onload = () => {
setTimeout(() => {
this.showingImage = true
}, 100)
}
newImg.src =
this.pleromaTanVariant === pleromaTan ? pleromaTanMask : pleromaTanFoxMask
},
}
export default UpdateNotification
diff --git a/src/components/update_notification/update_notification.scss b/src/components/update_notification/update_notification.scss
index 23ca3ba2e5..3c154eb332 100644
--- a/src/components/update_notification/update_notification.scss
+++ b/src/components/update_notification/update_notification.scss
@@ -1,113 +1,140 @@
.UpdateNotification {
overflow: hidden;
}
.UpdateNotificationModal {
--__top-fringe: 15em; // how much pleroma-tan should stick her head above
--__bottom-fringe: 80em; // just reserving as much as we can, number is mostly irrelevant
--__right-fringe: 8em;
+ --__panel-background: rgb(0,255,255 / 80%);
font-size: 15px;
position: relative;
transition: transform;
transition-timing-function: ease-in-out;
transition-duration: 500ms;
.text {
max-width: 40em;
padding-left: 1em;
}
+ .footer-buttons {
+ --panel-heading-height-padding: calc(var(--panel-header-height) * 0.2);
+ --__panel-heading-gap: calc(var(--panel-header-height) * 0.1565);
+ --__panel-heading-height: var(--panel-header-height);
+ --__panel-heading-height-inner: calc(var(--__panel-heading-height) - 2 * var(--panel-heading-height-padding, 0));
+
+ box-sizing: border-box;
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ display: grid;
+ z-index: 20;
+ grid-auto-flow: column;
+ grid-template-columns: auto;
+ grid-auto-columns: auto;
+ grid-column-gap: var(--__panel-heading-gap);
+ padding: var(--panel-heading-height-padding);
+ height: var(--__panel-heading-height);
+ line-height: var(--__panel-heading-height-inner);
+ font-size: calc(var(--panelHeaderSize) / 3.2);
+ }
+
@media all and (width <= 800px) {
/* For mobile, the modal takes 100% of the available screen.
This ensures the minimized modal is always 50px above the browser
bottom bar regardless of whether or not it is visible.
*/
width: 100vw;
}
@media all and (height <= 600px) {
display: none;
}
.content {
overflow: hidden;
margin-top: calc(-1 * var(--__top-fringe));
margin-bottom: calc(-1 * var(--__bottom-fringe));
margin-right: calc(-1 * var(--__right-fringe));
&.-noImage {
.text {
padding-right: var(--__right-fringe);
}
}
}
.panel-body {
border-width: 0 0 1px;
border-style: solid;
border-color: var(--border);
}
.panel-footer {
- z-index: 22;
+ z-index: 10;
position: relative;
border-width: 0;
- grid-template-columns: auto;
+
+ .button-default {
+ z-index: 2;
+ }
}
.pleroma-tan {
object-fit: cover;
object-position: top;
transition: position, left, right, top, bottom, max-width, max-height;
transition-timing-function: ease-in-out;
transition-duration: 500ms;
width: 25em;
float: right;
z-index: 20;
position: relative;
shape-margin: 0.5em;
filter: drop-shadow(5px 5px 10px rgb(0 0 0 / 50%));
pointer-events: none;
}
.spacer-top {
min-height: var(--__top-fringe);
}
.spacer-bottom {
- min-height: var(--__bottom-fringe);
+ min-height: calc(var(--__bottom-fringe) + var(--panel-header-height));
}
.extra-info-group {
transition: max-height, padding, height;
transition-timing-function: ease-in;
transition-duration: 700ms;
max-height: 70vh;
mask:
linear-gradient(to top, white, transparent) bottom/100% 2px no-repeat,
linear-gradient(to top, white, white);
}
.art-credit {
text-align: right;
}
&.-peek {
/* Explanation:
* 100vh - 100% = Distance between modal's top+bottom boundaries and screen
* (100vh - 100%) / 2 = Distance between bottom (or top) boundary and screen
*/
transform: translateY(calc(((100vh - 100%) / 2)));
.pleroma-tan {
float: right;
z-index: 10;
shape-image-threshold: 70%;
}
.extra-info-group {
max-height: 0;
}
}
}
diff --git a/src/components/update_notification/update_notification.vue b/src/components/update_notification/update_notification.vue
index a35c70f86b..56976f016a 100644
--- a/src/components/update_notification/update_notification.vue
+++ b/src/components/update_notification/update_notification.vue
@@ -1,106 +1,106 @@
<template>
<Modal
:is-open="!!shouldShow"
class="UpdateNotification"
:no-background="true"
>
<div
class="UpdateNotificationModal panel"
:class="{ '-peek': !showingMore }"
>
<div class="panel-heading">
<h1 class="title">
{{ $t('update.big_update_title') }}
</h1>
</div>
<div class="panel-body">
<div
class="content"
:class="{ '-noImage': !showingImage }"
>
<img
v-if="showingImage"
class="pleroma-tan"
:src="pleromaTanVariant"
:style="pleromaTanStyles"
>
<div class="spacer-top" />
<div class="text">
<p>
- {{ $t('update.big_update_content') }}
+ {{ $t('update.big_update_content2') }}
</p>
<div
ref="animatedText"
class="extra-info-group"
>
<i18n-t
scope="global"
- keypath="update.update_bugs"
+ keypath="update.update_bugs2"
tag="p"
>
- <template #pleromaGitlab>
+ <template #pleromaForgejo>
<a
target="_blank"
href="https://git.pleroma.social/"
- >{{ $t('update.update_bugs_gitlab') }}</a>
+ >{{ $t('update.update_bugs_forgejo') }}</a>
</template>
</i18n-t>
<i18n-t
scope="global"
keypath="update.update_changelog"
tag="p"
>
<template #theFullChangelog>
<a
target="_blank"
href="https://pleroma.social/announcements/"
>{{ $t('update.update_changelog_here') }}</a>
</template>
</i18n-t>
<p class="art-credit">
<i18n-t
scope="global"
keypath="update.art_by"
tag="small"
>
<template #linkToArtist>
<a
target="_blank"
href="https://post.ebin.club/users/pipivovott"
>pipivovott</a>
</template>
</i18n-t>
</p>
</div>
</div>
+ <div class="footer-buttons">
+ <button
+ class="button-default"
+ @click.prevent="neverShowAgain"
+ >
+ {{ $t("general.never_show_again") }}
+ </button>
+ <button
+ v-if="!showingMore"
+ class="button-default"
+ @click.prevent="toggleShow"
+ >
+ {{ $t("general.show_more") }}
+ </button>
+ <button
+ class="button-default"
+ @click.prevent="dismiss"
+ >
+ {{ $t("general.dismiss") }}
+ </button>
+ </div>
<div class="spacer-bottom" />
</div>
</div>
- <div class="panel-footer">
- <button
- class="button-default"
- @click.prevent="neverShowAgain"
- >
- {{ $t("general.never_show_again") }}
- </button>
- <button
- v-if="!showingMore"
- class="button-default"
- @click.prevent="toggleShow"
- >
- {{ $t("general.show_more") }}
- </button>
- <button
- class="button-default"
- @click.prevent="dismiss"
- >
- {{ $t("general.dismiss") }}
- </button>
- </div>
</div>
</Modal>
</template>
<script src="./update_notification.js"></script>
<style src="./update_notification.scss" lang="scss"></style>
diff --git a/src/components/user_avatar/user_avatar.js b/src/components/user_avatar/user_avatar.js
index fdf766f584..d484303c13 100644
--- a/src/components/user_avatar/user_avatar.js
+++ b/src/components/user_avatar/user_avatar.js
@@ -1,57 +1,57 @@
import StillImage from '../still-image/still-image.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faPeopleGroup, faRobot } from '@fortawesome/free-solid-svg-icons'
library.add(faRobot, faPeopleGroup)
const UserAvatar = {
props: {
// User object to show avatar of
user: {
required: true,
type: Object,
},
// Use less space and use alternative roundness
compact: {
required: false,
type: Boolean,
default: false,
},
// Show small icon indicating if account is a bot or group
showActorTypeIndicator: {
required: false,
type: Boolean,
default: false,
},
// Override avatar image URL, useful for profile editing
url: {
required: false,
type: String,
default: null,
},
},
data() {
return {
showPlaceholder: false,
- defaultAvatar: `${useInstanceStore().server + useInstanceStore().defaultAvatar}`,
+ defaultAvatar: `${useInstanceStore().server + useInstanceStore().instanceIdentity.defaultAvatar}`,
betterShadow: useInterfaceStore().browserSupport.cssFilter,
}
},
components: {
StillImage,
},
methods: {
imgSrc(src) {
return !src || this.showPlaceholder ? this.defaultAvatar : src
},
imageLoadError() {
this.showPlaceholder = true
},
},
}
export default UserAvatar
diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js
index 8e3b4b9b75..b8bb69010e 100644
--- a/src/components/user_card/user_card.js
+++ b/src/components/user_card/user_card.js
@@ -1,573 +1,564 @@
import ldEscape from 'lodash/escape'
import isEqual from 'lodash/isEqual'
import merge from 'lodash/merge'
import ldUnescape from 'lodash/unescape'
-import { mapGetters } from 'vuex'
+import { mapState } from 'pinia'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ColorInput from 'src/components/color_input/color_input.vue'
import DialogModal from 'src/components/dialog_modal/dialog_modal.vue'
import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
import suggestor from 'src/components/emoji_input/suggestor.js'
import ImageCropper from 'src/components/image_cropper/image_cropper.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import UserTimedFilterModal from 'src/components/user_timed_filter_modal/user_timed_filter_modal.vue'
-import { useInterfaceStore } from '../../stores/interface'
-import { useMediaViewerStore } from '../../stores/media_viewer'
import AccountActions from '../account_actions/account_actions.vue'
import FollowButton from '../follow_button/follow_button.vue'
import ModerationTools from '../moderation_tools/moderation_tools.vue'
import ProgressButton from '../progress_button/progress_button.vue'
import RemoteFollow from '../remote_follow/remote_follow.vue'
import Select from '../select/select.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserLink from '../user_link/user_link.vue'
import UserNote from '../user_note/user_note.vue'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
+import { useInterfaceStore } from 'src/stores/interface'
+import { useMediaViewerStore } from 'src/stores/media_viewer'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { usePostStatusStore } from 'src/stores/post_status'
+import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import { propsToNative } from 'src/services/attributes_helper/attributes_helper.service.js'
import localeService from 'src/services/locale/locale.service.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBell,
faBirthdayCake,
faClockRotateLeft,
faEdit,
faExpandAlt,
faExternalLinkAlt,
faRss,
faSave,
faSearchPlus,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSave,
faRss,
faBell,
faSearchPlus,
faExternalLinkAlt,
faEdit,
faTimes,
faExpandAlt,
faBirthdayCake,
faClockRotateLeft,
)
export default {
props: {
// Enables all the options for profile editing, used in settings -> profile tab
editable: {
required: false,
default: false,
type: Boolean,
},
// ID of user to show data of
userId: {
required: true,
type: String,
},
// Use a compact layout that hides bio, stats etc.
hideBio: {
required: false,
default: false,
type: Boolean,
},
// default - open profile, 'zoom' - zoom, function - call function
avatarAction: {
required: false,
type: String,
default: 'default',
},
// Show note editor if supported
hasNoteEditor: {
required: false,
type: Boolean,
default: false,
},
// Show close icon (for popovers)
showClose: {
required: false,
type: Boolean,
default: false,
},
// Show close icon (for popovers)
showExpand: {
required: false,
type: Boolean,
default: false,
},
},
components: {
DialogModal,
UserAvatar,
Checkbox,
RemoteFollow,
ModerationTools,
AccountActions,
ProgressButton,
FollowButton,
Select,
RichContent,
UserLink,
UserNote,
UserTimedFilterModal,
ColorInput,
EmojiInput,
ImageCropper,
},
data() {
const user = this.$store.getters.findUser(this.userId)
return {
followRequestInProgress: false,
// Editable stuff
editImage: false,
newName: user.name_unescaped,
editingName: false,
newBio: ldUnescape(user.description),
editingBio: false,
newAvatar: null,
newAvatarFile: null,
newBanner: null,
newBannerFile: null,
newActorType: user.actor_type,
newBirthday: user.birthday,
newShowBirthday: user.show_birthday,
newShowRole: user.show_role,
newFields: user.fields?.map((field) => ({
name: field.name,
value: field.value,
})),
editingFields: false,
}
},
created() {
this.$store.dispatch('fetchUserRelationship', this.user.id)
},
computed: {
escapedNewBio() {
return ldEscape(this.newBio).replace(/\n/g, '<br>')
},
somethingToSave() {
if (this.newName !== this.user.name_unescaped) return true
if (this.newBio !== ldUnescape(this.user.description)) return true
if (this.newAvatar !== null) return true
if (this.newBanner !== null) return true
if (this.newActorType !== this.user.actor_type) return true
if (this.newBirthday !== this.user.birthday) return true
if (this.newShowBirthday !== this.user.show_birthday) return true
if (this.newShowRole !== this.user.show_role) return true
if (
!isEqual(
this.newFields,
this.user.fields?.map((field) => ({
name: field.name,
value: field.value,
})),
)
)
return true
return false
},
groupActorAvailable() {
return useInstanceCapabilitiesStore().groupActorAvailable
},
availableActorTypes() {
return this.groupActorAvailable
? ['Person', 'Service', 'Group']
: ['Person', 'Service']
},
user() {
return this.$store.getters.findUser(this.userId)
},
role() {
return this.user.role
},
relationship() {
return this.$store.getters.relationship(this.userId)
},
isOtherUser() {
return this.user.id !== this.$store.state.users.currentUser.id
},
subscribeUrl() {
const serverUrl = new URL(this.user.statusnet_profile_url)
return `${serverUrl.protocol}//${serverUrl.host}/main/ostatus`
},
loggedIn() {
return this.$store.state.users.currentUser
},
dailyAvg() {
const days = Math.ceil(
(new Date() - new Date(this.user.created_at)) / (60 * 60 * 24 * 1000),
)
return Math.round(this.user.statuses_count / days)
},
emoji() {
return useEmojiStore().customEmoji.map((e) => ({
shortcode: e.displayText,
static_url: e.imageUrl,
url: e.imageUrl,
}))
},
userHighlightType: {
get() {
- const data =
- this.$store.getters.mergedConfig.highlight[this.user.screen_name]
- return (data && data.type) || 'disabled'
+ return useUserHighlightStore().get(this.user.screen_name).type
},
set(type) {
- const data =
- this.$store.getters.mergedConfig.highlight[this.user.screen_name]
if (type !== 'disabled') {
- this.$store.dispatch('setHighlight', {
+ useUserHighlightStore().setAndSave({
user: this.user.screen_name,
- color: (data && data.color) || '#FFFFFF',
- type,
+ value: { type },
})
} else {
- this.$store.dispatch('setHighlight', {
- user: this.user.screen_name,
- color: undefined,
- })
+ useUserHighlightStore().unsetAndSave({ user: this.user.screen_name })
}
},
- ...mapGetters(['mergedConfig']),
},
userHighlightColor: {
get() {
- const data =
- this.$store.getters.mergedConfig.highlight[this.user.screen_name]
- return data && data.color
+ return useUserHighlightStore().get(this.user.screen_name).color
},
set(color) {
- this.$store.dispatch('setHighlight', {
+ useUserHighlightStore().setAndSave({
user: this.user.screen_name,
- color,
+ value: { color },
})
},
},
visibleRole() {
if (!this.newShowRole) {
return
}
const rights = this.user.rights
if (!rights) {
return
}
const validRole = rights.admin || rights.moderator
const roleTitle = rights.admin ? 'admin' : 'moderator'
return validRole && roleTitle
},
hideFollowsCount() {
return this.isOtherUser && this.user.hide_follows_count
},
hideFollowersCount() {
return this.isOtherUser && this.user.hide_followers_count
},
showModerationMenu() {
const privileges = this.loggedIn.privileges
return (
this.loggedIn.role === 'admin' ||
privileges.includes('users_manage_activation_state') ||
privileges.includes('users_delete') ||
privileges.includes('users_manage_tags')
)
},
hasNote() {
return this.relationship.note
},
supportsNote() {
return 'note' in this.relationship
},
muteExpiryAvailable() {
return Object.hasOwn(this.user, 'mute_expires_at')
},
muteExpiry() {
return this.user.mute_expires_at === false
? this.$t('user_card.mute_expires_forever')
: this.$t('user_card.mute_expires_at', [
new Date(this.user.mute_expires_at).toLocaleString(),
])
},
blockExpiryAvailable() {
return Object.hasOwn(this.user, 'block_expires_at')
},
blockExpiry() {
return this.user.block_expires_at == null
? this.$t('user_card.block_expires_forever')
: this.$t('user_card.block_expires_at', [
new Date(this.user.mute_expires_at).toLocaleString(),
])
},
formattedBirthday() {
const browserLocale = localeService.internalToBrowserLocale(
this.$i18n.locale,
)
return (
this.user.birthday &&
new Date(Date.parse(this.user.birthday)).toLocaleDateString(
browserLocale,
{ timeZone: 'UTC', day: 'numeric', month: 'long', year: 'numeric' },
)
)
},
// Editable stuff
avatarImgSrc() {
const currentUrl =
this.user.profile_image_url_original || this.defaultAvatar
if (!this.editable) return currentUrl
const newUrl =
this.newAvatar === null ? this.defaultAvatar : this.newAvatar
return this.newAvatar === null ? currentUrl : newUrl
},
bannerImgSrc() {
const currentUrl = this.user.cover_photo || this.defaultBanner
if (!this.editable) return currentUrl
const newUrl =
this.newBanner === null ? this.defaultBanner : this.newBanner
return this.newBanner === null ? currentUrl : newUrl
},
defaultAvatar() {
return (
useInstanceStore().server +
useInstanceStore().instanceIdentity.defaultAvatar
)
},
defaultBanner() {
return (
useInstanceStore().server +
useInstanceStore().instanceIdentity.defaultBanner
)
},
isDefaultAvatar() {
const baseAvatar = useInstanceStore().defaultAvatar
return (
!this.$store.state.users.currentUser.profile_image_url ||
this.$store.state.users.currentUser.profile_image_url.includes(
baseAvatar,
)
)
},
isDefaultBanner() {
const baseBanner = useInstanceStore().defaultBanner
return (
!this.$store.state.users.currentUser.cover_photo ||
this.$store.state.users.currentUser.cover_photo.includes(baseBanner)
)
},
fieldsLimits() {
return useInstanceStore().limits.fieldsLimits
},
maxFields() {
return this.fieldsLimits ? this.fieldsLimits.maxFields : 0
},
emojiUserSuggestor() {
return suggestor({
emoji: [
...useEmojiStore().standardEmojiList,
...useEmojiStore().customEmoji,
],
store: this.$store,
})
},
emojiSuggestor() {
return suggestor({
emoji: [
...useEmojiStore().standardEmojiList,
...useEmojiStore().customEmoji,
],
})
},
- ...mapGetters(['mergedConfig']),
+ ...mapState(useMergedConfigStore, ['mergedConfig']),
},
methods: {
muteUser() {
this.$refs.timedMuteDialog.optionallyPrompt()
},
unmuteUser() {
this.$store.dispatch('unmuteUser', this.user.id)
},
subscribeUser() {
return this.$store.dispatch('subscribeUser', this.user.id)
},
unsubscribeUser() {
return this.$store.dispatch('unsubscribeUser', this.user.id)
},
linkClicked({ target }) {
if (target.tagName === 'SPAN') {
target = target.parentNode
}
if (target.tagName === 'A') {
window.open(target.href, '_blank')
}
},
userProfileLink(user) {
return generateProfileLink(
user.id,
user.screen_name,
useInstanceStore().restrictedNicknames,
)
},
openProfileTab() {
useInterfaceStore().openSettingsModalTab('profile')
},
zoomAvatar() {
const attachment = {
url: this.user.profile_image_url_original,
type: 'image',
}
useMediaViewerStore().setMedia([attachment])
useMediaViewerStore().setCurrentMedia(attachment)
},
mentionUser() {
usePostStatusStore().openPostStatusModal({
profileMention: true,
repliedUser: this.user,
})
},
onAvatarClickHandler(e) {
if (this.onAvatarClick) {
e.preventDefault()
this.onAvatarClick()
}
},
// Editable stuff
changeAvatar() {
this.editImage = 'avatar'
},
changeBanner() {
this.editImage = 'banner'
},
submitImage({ canvas, file }) {
if (canvas) {
return canvas.toBlob((data) =>
this.submitImage({ canvas: null, file: data }),
)
}
const reader = new window.FileReader()
reader.onload = (e) => {
const dataUrl = e.target.result
if (this.editImage === 'avatar') {
this.newAvatar = dataUrl
this.newAvatarFile = file
} else {
this.newBanner = dataUrl
this.newBannerFile = file
}
this.editImage = false
}
reader.readAsDataURL(file)
},
resetImage() {
if (this.editImage === 'avatar') {
this.newAvatar = null
this.newAvatarFile = null
} else {
this.newBanner = null
this.newBannerFile = null
}
this.editImage = false
},
addField() {
if (this.newFields.length < this.maxFields) {
this.newFields.push({ name: '', value: '' })
}
},
deleteField(index) {
this.newFields.splice(index, 1)
},
propsToNative(props) {
return propsToNative(props)
},
cancelImageText() {
return
},
resetState() {
const user = this.$store.state.users.currentUser
this.newName = user.name_unescaped
this.newBio = ldUnescape(user.description)
this.newAvatar = null
this.newAvatarFile = null
this.newBanner = null
this.newBannerFile = null
this.newActorType = user.actor_type
this.newBirthday = user.birthday
this.newShowBirthday = user.show_birthday
this.newShowRole = user.show_role
this.newFields = user.fields.map((field) => ({
name: field.name,
value: field.value,
}))
},
updateProfile() {
const params = {
note: this.newBio,
// Backend notation.
display_name: this.newName,
fields_attributes: this.newFields.filter((el) => el != null),
show_role: !!this.newShowRole,
birthday: this.newBirthday || '',
show_birthday: !!this.newShowBirthday,
}
if (this.newActorType) {
params.actor_type = this.newActorType
}
if (this.newAvatarFile !== null) {
params.avatar = this.newAvatarFile
}
if (this.newBannerFile !== null) {
params.header = this.newBannerFile
}
this.$store.state.api.backendInteractor
.updateProfile({ params })
.then((user) => {
this.newFields.splice(this.newFields.length)
merge(this.newFields, user.fields)
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
this.resetState()
})
.catch((error) => {
this.displayUploadError(error)
})
},
displayUploadError(error) {
useInterfaceStore().pushGlobalNotice({
messageKey: 'upload.error.message',
messageArgs: [error.message],
level: 'error',
})
},
},
}
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index f5ddf9a8c2..1c3a912826 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -1,708 +1,708 @@
<template>
<div class="user-card">
<div class="user-card-inner">
<div class="user-info">
<div class="user-identity">
<div class="header-overlay">
<div class="banner-image">
<img
:src="bannerImgSrc"
:class="{ 'hide-bio': hideBio }"
>
</div>
<div
class="banner-overlay"
:class="{ 'hide-bio': hideBio }"
/>
</div>
<a
v-if="avatarAction === 'zoom'"
class="user-info-avatar -link"
@click="zoomAvatar"
>
<UserAvatar :user="user" />
<div class="user-info-avatar -link -overlay">
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="search-plus"
/>
</div>
</a>
<button
v-else-if="editable"
class="user-info-avatar button-unstyled -link"
:class="{ '-editable': editable }"
@click="changeAvatar"
>
<UserAvatar
:user="user"
:url="avatarImgSrc"
/>
<div class="user-info-avatar -link -overlay">
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="pencil"
/>
</div>
</button>
<UserAvatar
v-else-if="typeof avatarAction === 'function'"
class="user-info-avatar"
:user="user"
@click="avatarAction"
/>
<router-link
v-else
class="user-info-avatar"
:to="userProfileLink(user)"
>
<UserAvatar :user="user" />
</router-link>
<div class="user-summary">
<div class="top-line">
<div
class="other-actions"
>
<button
v-if="editable"
:disabled="newName && newName.length === 0"
class="btn button-unstyled edit-banner-button"
@click="changeBanner"
>
{{ $t('settings.change_banner') }}
<FAIcon
fixed-width
class="icon"
icon="pencil"
:title="$t('settings.change_banner')"
/>
</button>
<button
v-else-if="!editable && !isOtherUser && user.is_local"
class="button-unstyled edit-profile-button"
@click.stop="openProfileTab"
>
<FAIcon
fixed-width
class="icon"
icon="edit"
:title="$t('user_card.edit_profile')"
/>
</button>
<a
v-if="isOtherUser && !user.is_local"
:href="user.statusnet_profile_url"
target="_blank"
class="button-unstyled external-link-button"
>
<FAIcon
class="icon"
icon="external-link-alt"
/>
</a>
<AccountActions
v-if="isOtherUser && loggedIn"
:user="user"
:relationship="relationship"
/>
<router-link
v-if="showExpand"
:to="userProfileLink(user)"
class="button-unstyled external-link-button"
@click="$emit('close')"
>
<FAIcon
class="icon"
icon="expand-alt"
/>
</router-link>
<button
v-if="showClose"
class="button-unstyled external-link-button"
@click="$emit('close')"
>
<FAIcon
class="icon"
icon="times"
/>
</button>
</div>
<div class="name-wrapper">
<router-link
v-if="!editable || !editingName"
:to="userProfileLink(user)"
class="user-name"
>
<RichContent
:title="editable ? newName : user.name_unescaped"
:html="editable ? newName : user.name_unescaped"
:emoji="editable ? emoji : user.emoji"
/>
</router-link>
<EmojiInput
v-else-if="editingName"
v-model="newName"
enable-emoji-picker
:suggest="emojiSuggestor"
>
<template #default="inputProps">
<input
id="username"
v-model="newName"
class="input name-changer"
v-bind="propsToNative(inputProps)"
>
</template>
</EmojiInput>
<button
v-if="editable"
class="button-unstyled edit-button"
:title="$t('settings.toggle_edit')"
@click="editingName = !editingName"
>
<FAIcon
class="icon"
icon="pencil"
/>
</button>
</div>
</div>
<div class="bottom-line">
<UserLink
class="user-screen-name"
:user="user"
/>
<span
v-if="user.locked"
class="lock-icon"
>
<FAIcon
icon="lock"
size="sm"
/>
</span>
<span
v-if="relationship.followed_by && loggedIn && isOtherUser"
class="alert neutral user-role"
>
{{ $t('user_card.follows_you') }}
</span>
<template v-if="!hideBio">
<span
v-if="user.deactivated"
class="alert neutral user-role"
>
{{ $t('user_card.deactivated') }}
</span>
<span
v-if="!!visibleRole"
class="alert neutral user-role"
>
{{ $t(`general.role.${visibleRole}`) }}
</span>
<span
v-if="user.actor_type === 'Service'"
class="alert neutral user-role"
>
{{ $t('user_card.bot') }}
</span>
<span
v-if="user.actor_type === 'Group'"
class="alert neutral user-role"
>
{{ $t('user_card.group') }}
</span>
</template>
</div>
</div>
</div>
<div
v-if="loggedIn && isOtherUser"
class="user-interactions"
>
<div class="btn-group">
<FollowButton
:relationship="relationship"
:user="user"
/>
<template v-if="relationship.following">
<ProgressButton
v-if="!relationship.notifying"
class="btn button-default"
:click="subscribeUser"
:title="$t('user_card.subscribe')"
>
<FAIcon icon="bell" />
</ProgressButton>
<ProgressButton
v-else
class="btn button-default toggled"
:click="unsubscribeUser"
:title="$t('user_card.unsubscribe')"
>
<FALayers>
<FAIcon
icon="rss"
transform="left-5 shrink-6 up-3 rotate-20"
flip="horizontal"
/>
<FAIcon
icon="rss"
transform="right-5 shrink-6 up-3 rotate-20"
/>
<FAIcon icon="bell" />
</FALayers>
</ProgressButton>
</template>
</div>
<button
v-if="relationship.muting"
class="btn button-default btn-mute toggled"
:disabled="user.deactivated"
@click="unmuteUser"
>
{{ $t('user_card.muted') }}
</button>
<button
v-else
class="btn button-default btn-mute"
:disabled="user.deactivated"
@click="muteUser"
>
{{ $t('user_card.mute') }}
</button>
<button
class="btn button-default btn-mention"
:disabled="user.deactivated"
@click="mentionUser"
>
{{ $t('user_card.mention') }}
</button>
<ModerationTools
v-if="showModerationMenu"
class="moderation-menu"
:user="user"
/>
</div>
<div
v-if="!loggedIn && user.is_local"
class="user-interactions"
>
<RemoteFollow :user="user" />
</div>
</div>
</div>
<div
- v-if="!editable && loggedIn && isOtherUser && (hasNote || !hideBio) && !mergedConfig.userCardHidePersonalMarks"
+ v-if="!editable && loggedIn && isOtherUser && (hasNote || !hideBio) && !userCardHidePersonalMarks"
class="personal-marks"
>
<UserNote
v-if="hasNote || (hasNoteEditor && supportsNote)"
:user="user"
:relationship="relationship"
:editable="hasNoteEditor"
/>
<div
v-if="!hideBio"
class="highlighter"
>
<h4>{{ $t('user_card.highlight_header') }}</h4>
<Select
:id="'userHighlightSel'+user.id"
v-model="userHighlightType"
class="userHighlightSel unstyled"
:class="{ '-none': userHighlightType === 'disabled' }"
>
<option value="disabled">
{{ $t('user_card.highlight_new.disabled') }}
</option>
<option value="solid">
{{ $t('user_card.highlight_new.solid') }}
</option>
<option value="striped">
{{ $t('user_card.highlight_new.striped') }}
</option>
<option value="side">
{{ $t('user_card.highlight_new.side') }}
</option>
</Select>
<!-- id's need to be unique, otherwise vue confuses which user-card checkbox belongs to -->
<ColorInput
v-if="userHighlightType !== 'disabled'"
v-model="userHighlightColor"
class="highlighter-color"
:show-optional-checkbox="false"
name="'userHighlightColorTx'+user.id"
:unstyled="true"
/>
</div>
</div>
<h3 v-if="editable">
<span>
{{ $t('settings.bio') }}
</span>
{{ ' ' }}
<button
class="button-default"
@click="editingBio = !editingBio"
>
{{ $t('settings.toggle_edit') }}
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="pencil"
/>
</button>
</h3>
<template v-if="!editable || !editingBio">
<RichContent
v-if="!hideBio"
class="user-card-bio"
:class="{ '-justify-left': mergedConfig.userCardLeftJustify }"
:html="editable ? escapedNewBio : user.description_html"
:emoji="editable ? emoji : user.emoji"
:handle-links="true"
/>
</template>
<template v-else-if="editingBio">
<EmojiInput
v-model="newBio"
enable-emoji-picker
class="user-card-bio"
:class="{ '-justify-left': mergedConfig.userCardLeftJustify }"
:suggest="emojiUserSuggestor"
>
<template #default="inputProps">
<textarea
v-model="newBio"
class="input bio resize-height"
v-bind="propsToNative(inputProps)"
:rows="newBio.split(/\n/g).length"
/>
</template>
</EmojiInput>
</template>
<h3 v-if="editable">
<span>
{{ $t('settings.profile_fields.label') }}
</span>
{{ ' ' }}
<button
class="button-default"
@click="editingFields = !editingFields"
>
{{ $t('settings.toggle_edit') }}
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="pencil"
/>
</button>
</h3>
<template v-if="!editable || !editingFields">
<div
v-if="!hideBio && user.fields_html && user.fields_html.length > 0"
class="user-profile-fields"
>
<dl
v-for="(field, index) in (editable ? newFields : user.fields_html)"
:key="index"
class="user-profile-field"
>
<dt
:title="field.name"
class="user-profile-field-name"
>
<RichContent
:html="field.name"
:emoji="editable ? emoji : user.emoji"
/>
</dt>
<dd
:title="field.value"
class="user-profile-field-value"
>
<RichContent
:html="field.value"
:emoji="editable ? emoji : user.emoji"
/>
</dd>
</dl>
</div>
</template>
<template v-else-if="editingFields">
<div
v-if="maxFields > 0"
class="user-profile-fields"
>
<dl
v-for="(_, i) in newFields"
:key="i"
class="user-profile-field"
>
<dt
class="user-profile-field-name -edit"
>
<EmojiInput
v-model="newFields[i].name"
enable-emoji-picker
:suggest="emojiSuggestor"
>
<template #default="inputProps">
<input
v-model="newFields[i].name"
:placeholder="$t('settings.profile_fields.name')"
v-bind="propsToNative(inputProps)"
class="input"
>
</template>
</EmojiInput>
</dt>
<dd
class="user-profile-field-value -edit"
>
<EmojiInput
v-model="newFields[i].value"
enable-emoji-picker
:suggest="emojiSuggestor"
>
<template #default="inputProps">
<input
v-model="newFields[i].value"
:placeholder="$t('settings.profile_fields.value')"
v-bind="propsToNative(inputProps)"
class="input input"
>
</template>
</EmojiInput>
<button
class="delete-field button-default -hover-highlight"
@click="deleteField(i)"
>
<!-- TODO something is wrong with v-show here -->
<FAIcon
v-if="newFields.length > 1"
icon="times"
/>
</button>
</dd>
</dl>
<button
v-if="newFields.length < maxFields"
class="user-profile-field-add add-field button-default -hover-highlight"
@click="addField"
>
<FAIcon
icon="plus"
class="icon"
/>
<span class="label">
{{ $t("settings.profile_fields.add_field") }}
</span>
</button>
</div>
</template>
<div
v-if="!hideBio"
class="user-extras"
>
<span
- v-if="!editable && !mergedConfig.hideUserStats"
+ v-if="!editable && !hideUserStats"
class="user-stats"
>
<dl
- v-if="!mergedConfig.hideUserStats && !hideBio"
+ v-if="!hideUserStats && !hideBio"
class="user-count"
>
<dd>{{ user.statuses_count }}</dd>
{{ ' ' }}
<dt>{{ $t('user_card.statuses') }}</dt>
</dl>
<dl class="user-count">
<dd>{{ dailyAvg }}</dd>
{{ ' ' }}
<dt>{{ $t('user_card.statuses_per_day') }}</dt>
</dl>
<dl class="user-count">
<dd>{{ hideFollowsCount ? $t('user_card.hidden') : user.friends_count }}</dd>
{{ ' ' }}
<dt>{{ $t('user_card.followees') }}</dt>
</dl>
<dl class="user-count">
<dd>{{ hideFollowersCount ? $t('user_card.hidden') : user.followers_count }}</dd>
{{ ' ' }}
<dt>{{ $t('user_card.followers') }}</dt>
</dl>
</span>
<template v-if="!hideBio">
<div
v-if="user.birthday && !editable"
class="birthday"
>
<FAIcon
class="fa-old-padding"
icon="birthday-cake"
/>
{{ $t('user_card.birthday', { birthday: formattedBirthday }) }}
</div>
<div
v-else-if="editable"
class="birthday"
>
<div>
<Checkbox v-model="newShowBirthday">
{{ $t('settings.birthday.show_birthday') }}
</Checkbox>
</div>
<FAIcon
class="fa-old-padding"
icon="birthday-cake"
/>
{{ $t('settings.birthday.label') }}
<input
id="birthday"
v-model="newBirthday"
type="date"
class="input birthday-input"
>
</div>
</template>
</div>
<template v-if="editable">
<h3>{{ $t('settings.profile_other') }}</h3>
<p
v-if="role === 'admin' || role === 'moderator'"
class="user-card-setting"
>
<Checkbox v-model="newShowRole">
<template v-if="role === 'admin'">
{{ $t('settings.show_admin_badge') }}
</template>
<template v-if="role === 'moderator'">
{{ $t('settings.show_moderator_badge') }}
</template>
</Checkbox>
</p>
<p class="user-card-setting">
<label>
{{ $t('settings.actor_type') }}
<Select v-model="newActorType">
<option
v-for="option in availableActorTypes"
:key="option"
:value="option"
>
{{ $t('settings.actor_type_' + (option === 'Person' ? 'person_proper' : option)) }}
</option>
</Select>
<div v-if="groupActorAvailable">
<small>
{{ $t('settings.actor_type_description') }}
</small>
</div>
</label>
</p>
<div class="bottom-buttons">
<button
v-if="editable"
:disabled="!somethingToSave"
class="btn button-default reset-profile-button"
@click="resetState"
>
{{ $t('settings.reset') }}
<FAIcon
fixed-width
class="icon"
icon="clock-rotate-left"
:title="$t('user_card.edit_profile')"
/>
</button>
<button
v-if="editable"
:disabled="!somethingToSave"
class="btn button-default save-profile-button"
@click="updateProfile"
>
{{ $t('settings.save') }}
<FAIcon
fixed-width
class="icon"
icon="save"
:title="$t('user_card.edit_profile')"
/>
</button>
</div>
</template>
<teleport to="#modal">
<UserTimedFilterModal
ref="timedMuteDialog"
:user="user"
:is-mute="true"
/>
</teleport>
<teleport to="#modal">
<DialogModal
v-if="editImage"
class="edit-image"
>
<template #header>
{{ editImage === 'avatar' ? $t('settings.change_avatar') : $t('settings.change_banner') }}
</template>
<p>
{{ editImage === 'avatar' ? $t('settings.avatar_size_instruction') : $t('settings.banner_size_instruction' ) }}
</p>
<div
class="image-container"
:class="{ '-banner': editImage === 'banner' }"
>
<image-cropper
ref="cropper"
class="cropper"
:aspect-ratio="editImage === 'avatar' ? 1 : 3"
@submit="submitImage"
/>
</div>
<button
id="pick-image"
class="button-default btn"
type="button"
@click="() => $refs.cropper.pickImage()"
>
{{ $t('settings.select_picture') }}
</button>
<template #footer>
<button
class="button-default btn"
type="button"
@click="editImage = false"
>
{{ $t('image_cropper.cancel') }}
</button>
<button
:title="editImage === 'avatar' ? $t('settings.reset_avatar') : $t('settings.reset_banner')"
class="button-default btn reset-button"
@click="resetImage"
>
{{ editImage === 'avatar' ? $t('settings.reset_avatar') : $t('settings.reset_banner' ) }}
</button>
<button
class="button-default btn"
type="button"
@click="$refs.cropper.submit(false)"
>
{{ $t('image_cropper.save_without_cropping') }}
</button>
<button
class="button-default btn"
type="button"
@click="$refs.cropper.submit(true)"
>
{{ $t('image_cropper.save') }}
</button>
</template>
</DialogModal>
</teleport>
</div>
</template>
<script src="./user_card.js"></script>
<style lang="scss" src="./user_card.scss" />
diff --git a/src/components/user_popover/user_popover.js b/src/components/user_popover/user_popover.js
index 6d83aaa21f..8c5f63f258 100644
--- a/src/components/user_popover/user_popover.js
+++ b/src/components/user_popover/user_popover.js
@@ -1,22 +1,22 @@
+import { mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import UserCard from '../user_card/user_card.vue'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+
const UserPopover = {
name: 'UserPopover',
props: ['userId', 'overlayCenters', 'disabled', 'overlayCentersSelector'],
components: {
UserCard,
Popover: defineAsyncComponent(() => import('../popover/popover.vue')),
},
- computed: {
- userPopoverAvatarAction() {
- return this.$store.getters.mergedConfig.userPopoverAvatarAction
- },
- userPopoverOverlay() {
- return this.$store.getters.mergedConfig.userPopoverOverlay
- },
- },
+ computed: mapState(useMergedConfigStore, {
+ userPopoverAvatarAction: (state) =>
+ state.mergedConfig.userPopoverAvatarAction,
+ userPopoverOverlay: (state) => state.mergedConfig.userPopoverOverlay,
+ }),
}
export default UserPopover
diff --git a/src/components/user_timed_filter_modal/user_timed_filter_modal.js b/src/components/user_timed_filter_modal/user_timed_filter_modal.js
index 6c005ab4b4..528f92ddfe 100644
--- a/src/components/user_timed_filter_modal/user_timed_filter_modal.js
+++ b/src/components/user_timed_filter_modal/user_timed_filter_modal.js
@@ -1,107 +1,112 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ConfirmModal from 'src/components/confirm_modal/confirm_modal.vue'
import Select from 'src/components/select/select.vue'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
+
import { durationStrToMs } from 'src/services/date_utils/date_utils.js'
const UserTimedFilterModal = {
data() {
const action = this.isMute
- ? this.$store.getters.mergedConfig.onMuteDefaultAction
- : this.$store.getters.mergedConfig.onBlockDefaultAction
+ ? useMergedConfigStore().mergedConfig.onMuteDefaultAction
+ : useMergedConfigStore().mergedConfig.onBlockDefaultAction
const doAsk = action === 'ask'
const defaultValues = {}
if (doAsk || action === 'forever') {
defaultValues.expiration = 14
defaultValues.expirationUnit = 'd'
if (action === 'forever') {
defaultValues.forever = true
}
} else {
const unit = action.replace(/[0-9,.]+/, '')
const value = action.replace(/[^0-9,.]+/, '')
defaultValues.expiration = value
defaultValues.expirationUnit = unit
}
return {
showing: false,
forever: false,
dontAskAgain: false,
...defaultValues,
}
},
components: {
ConfirmModal,
Select,
Checkbox,
},
props: {
isMute: Boolean,
user: Object,
},
computed: {
shouldConfirm() {
if (this.isMute) {
- return this.$store.getters.mergedConfig.onMuteDefaultAction === 'ask'
+ return useMergedConfigStore().mergedConfig.onMuteDefaultAction === 'ask'
} else {
- return this.$store.getters.mergedConfig.onBlockDefaultAction === 'ask'
+ return (
+ useMergedConfigStore().mergedConfig.onBlockDefaultAction === 'ask'
+ )
}
},
expiryString() {
return this.expiration.toString() + this.expirationUnit
},
expirySeconds() {
return Math.floor(durationStrToMs(this.expiryString) / 1000)
},
requestBody() {
const object = { id: this.user.id }
if (!this.forever) {
object.expiresIn = this.expirySeconds
}
return object
},
},
watch: {
expiration(newVal) {
if (newVal <= 0) {
this.expiration = 1
}
},
},
methods: {
optionallyPrompt() {
if (this.shouldConfirm) {
this.showing = true
} else {
this.accept()
}
},
accept() {
if (this.isMute) {
this.$store.dispatch('muteUser', this.requestBody)
if (this.dontAskAgain) {
- this.$store.dispatch('setOption', {
- name: 'onMuteDefaultAction',
+ useSyncConfigStore().setSimplePrefAndSave({
+ path: 'onMuteDefaultAction',
value: this.expiryString,
})
}
} else {
this.$store.dispatch('blockUser', this.requestBody)
if (this.dontAskAgain) {
- this.$store.dispatch('setOption', {
- name: 'onBlockDefaultAction',
+ useSyncConfigStore().setSimplePrefAndSave({
+ path: 'onBlockDefaultAction',
value: this.expiryString,
})
}
}
this.showing = false
},
cancel() {
this.showing = false
},
},
}
export default UserTimedFilterModal
diff --git a/src/components/video_attachment/video_attachment.js b/src/components/video_attachment/video_attachment.js
index 92e915da81..6904091694 100644
--- a/src/components/video_attachment/video_attachment.js
+++ b/src/components/video_attachment/video_attachment.js
@@ -1,51 +1,53 @@
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+
const VideoAttachment = {
props: ['attachment', 'controls'],
data() {
return {
blocksSuspend: false,
// Start from true because removing "loop" property seems buggy in Vue
hasAudio: true,
}
},
computed: {
loopVideo() {
- if (this.$store.getters.mergedConfig.loopVideoSilentOnly) {
+ if (useMergedConfigStore().mergedConfig.loopVideoSilentOnly) {
return !this.hasAudio
}
- return this.$store.getters.mergedConfig.loopVideo
+ return useMergedConfigStore().mergedConfig.loopVideo
},
},
methods: {
onPlaying(e) {
this.setHasAudio(e)
if (this.loopVideo) {
this.$emit('play', { looping: true })
return
}
this.$emit('play')
},
onPaused() {
this.$emit('pause')
},
setHasAudio(e) {
const target = e.srcElement || e.target
// If hasAudio is false, we've already marked this video to not have audio,
// a video can't gain audio out of nowhere so don't bother checking again.
if (!this.hasAudio) return
if (typeof target.webkitAudioDecodedByteCount !== 'undefined') {
// non-zero if video has audio track
if (target.webkitAudioDecodedByteCount > 0) return
}
if (typeof target.mozHasAudio !== 'undefined') {
// true if video has audio track
if (target.mozHasAudio) return
}
if (typeof target.audioTracks !== 'undefined') {
if (target.audioTracks.length > 0) return
}
this.hasAudio = false
},
},
}
export default VideoAttachment
diff --git a/src/components/who_to_follow/who_to_follow.js b/src/components/who_to_follow/who_to_follow.js
index d90d626eb0..720b150414 100644
--- a/src/components/who_to_follow/who_to_follow.js
+++ b/src/components/who_to_follow/who_to_follow.js
@@ -1,40 +1,42 @@
import apiService from '../../services/api/api.service.js'
import FollowCard from '../follow_card/follow_card.vue'
+import { useInstanceStore } from 'src/stores/instance.js'
+
const WhoToFollow = {
components: {
FollowCard,
},
data() {
return {
users: [],
}
},
mounted() {
this.getWhoToFollow()
},
methods: {
showWhoToFollow(reply) {
reply.forEach((i) => {
this.$store.state.api.backendInteractor
.fetchUser({ id: i.acct })
.then((externalUser) => {
if (!externalUser.error) {
this.$store.commit('addNewUsers', [externalUser])
this.users.push(externalUser)
}
})
})
},
getWhoToFollow() {
const credentials = this.$store.state.users.currentUser.credentials
if (credentials) {
apiService.suggestions({ credentials }).then((reply) => {
this.showWhoToFollow(reply)
})
}
},
},
}
export default WhoToFollow
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 3302df88b1..7851c297cb 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -1,1908 +1,1913 @@
{
"about": {
"mrf": {
"federation": "Federation",
"keyword": {
"keyword_policies": "Keyword policies",
"ftl_removal": "Removal from \"The Whole Known Network\" Timeline",
"reject": "Reject",
"replace": "Replace",
"is_replaced_by": "→"
},
"mrf_policies": "Enabled MRF policies",
"mrf_policies_desc": "MRF policies manipulate the federation behaviour of the instance. The following policies are enabled:",
"simple": {
"simple_policies": "Instance-specific policies",
"instance": "Instance",
"reason": "Reason",
"not_applicable": "N/A",
"accept": "Accept",
"accept_desc": "This instance only accepts messages from the following instances:",
"reject": "Reject",
"reject_desc": "This instance will not accept messages from the following instances:",
"quarantine": "Quarantine",
"quarantine_desc": "This instance will send only public posts to the following instances:",
"ftl_removal": "Removal from \"Known Network\" Timeline",
"ftl_removal_desc": "This instance removes these instances from \"Known Network\" timeline:",
"media_removal": "Media Removal",
"media_removal_desc": "This instance removes media from posts on the following instances:",
"media_nsfw": "Media force-set as sensitive",
"media_nsfw_desc": "This instance forces media to be set sensitive in posts on the following instances:"
}
},
"staff": "Staff",
"terms": "Terms of Service"
},
"announcements": {
"page_header": "Announcements",
"title": "Announcement",
"mark_as_read_action": "Mark as read",
"post_form_header": "Post announcement",
"post_placeholder": "Type your announcement content here...",
"post_action": "Post",
"post_error": "Error: {error}",
"close_error": "Close",
"delete_action": "Delete",
"start_time_prompt": "Start time: ",
"end_time_prompt": "End time: ",
"all_day_prompt": "This is an all-day event",
"published_time_display": "Published at {time}",
"start_time_display": "Starts at {time}",
"end_time_display": "Ends at {time}",
"edit_action": "Edit",
"submit_edit_action": "Submit",
"cancel_edit_action": "Cancel",
"inactive_message": "This announcement is inactive"
},
"shoutbox": {
"title": "Shoutbox"
},
"domain_mute_card": {
"mute": "Mute",
"mute_progress": "Muting…",
"unmute": "Unmute",
"unmute_progress": "Unmuting…"
},
"exporter": {
"export": "Export",
"processing": "Processing, you'll soon be asked to download your file"
},
"features_panel": {
"shout": "Shoutbox",
"pleroma_chat_messages": "Pleroma Chat",
"gopher": "Gopher",
"media_proxy": "Media proxy",
"scope_options": "Scope options",
"text_limit": "Text limit",
"title": "Features",
"who_to_follow": "Who to follow",
"upload_limit": "Upload limit"
},
"finder": {
"error_fetching_user": "Error fetching user",
"find_user": "Find user"
},
"general": {
"apply": "Apply",
"submit": "Submit",
"more": "More",
"loading": "Loading…",
"generic_error": "An error occured",
"generic_error_message": "An error occured: {0}",
"error_retry": "Please try again",
"retry": "Try again",
"optional": "optional",
"show_more": "Show more",
"show_less": "Show less",
"never_show_again": "Never show again",
"dismiss": "Dismiss",
"cancel": "Cancel",
"disable": "Disable",
"enable": "Enable",
"confirm": "Confirm",
"verify": "Verify",
"close": "Close",
"undo": "Undo",
"yes": "Yes",
"no": "No",
"peek": "Peek",
"scroll_to_top": "Scroll to top",
"role": {
"admin": "Admin",
"moderator": "Moderator"
},
"unpin": "Unpin item",
"pin": "Pin item",
"flash_content": "Click to show Flash content using Ruffle (Experimental, may not work).",
"flash_security": "Note that this can be potentially dangerous since Flash content is still arbitrary code.",
"flash_fail": "Failed to load flash content, see console for details.",
"scope_in_timeline": {
"local": "Non-federated",
"direct": "Direct",
"private": "Followers-only",
"public": "Public",
"unlisted": "Unlisted"
}
},
"image_cropper": {
"crop_picture": "Crop picture",
"save": "Save",
"save_without_cropping": "Save without cropping",
"cancel": "Cancel"
},
"importer": {
"submit": "Submit",
"import": "Import",
"success": "Imported successfully.",
"error": "An error occured while importing this file."
},
"login": {
"login": "Log in",
"description": "Log in with OAuth",
"logout": "Log out",
"logout_confirm_title": "Logout confirmation",
"logout_confirm": "Do you really want to logout?",
"logout_confirm_accept_button": "Logout",
"logout_confirm_cancel_button": "Do not logout",
"password": "Password",
"placeholder": "e.g. lain",
"register": "Register",
"username": "Username",
"hint": "Log in to join the discussion",
"authentication_code": "Authentication code",
"enter_recovery_code": "Enter a recovery code",
"enter_two_factor_code": "Enter a two-factor code",
"recovery_code": "Recovery code",
"heading": {
"totp": "Two-factor authentication",
"recovery": "Two-factor recovery"
}
},
"media_modal": {
"previous": "Previous",
"next": "Next",
"counter": "{current} / {total}",
"hide": "Close media viewer"
},
"nav": {
"about": "About",
"administration": "Administration",
"back": "Back",
"friend_requests": "Follow requests",
"mentions": "Mentions",
"interactions": "Interactions",
"dms": "Direct messages",
"public_tl": "Public timeline",
"bubble": "Bubble timeline",
"timeline": "Timeline",
"home_timeline": "Home timeline",
"twkn": "Known Network",
"bookmarks": "Bookmarks",
"all_bookmarks": "All bookmarks",
"bookmark_folders": "Bookmark folders",
"user_search": "User Search",
"search": "Search",
"search_close": "Close search bar",
"who_to_follow": "Who to follow",
"preferences": "Preferences",
"timelines": "Timelines",
"chats": "Chats",
"lists": "Lists",
"edit_nav_mobile": "Customize navigation bar",
"edit_pinned": "Edit pinned items",
"edit_finish": "Done editing",
"mobile_sidebar": "Toggle mobile sidebar",
"mobile_notifications": "Open notifications (there are unread ones)",
"mobile_notifications_close": "Close notifications",
"mobile_notifications_mark_as_seen": "Mark all as seen",
"announcements": "Announcements",
"quotes": "Quotes",
"drafts": "Drafts"
},
"notifications": {
"broken_favorite": "Unknown status, searching for it…",
"error": "Error fetching notifications: {0}",
"favorited_you": "favorited your status",
"followed_you": "followed you",
"follow_request": "wants to follow you",
"load_older": "Load older notifications",
"notifications": "Notifications",
"read": "Read!",
"repeated_you": "repeated your status",
"no_more_notifications": "No more notifications",
"migrated_to": "migrated to",
"reacted_with": "reacted with {0}",
"submitted_report": "submitted a report",
"poll_ended": "poll has ended",
"unread_announcements": "{num} unread announcement | {num} unread announcements",
"unread_chats": "{num} unread chat | {num} unread chats",
"unread_follow_requests": "{num} new follow request | {num} new follow requests",
"configuration_tip": "You can customize what to display here in {theSettings}. {dismiss}",
"configuration_tip_settings": "the settings",
"configuration_tip_dismiss": "Do not show again",
"subscribed_status": "posted"
},
"polls": {
"add_poll": "Add poll",
"add_option": "Add option",
"option": "Option",
"votes": "votes",
"people_voted_count": "{count} person voted | {count} people voted",
"votes_count": "{count} vote | {count} votes",
"vote": "Vote",
"type": "Poll type",
"single_choice": "Single choice",
"multiple_choices": "Multiple choices",
"expiry": "Poll age",
"expires_at": "Poll ends {0}",
"expires_in": "Poll ends in {0}",
"expired": "Poll ended {0} ago",
"expired_at": "Poll ended {0}",
"not_enough_options": "Too few unique options in poll",
"non_anonymous": "Public poll",
"non_anonymous_title": "Other instances may display the options you voted for"
},
"emoji": {
"stickers": "Stickers",
"emoji": "Emoji",
"keep_open": "Keep picker open",
"search_emoji": "Search for an emoji",
"add_emoji": "Insert emoji",
"custom": "Custom emoji",
"hide_custom_emoji": "Hide custom emojis",
"unpacked": "Unpacked emoji",
"unicode": "Unicode emoji",
"unicode_groups": {
"activities": "Activities",
"animals-and-nature": "Animals & Nature",
"flags": "Flags",
"food-and-drink": "Food & Drink",
"objects": "Objects",
"people-and-body": "People & Body",
"smileys-and-emotion": "Smileys & Emotion",
"symbols": "Symbols",
"travel-and-places": "Travel & Places"
},
"load_all_hint": "Loaded first {saneAmount} emoji, loading all emoji may cause performance issues.",
"load_all": "Loading all {emojiAmount} emoji",
"regional_indicator": "Regional indicator {letter}"
},
"errors": {
"storage_unavailable": "Pleroma could not access browser storage. Your login or your local settings won't be saved and you might encounter unexpected issues. Try enabling cookies."
},
"interactions": {
"favs_repeats": "Repeats and favorites",
"follows": "New follows",
"emoji_reactions": "Emoji Reactions",
"reports": "Reports",
"moves": "User migrates",
"load_older": "Load older interactions",
"statuses": "Subscriptions"
},
"post_status": {
"edit_status": "Edit status",
"new_status": "Post new status",
"reply_option": "Reply to this status",
"quote_option": "Quote this status",
"quote_url": "Link to quoted post",
"account_not_locked_warning": "Your account is not {0}. Anyone can follow you to view your follower-only posts.",
"account_not_locked_warning_link": "locked",
"attachments_sensitive": "Mark attachments as sensitive",
"media_description": "Media description",
"content_type": {
"text/plain": "Plain text",
"text/html": "HTML",
"text/markdown": "Markdown",
"text/bbcode": "BBCode",
"text/x.misskeymarkdown": "MFM"
},
"content_type_selection": "Post format",
"content_warning": "Subject (optional)",
"default": "Just landed in L.A.",
"direct_warning_to_all": "This post will be visible to all the mentioned users.",
"direct_warning_to_first_only": "This post will only be visible to the mentioned users at the beginning of the message.",
"edit_remote_warning": "Other remote instances may not support editing and unable to receive the latest version of your post.",
"edit_unsupported_warning": "Pleroma does not support editing mentions or polls.",
"posting": "Posting",
"post": "Post",
"preview": "Preview",
"preview_empty": "Empty",
"empty_status_error": "Can't post an empty status with no files",
"media_description_error": "Failed to update media, try again",
"scope_notice": {
"public": "This post will be visible to everyone",
"private": "This post will be visible to your followers only",
"unlisted": "This post will not be visible in Public Timeline and The Whole Known Network"
},
"scope_notice_dismiss": "Close this notice",
"scope": {
"direct": "Direct - post to mentioned users only",
"private": "Followers-only - post to followers only",
"public": "Public - post to public timelines",
"unlisted": "Unlisted - do not post to public timelines"
},
"close_confirm_title": "Closing post form",
"close_confirm": "What do you want to do with your current writing?",
"close_confirm_save_button": "Save",
"close_confirm_discard_button": "Discard",
"close_confirm_continue_composing_button": "Continue composing",
"auto_save_nothing_new": "Nothing new to save.",
"auto_save_saved": "Saved.",
"auto_save_saving": "Saving...",
"save_to_drafts_button": "Save to drafts",
"save_to_drafts_and_close_button": "Save to drafts and close",
"more_post_actions": "More post actions..."
},
"registration": {
"bio_optional": "Bio (optional)",
"email": "Email",
"email_optional": "Email (optional)",
"fullname": "Display name",
"password_confirm": "Password confirmation",
"registration": "Registration",
"token": "Invite token",
"captcha": "CAPTCHA",
"new_captcha": "Click the image to get a new captcha",
"username_placeholder": "e.g. lain",
"fullname_placeholder": "e.g. Lain Iwakura",
"bio_placeholder": "e.g.\nHi, I'm Lain.\nI’m an anime girl living in suburban Japan. You may know me from the Wired.",
"reason": "Reason to register",
"reason_placeholder": "This instance approves registrations manually.\nLet the administration know why you want to register.",
"register": "Register",
"validations": {
"username_required": "cannot be left blank",
"fullname_required": "cannot be left blank",
"email_required": "cannot be left blank",
"password_required": "cannot be left blank",
"password_confirmation_required": "cannot be left blank",
"password_confirmation_match": "should be the same as password",
"birthday_required": "cannot be left blank",
"birthday_min_age": "must be on or before {date}"
},
"email_language": "In which language do you want to receive emails from the server?",
"birthday": "Birthday:",
"birthday_optional": "Birthday (optional):"
},
"remote_user_resolver": {
"remote_user_resolver": "Remote user resolver",
"searching_for": "Searching for",
"error": "Not found."
},
"report": {
"reporter": "Reporter:",
"reported_user": "Reported user:",
"reported_statuses": "Reported statuses:",
"notes": "Notes:",
"state": "State:",
"state_open": "Open",
"state_closed": "Closed",
"state_resolved": "Resolved"
},
"selectable_list": {
"select_all": "Select all"
},
"settings": {
+ "invalid_settings_imported": "Error importing settings",
"add_language": "Add fallback language",
"remove_language": "Remove",
"primary_language": "Primary language:",
"fallback_language": "Fallback language {index}:",
"actor_type": "This account is:",
"actor_type_description": "Marking your account as a group will make it automatically repeat statuses that mention it.",
"actor_type_Person": "a normal user",
"actor_type_person_proper": "a person",
"actor_type_Service": "a bot",
"actor_type_Group": "a group",
"mobile_center_dialog": "Vertically center dialogs on mobile",
"app_name": "App name",
"expert_mode": "Show advanced",
"save": "Save changes",
"reset": "Reset changes",
"security": "Security",
"toggle_edit": "Edit",
"change_banner": "Change banner",
"change_avatar": "Change avatar",
"setting_changed": "Setting is different from default",
"setting_server_side": "This setting is tied to your profile and affects all sessions and clients",
+ "setting_local_side": "This setting is tied to current session and doesn't affects other devices and browsers",
"enter_current_password_to_confirm": "Enter your current password to confirm your identity",
"post_look_feel": "Posts Look & Feel",
"posts": "Posts",
"developer": "Developer",
"debug": "Debug",
"mention_links": "Mention Links",
"appearance": "Appearance",
"confirm_new_setting": "Confirm new setting?",
"confirm_new_question": "Does this look ok? Setting will be reverted in 10 seconds.",
"confirm_new_question_countdown": "Does this look ok? Setting will be reverted in 1 second. | Does this look ok? Setting will be reverted in {count} seconds.",
"revert": "Revert",
"confirm": "Confirm",
"text_size": "Text and interface size",
"text_size_tip": "Use {0} for absolute values, {1} will scale with browser default text size.",
"text_size_tip2": "Values other than {0} might break some things and themes",
"emoji_size": "Emoji size",
"navbar_size": "Top bar size",
"panel_header_size": "Panel header size",
"visual_tweaks": "Minor visual tweaks",
"theme_debug": "Show what background theme engine assumes when dealing with transparancy",
"scale_and_layout": "Interface scale and layout",
"timelines": "Timelines",
"format_and_language": "Format and Language",
"confirmations": "Confirmations",
"layout": "Layout",
"enabled": "Enabled",
"clutter": "Clutter",
"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",
"wait_pre_setup_otp": "presetting OTP",
"confirm_and_enable": "Confirm & enable OTP",
"title": "Two-factor Authentication",
"generate_new_recovery_codes": "Generate new recovery codes",
"warning_of_generate_new_codes": "When you generate new recovery codes, your old codes won’t work anymore.",
"recovery_codes": "Recovery codes.",
"waiting_a_recovery_codes": "Receiving backup codes…",
"recovery_codes_warning": "Write the codes down or save them somewhere secure - otherwise you won't see them again. If you lose access to your 2FA app and recovery codes you'll be locked out of your account.",
"authentication_methods": "Authentication methods",
"scan": {
"title": "Scan",
"desc": "Using your two-factor app, scan this QR code or enter text key:",
"secret_code": "Key"
},
"verify": {
"desc": "To enable two-factor authentication, enter the code from your two-factor app:"
}
},
"units": {
"time": {
"m": "minutes",
"s": "seconds",
"h": "hours",
"d": "days"
}
},
"lists_navigation": "Show lists in navigation",
"allow_following_move": "Allow auto-follow when following account moves",
"attachmentRadius": "Attachments",
"attachments": "Attachments",
"image_compression": "Compress images before uploading",
"always_use_jpeg": "Always convert images to JPEG format",
"avatar": "Avatar",
"avatarAltRadius": "Avatars (notifications)",
"avatarRadius": "Avatars",
"background": "Background",
"bio": "Bio",
"profile_other": "Other",
"email_language": "Language for receiving emails from the server",
"block_export": "Block export",
"block_export_button": "Export your blocks to a csv file",
"block_import": "Block import",
"block_import_error": "Error importing blocks",
"blocks_imported": "Blocks imported! Processing them will take a while.",
"mute_export": "Mute export",
"mute_export_button": "Export your mutes to a csv file",
"mute_import": "Mute import",
"mute_import_error": "Error importing mutes",
"mutes_imported": "Mutes imported! Processing them will take a while.",
"import_mutes_from_a_csv_file": "Import mutes from a csv file",
"account_backup": "Account backup",
"account_backup_description": "This allows you to download an archive of your account information and your posts, but they cannot yet be imported into a Pleroma account.",
"account_backup_table_head": "Backup",
"download_backup": "Download",
"backup_not_ready": "This backup is not ready yet.",
"backup_running": "This backup is in progress, processed {number} record. | This backup is in progress, processed {number} records.",
"backup_failed": "This backup has failed.",
"remove_backup": "Remove",
"list_backups_error": "Error fetching backup list: {error}",
"add_backup": "Create a new backup",
"added_backup": "Added a new backup.",
"add_backup_error": "Error adding a new backup: {error}",
"blocks_tab": "Blocks",
"btnRadius": "Buttons",
"cBlue": "Blue (Reply, follow)",
"cGreen": "Green (Retweet)",
"cOrange": "Orange (Favorite)",
"cRed": "Red (Cancel)",
"change_email": "Change email",
"change_email_error": "There was an issue changing your email.",
"changed_email": "Email changed successfully!",
"change_password": "Change password",
"change_password_error": "There was an issue changing your password.",
"changed_password": "Password changed successfully!",
"chatMessageRadius": "Chat message",
"collapse_subject": "Collapse posts with subjects",
"composing": "Composing",
"replies": "Replying",
"confirm_new_password": "Confirm new password",
"current_password": "Current password",
"confirm_dialogs": "Ask for confirmation when",
"confirm_dialogs_repeat": "repeating a status",
"confirm_dialogs_unfollow": "unfollowing a user",
"confirm_dialogs_block": "blocking a user",
"confirm_dialogs_mute": "muting a user",
"confirm_dialogs_mute_domain": "muting domains",
"confirm_dialogs_mute_conversation": "muting conversations",
"confirm_dialogs_delete": "deleting a status",
"confirm_dialogs_logout": "logging out",
"confirm_dialogs_approve_follow": "approving a follower",
"confirm_dialogs_deny_follow": "denying a follower",
"confirm_dialogs_remove_follower": "removing a follower",
"mutes_and_blocks": "Mutes and Blocks",
"data_import_export_tab": "Data import / export",
"default_vis": "Default visibility scope",
"delete_account": "Delete account",
"delete_account_description": "Permanently delete your data and deactivate your account.",
"delete_account_error": "There was an issue deleting your account. If this persists please contact your instance administrator.",
"delete_account_instructions": "Type your password in the input below to confirm account deletion.",
"account_alias": "Account aliases",
"account_alias_table_head": "Alias",
"list_aliases_error": "Error fetching aliases: {error}",
"hide_list_aliases_error_action": "Close",
"remove_alias": "Remove this alias",
"new_alias_target": "Add a new alias (e.g. {example})",
"added_alias": "Alias is added.",
"add_alias_error": "Error adding alias: {error}",
"move_account": "Move account",
"move_account_notes": "If you want to move the account somewhere else, you must go to your target account and add an alias pointing here.",
"move_account_target": "Target account (e.g. {example})",
"moved_account": "Account is moved.",
"move_account_error": "Error moving account: {error}",
"discoverable": "Allow discovery of this account in search results and other services",
"domain_mutes": "Domains",
"avatar_size_instruction": "The recommended minimum size for avatar images is 150x150 pixels. Recommended aspect ratio is 1:1",
"banner_size_instruction": "The recommended minimum size for banner images is 450x150 pixels. Recommended aspect ratio is 3:1",
"pad_emoji": "Pad emoji with spaces when adding from picker",
"autocomplete_select_first": "Automatically select the first candidate when autocomplete results are available",
"unsaved_post_action": "When you try to close an unsaved posting form",
"unsaved_post_action_save": "Save it to drafts",
"unsaved_post_action_discard": "Discard it",
"unsaved_post_action_confirm": "Ask every time",
"auto_save_draft": "Save drafts as you compose",
"emoji_reactions_on_timeline": "Show emoji reactions on timeline",
"emoji_reactions_scale": "Reactions scale factor",
"absolute_time_format": "Use absolute time format",
"absolute_time_format_min_age": "Only use for time older than this amount of time",
"absolute_time_format_12h": "Time format",
"absolute_time_format_12h_12h": "12 hour format (i.e. 10:00 PM)",
"absolute_time_format_12h_24h": "24 hour format (i.e. 22:00)",
"export_theme": "Save preset",
"filtering": "Filtering",
"wordfilter": "Wordfilter",
"filtering_explanation": "All statuses containing these words will be muted, one per line",
"word_filter_and_more": "Word filter and more...",
"follow_export": "Follow export",
"follow_export_button": "Export your follows to a csv file",
"follow_import": "Follow import",
"follow_import_error": "Error importing followers",
"import_export": {
"title": "Import / Export",
"follows": "List of users you follow",
"blocks": "List of users you block",
"mutes": "List of users you mute"
},
"follows_imported": "Follows imported! Processing them will take a while.",
"accent": "Accent",
"foreground": "Foreground",
"general": "General",
"hide_attachments_in_convo": "Hide attachments in conversations",
"hide_attachments_in_tl": "Hide attachments in timeline",
"hide_media_previews": "Hide media previews",
"hide_muted_posts": "Hide posts of muted users",
"mute_bot_posts": "Mute bot posts",
"hide_actor_type_indication": "Hide actor type (bots, groups, etc.) indication in posts",
"hide_scrobbles": "Hide scrobbles",
"hide_scrobbles_after": "Hide scrobbles older than",
"mute_sensitive_posts": "Mute sensitive posts",
"hide_all_muted_posts": "Hide muted posts",
"max_thumbnails": "Maximum amount of thumbnails per post (empty = no limit)",
"hide_isp": "Hide instance-specific panel",
"hide_shoutbox": "Hide instance shoutbox",
"right_sidebar": "Reverse order of columns",
"navbar_column_stretch": "Stretch navbar to columns width",
"always_show_post_button": "Always show floating New Post button",
"hide_wallpaper": "Hide instance wallpaper",
"preload_images": "Preload images",
"use_one_click_nsfw": "Open NSFW attachments with just one click",
"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",
"import_followers_from_a_csv_file": "Import follows from a csv file",
"import_theme": "Load preset",
"inputRadius": "Input fields",
"checkboxRadius": "Checkboxes",
"instance_default": "(default: {value})",
"instance_default_simple": "(default)",
"interface": "Interface",
"interfaceLanguage": "Interface language",
"invalid_theme_imported": "The selected file is not a supported Pleroma theme. No changes to your theme were made.",
"limited_availability": "Unavailable in your browser",
"links": "Links",
"lock_account_description": "Restrict your account to approved followers only",
"loop_video": "Loop videos",
"loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")",
"mutes_tab": "Mutes",
"play_videos_in_modal": "Play videos in a popup frame",
"url": "URL",
"preview": "Preview",
"file_export_import": {
"backup_restore": "Settings backup",
"backup_settings": "Backup settings to file",
"backup_settings_theme": "Backup settings and theme to file",
"restore_settings": "Restore settings from file",
"errors": {
"invalid_file": "The selected file is not a supported Pleroma settings backup. No changes were made.",
"file_too_new": "Incompatile major version: {fileMajor}, this PleromaFE (settings ver {feMajor}) is too old to handle it",
"file_too_old": "Incompatile major version: {fileMajor}, file version is too old and not supported (min. set. ver. {feMajor})",
"file_slightly_new": "File minor version is different, some settings might not load"
}
},
"profile_fields": {
"label": "Profile metadata",
"add_field": "Add field",
"name": "Label",
"value": "Content"
},
"birthday": {
"label": "Birthday",
"show_birthday": "Show my birthday"
},
"account_profile_edit": "Edit Profile",
"account_privacy": "Privacy",
"use_contain_fit": "Don't crop the attachment in thumbnails",
"name": "Name",
"name_bio": "Name & bio",
"new_email": "New email",
"new_password": "New password",
"user_profiles": "User Profiles",
"notification_visibility": "Types of notifications to show",
"notification_visibility_in_column": "Show in notifications column/drawer",
"notification_visibility_native_notifications": "Show a native notification",
"notification_visibility_follows": "Follows",
"notification_visibility_follow_requests": "Follow requests",
"notification_visibility_likes": "Favorites",
"notification_visibility_mentions": "Mentions",
"notification_visibility_repeats": "Repeats",
"notification_visibility_reports": "Reports",
"notification_visibility_moves": "User Migrates",
"notification_visibility_emoji_reactions": "Reactions",
"notification_visibility_polls": "Ends of polls you voted in",
"notification_visibility_statuses": "Subscriptions",
"notification_show_extra": "Show extra notifications in the notifications column",
"notification_extra_chats": "Show unread chats",
"notification_extra_announcements": "Show unread announcements",
"notification_extra_follow_requests": "Show new follow requests",
"notification_extra_tip": "Show the customization tip for extra notifications",
"no_rich_text_description": "Strip rich text formatting from all posts",
"no_blocks": "No blocks",
"no_mutes": "No mutes",
"hide_favorites_description": "Don't show list of my favorites (people still get notified)",
"hide_follows_description": "Don't show who I'm following",
"hide_followers_description": "Don't show who's following me",
"hide_follows_count_description": "Don't show follow count",
"hide_followers_count_description": "Don't show follower count",
"show_admin_badge": "Show \"Admin\" badge in my profile",
"show_moderator_badge": "Show \"Moderator\" badge in my profile",
"nsfw_clickthrough": "Hide sensitive/NSFW media",
"oauth_tokens": "OAuth tokens",
"token": "Token",
"refresh_token": "Refresh token",
"valid_until": "Valid until",
"revoke_token": "Revoke",
"panelRadius": "Panels",
"pause_on_unfocused": "Pause when tab is not focused",
"presets": "Presets",
"profile_background": "Profile background",
"profile_banner": "Profile banner",
"profile_tab": "Profile",
"radii_help": "Set up interface edge rounding (in pixels)",
"replies_in_timeline": "Replies in timeline",
"reply_visibility_all": "Show all replies",
"reply_visibility_following": "Only show replies directed at me or users I'm following",
"reply_visibility_self": "Only show replies directed at me",
"reply_visibility_following_short": "Show replies to my follows",
"reply_visibility_self_short": "Show replies to self only",
"autohide_floating_post_button": "Automatically hide New Post button (mobile)",
"saving_err": "Error saving settings",
"saving_ok": "Settings saved",
"search_user_to_block": "Search whom you want to block",
"search_user_to_mute": "Search whom you want to mute",
"security_tab": "Security",
"scope_copy": "Copy scope when replying (DMs are always copied)",
"minimal_scopes_mode": "Minimize post scope selection options",
"set_new_avatar": "Set new avatar",
"set_new_profile_background": "Set new profile background",
"set_new_background": "Set new background",
"set_new_profile_banner": "Set new profile banner",
"reset_avatar": "Reset avatar",
"reset_banner": "Reset banner",
"reset_profile_background": "Reset profile background",
"reset_profile_banner": "Reset profile banner",
"reset_avatar_confirm": "Do you really want to reset the avatar?",
"reset_banner_confirm": "Do you really want to reset the banner?",
"reset_background_confirm": "Do you really want to reset the background?",
"settings": "Settings",
"subject_input_always_show": "Always show subject field",
"subject_line_behavior": "Copy subject when replying",
"subject_line_email": "Like email: \"re: subject\"",
"subject_line_mastodon": "Like mastodon: copy as is",
"subject_line_noop": "Do not copy",
"force_theme_recompilation_debug": "Disable theme cahe, force recompile on each boot",
"conversation_display": "Conversation display style",
"conversation_display_tree": "Tree-style",
"conversation_display_tree_quick": "Tree view",
"disable_sticky_headers": "Don't stick column headers to top of the screen",
"show_scrollbars": "Show side column's scrollbars",
"third_column_mode": "When there's enough space, show third column containing",
"third_column_mode_none": "Don't show third column at all",
"third_column_mode_notifications": "Notifications column",
"third_column_mode_postform": "Main post form and navigation",
"columns": "Columns",
"column_sizes": "Column sizes",
"column_sizes_sidebar": "Sidebar",
"column_sizes_content": "Content",
"column_sizes_notifs": "Notifications",
"scale_and_font": "Scale and Font",
"theme_editor_min_width": "Minimum width of theme editor (0 for \"fit-content\")",
"tree_advanced": "Allow more flexible navigation in tree view",
"tree_fade_ancestors": "Display ancestors of the current status in faint text",
"conversation_display_linear": "Linear-style",
"conversation_display_linear_quick": "Linear view",
"conversation_other_replies_button": "Show the \"other replies\" button",
"conversation_other_replies_button_below": "Below statuses",
"conversation_other_replies_button_inside": "Inside statuses",
"max_depth_in_thread": "Maximum number of levels in thread to display by default",
"post_status_content_type": "Post status content type",
"default_post_status_content_type": "Default post status content type",
"sensitive_by_default": "Mark posts as sensitive by default",
"stop_gifs": "Pause animated images until you hover on them",
"streaming": "Automatically show new posts when scrolled to the top",
"auto_update": "Show new posts automatically",
"user_mutes": "Users",
"useStreamingApi": "Receive posts and notifications real-time",
"use_websockets": "Use websockets (Realtime updates)",
"text": "Text",
"theme": "Theme",
"theme_old": "Theme editor (old)",
"theme_help": "Use hex color codes (#rrggbb) to customize your color theme.",
"theme_help_v2_1": "You can also override certain component's colors and opacity by toggling the checkbox, use \"Clear all\" button to clear all overrides.",
"theme_help_v2_2": "Icons underneath some entries are background/text contrast indicators, hover over for detailed info. Please keep in mind that when using transparency contrast indicators show the worst possible case.",
"tooltipRadius": "Tooltips/alerts",
"type_domains_to_mute": "Search domains to mute",
"upload_a_photo": "Upload a photo",
"upload_picture": "Upload picture",
"select_picture": "Select picture",
"user_settings": "User Settings",
"values": {
"false": "no",
"true": "yes"
},
"virtual_scrolling": "Optimize timeline rendering",
"use_at_icon": "Display {'@'} symbol as an icon instead of text",
"mention_link_display": "Display mention links",
"mention_link_display_short": "always as short names (e.g. {'@'}foo)",
"mention_link_display_full_for_remote": "as full names only for remote users (e.g. {'@'}foo{'@'}example.org)",
"mention_link_display_full": "always as full names (e.g. {'@'}foo{'@'}example.org)",
"mention_link_use_tooltip": "Show user card when clicking mention links",
"mention_link_show_avatar": "Show user avatar beside the link",
"mention_link_show_avatar_quick": "Show user avatar next to mentions",
"mention_link_fade_domain": "Fade domains (e.g. {'@'}example.org in {'@'}foo{'@'}example.org)",
"mention_link_bolden_you": "Highlight mention of you when you are mentioned",
"user_popover_avatar_action": "Popover avatar click action",
"user_popover_avatar_action_zoom": "Zoom the avatar",
"user_popover_avatar_action_close": "Close the popover",
"user_popover_avatar_action_open": "Open profile",
"user_popover_avatar_overlay": "Show user popover over user avatar",
"user_card_left_justify": "Justify user bio to the left",
"user_card_hide_personal_marks": "Hide personal marks (highlight/note) in user profiles",
"posts_appearance": "Posts Appearance",
"fun": "Fun",
"greentext": "Meme arrows",
"plaintext_quotes": "Highlight plaintext {0}",
"greentext_quotes": ">quotes",
"show_yous": "Show (You)s",
"notifications": "Notifications",
"notification_setting_annoyance": "Annoyance",
"notification_setting_drawer_marks_as_seen": "Closing drawer (mobile) marks all notifications as read",
"notification_setting_ignore_inactionable_seen": "Ignore read state of inactionable notifications (likes, repeats etc)",
"notification_setting_ignore_inactionable_seen_tip": "This will not actually mark those notifications as read, and you'll still get desktop notifications about them if you chose so",
"notification_setting_unseen_at_top": "Show unread notifications above others",
"notification_setting_filters": "Filters",
"notification_setting_filters_chrome_push": "On some browsers (chrome) it might be impossible to completely filter out notifications by type when they arrive by Push",
"notification_setting_block_from_strangers": "Block notifications from users who you do not follow",
"notification_setting_privacy": "Privacy",
"notification_setting_hide_notification_contents": "Hide the sender and contents of push notifications",
"notification_mutes": "To stop receiving notifications from a specific user, use a mute.",
"notification_blocks": "Blocking a user stops all notifications as well as unsubscribes them.",
"enable_web_push_notifications": "Enable web push notifications",
"enable_web_push_always_show": "Always show web push notifications",
"enable_web_push_always_show_tip": "Some browsers (Chromium, Chrome) require that push messages always result in a notification, otherwise generic 'Website was updated in background' is shown, enable this to prevent this notification from showing, as Chrome seem to hide push notifications if tab is in focus. Can result in showing duplicate notifications on other browsers.",
"more_settings": "More settings",
"style": {
"style_section": "Style",
"custom_theme_used": "(Custom theme)",
"custom_style_used": "(Custom style)",
"stock_theme_used": "(Stock theme)",
"themes2_outdated": "Editor for Themes V2 is being phased out and will eventually be replaced with a new one that takes advantage of new Themes V3 engine. It should still work but experience might be degraded and inconsistent.",
"appearance_tab_note": "Changes on this tab do not affect the theme used, so exported theme will be different from what seen in the UI",
"visual_tweaks_section_note": "Changes in this section do not affect the theme used, exported theme will be different from what seen in the UI",
"update_preview": "Update preview",
"themes3": {
"define": "Override",
"palette": {
"label": "Color schemes",
"name_label": "Color scheme name",
"import": "Import palette",
"export": "Export palette",
"apply": "Apply palette",
"bg": "Panel background",
"fg": "Buttons etc.",
"text": "Text",
"link": "Links",
"accent": "Accent color",
"cRed": "Red color",
"cBlue": "Blue color",
"cGreen": "Green color",
"cOrange": "Orange color",
"wallpaper": "Wallpaper",
"v2_unsupported": "Older v2 themes don't support palettes. Switch to v3 theme to make use of palettes",
"bundled": "Bundled palettes",
"style": "Palettes provided by selected style",
"user": "Custom palette",
"imported": "Imported"
},
"editor": {
"title": "Style editor",
"reset_style": "Reset",
"load_style": "Open from file",
"save_style": "Save",
"style_name": "Stylesheet name",
"style_author": "Made by",
"style_license": "License",
"style_website": "Website",
"component_selector": "Component",
"variant_selector": "Variant",
"states_selector": "States",
"main_tab": "Main",
"shadows_tab": "Shadows",
"background": "Background color",
"text_color": "Text color",
"icon_color": "Icon color",
"link_color": "Link color",
"contrast": "Text contrast",
"roundness": "Roundness",
"opacity": "Opacity",
"border_color": "Border color",
"include_in_rule": "Add to rule",
"test_string": "TEST",
"invalid": "Invalid",
"refresh_preview": "Refresh preview",
"apply_preview": "Apply",
"text_auto": {
"label": "Auto-contrast",
"no-preserve": "Black or White",
"preserve": "Keep color",
"no-auto": "Disabled"
},
"component_tab": "Components style",
"palette_tab": "Color schemes",
"variables_tab": "Variables (Advanced)",
"variables": {
"label": "Variables",
"name_label": "Name:",
"type_label": "Type:",
"type_shadow": "Shadow",
"type_color": "Color",
"type_generic": "Generic",
"virtual_color": "Variable color value"
}
},
"hacks": {
"underlay_overrides": "Change underlay",
"underlay_override_mode_none": "Theme default",
"underlay_override_mode_opaque": "Replace with solid color",
"underlay_override_mode_transparent": "Remove entirely (might break some themes)",
"force_interface_roundness": "Override interface roundness/sharpness",
"forced_roundness_mode_disabled": "Use theme defaults",
"forced_roundness_mode_sharp": "Force sharp edges",
"forced_roundness_mode_nonsharp": "Force not-so-sharp (1px roundness) edges",
"forced_roundness_mode_round": "Force round edges"
},
"font": {
"group-builtin": "Browser default fonts",
"builtin": {
"serif": "Serif",
"sans-serif": "Sans-serif",
"monospace": "Monospace",
"inherit": "Unchanged"
},
"group-local": "Locally installed fonts",
"local-unavailable1": "List of locally installed fonts unavailalbe",
"local-unavailable2": "Use manual entry to specify custom font",
"font_list_unavailable": "Couldn't get locally installed fonts: {error}",
"lookup_local_fonts": "Load list of fonts installed on this computer",
"enter_manually": "Enter font name family manually",
"entry": "Enter {fontFamily}",
"select": "Select font",
"label": "{label} font"
}
},
"interface_font_user_override": "Override theme/browser font used",
"switcher": {
"keep_color": "Keep colors",
"keep_shadows": "Keep shadows",
"keep_opacity": "Keep opacity",
"keep_roundness": "Keep roundness",
"keep_fonts": "Keep fonts",
"save_load_hint": "\"Keep\" options preserve currently set options when selecting or loading themes, it also stores said options when exporting a theme. When all checkboxes unset, exporting theme will save everything.",
"reset": "Reset",
"clear_all": "Clear all",
"clear_opacity": "Clear opacity",
"load_theme": "Load theme",
"keep_as_is": "Keep as is",
"use_snapshot": "Old version",
"use_source": "New version",
"help": {
"upgraded_from_v2": "PleromaFE has been upgraded, theme could look a little bit different than you remember.",
"v2_imported": "File you imported was made for older FE. We try to maximize compatibility but there still could be inconsistencies.",
"future_version_imported": "File you imported was made in newer version of FE.",
"older_version_imported": "File you imported was made in older version of FE.",
"snapshot_present": "Theme snapshot is loaded, so all values are overriden. You can load theme's actual data instead.",
"snapshot_missing": "No theme snapshot was in the file so it could look different than originally envisioned.",
"fe_upgraded": "PleromaFE's theme engine upgraded after version update.",
"fe_downgraded": "PleromaFE's version rolled back.",
"migration_snapshot_ok": "Just to be safe, theme snapshot loaded. You can try loading theme data.",
"migration_napshot_gone": "For whatever reason snapshot was missing, some stuff could look different than you remember.",
"snapshot_source_mismatch": "Versions conflict: most likely FE was rolled back and updated again, if you changed theme using older version of FE you most likely want to use old version, otherwise use new version."
}
},
"common": {
"color": "Color",
"opacity": "Opacity",
"contrast": {
"hint": "Contrast ratio is {ratio}, it {level} {context}",
"level": {
"aa": "meets Level AA guideline (minimal)",
"aaa": "meets Level AAA guideline (recommended)",
"bad": "doesn't meet any accessibility guidelines"
},
"context": {
"18pt": "for large (18pt+) text",
"text": "for text"
}
}
},
"common_colors": {
"_tab_label": "Common",
"main": "Common colors",
"foreground_hint": "See \"Advanced\" tab for more detailed control",
"rgbo": "Icons, accents, badges"
},
"advanced_colors": {
"_tab_label": "Advanced",
"alert": "Alert background",
"alert_error": "Error",
"alert_warning": "Warning",
"alert_neutral": "Neutral",
"post": "Posts/User bios",
"badge": "Badge background",
"popover": "Tooltips, menus, popovers",
"badge_notification": "Notification",
"panel_header": "Panel header",
"top_bar": "Top bar",
"borders": "Borders",
"buttons": "Buttons",
"inputs": "Input fields",
"faint_text": "Faded text",
"underlay": "Underlay",
"wallpaper": "Wallpaper",
"poll": "Poll graph",
"icons": "Icons",
"highlight": "Highlighted elements",
"pressed": "Pressed",
"selectedPost": "Selected post",
"selectedMenu": "Selected menu item",
"disabled": "Disabled",
"toggled": "Toggled",
"tabs": "Tabs",
"chat": {
"incoming": "Incoming",
"outgoing": "Outgoing",
"border": "Border"
}
},
"radii": {
"_tab_label": "Roundness"
},
"shadows": {
"_tab_label": "Shadow and lighting",
"component": "Component",
"override": "Override",
"shadow_id": "Shadow #{value}",
"offset": "Shadow offset",
"zoom": "Zoom",
"offset-x": "x:",
"offset-y": "y:",
"light_grid": "Use light checkerboard",
"color_override": "Use different color",
"name": "Name",
"blur": "Blur",
"spread": "Spread",
"inset": "Inset",
"raw": "Plain shadow",
"expression": "Expression (advanced)",
"empty_expression": "Empty expression",
"hintV3": "For shadows you can also use the {0} notation to use other color slot.",
"filter_hint": {
"always_drop_shadow": "Warning, this shadow always uses {0} when browser supports it.",
"drop_shadow_syntax": "{0} does not support {1} parameter and {2} keyword.",
"avatar_inset_short": "Separate inset shadow",
"avatar_inset": "Please note that combining both inset and non-inset shadows on avatars might give unexpected results with transparent avatars.",
"spread_zero": "Shadows with spread > 0 will appear as if it was set to zero",
"inset_classic": "Inset shadows will be using {0}"
},
"components": {
"panel": "Panel",
"panelHeader": "Panel header",
"topBar": "Top bar",
"avatar": "User avatar (in profile view)",
"avatarStatus": "User avatar (in post display)",
"popup": "Popups and tooltips",
"button": "Button",
"buttonHover": "Button (hover)",
"buttonPressed": "Button (pressed)",
"buttonPressedHover": "Button (pressed+hover)",
"input": "Input field"
}
},
"fonts": {
"_tab_label": "Fonts",
"help": "Select font to use for elements of UI. For \"custom\" you have to enter exact font name as it appears in system.",
"components": {
"interface": "Interface",
"input": "Input fields",
"post": "Post text",
"monospace": "Monospaced text"
},
"components_inline": {
"interface": "interface",
"input": "input fields",
"post": "post text",
"monospace": "monospaced text"
},
"override": "Override {0} font",
"family": "Font name",
"size": "Size (in px)",
"weight": "Weight (boldness)",
"custom": "Custom"
},
"preview": {
"header": "Preview",
"content": "Content",
"error": "Example error",
"button": "Button",
"text": "A bunch of more {0} and {1}",
"mono": "content",
"input": "Just landed in L.A.",
"faint_link": "helpful manual",
"fine_print": "Read our {0} to learn nothing useful!",
"header_faint": "This is fine",
"checkbox": "I have skimmed over terms and conditions",
"link": "a nice lil' link"
}
},
"version": {
"title": "Version",
"backend_version": "Backend version",
"frontend_version": "Frontend version"
},
"commit_value": "Save",
"commit_value_tooltip": "Value is not saved, press this button to commit your changes",
"reset_value": "Reset",
"reset_value_tooltip": "Reset draft",
"hard_reset_value": "Hard reset",
"hard_reset_value_tooltip": "Remove setting from storage, forcing use of default value",
"cache": "Cache",
"clear_asset_cache": "Clear asset cache",
"clear_emoji_cache": "Clear emoji cache"
},
"admin_dash": {
"window_title": "Administration",
"wip_notice": "This admin dashboard is experimental and WIP, {adminFeLink}.",
"old_ui_link": "old admin UI available here",
"reset_all": "Reset all",
"commit_all": "Save all",
"tabs": {
"nodb": "No DB Config",
"instance": "Instance",
"limits": "Limits",
"frontends": "Front-ends",
"mailer": "EMails",
"media_proxy": "Media Proxy",
"emoji": "Emoji",
"uploads": "Uploads",
"monitoring": "Monitoring",
"registrations": "Registrations",
"links": "Links",
"job_queues": "Job Queues",
"auth": "Auth",
"posts": "Posts",
"rate_limit": "Rate Limits",
"http": "HTTP",
"federation": "Federation",
"other": "Other"
},
"posts": {
"global": "Global settings",
"local": "Local posts",
"remote": "Remote posts"
},
"other": {
"uncategorized": "Uncategorized",
"user_backup": "User Backup",
"reports": "Reports",
"privileges": "Privileges"
},
"monitoring": {
"builtins": "Built-in Tools",
"prometheus": "Prometheus Exporter"
},
"federation": {
"global": "Global settings",
"restrictions": "Restrictions",
"activitypub": "ActivityPub"
},
"auth": {
"MFA": "Multi-factor Authentication",
"LDAP": "LDAP Settings",
"OAuth": "Oauth2 settings",
"TOTP": "One-time Passwords (TOTP)",
"backup_codes": "Backup codes"
},
"job_queues": {
"Gun": {
"title": "Gun queues",
"connections_pools": "Gun connections pool",
"pools": {
"title": "Gun worker pools",
"default": "Default pool",
"federation": "Federation pool",
"media": "Media pool",
"rich_media": "Rich media pool",
"upload": "Upload pool"
}
},
"Hackney": {
"title": "Hackney pools",
"federation": "Federation",
"media": "Media",
"rich_media": "Rich media",
"upload": "Upload"
},
"queues": "Queues"
},
"rate_limit": {
"rate_limit": "Rate Limit",
"amount": "Amount",
"unauthenticated": "Unauthenticated",
"authenticated": "Authenticated",
"period": "Time period",
"separate": "Separate rate limits for authenticated/unauthenticated users"
},
"nodb": {
"heading": "Database config is disabled",
"text": "You need to change backend config files so that {property} is set to {value}, see more in {documentation}.",
"documentation": "documentation",
"text2": "Most configuration options will be unavailable."
},
"captcha": {
"native": "Native",
"kocaptcha": "KoCaptcha"
},
"http": {
"outbound": "Outgoing connections",
"incoming": "Incoming connections",
"security": "HTTP Security",
"web_push": "Web Push",
"web_push_description": "Web Push VAPID settings. You can use the mix task web_push.gen.keypair to generate it."
},
"registrations": {
"welcome": {
"title": "Welcome message",
"description": "Send new users a message when they sign up",
"direct_message": "Via direct message",
"chat_message": "Via chat",
"email_message": "Via email"
},
"restrictions": "Restrictions",
"autofollow": "Autofollow"
},
"links": {
"no_scheme": "No scheme",
"link_previews": "Link previews",
"link_formatter": "Link formatter"
},
"uploads": {
"attachments": "Attachments settings",
"upload": "Upload",
"local_uploader": "Local files",
"filenames": "Filenames, Titles and Descriptions",
"uploader_settings": "Uploader settings"
},
"media_proxy": {
"basic": "Basic Settings",
"invalidation": "Cache Invalidation",
"limits": "Limits",
"thumbnails": "Thumbnail Generation",
"invalidation_settings": "Cache Invalidation"
},
"mailer": {
"styling": "Styling",
"assets": "Assets",
"colors": "Color palette",
"adapter": "Mailing Adapter",
"auth": "Authentication"
},
"limits": {
"arbitrary_limits": "Arbitrary limits",
"posts": "Post limits",
"other": "Misc. limits",
"uploads": "Attachments limits",
"users": "User profile limits",
"profile_fields": "Profile fields limits",
"user_uploads": "Profile media limits"
},
"frontend": {
"title": "Frontend management",
"repository": "Repository link",
"versions": "Available versions",
"build_url": "Build URL",
"reinstall": "Reinstall",
"is_default": "(Default)",
"is_default_custom": "(Default, version: {version})",
"install": "Install",
"install_version": "Install version {version}",
"more_install_options": "More install options",
"more_default_options": "More default setting options",
"set_default": "Set default",
"set_default_version": "Set version {version} as default",
"wip_notice": "Please note that this section is a WIP and lacks certain features as backend implementation of front-end management is incomplete.",
"default_frontend": "Default frontend",
"default_frontend_tip": "Default frontend will be shown to all users. Currently there's no way to for a user to select personal frontend. If you switch away from PleromaFE you'll most likely have to use old and buggy AdminFE to do instance configuration until we replace it.",
"default_frontend_unavail": "Default frontend settings are not available, as this requires configuration in the database",
"available_frontends": "Available for install",
"failure_installing_frontend": "Failed to install frontend {version}: {reason}",
"success_installing_frontend": "Frontend {version} successfully installed"
},
"emoji": {
"global_actions": "Global actions",
"reload": "Reload emoji",
"reload_short": "Refresh",
"advanced": "Advanced",
"importFS": "Import emoji from filesystem",
"import_pack": "Upload emoji pack",
"import_pack_short": "Import",
"error": "Error: {0}",
"create_pack": "Create pack",
"delete_pack": "Delete pack",
"new_pack_name": "New pack name",
"create": "Create",
"emoji_packs": "Emoji packs",
"remote_packs": "Remote packs",
"remote_packs_short": "Remote",
"do_list": "List",
"remote_pack_instance": "Remote pack instance",
"emoji_pack": "Emoji pack",
"edit_pack": "Edit pack",
"metadata": "Metadata",
"description": "Description",
"homepage": "Homepage",
"fallback_src": "Fallback source",
"fallback_sha256": "Fallback SHA256",
"share": "Share",
"save": "Save",
"save_meta": "Save metadata",
"revert_meta": "Revert metadata",
"delete": "Delete",
"revert": "Revert",
"add_file": "Add file",
"adding_new": "Adding new emoji",
"shortcode": "Shortcode",
"filename": "Filename",
"emoji_source": "Emoji file source",
"upload_url": "Upload from URL",
"new_shortcode": "Shortcode, leave blank to infer",
"new_filename": "Filename, leave blank to infer",
"delete_confirm": "Are you sure you want to delete {0}?",
"download_pack": "Download pack",
"downloading_pack": "Downloading {0}",
"download": "Download",
"download_as_name": "New name",
"download_as_name_full": "New name, leave blank to reuse",
"files": "Files",
"editing": "Editing {0}",
"copying": "Copying {0}",
"copy_to": "Copy to",
"copy_to_pack": "Copy to local pack",
"delete_title": "Delete?",
"metadata_changed": "Metadata different from saved",
"emoji_changed": "Unsaved emoji file changes, check highlighted emoji",
"replace_warning": "This will REPLACE the local pack of the same name",
"copied_successfully": "Successfully copied emoji \"{0}\" to pack \"{1}\""
},
"generic_enforcement": {
"if_available": "If available",
"always": "Always",
"never": "Never"
},
"instance": {
"instance": "Instance information",
"registrations": "User sign-ups",
"captcha_header": "CAPTCHA",
"kocaptcha": "KoCaptcha settings",
"pwa": {
"manifest": "PWA Manifest",
"optional": "(optional)",
"no_icons": "No icons defined",
"icon": {
"purpose": "Icon purpose",
"any": "Any",
"monochrome": "Monochrome",
"maskable": "Maskable"
}
},
"misc_brand": "Miscellaneous elements",
"branding": "Branding",
"access": "Instance access",
"rich_metadata": "Metadata",
"restrict": {
"header": "Restrict access for anonymous visitors",
"description": "Detailed setting for allowing/disallowing access to certain aspects of API. By default (indeterminate state) it will disallow if instance is not public, ticked checkbox means disallow access even if instance is public, unticked means allow access even if instance is private. Please note that unexpected behavior might happen if some settings are set, i.e. if profile access is disabled posts will show without profile information.",
"timelines": "Timelines access",
"profiles": "User profiles access",
"activities": "Statuses/activities access"
},
":unauthenticated": "Unauthenticated",
":all": "Everyone"
},
"temp_overrides": {
":pleroma": {
":connections_pool": {
":max_idle_time": {
"label": "Maximum idle time",
"description": "Maximum idle time before CONFIRM"
},
":retry": {
"label": "Retry",
"description": "Number of retries for making a connection CONFIRM"
}
},
":pools": {
":rich_media": {
"label": "Rich media",
"description": "idk"
}
},
":rate_limit": {
":oauth_app_creation": {
"label": "OAuth app creation",
"description": "For registering new OAuth App ID"
},
":ap_routes": {
"label": "ActivityPub",
"description": "Federation endpoints"
},
":account_confirmation_resend": {
"label": "Account confirmation resend",
"description": "How often user can resend confirmation mail"
}
},
"Pleroma_DOT_Formatter": {
":attribute_toggle": {
"label": "Set {attr} attribute"
},
":truncate_toggle": {
"label": "Truncate"
},
":class": {
"label": "Value"
},
":rel": {
"label": "Value"
}
},
"Pleroma_DOT_Uploaders_DOT_Uploader": {
":timeout": {
"label": "Timeout",
"description": "Amount of milliseconds before dropping connection"
}
},
"Pleroma_DOT_Upload": {
":default_description": {
"label": "Default description",
"description": "Default description to give to a file. Setting it to ':filename' will use file's filename as description."
}
},
":instance": {
":public": {
"label": "Instance is public",
"description": "Disabling this will make all API accessible only for logged-in users, this will make Public and Federated timelines inaccessible to anonymous visitors."
},
":limit_to_local_content": {
"label": "Limit search to local content",
"description": "Disables global network search for unauthenticated (default), all users or none"
},
":description_limit": {
"label": "Limit",
"description": "Character limit for attachment descriptions"
},
":background_image": {
"label": "Background image",
"description": "Background image (primarily used by PleromaFE)"
}
},
":http_security": {
":allow_unsafe_eval": {
"label": "Allow unsafe-eval",
"description": "Allow unsafe evaluation of scripts (required for Flash support)"
},
":report_url": {
"label": "Report URL",
"description": "URL to report security violations to"
}
}
}
}
},
"time": {
"unit": {
"days": "{0} day | {0} days",
"days_short": "{0}d",
"days_suffix": "day(s)",
"hours": "{0} hour | {0} hours",
"hours_short": "{0}h",
"hours_suffix": "hour(s)",
"minutes": "{0} minute | {0} minutes",
"minutes_short": "{0}min",
"minutes_suffix": "minute(s)",
"months": "{0} month | {0} months",
"months_short": "{0}mo",
"months_suffix": "month(s)",
"seconds": "{0} second | {0} seconds",
"seconds_short": "{0}s",
"seconds_suffix": "second(s)",
"weeks": "{0} week | {0} weeks",
"weeks_short": "{0}w",
"weeks_suffix": "week(s)",
"years": "{0} year | {0} years",
"years_short": "{0}y",
"years_suffix": "year(s)"
},
"in_future": "in {0}",
"in_past": "{0} ago",
"now": "just now",
"now_short": "now"
},
"timeline": {
"collapse": "Collapse",
"conversation": "Conversation",
"error": "Error fetching timeline: {0}",
"load_older": "Load older statuses",
"no_retweet_hint": "Post is marked as followers-only or direct and cannot be repeated",
"repeated": "repeated",
"show_new": "Show new",
"reload": "Reload",
"up_to_date": "Up-to-date",
"no_more_statuses": "No more statuses",
"no_statuses": "No statuses",
"socket_reconnected": "Realtime connection established",
"socket_broke": "Realtime connection lost: CloseEvent code {0}",
"quick_view_settings": "Quick view settings",
"quick_filter_settings": "Quick filter settings",
"filter_settings": "Filter"
},
"status": {
"favorites": "Favorites",
"repeats": "Repeats",
"quotes": "Quotes",
"repeat_confirm": "Do you really want to repeat this status?",
"repeat_confirm_title": "Repeat confirmation",
"repeat_confirm_accept_button": "Repeat",
"repeat_confirm_cancel_button": "Do not repeat",
"delete": "Delete status",
"delete_error": "Error deleting status: {0}",
"edit": "Edit status",
"edited_at": "(last edited {time})",
"pin": "Pin on profile",
"unpin": "Unpin from profile",
"pinned": "Pinned",
"bookmark": "Bookmark",
"unbookmark": "Unbookmark",
"delete_confirm": "Do you really want to delete this status?",
"delete_confirm_title": "Delete confirmation",
"delete_confirm_accept_button": "Delete",
"delete_confirm_cancel_button": "Keep",
"reply_to": "Reply to",
"reply_to_with_icon": "{icon} {replyTo}",
"reply_to_with_arg": "{replyToWithIcon} {user}",
"mentions": "Mentions",
"replies_list": "Replies:",
"replies_list_with_others": "Replies (+{numReplies} other): | Replies (+{numReplies} others):",
"mute_ellipsis": "Mute…",
"mute_user": "Mute user",
"unmute_user": "Unmute user",
"mute_domain": "Mute domain",
"unmute_domain": "Unmute domain",
"mute_conversation": "Mute conversation",
"unmute_conversation": "Unmute conversation",
"status_unavailable": "Status unavailable",
"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",
"thread_muted_and_words": ", has words:",
"sensitive_muted": "Muting sensitive content",
"bot_muted": "Muting bot content",
"show_full_subject": "Show full subject",
"hide_full_subject": "Hide full subject",
"show_content": "Show content",
"hide_content": "Hide content",
"status_deleted": "This post was deleted",
"unknown_user": "unknown user",
"unknown_user_info": "Unable to fetch information about this user",
"nsfw": "NSFW",
"expand": "Expand",
"you": "(You)",
"plus_more": "+{number} more",
"many_attachments": "Post has {number} attachment(s)",
"collapse_attachments": "Collapse attachments",
"show_all_attachments": "Show all attachments",
"show_attachment_in_modal": "Show in media modal",
"show_attachment_description": "Preview description (open attachment for full description)",
"hide_attachment": "Hide attachment",
"remove_attachment": "Remove attachment",
"attachment_stop_flash": "Stop Flash player",
"move_up": "Shift attachment left",
"move_down": "Shift attachment right",
"open_gallery": "Open gallery",
"thread_hide": "Hide this thread",
"thread_show": "Show this thread",
"thread_show_full": "Show everything under this thread ({numStatus} status in total, max depth {depth}) | Show everything under this thread ({numStatus} statuses in total, max depth {depth})",
"thread_show_full_with_icon": "{icon} {text}",
"thread_follow": "See the remaining part of this thread ({numStatus} status in total) | See the remaining part of this thread ({numStatus} statuses in total)",
"thread_follow_with_icon": "{icon} {text}",
"ancestor_follow": "See {numReplies} other reply under this status | See {numReplies} other replies under this status",
"ancestor_follow_with_icon": "{icon} {text}",
"show_all_conversation_with_icon": "{icon} {text}",
"show_all_conversation": "Show full conversation ({numStatus} other status) | Show full conversation ({numStatus} other statuses)",
"show_only_conversation_under_this": "Only show replies to this status",
"status_history": "Status history",
"reaction_count_label": "{num} person reacted | {num} people reacted",
"hide_quote": "Hide the quoted status",
"display_quote": "Display the quoted status",
"invisible_quote": "Quoted status unavailable: {link}",
"more_actions": "More actions on this status",
"loading": "Loading...",
"load_error": "Unable to load status: {error}"
},
"user_card": {
"approve": "Approve",
"approve_confirm_title": "Approve confirmation",
"approve_confirm_accept_button": "Approve",
"approve_confirm_cancel_button": "Do not approve",
"approve_confirm": "Do you want to approve {user}'s follow request?",
"block": "Block",
"blocked": "Blocked!",
"block_confirm_title": "Block confirmation",
"block_confirm": "Do you really want to block {user}?",
"block_confirm_accept_button": "Block",
"block_confirm_cancel_button": "Do not block",
"deactivated": "Deactivated",
"deny": "Deny",
"deny_confirm_title": "Deny confirmation",
"deny_confirm_accept_button": "Deny",
"deny_confirm_cancel_button": "Do not deny",
"deny_confirm": "Do you want to deny {user}'s follow request?",
"edit_profile": "Edit profile",
"favorites": "Favorites",
"follow": "Follow",
"follow_cancel": "Cancel request",
"follow_sent": "Request sent!",
"follow_progress": "Requesting…",
"follow_unfollow": "Unfollow",
"unfollow_confirm_title": "Unfollow confirmation",
"unfollow_confirm": "Do you really want to unfollow {user}?",
"unfollow_confirm_accept_button": "Unfollow",
"unfollow_confirm_cancel_button": "Do not unfollow",
"followees": "Following",
"followers": "Followers",
"following": "Following!",
"follows_you": "Follows you!",
"hidden": "Hidden",
"its_you": "It's you!",
"media": "Media",
"mention": "Mention",
"message": "Message",
"mute": "Mute",
"muted": "Muted",
"mute_confirm_title": "Mute confirmation",
"mute_confirm": "Do you really want to mute {user}?",
"mute_domain_confirm": "Do you really want to mute entire {domain}?",
"mute_confirm_accept_button": "Mute",
"mute_confirm_cancel_button": "Do not mute",
"mute_or": "or",
"expire_in": "Expire in",
"expire_mute_message": "Are you sure you want to mute {0}?",
"expire_block_message": "Are you sure you want to block {0}?",
"dont_ask_again_mute": "Always mute users this way",
"dont_ask_again_block": "Always block users this way",
"mute_block_temporarily": "Temporarily",
"mute_block_forever": "Forever",
"mute_block_never": "Never",
"mute_block_ask": "Ask",
"default_mute_expiration": "Always mute users",
"default_block_expiration": "Always block users",
"default_expiration_time": "Expire in",
"mute_expires_forever": "Muted forever",
"mute_expires_at": "Muted until {0}",
"block_expires_forever": "Blocked forever",
"block_expires_at": "Blocked until {0}",
"mute_duration_prompt": "Mute this user for (0 for indefinite time):",
"statuses_per_day": "Statuses per day",
"remote_follow": "Remote follow",
"remove_follower": "Remove follower",
"remove_follower_confirm_title": "Remove follower confirmation",
"remove_follower_confirm_accept_button": "Remove",
"remove_follower_confirm_cancel_button": "Keep",
"remove_follower_confirm": "Do you really want to remove {user} from your followers?",
"report": "Report",
"statuses": "Statuses",
"subscribe": "Subscribe",
"unsubscribe": "Unsubscribe",
"unblock": "Unblock",
"unblock_progress": "Unblocking…",
"block_progress": "Blocking…",
"unmute": "Unmute",
"unmute_progress": "Unmuting…",
"mute_progress": "Muting…",
"hide_repeats": "Hide repeats",
"show_repeats": "Show repeats",
"bot": "Bot",
"group": "Group",
"birthday": "Born {birthday}",
"admin_menu": {
"moderation": "Moderation",
"grant_admin": "Grant Admin",
"revoke_admin": "Revoke Admin",
"grant_moderator": "Grant Moderator",
"revoke_moderator": "Revoke Moderator",
"activate_account": "Activate account",
"deactivate_account": "Deactivate account",
"delete_account": "Delete account",
"force_nsfw": "Mark all posts as NSFW",
"strip_media": "Remove media from posts",
"force_unlisted": "Force posts to be unlisted",
"sandbox": "Force posts to be followers-only",
"disable_remote_subscription": "Disallow following user from remote instances",
"disable_any_subscription": "Disallow following user at all",
"quarantine": "Disallow user posts from federating",
"delete_user": "Delete user",
"delete_user_data_and_deactivate_confirmation": "This will permanently delete the data from this account and deactivate it. Are you absolutely sure?"
},
"highlight_new": {
"disabled": "Don't highlight",
"solid": "Solid background",
"striped": "Striped background",
"side": "Side stripe"
},
"personal_note": "Personal note",
"note_blank_click": "Click to add note",
"highlight_header": "Highlight user's posts and mentions"
},
"user_profile": {
"timeline_title": "User timeline",
"profile_does_not_exist": "Sorry, this profile does not exist.",
"profile_loading_error": "Sorry, there was an error loading this profile."
},
"user_reporting": {
"title": "Reporting {0}",
"add_comment_description": "The report will be sent to your instance moderators. You can provide an explanation of why you are reporting this account below:",
"additional_comments": "Additional comments",
"forward_description": "The account is from another server. Send a copy of the report there as well?",
"forward_to": "Forward to {0}",
"submit": "Submit",
"generic_error": "An error occurred while processing your request."
},
"who_to_follow": {
"more": "More",
"who_to_follow": "Who to follow"
},
"tool_tip": {
"media_upload": "Upload media",
"mentions": "Mentions",
"repeat": "Repeat",
"unrepeat": "Unrepeat",
"reply": "Reply",
"favorite": "Favorite",
"unfavorite": "Unfavorite",
"add_reaction": "Add Reaction",
"add_quote": "Add quote",
"user_settings": "User Settings",
"accept_follow_request": "Accept follow request",
"reject_follow_request": "Reject follow request",
"bookmark": "Bookmark",
"toggle_expand": "Expand or collapse notification to show post in full",
"toggle_mute": "Expand or collapse notification to reveal muted content",
"autocomplete_available": "{number} result is available. Use up and down keys to navigate through them. | {number} results are available. Use up and down keys to navigate through them."
},
"upload": {
"error": {
"base": "Upload failed.",
"message": "Upload failed: {0}",
"file_too_big": "File too big [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"default": "Try again later"
},
"file_size_units": {
"B": "B",
"KiB": "KiB",
"MiB": "MiB",
"GiB": "GiB",
"TiB": "TiB"
}
},
"search": {
"people": "People",
"hashtags": "Hashtags",
"person_talking": "{count} person talking",
"people_talking": "{count} people talking",
"no_results": "No results",
"no_more_results": "No more results",
"load_more": "Load more results"
},
"password_reset": {
"forgot_password": "Forgot password?",
"password_reset": "Password reset",
"instruction": "Enter your email address or username. We will send you a link to reset your password.",
"placeholder": "Your email or username",
"check_email": "Check your email for a link to reset your password.",
"return_home": "Return to the home page",
"too_many_requests": "You have reached the limit of attempts, try again later.",
"password_reset_disabled": "Password reset is disabled. Please contact your instance administrator.",
"password_reset_required": "You must reset your password to log in.",
"password_reset_required_but_mailer_is_disabled": "You must reset your password, but password reset is disabled. Please contact your instance administrator."
},
"chats": {
"you": "You:",
"message_user": "Message {nickname}",
"delete": "Delete",
"chats": "Chats",
"new": "New Chat",
"empty_message_error": "Cannot post empty message",
"more": "More",
"delete_confirm": "Do you really want to delete this message?",
"error_loading_chat": "Something went wrong when loading the chat.",
"error_sending_message": "Something went wrong when sending the message.",
"empty_chat_list_placeholder": "You don't have any chats yet. Start a new chat!"
},
"bookmarks": {
"manage_bookmark_folders": "Manage bookmark folders"
},
"lists": {
"lists": "Lists",
"new": "New List",
"title": "List title",
"search": "Search users",
"create": "Create",
"save": "Save changes",
"delete": "Delete list",
"following_only": "Limit to Following",
"manage_lists": "Manage lists",
"manage_members": "Manage list members",
"add_members": "Search for more users",
"remove_from_list": "Remove from list",
"add_to_list": "Add to list",
"is_in_list": "Already in list",
"editing_list": "Editing list {listTitle}",
"creating_list": "Creating new list",
"update_title": "Save Title",
"really_delete": "Really delete list?",
"error": "Error manipulating lists: {0}"
},
"file_type": {
"audio": "Audio",
"video": "Video",
"image": "Image",
"file": "File"
},
"display_date": {
"today": "Today"
},
"update": {
"big_update_title": "Please bear with us",
"big_update_content": "We haven't had a release in a while, so things might look and feel different than what you're used to.",
- "update_bugs": "Please report any issues and bugs on {pleromaGitlab}, as we have changed a lot, and although we test thoroughly and use development versions ourselves, we may have missed some things. We welcome your feedback and suggestions on issues you might encounter, or how to improve Pleroma and Pleroma-FE.",
+ "big_update_content2": "We implemented synchronized settings! This means (nearly) all your settings are now properly synchronized between devices and sessions. We try to migrate settings from old config but migration is performed only once, so some settings might be incorrect.",
+ "update_bugs": "Please report any issues and bugs on {pleromaForgejo}, as we have changed a lot, and although we test thoroughly and use development versions ourselves, we may have missed some things. We welcome your feedback and suggestions on issues you might encounter, or how to improve Pleroma and Pleroma-FE.",
+ "update_bugs2": "Let us know if you don't want certain setting synchronized on {pleromaForgejo}.",
"update_bugs_gitlab": "Pleroma GitLab",
+ "update_bugs_forgejo": "Pleroma Forgejo",
"update_changelog": "For more details on what's changed, see {theFullChangelog}.",
"update_changelog_here": "the full changelog",
"art_by": "Art by {linkToArtist}"
},
"unicode_domain_indicator": {
"tooltip": "This domain contains non-ascii characters."
},
"drafts": {
"drafts": "Drafts",
"no_drafts": "You have no drafts",
"empty": "(No content)",
"poll_tooltip": "Draft contains a poll",
"continue": "Continue composing",
"save": "Save without posting",
"abandon": "Abandon draft",
"abandon_confirm_title": "Abandon confirmation",
"abandon_confirm": "Do you really want to abandon this draft?",
"abandon_confirm_accept_button": "Abandon",
"abandon_confirm_cancel_button": "Keep",
"replying": "Replying to {statusLink}",
"editing": "Editing {statusLink}",
"unavailable": "(unavailable)"
},
"splash": {
"loading": "Loading...",
"theme": "Applying theme, please wait warmly...",
"fun_1": "Drink more water",
"fun_2": "Take it easy!",
"fun_3": "Suya...",
"fun_4": "My Pleroma machine is full power!",
"error": "Something went wrong"
},
"bookmark_folders": {
"select_folder": "Select bookmark folder",
"creating_folder": "Creating bookmark folder",
"editing_folder": "Editing folder {folderName}",
"emoji": "Emoji",
"name": "Folder name",
"new": "New Folder",
"create": "Create folder",
"delete": "Delete folder",
"update_folder": "Save changes",
"really_delete": "Do you really want to delete the folder?",
"error": "Error manipulating bookmark folders: {0}"
}
}
diff --git a/src/lib/language.js b/src/lib/language.js
new file mode 100644
index 0000000000..af53eeffdb
--- /dev/null
+++ b/src/lib/language.js
@@ -0,0 +1,18 @@
+import Cookies from 'js-cookie'
+
+import { useEmojiStore } from 'src/stores/emoji.js'
+import { useI18nStore } from 'src/stores/i18n.js'
+
+export const piniaLanguagePlugin = ({ store, options }) => {
+ if (store.$id === 'sync_config') {
+ store.$onAction(({ name, args }) => {
+ if (name === 'setPreference') {
+ const { path, value } = args[0]
+ if (path === 'simple.interfaceLanguage') {
+ useI18nStore().setLanguage(value)
+ useEmojiStore().loadUnicodeEmojiData(value)
+ }
+ }
+ })
+ }
+}
diff --git a/src/lib/push_notifications_plugin.js b/src/lib/push_notifications_plugin.js
index 9732a82c9f..f4dcb1dbcb 100644
--- a/src/lib/push_notifications_plugin.js
+++ b/src/lib/push_notifications_plugin.js
@@ -1,39 +1,93 @@
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
-export default (store) => {
+export const piniaPushNotificationsPlugin = ({ store }) => {
+ if (
+ store.$id !== 'sync_config' &&
+ store.$id !== 'instance' &&
+ store.$id !== 'interface'
+ )
+ return
+
+ store.$onAction(({ name: actionName, args }) => {
+ if (
+ store.$id === 'interface' &&
+ actionName !== 'setNotificationPermission' &&
+ actionName !== 'setLoginStatus'
+ )
+ return
+
+ // Initial state
+ let vapidPublicKey = useInstanceStore().vapidPublicKey
+ let enabled = useMergedConfigStore().mergedConfig.webPushNotifications
+ let permissionGranted =
+ useInterfaceStore().notificationPermission === 'granted'
+ let permissionPresent =
+ useInterfaceStore().notificationPermission !== undefined
+ let user = !!window.vuex.state.users.currentUser
+
+ if (store.$id === 'instance') {
+ if (actionName === 'set' && args[0].path === 'vapidPublicKey') {
+ const { value } = args[0]
+ vapidPublicKey = value
+ }
+ }
+
+ if (!vapidPublicKey || !permissionPresent) {
+ return
+ }
+
+ if (store.$id === 'interface') {
+ if (actionName === 'setNotificationPermission') {
+ permissionGranted = args[0] === 'granted'
+ } else if (actionName === 'setLoginStatus') {
+ user = args[0]
+ } else {
+ return
+ }
+ } else if (store.$id === 'sync_config') {
+ if (
+ actionName === 'setPreference' &&
+ args[0].path === 'simple.webPushNotifications'
+ ) {
+ const { value } = args[0]
+ enabled = value
+ } else {
+ return
+ }
+ }
+
+ if (permissionGranted && enabled && user) {
+ return window.vuex.dispatch('registerPushNotifications')
+ } else {
+ return window.vuex.dispatch('unregisterPushNotifications')
+ }
+ })
+}
+
+export const vuexPushNotificationsPlugin = (store) => {
store.subscribe((mutation, state) => {
+ // Initial state
const vapidPublicKey = useInstanceStore().vapidPublicKey
- const webPushNotification = state.config.webPushNotifications
- const permission = useInterfaceStore().notificationPermission === 'granted'
+ const enabled = useMergedConfigStore().mergedConfig.webPushNotifications
+ const permissionGranted =
+ useInterfaceStore().notificationPermission === 'granted'
+ const permissionPresent =
+ useInterfaceStore().notificationPermission !== undefined
const user = state.users.currentUser
- const isUserMutation = mutation.type === 'setCurrentUser'
- const isVapidMutation =
- mutation.type === 'setInstanceOption' &&
- mutation.payload.name === 'vapidPublicKey'
- const isPermMutation =
- mutation.type === 'setNotificationPermission' &&
- mutation.payload === 'granted'
- const isUserConfigMutation =
- mutation.type === 'setOption' &&
- mutation.payload.name === 'webPushNotifications'
- const isVisibilityMutation =
- mutation.type === 'setOption' &&
- mutation.payload.name === 'notificationVisibility'
+ if (!permissionPresent || !vapidPublicKey) return
if (
- isUserMutation ||
- isVapidMutation ||
- isPermMutation ||
- isUserConfigMutation ||
- isVisibilityMutation
+ mutation.type === 'setCurrentUser' ||
+ mutation.type === 'clearCurrentUser'
) {
- if (user && vapidPublicKey && permission && webPushNotification) {
+ if (user && permissionGranted && enabled) {
return store.dispatch('registerPushNotifications')
- } else if (isUserConfigMutation && !webPushNotification) {
+ } else {
return store.dispatch('unregisterPushNotifications')
}
}
})
}
diff --git a/src/lib/style.js b/src/lib/style.js
new file mode 100644
index 0000000000..534d120e39
--- /dev/null
+++ b/src/lib/style.js
@@ -0,0 +1,50 @@
+import { useInterfaceStore } from 'src/stores/interface.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+
+import { applyStyleConfig } from 'src/services/style_setter/style_setter.js'
+
+const ACTIONS = new Set([
+ 'setPreference',
+ 'unsetPreference',
+ 'set',
+ 'unset',
+ 'setTemporarily',
+ 'unsetTemporarily',
+])
+
+const APPEARANCE_SETTINGS_KEYS = [
+ 'sidebarColumnWidth',
+ 'contentColumnWidth',
+ 'notifsColumnWidth',
+ 'themeEditorMinWidth',
+ 'textSize',
+ 'navbarSize',
+ 'panelHeaderSize',
+ 'forcedRoundness',
+ 'emojiSize',
+ 'emojiReactionsScale',
+ 'fontInterface',
+ 'fontPosts',
+ 'fontInput',
+ 'fontMonospace',
+ 'underlay',
+]
+
+const MIXED_KEYS = new Set([
+ ...APPEARANCE_SETTINGS_KEYS,
+ ...APPEARANCE_SETTINGS_KEYS.map((x) => 'simple.' + x),
+])
+
+export const piniaStylePlugin = ({ store, options }) => {
+ if (store.$id === 'sync_config' || store.$id === 'local_config') {
+ store.$onAction(({ name, args, after }) => {
+ if (ACTIONS.has(name)) {
+ const { path } = args[0]
+ if (MIXED_KEYS.has(path)) {
+ after(() => applyStyleConfig(useMergedConfigStore().mergedConfig))
+ after(() => useInterfaceStore().applyTheme({ recompile: true }))
+ }
+ }
+ })
+ }
+}
diff --git a/src/main.js b/src/main.js
index 91653973d9..44b01aabb7 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,112 +1,121 @@
/* global process */
import { createPinia } from 'pinia'
import { createStore } from 'vuex'
import 'custom-event-polyfill'
import './lib/event_target_polyfill.js'
// Polyfill for Array.prototype.toSorted (ES2023)
if (!Array.prototype.toSorted) {
Array.prototype.toSorted = function (compareFn) {
return [...this].sort(compareFn)
}
}
import { createI18n } from 'vue-i18n'
import afterStoreSetup from './boot/after_store.js'
import messages from './i18n/messages.js'
import createPersistedState, {
piniaPersistPlugin,
} from './lib/persisted_state.js'
-import pushNotifications from './lib/push_notifications_plugin.js'
+import {
+ piniaPushNotificationsPlugin,
+ vuexPushNotificationsPlugin,
+} from './lib/push_notifications_plugin.js'
import vuexModules from './modules/index.js'
+import { piniaLanguagePlugin } from 'src/lib/language.js'
+import { piniaStylePlugin } from 'src/lib/style.js'
+
const currentLocale = (window.navigator.language || 'en').split('-')[0]
const i18n = createI18n({
// By default, use the browser locale, we will update it if neccessary
locale: 'en',
fallbackLocale: 'en',
messages: messages.default,
})
messages.setLanguage(i18n.global, currentLocale)
const persistedStateOptions = {
- paths: ['serverSideStorage.cache', 'config', 'users.lastLoginName', 'oauth'],
+ paths: ['users.lastLoginName', 'oauth', 'config'],
}
;(async () => {
const isFox = Math.floor(Math.random() * 2) > 0 ? '_fox' : ''
const splashError = (i18n, e) => {
const throbber = document.querySelector('#throbber')
throbber.addEventListener('animationend', () => {
document.querySelector('#mascot').src =
`/static/pleromatan_orz${isFox}.png`
})
throbber.classList.add('dead')
document.querySelector('#status').textContent =
i18n.global.t('splash.error')
console.error('PleromaFE failed to initialize: ', e)
document.querySelector('#statusError').textContent = e
document.querySelector('#statusStack').textContent = e.stack
document.querySelector('#statusError').style = 'display: block'
document.querySelector('#statusStack').style = 'display: block'
}
window.splashError = (e) => splashError(i18n, e)
window.splashUpdate = (key) => {
if (document.querySelector('#status')) {
document.querySelector('#status').textContent = i18n.global.t(key)
}
}
try {
let storageError
- const plugins = [pushNotifications]
+ const plugins = [vuexPushNotificationsPlugin]
const pinia = createPinia()
pinia.use(piniaPersistPlugin())
+ pinia.use(piniaLanguagePlugin)
+ pinia.use(piniaStylePlugin)
+ pinia.use(piniaPushNotificationsPlugin)
try {
const persistedState = await createPersistedState(persistedStateOptions)
plugins.push(persistedState)
} catch (e) {
console.error('Storage error', e)
storageError = e
}
document.querySelector('#splash').classList.remove('initial-hidden')
document.querySelector('#mascot').src =
`/static/pleromatan_apology${isFox}_small.webp`
document.querySelector('#status').removeAttribute('class')
document.querySelector('#status').textContent =
i18n.global.t('splash.loading')
document.querySelector('#splash-credit').textContent = i18n.global.t(
'update.art_by',
{ linkToArtist: 'pipivovott' },
)
const store = createStore({
modules: vuexModules,
plugins,
options: {
devtools: process.env.NODE_ENV !== 'production',
},
strict: false, // Socket modifies itself, let's ignore this for now.
// strict: process.env.NODE_ENV !== 'production'
})
window.vuex = store
// Temporarily passing pinia and vuex stores along with storageError result until migration is fully complete.
return await afterStoreSetup({ pinia, store, storageError, i18n })
} catch (e) {
splashError(i18n, e)
}
})()
// These are inlined by webpack's DefinePlugin
// biome-ignore-start lint: added in build process
window.___pleromafe_mode = process.env
window.___pleromafe_commit_hash = COMMIT_HASH
window.___pleromafe_dev_overrides = DEV_OVERRIDES
// biome-ignore-end lint: added in build process
diff --git a/src/modules/config.js b/src/modules/config.js
deleted file mode 100644
index b68d547df0..0000000000
--- a/src/modules/config.js
+++ /dev/null
@@ -1,200 +0,0 @@
-import Cookies from 'js-cookie'
-import { set } from 'lodash'
-
-import messages from '../i18n/messages'
-import localeService from '../services/locale/locale.service.js'
-import { applyStyleConfig } from '../services/style_setter/style_setter.js'
-import { defaultState, instanceDefaultConfig } from './default_config_state.js'
-
-import { useEmojiStore } from 'src/stores/emoji.js'
-import { useI18nStore } from 'src/stores/i18n.js'
-import { useInstanceStore } from 'src/stores/instance.js'
-import { useInterfaceStore } from 'src/stores/interface.js'
-
-const BACKEND_LANGUAGE_COOKIE_NAME = 'userLanguage'
-const APPEARANCE_SETTINGS_KEYS = new Set([
- 'sidebarColumnWidth',
- 'contentColumnWidth',
- 'notifsColumnWidth',
- 'themeEditorMinWidth',
- 'textSize',
- 'navbarSize',
- 'panelHeaderSize',
- 'forcedRoundness',
- 'emojiSize',
- 'emojiReactionsScale',
-])
-
-/* TODO this is a bit messy.
- * We need to declare settings with their types and also deal with
- * instance-default settings in some way, hopefully try to avoid copy-pasta
- * in general.
- */
-export const multiChoiceProperties = [
- 'postContentType',
- 'subjectLineBehavior',
- 'conversationDisplay', // tree | linear
- 'conversationOtherRepliesButton', // below | inside
- 'mentionLinkDisplay', // short | full_for_remote | full
- 'userPopoverAvatarAction', // close | zoom | open
- 'unsavedPostAction', // save | discard | confirm
-]
-
-// caching the instance default properties
-export const instanceDefaultProperties = Object.keys(instanceDefaultConfig)
-
-const config = {
- state: { ...defaultState },
- getters: {
- defaultConfig() {
- return {
- ...defaultState,
- ...Object.fromEntries(
- instanceDefaultProperties.map((key) => [
- key,
- useInstanceStore().prefsStorage[key],
- ]),
- ),
- }
- },
- mergedConfig(state) {
- const instancePrefs = useInstanceStore().prefsStorage
- const result = Object.fromEntries(
- Object.keys(defaultState).map((key) => [
- key,
- state[key] ?? instancePrefs[key],
- ]),
- )
- return result
- },
- },
- mutations: {
- setOptionTemporarily(state, { name, value }) {
- set(state, name, value)
- applyStyleConfig(state)
- },
- setOption(state, { name, value }) {
- set(state, name, value)
- },
- setHighlight(state, { user, color, type }) {
- const data = this.state.config.highlight[user]
- if (color || type) {
- state.highlight[user] = {
- color: color || data.color,
- type: type || data.type,
- }
- } else {
- delete state.highlight[user]
- }
- },
- },
- actions: {
- loadSettings({ dispatch }, data) {
- const knownKeys = new Set(Object.keys(defaultState))
- const presentKeys = new Set(Object.keys(data))
- const intersection = new Set()
- for (const elem of presentKeys) {
- if (knownKeys.has(elem)) {
- intersection.add(elem)
- }
- }
-
- intersection.forEach((name) =>
- dispatch('setOption', { name, value: data[name] }),
- )
- },
- setHighlight({ commit }, { user, color, type }) {
- commit('setHighlight', { user, color, type })
- },
- setOptionTemporarily({ commit, dispatch, state }, { name, value }) {
- if (useInterfaceStore().temporaryChangesTimeoutId !== null) {
- console.warn("Can't track more than one temporary change")
- return
- }
- const oldValue = state[name]
-
- commit('setOptionTemporarily', { name, value })
-
- const confirm = () => {
- dispatch('setOption', { name, value })
- useInterfaceStore().clearTemporaryChanges()
- }
-
- const revert = () => {
- commit('setOptionTemporarily', { name, value: oldValue })
- useInterfaceStore().clearTemporaryChanges()
- }
-
- useInterfaceStore().setTemporaryChanges({
- confirm,
- revert,
- })
- },
- setThemeV2({ commit, dispatch }, { customTheme, customThemeSource }) {
- commit('setOption', { name: 'theme', value: 'custom' })
- commit('setOption', { name: 'customTheme', value: customTheme })
- commit('setOption', {
- name: 'customThemeSource',
- value: customThemeSource,
- })
- dispatch('setTheme', { themeData: customThemeSource, recompile: true })
- },
- setOption({ commit, dispatch, state }, { name, value }) {
- const exceptions = new Set(['useStreamingApi'])
-
- if (exceptions.has(name)) {
- switch (name) {
- case 'useStreamingApi': {
- const action = value ? 'enableMastoSockets' : 'disableMastoSockets'
-
- dispatch(action)
- .then(() => {
- commit('setOption', { name: 'useStreamingApi', value })
- })
- .catch((e) => {
- console.error('Failed starting MastoAPI Streaming socket', e)
- dispatch('disableMastoSockets')
- dispatch('setOption', { name: 'useStreamingApi', value: false })
- })
- break
- }
- }
- } else {
- commit('setOption', { name, value })
- if (APPEARANCE_SETTINGS_KEYS.has(name)) {
- applyStyleConfig(state)
- }
- if (name.startsWith('theme3hacks')) {
- dispatch('applyTheme', { recompile: true })
- }
- switch (name) {
- case 'theme':
- if (value === 'custom') break
- dispatch('setTheme', {
- themeName: value,
- recompile: true,
- saveData: true,
- })
- break
- case 'themeDebug': {
- dispatch('setTheme', { recompile: true })
- break
- }
- case 'interfaceLanguage':
- messages.setLanguage(useI18nStore().i18n, value)
- useEmojiStore().loadUnicodeEmojiData(value)
- Cookies.set(
- BACKEND_LANGUAGE_COOKIE_NAME,
- localeService.internalToBackendLocaleMulti(value),
- )
- break
- case 'thirdColumnMode':
- useInterfaceStore().setLayoutWidth(undefined)
- break
- }
- }
- },
- },
-}
-
-export default config
diff --git a/src/modules/config_declaration.js b/src/modules/config_declaration.js
deleted file mode 100644
index 5c0d7ac0ef..0000000000
--- a/src/modules/config_declaration.js
+++ /dev/null
@@ -1,43 +0,0 @@
-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,
- },
- })
- })
- },
- },
-]
diff --git a/src/modules/default_config_state.js b/src/modules/default_config_state.js
index 2f18fe9a5d..5f797126d6 100644
--- a/src/modules/default_config_state.js
+++ b/src/modules/default_config_state.js
@@ -1,229 +1,815 @@
+import { get, set } from 'lodash'
+
const browserLocale = (navigator.language || 'en').split('-')[0]
+export const convertDefinitions = (definitions) =>
+ Object.fromEntries(
+ Object.entries(definitions).map(([k, v]) => {
+ const defaultValue = v.default ?? null
+ return [k, defaultValue]
+ }),
+ )
+
/// Instance config entries provided by static config or pleroma api
/// Put settings here only if it does not make sense for a normal user
/// to override it.
-export const staticOrApiConfigDefault = {
- name: 'PleromaFE',
- theme: 'pleroma-dark',
- palette: null,
- style: null,
- defaultAvatar: '/images/avi.png',
- defaultBanner: '/images/banner.png',
- background: '/static/aurora_borealis.jpg',
- embeddedToS: true,
- logo: '/static/logo.svg',
- logoMargin: '.2em',
- logoMask: true,
- logoLeft: false,
- redirectRootLogin: '/main/friends',
- redirectRootNoLogin: '/main/all',
- hideSitename: false,
- nsfwCensorImage: null,
- showFeaturesPanel: true,
- showInstanceSpecificPanel: false,
+export const INSTANCE_IDENTITY_DEFAULT_DEFINITIONS = {
+ style: {
+ description: 'Instance default style name',
+ type: 'string',
+ required: false,
+ },
+ palette: {
+ description: 'Instance default palette name',
+ type: 'string',
+ required: false,
+ },
+ theme: {
+ description: 'Instance default theme name',
+ type: 'string',
+ required: false,
+ },
+ defaultAvatar: {
+ description: "Default avatar image to use when user doesn't have one set",
+ type: 'string',
+ default: '/images/avi.png',
+ },
+ defaultBanner: {
+ description: "Default banner image to use when user doesn't have one set",
+ type: 'string',
+ default: '/images/banner.png',
+ },
+ background: {
+ description: 'Instance background/wallpaper',
+ type: 'string',
+ default: '/static/aurora_borealis.jpg',
+ },
+ embeddedToS: {
+ description: 'Whether to show Terms of Service title bar',
+ type: 'boolean',
+ default: true,
+ },
+ logo: {
+ description: 'Instance logo',
+ type: 'string',
+ default: '/static/logo.svg',
+ },
+ logoMargin: {
+ description: 'Margin for logo (spacing above/below)',
+ type: 'string',
+ default: '.2em',
+ },
+ logoMask: {
+ description:
+ 'Use logo as a mask (works well for monochrome/transparent logos)',
+ type: 'boolean',
+ default: true,
+ },
+ logoLeft: {
+ description: 'Show logo on the left side of navbar',
+ type: 'boolean',
+ default: false,
+ },
+ redirectRootLogin: {
+ description: 'Where to redirect user after login',
+ type: 'string',
+ default: '/main/friends',
+ },
+ redirectRootNoLogin: {
+ description: 'Where to redirect anonymous visitors',
+ type: 'string',
+ default: '/main/all',
+ },
+ hideSitename: {
+ description: 'Hide the instance name in navbar',
+ type: 'boolean',
+ default: false,
+ },
+ nsfwCensorImage: {
+ description: 'Default NSFW censor image',
+ type: 'string',
+ required: false,
+ },
+ showFeaturesPanel: {
+ description: 'Show features panel to anonymous visitors',
+ type: 'boolean',
+ default: true,
+ },
+ showInstanceSpecificPanel: {
+ description: 'Show instance-specific panel',
+ type: 'boolean',
+ default: false,
+ },
// Html stuff
- instanceSpecificPanelContent: '',
- tos: '',
+ instanceSpecificPanelContent: {
+ description: 'HTML of Instance-specific panel',
+ type: 'string',
+ required: false,
+ },
+ tos: {
+ description: 'HTML of Terms of Service panel',
+ type: 'string',
+ required: false,
+ },
+ name: {
+ description: 'Instance Name',
+ type: 'string',
+ required: false,
+ },
}
+export const INSTANCE_IDENTITY_DEFAULT = convertDefinitions(
+ INSTANCE_IDENTITY_DEFAULT_DEFINITIONS,
+)
+export const INSTANCE_IDENTIY_EXTERNAL = new Set([
+ 'tos',
+ 'instanceSpecificPanelContent',
+])
/// This object contains setting entries that makes sense
/// at the user level. The defaults can also be overriden by
/// instance admins in the frontend_configuration endpoint or static config.
-export const instanceDefaultConfig = {
- expertLevel: 0, // used to track which settings to show and hide
- hideISP: false,
- hideInstanceWallpaper: false,
- hideShoutbox: false,
- // bad name: actually hides posts of muted USERS
- hideMutedPosts: false,
- hideMutedThreads: true,
- hideWordFilteredPosts: false,
- muteBotStatuses: false,
- muteSensitiveStatuses: false,
- collapseMessageWithSubject: false,
- padEmoji: true,
- hideAttachments: false,
- hideAttachmentsInConv: false,
- hideScrobbles: false,
- hideScrobblesAfter: '2d',
- maxThumbnails: 16,
- hideNsfw: true,
- preloadImage: true,
- loopVideo: true,
- loopVideoSilentOnly: true,
+export const INSTANCE_DEFAULT_CONFIG_DEFINITIONS = {
+ expertLevel: {
+ description:
+ 'Used to track which settings to show and hide in settings modal',
+ type: 'number', // not a boolean so we could potentially make multiple levels of expert-ness
+ default: 0,
+ },
+ hideISP: {
+ description: 'Hide Instance-specific panel',
+ default: false,
+ },
+ hideInstanceWallpaper: {
+ description: 'Hide Instance default background',
+ default: false,
+ },
+ hideShoutbox: {
+ description: 'Hide shoutbox if present',
+ default: false,
+ },
+ hideMutedPosts: {
+ // bad name
+ description: 'Hide posts of muted users entirely',
+ default: false,
+ },
+ hideMutedThreads: {
+ description: 'Hide muted threads entirely',
+ default: true,
+ },
+ hideWordFilteredPosts: {
+ description: 'Hide wordfiltered posts entirely',
+ default: false,
+ },
+ muteBotStatuses: {
+ description: 'Mute posts made by bots',
+ default: false,
+ },
+ muteSensitiveStatuses: {
+ description: 'Mute posts marked as NSFW',
+ default: false,
+ },
+ collapseMessageWithSubject: {
+ description: 'Collapse posts with subject',
+ default: false,
+ },
+ padEmoji: {
+ description: 'Pad emoji with spaces when using emoji picker',
+ default: true,
+ },
+ hideAttachmentsInConv: {
+ description: 'Hide attachments',
+ default: false,
+ },
+ hideScrobbles: {
+ description: 'Hide scrobbles',
+ default: false,
+ },
+ hideScrobblesAfter: {
+ description: 'Hide scrobbles older than',
+ default: '2d',
+ },
+ maxThumbnails: {
+ description: 'Maximum attachments to show',
+ default: 16,
+ },
+ loopVideo: {
+ description: 'Loop videos',
+ default: true,
+ },
+ loopVideoSilentOnly: {
+ description: 'Loop only videos without sound',
+ default: true,
+ },
/// This is not the streaming API configuration, but rather an option
/// for automatically loading new posts into the timeline without
/// the user clicking the Show New button.
- streaming: false,
- emojiReactionsOnTimeline: true,
- alwaysShowNewPostButton: false,
- autohideFloatingPostButton: false,
- pauseOnUnfocused: true,
- stopGifs: true,
- replyVisibility: 'all',
- thirdColumnMode: 'notifications',
+ streaming: {
+ description: 'Automatically show new posts',
+ default: false,
+ },
+ pauseOnUnfocused: {
+ description: 'Pause showing new posts when tab is unfocused',
+ default: true,
+ },
+ emojiReactionsOnTimeline: {
+ description: 'Show emoji reactions on timeline',
+ default: true,
+ },
+ alwaysShowNewPostButton: {
+ description: 'Always show mobile "new post" button, even in desktop mode',
+ default: false,
+ },
+ autohideFloatingPostButton: {
+ description:
+ 'Automatically hide mobile "new post" button when scrolling down',
+ default: false,
+ },
+ stopGifs: {
+ description: 'Play animated gifs on hover only',
+ default: true,
+ },
+ replyVisibility: {
+ description: 'Type of replies to show',
+ default: 'all',
+ },
+ thirdColumnMode: {
+ description: 'What to display in third column',
+ default: 'notifications',
+ },
notificationVisibility: {
- follows: true,
- mentions: true,
- statuses: true,
- likes: true,
- repeats: true,
- moves: true,
- emojiReactions: true,
- followRequest: true,
- reports: true,
- chatMention: true,
- polls: true,
+ description: 'What types of notifications to show',
+ default: {
+ follows: true,
+ mentions: true,
+ statuses: true,
+ likes: true,
+ repeats: true,
+ moves: true,
+ emojiReactions: true,
+ followRequest: true,
+ reports: true,
+ chatMention: true,
+ polls: true,
+ },
},
notificationNative: {
- follows: true,
- mentions: true,
- statuses: true,
- likes: false,
- repeats: false,
- moves: false,
- emojiReactions: false,
- followRequest: true,
- reports: true,
- chatMention: true,
- polls: true,
- },
- webPushNotifications: false,
- webPushAlwaysShowNotifications: false,
- interfaceLanguage: browserLocale,
- hideScopeNotice: false,
- useStreamingApi: false,
- sidebarRight: false,
- scopeCopy: true,
- subjectLineBehavior: 'email',
- alwaysShowSubjectInput: true,
- postContentType: 'text/plain',
- minimalScopesMode: false,
+ description: 'What type of notifications to show desktop notification for',
+ default: {
+ follows: true,
+ mentions: true,
+ statuses: true,
+ likes: false,
+ repeats: false,
+ moves: false,
+ emojiReactions: false,
+ followRequest: true,
+ reports: true,
+ chatMention: true,
+ polls: true,
+ },
+ },
+ webPushNotifications: {
+ description: 'Use WebPush',
+ default: false,
+ },
+ webPushAlwaysShowNotifications: {
+ description: 'Ignore filter when using WebPush',
+ default: false,
+ },
+ interfaceLanguage: {
+ description: 'UI language',
+ default: [browserLocale],
+ },
+ hideScopeNotice: {
+ description: 'Hide scope notification',
+ default: false,
+ },
+ scopeCopy: {
+ description: 'Copy scope like mastodon does',
+ default: true,
+ },
+ subjectLineBehavior: {
+ description: 'How to treat subject line',
+ default: 'email',
+ },
+ alwaysShowSubjectInput: {
+ description: 'Always show subject line field',
+ default: true,
+ },
+ minimalScopesMode: {
+ description: 'Minimize amount of options shown in scope selector',
+ default: false,
+ },
// This hides statuses filtered via a word filter
- hideFilteredStatuses: false,
+ hideFilteredStatuses: {
+ description: 'Hide wordfiltered entirely',
+ default: false,
+ },
// Confirmations
- modalOnRepeat: false,
- modalOnUnfollow: false,
- modalOnBlock: true,
- modalOnMute: false,
- modalOnMuteConversation: false,
- modalOnMuteDomain: true,
- modalOnDelete: true,
- modalOnLogout: true,
- modalOnApproveFollow: false,
- modalOnDenyFollow: false,
- modalOnRemoveUserFromFollowers: false,
+ modalOnRepeat: {
+ description: 'Show confirmation modal for repeat',
+ default: false,
+ },
+ modalOnUnfollow: {
+ description: 'Show confirmation modal for unfollow',
+ default: false,
+ },
+ modalOnBlock: {
+ description: 'Show confirmation modal for block',
+ default: true,
+ },
+ modalOnMute: {
+ description: 'Show confirmation modal for mute',
+ default: false,
+ },
+ modalOnMuteConversation: {
+ description: 'Show confirmation modal for mute conversation',
+ default: false,
+ },
+ modalOnMuteDomain: {
+ description: 'Show confirmation modal for mute domain',
+ default: true,
+ },
+ modalOnDelete: {
+ description: 'Show confirmation modal for delete',
+ default: true,
+ },
+ modalOnLogout: {
+ description: 'Show confirmation modal for logout',
+ default: true,
+ },
+ modalOnApproveFollow: {
+ description: 'Show confirmation modal for approve follow',
+ default: false,
+ },
+ modalOnDenyFollow: {
+ description: 'Show confirmation modal for deny follow',
+ default: false,
+ },
+ modalOnRemoveUserFromFollowers: {
+ description: 'Show confirmation modal for follower removal',
+ default: false,
+ },
// Expiry confirmations/default actions
- onMuteDefaultAction: 'ask',
- onBlockDefaultAction: 'ask',
-
- modalMobileCenter: false,
- playVideosInModal: false,
- useOneClickNsfw: false,
- useContainFit: true,
- disableStickyHeaders: false,
- showScrollbars: false,
- userPopoverAvatarAction: 'open',
- userPopoverOverlay: false,
- userCardLeftJustify: false,
- userCardHidePersonalMarks: false,
- sidebarColumnWidth: '25rem',
- contentColumnWidth: '45rem',
- notifsColumnWidth: '25rem',
- themeEditorMinWidth: '0rem',
- emojiReactionsScale: 0.5,
- textSize: '1rem',
- emojiSize: '2.2rem',
- navbarSize: '3.5rem',
- panelHeaderSize: '3.2rem',
- forcedRoundness: -1,
- navbarColumnStretch: false,
- greentext: false,
- mentionLinkDisplay: 'short',
- mentionLinkShowTooltip: true,
- mentionLinkShowAvatar: false,
- mentionLinkFadeDomain: true,
- mentionLinkShowYous: false,
- mentionLinkBoldenYou: true,
- hidePostStats: false,
- hideBotIndication: false,
- hideUserStats: false,
- virtualScrolling: true,
- sensitiveByDefault: false,
- conversationDisplay: 'linear',
- conversationTreeAdvanced: false,
- conversationOtherRepliesButton: 'below',
- conversationTreeFadeAncestors: false,
- showExtraNotifications: true,
- showExtraNotificationsTip: true,
- showChatsInExtraNotifications: true,
- showAnnouncementsInExtraNotifications: true,
- showFollowRequestsInExtraNotifications: true,
- maxDepthInThread: 6,
- autocompleteSelect: false,
- closingDrawerMarksAsSeen: true,
- unseenAtTop: false,
- ignoreInactionableSeen: false,
- unsavedPostAction: 'confirm',
- autoSaveDraft: false,
- useAbsoluteTimeFormat: false,
- absoluteTimeFormatMinAge: '0d',
- absoluteTime12h: '24h',
- imageCompression: true,
- alwaysUseJpeg: false,
+ onMuteDefaultAction: {
+ description: 'Default action when muting user',
+ default: 'ask',
+ },
+ onBlockDefaultAction: {
+ description: 'Default action when blocking user',
+ default: 'ask',
+ },
+
+ modalMobileCenter: {
+ description: 'Center mobile dialogs vertically',
+ default: false,
+ },
+ playVideosInModal: {
+ description: 'Play videos in gallery view',
+ default: false,
+ },
+ useContainFit: {
+ description: 'Use object-fit: contain for attachments',
+ default: true,
+ },
+ disableStickyHeaders: {
+ description: 'Disable sticky headers',
+ default: false,
+ },
+ showScrollbars: {
+ description: 'Always show scrollbars',
+ default: false,
+ },
+ userPopoverAvatarAction: {
+ description: 'What to do when clicking popover avatar',
+ default: 'open',
+ },
+ userPopoverOverlay: {
+ description: 'Overlay user popover with centering on avatar',
+ default: false,
+ },
+ userCardLeftJustify: {
+ description: 'Justify user bio to the left',
+ default: false,
+ },
+ userCardHidePersonalMarks: {
+ description: 'Hide highlight/personal note in user view',
+ default: false,
+ },
+ forcedRoundness: {
+ description: 'Force roundness of the theme',
+ default: -1,
+ },
+ greentext: {
+ description: 'Highlight plaintext >quotes',
+ default: false,
+ },
+ mentionLinkShowTooltip: {
+ description: 'Show tooltips for mention links',
+ default: true,
+ },
+ mentionLinkShowAvatar: {
+ description: 'Show avatar next to mention link',
+ default: false,
+ },
+ mentionLinkFadeDomain: {
+ description:
+ 'Mute (fade) domain name in mention links if configured to show it',
+ default: true,
+ },
+ mentionLinkShowYous: {
+ description: 'Show (you)s when you are mentioned',
+ default: false,
+ },
+ mentionLinkBoldenYou: {
+ description: 'Boldern mentionlink of you',
+ default: true,
+ },
+ hidePostStats: {
+ description: 'Hide post stats (rt, favs)',
+ default: false,
+ },
+ hideBotIndication: {
+ description: 'Hide bot indicator',
+ default: false,
+ },
+ hideUserStats: {
+ description: 'Hide user stats (followers etc)',
+ default: false,
+ },
+ virtualScrolling: {
+ description: 'Timeline virtual scrolling',
+ default: true,
+ },
+ sensitiveByDefault: {
+ description: 'Assume attachments are NSFW by default',
+ default: false,
+ },
+ conversationDisplay: {
+ description: 'Style of conversation display',
+ default: 'linear',
+ },
+ conversationTreeAdvanced: {
+ description: 'Advanced features of tree view conversation',
+ default: false,
+ },
+ conversationOtherRepliesButton: {
+ description: 'Where to show "other replies" in tree conversation view',
+ default: 'below',
+ },
+ conversationTreeFadeAncestors: {
+ description: 'Fade ancestors in tree conversation view',
+ default: false,
+ },
+ showExtraNotifications: {
+ description:
+ 'Show extra notifications (chats, announcements etc) in notification panel',
+ default: true,
+ },
+ showExtraNotificationsTip: {
+ description: 'Show tip for extra notifications (that user can remove them)',
+ default: true,
+ },
+ showChatsInExtraNotifications: {
+ description: 'Show chat messages in notifications',
+ default: true,
+ },
+ showAnnouncementsInExtraNotifications: {
+ description: 'Show announcements in notifications',
+ default: true,
+ },
+ showFollowRequestsInExtraNotifications: {
+ description: 'Show follow requests in notifications',
+ default: true,
+ },
+ maxDepthInThread: {
+ description: 'Maximum depth in tree conversation view',
+ default: 6,
+ },
+ autocompleteSelect: {
+ description: '',
+ default: false,
+ },
+ closingDrawerMarksAsSeen: {
+ description: 'Closing mobile notification pane marks everything as seen',
+ default: true,
+ },
+ unseenAtTop: {
+ description: 'Show unseen notifications above others',
+ default: false,
+ },
+ ignoreInactionableSeen: {
+ description: 'Treat inactionable (fav, rt etc) notifications as "seen"',
+ default: false,
+ },
+ unsavedPostAction: {
+ description: 'What to do if post is aborted',
+ default: 'confirm',
+ },
+ autoSaveDraft: {
+ description: 'Save drafts automatically',
+ default: false,
+ },
+ useAbsoluteTimeFormat: {
+ description: 'Use absolute time format',
+ default: false,
+ },
+ absoluteTimeFormatMinAge: {
+ description: 'Show absolute time format only after this post age',
+ default: '0d',
+ },
+ absoluteTime12h: {
+ description: 'Use 24h time format',
+ default: '24h',
+ },
+ themeChecksum: {
+ description: 'Checksum of theme used',
+ type: 'string',
+ required: false,
+ },
+ highlights: {
+ description: 'User highlights',
+ type: 'object',
+ required: false,
+ default: {},
+ },
+ underlay: {
+ description: 'Underlay override',
+ required: true,
+ default: 'none',
+ },
+}
+export const INSTANCE_DEFAULT_CONFIG = convertDefinitions(
+ INSTANCE_DEFAULT_CONFIG_DEFINITIONS,
+)
+
+export const LOCAL_DEFAULT_CONFIG_DEFINITIONS = {
+ // TODO these two used to be separate but since separation feature got broken it doesn't matter
+ hideAttachments: {
+ description: 'Hide attachments in timeline',
+ default: false,
+ },
+ hideAttachmentsInConv: {
+ description: 'Hide attachments in coversation',
+ default: false,
+ },
+ hideNsfw: {
+ description: 'Hide nsfw posts',
+ default: true,
+ },
+ useOneClickNsfw: {
+ description: 'Open NSFW images directly in media modal',
+ default: false,
+ },
+ preloadImage: {
+ description: 'Preload images for NSFW',
+ default: true,
+ },
+ postContentType: {
+ description: 'Default post content type',
+ default: 'text/plain',
+ },
+ sidebarRight: {
+ description: 'Reverse order of columns',
+ default: false,
+ },
+ sidebarColumnWidth: {
+ description: 'Sidebar column width',
+ default: '25rem',
+ },
+ contentColumnWidth: {
+ description: 'Middle column width',
+ default: '45rem',
+ },
+ notifsColumnWidth: {
+ description: 'Notifications column width',
+ default: '25rem',
+ },
+ themeEditorMinWidth: {
+ description: 'Hack for theme editor on mobile',
+ default: '0rem',
+ },
+ emojiReactionsScale: {
+ description: 'Emoji reactions scale factor',
+ default: 0.5,
+ },
+ textSize: {
+ description: 'Font size',
+ default: '1rem',
+ },
+ emojiSize: {
+ description: 'Emoji size',
+ default: '2.2rem',
+ },
+ navbarSize: {
+ description: 'Navbar size',
+ default: '3.5rem',
+ },
+ panelHeaderSize: {
+ description: 'Panel header size',
+ default: '3.2rem',
+ },
+ navbarColumnStretch: {
+ description: 'Stretch navbar to match columns width',
+ default: false,
+ },
+ mentionLinkDisplay: {
+ description: 'How to display mention links',
+ default: 'short',
+ },
+ imageCompression: {
+ description: 'Image compression (WebP/JPEG)',
+ default: true,
+ },
+ alwaysUseJpeg: {
+ description: 'Compress images using JPEG only',
+ default: false,
+ },
+ useStreamingApi: {
+ description: 'Streaming API (WebSocket)',
+ default: false,
+ },
+ fontInterface: {
+ description: 'Interface font override',
+ type: 'object',
+ default: null,
+ },
+ fontInput: {
+ description: 'Input font override',
+ type: 'object',
+ default: null,
+ },
+ fontPosts: {
+ description: 'Post font override',
+ type: 'object',
+ default: null,
+ },
+ fontMonospace: {
+ description: 'Monospace font override',
+ type: 'object',
+ default: null,
+ },
+ themeDebug: {
+ description:
+ 'Debug mode that uses computed backgrounds instead of real ones to debug contrast functions',
+ default: false,
+ },
+ forceThemeRecompilation: {
+ description: 'Flag that forces recompilation on boot even if cache exists',
+ default: false,
+ },
}
+export const LOCAL_DEFAULT_CONFIG = convertDefinitions(
+ LOCAL_DEFAULT_CONFIG_DEFINITIONS,
+)
+
+export const LOCAL_ONLY_KEYS = new Set(Object.keys(LOCAL_DEFAULT_CONFIG))
+
+export const SYNC_DEFAULT_CONFIG_DEFINITIONS = {
+ dontShowUpdateNotifs: {
+ description: 'Never show update notification (pleroma-tan)',
+ default: false,
+ },
+ collapseNav: {
+ description: 'Collapse navigation panel to header only',
+ default: false,
+ },
+ muteFilters: {
+ description: 'Object containing mute filters',
+ type: 'object',
+ default: {},
+ },
+}
+export const SYNC_DEFAULT_CONFIG = convertDefinitions(
+ SYNC_DEFAULT_CONFIG_DEFINITIONS,
+)
+export const SYNC_ONLY_KEYS = new Set(Object.keys(SYNC_DEFAULT_CONFIG))
+
+export const THEME_CONFIG_DEFINITIONS = {
+ theme: {
+ description: 'Very old theme store, stores preset name, still in use',
+ default: null,
+ },
+ colors: {
+ description:
+ 'VERY old theme store, just colors of V1, probably not even used anymore',
+ default: {},
+ },
+ // V2
+ customTheme: {
+ description:
+ '"Snapshot", previously was used as actual theme store for V2 so it\'s still used in case of PleromaFE downgrade event.',
+ default: null,
+ },
+ customThemeSource: {
+ description: '"Source", stores original theme data',
+ default: null,
+ },
+ // V3
+ style: {
+ description: 'Style name for builtins',
+ default: null,
+ },
+ styleCustomData: {
+ description: 'Custom style data (i.e. not builtin)',
+ default: null,
+ },
+ palette: {
+ description: 'Palette name for builtins',
+ default: null,
+ },
+ paletteCustomData: {
+ description: 'Custom palette data (i.e. not builtin)',
+ default: null,
+ },
+}
+export const THEME_CONFIG = convertDefinitions(THEME_CONFIG_DEFINITIONS)
export const makeUndefined = (c) =>
Object.fromEntries(Object.keys(c).map((key) => [key, undefined]))
/// For properties with special processing or properties that does not
/// make sense to be overriden on a instance-wide level.
-export const defaultState = {
+export const ROOT_CONFIG = {
// Set these to undefined so it does not interfere with default settings check
- ...makeUndefined(instanceDefaultConfig),
+ ...INSTANCE_DEFAULT_CONFIG,
+ ...LOCAL_DEFAULT_CONFIG,
+ ...SYNC_DEFAULT_CONFIG,
+ ...THEME_CONFIG,
+}
- // Special processing
- // Theme stuff
- theme: undefined, // Very old theme store, stores preset name, still in use
+export const ROOT_CONFIG_DEFINITIONS = {
+ ...INSTANCE_DEFAULT_CONFIG_DEFINITIONS,
+ ...LOCAL_DEFAULT_CONFIG_DEFINITIONS,
+ ...SYNC_DEFAULT_CONFIG_DEFINITIONS,
+ ...THEME_CONFIG_DEFINITIONS,
+}
- // V1
- colors: {}, // VERY old theme store, just colors of V1, probably not even used anymore
+export const validateSetting = ({
+ value,
+ path: fullPath,
+ definition,
+ throwError,
+ defaultState,
+ validateObjects = true,
+}) => {
+ if (value === undefined) return undefined // only null is allowed as missing value
+ if (definition === undefined) return undefined // invalid definition
+ const path = fullPath.replace(/^simple./, '')
+ const depth = path.split('.')
+ if (
+ validateObjects &&
+ definition.type === 'object' &&
+ path.split('.').length <= 1
+ ) {
+ console.error(
+ `attempt to set object ${fullPath} instead of its children. ignoring.`,
+ )
+ return undefined
+ }
- // V2
- customTheme: undefined, // "snapshot", previously was used as actual theme store for V2 so it's still used in case of PleromaFE downgrade event.
- customThemeSource: undefined, // "source", stores original theme data
+ if (get(defaultState, path.split('.')[0]) === undefined) {
+ const string = `Unknown option ${fullPath}, value: ${value}`
- // V3
- style: null,
- styleCustomData: null,
- palette: null,
- paletteCustomData: null,
- themeDebug: false, // debug mode that uses computed backgrounds instead of real ones to debug contrast functions
- forceThemeRecompilation: false, // flag that forces recompilation on boot even if cache exists
- theme3hacks: {
- // Hacks, user overrides that are independent of theme used
- underlay: 'none',
- fonts: {
- interface: undefined,
- input: undefined,
- post: undefined,
- monospace: undefined,
- },
- },
+ if (throwError) {
+ throw new Error(string)
+ } else {
+ console.error(string)
+ return undefined
+ }
+ }
+
+ let { required, type, default: defaultValue } = definition
+
+ if (type == null && defaultValue != null) {
+ type = typeof defaultValue
+ }
+
+ if (required && value == null) {
+ const string = `Value required for setting ${path} but was provided nullish; defaulting`
+
+ if (throwError) {
+ throw new Error(string)
+ } else {
+ console.error(string)
+ return defaultValue
+ }
+ }
- // Special handling: These fields are not of a primitive type, and
- // might cause problems with current code because it specifically checks
- // them in state.config (not getters.mergedConfig).
+ if (depth > 2 && value !== null && type != null && typeof value !== type) {
+ const string = `Invalid type for setting ${path}: expected type ${type}, got ${typeof value}, value ${value}; defaulting`
- // Specifically, muteWords is now deprecated in favour of a server-side configuration.
- muteWords: [],
- highlight: {},
+ if (throwError) {
+ throw new Error(string)
+ } else {
+ console.error(string)
+ return defaultValue
+ }
+ }
- // If there are any configurations that does not make sense to
- // have instance-wide default, put it here and explain why.
+ return value
}
diff --git a/src/modules/index.js b/src/modules/index.js
index 15e57f7163..76bdbf7f33 100644
--- a/src/modules/index.js
+++ b/src/modules/index.js
@@ -1,21 +1,19 @@
import adminSettings from './adminSettings.js'
import api from './api.js'
import chats from './chats.js'
-import config from './config.js'
import drafts from './drafts.js'
import notifications from './notifications.js'
import profileConfig from './profileConfig.js'
import statuses from './statuses.js'
import users from './users.js'
export default {
statuses,
notifications,
users,
api,
- config,
profileConfig,
adminSettings,
drafts,
chats,
}
diff --git a/src/modules/notifications.js b/src/modules/notifications.js
index cb3d430dbd..bf1e4b9c0f 100644
--- a/src/modules/notifications.js
+++ b/src/modules/notifications.js
@@ -1,189 +1,189 @@
import apiService from '../services/api/api.service.js'
import {
closeAllDesktopNotifications,
closeDesktopNotification,
} from '../services/desktop_notification_utils/desktop_notification_utils.js'
import {
isStatusNotification,
isValidNotification,
maybeShowNotification,
} from '../services/notification_utils/notification_utils.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useReportsStore } from 'src/stores/reports.js'
-import { useServerSideStorageStore } from 'src/stores/serverSideStorage.js'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
const emptyNotifications = () => ({
desktopNotificationSilence: true,
maxId: 0,
minId: Number.POSITIVE_INFINITY,
data: [],
idStore: {},
loading: false,
})
export const defaultState = () => ({
...emptyNotifications(),
})
export const notifications = {
state: defaultState(),
mutations: {
addNewNotifications(state, { notifications }) {
notifications.forEach((notification) => {
state.data.push(notification)
state.idStore[notification.id] = notification
})
},
clearNotifications(state) {
const blankState = defaultState()
Object.keys(state).forEach((k) => {
state[k] = blankState[k]
})
},
updateNotificationsMinMaxId(state, id) {
state.maxId = id > state.maxId ? id : state.maxId
state.minId = id < state.minId ? id : state.minId
},
setNotificationsLoading(state, { value }) {
state.loading = value
},
setNotificationsSilence(state, { value }) {
state.desktopNotificationSilence = value
},
markNotificationsAsSeen(state) {
state.data.forEach((notification) => {
notification.seen = true
})
},
markSingleNotificationAsSeen(state, { id }) {
const notification = state.idStore[id]
if (notification) notification.seen = true
},
dismissNotification(state, { id }) {
state.data = state.data.filter((n) => n.id !== id)
delete state.idStore[id]
},
updateNotification(state, { id, updater }) {
const notification = state.idStore[id]
notification && updater(notification)
},
},
actions: {
addNewNotifications(store, { notifications }) {
const { commit, dispatch, state, rootState } = store
const validNotifications = notifications.filter((notification) => {
// If invalid notification, update ids but don't add it to store
if (!isValidNotification(notification)) {
console.error('Invalid notification:', notification)
commit('updateNotificationsMinMaxId', notification.id)
return false
}
return true
})
const statusNotifications = validNotifications.filter(
(notification) =>
isStatusNotification(notification.type) && notification.status,
)
// Synchronous commit to add all the statuses
commit('addNewStatuses', {
statuses: statusNotifications.map(
(notification) => notification.status,
),
})
// Update references to statuses in notifications to ones in the store
statusNotifications.forEach((notification) => {
const id = notification.status.id
const referenceStatus = rootState.statuses.allStatusesObject[id]
if (referenceStatus) {
notification.status = referenceStatus
}
})
validNotifications.forEach((notification) => {
if (notification.type === 'pleroma:report') {
useReportsStore().addReport(notification.report)
}
if (notification.type === 'pleroma:emoji_reaction') {
dispatch('fetchEmojiReactionsBy', notification.status.id)
}
// Only add a new notification if we don't have one for the same action
if (!Object.hasOwn(state.idStore, notification.id)) {
commit('updateNotificationsMinMaxId', notification.id)
commit('addNewNotifications', { notifications: [notification] })
maybeShowNotification(
store,
- Object.values(
- useServerSideStorageStore().prefsStorage.simple.muteFilters,
- ),
+ useMergedConfigStore().mergedConfig.notificationVisibility,
+ Object.values(useSyncConfigStore().prefsStorage.simple.muteFilters),
notification,
)
} else if (notification.seen) {
state.idStore[notification.id].seen = true
}
})
},
notificationClicked({ state, dispatch }, { id }) {
const notification = state.idStore[id]
const { type, seen } = notification
if (!seen) {
switch (type) {
case 'mention':
case 'pleroma:report':
case 'follow_request':
break
default:
dispatch('markSingleNotificationAsSeen', { id })
}
}
},
setNotificationsLoading({ commit }, { value }) {
commit('setNotificationsLoading', { value })
},
setNotificationsSilence({ commit }, { value }) {
commit('setNotificationsSilence', { value })
},
markNotificationsAsSeen({ rootState, state, commit }) {
commit('markNotificationsAsSeen')
apiService
.markNotificationsAsSeen({
id: state.maxId,
credentials: rootState.users.currentUser.credentials,
})
.then(() => {
closeAllDesktopNotifications(rootState)
})
},
markSingleNotificationAsSeen({ rootState, commit }, { id }) {
commit('markSingleNotificationAsSeen', { id })
apiService
.markNotificationsAsSeen({
single: true,
id,
credentials: rootState.users.currentUser.credentials,
})
.then(() => {
closeDesktopNotification(rootState, { id })
})
},
dismissNotificationLocal({ commit }, { id }) {
commit('dismissNotification', { id })
},
dismissNotification({ rootState, commit }, { id }) {
commit('dismissNotification', { id })
rootState.api.backendInteractor.dismissNotification({ id })
},
updateNotification({ commit }, { id, updater }) {
commit('updateNotification', { id, updater })
},
},
}
export default notifications
diff --git a/src/modules/default_config_state.js b/src/modules/old_default_config_state.js
similarity index 71%
copy from src/modules/default_config_state.js
copy to src/modules/old_default_config_state.js
index 2f18fe9a5d..935239d16b 100644
--- a/src/modules/default_config_state.js
+++ b/src/modules/old_default_config_state.js
@@ -1,229 +1,185 @@
-const browserLocale = (navigator.language || 'en').split('-')[0]
+// this is a snapshot of config keys used prior to sync config.
+// used to migrate from old config.
-/// Instance config entries provided by static config or pleroma api
-/// Put settings here only if it does not make sense for a normal user
-/// to override it.
-export const staticOrApiConfigDefault = {
- name: 'PleromaFE',
- theme: 'pleroma-dark',
- palette: null,
- style: null,
- defaultAvatar: '/images/avi.png',
- defaultBanner: '/images/banner.png',
- background: '/static/aurora_borealis.jpg',
- embeddedToS: true,
- logo: '/static/logo.svg',
- logoMargin: '.2em',
- logoMask: true,
- logoLeft: false,
- redirectRootLogin: '/main/friends',
- redirectRootNoLogin: '/main/all',
- hideSitename: false,
- nsfwCensorImage: null,
- showFeaturesPanel: true,
- showInstanceSpecificPanel: false,
-
- // Html stuff
- instanceSpecificPanelContent: '',
- tos: '',
-}
-
-/// This object contains setting entries that makes sense
-/// at the user level. The defaults can also be overriden by
-/// instance admins in the frontend_configuration endpoint or static config.
-export const instanceDefaultConfig = {
+// commented entries are unsynced stuff
+export const oldDefaultConfigSync = {
expertLevel: 0, // used to track which settings to show and hide
hideISP: false,
hideInstanceWallpaper: false,
hideShoutbox: false,
// bad name: actually hides posts of muted USERS
hideMutedPosts: false,
hideMutedThreads: true,
hideWordFilteredPosts: false,
muteBotStatuses: false,
muteSensitiveStatuses: false,
collapseMessageWithSubject: false,
padEmoji: true,
- hideAttachments: false,
- hideAttachmentsInConv: false,
hideScrobbles: false,
hideScrobblesAfter: '2d',
maxThumbnails: 16,
- hideNsfw: true,
- preloadImage: true,
loopVideo: true,
loopVideoSilentOnly: true,
/// This is not the streaming API configuration, but rather an option
/// for automatically loading new posts into the timeline without
/// the user clicking the Show New button.
streaming: false,
emojiReactionsOnTimeline: true,
alwaysShowNewPostButton: false,
autohideFloatingPostButton: false,
pauseOnUnfocused: true,
stopGifs: true,
replyVisibility: 'all',
thirdColumnMode: 'notifications',
notificationVisibility: {
follows: true,
mentions: true,
statuses: true,
likes: true,
repeats: true,
moves: true,
emojiReactions: true,
followRequest: true,
reports: true,
chatMention: true,
polls: true,
},
notificationNative: {
follows: true,
mentions: true,
statuses: true,
likes: false,
repeats: false,
moves: false,
emojiReactions: false,
followRequest: true,
reports: true,
chatMention: true,
polls: true,
},
webPushNotifications: false,
webPushAlwaysShowNotifications: false,
- interfaceLanguage: browserLocale,
+ //interfaceLanguage: '',
hideScopeNotice: false,
useStreamingApi: false,
sidebarRight: false,
scopeCopy: true,
subjectLineBehavior: 'email',
alwaysShowSubjectInput: true,
postContentType: 'text/plain',
minimalScopesMode: false,
// This hides statuses filtered via a word filter
hideFilteredStatuses: false,
// Confirmations
modalOnRepeat: false,
modalOnUnfollow: false,
modalOnBlock: true,
modalOnMute: false,
modalOnMuteConversation: false,
modalOnMuteDomain: true,
modalOnDelete: true,
modalOnLogout: true,
modalOnApproveFollow: false,
modalOnDenyFollow: false,
modalOnRemoveUserFromFollowers: false,
// Expiry confirmations/default actions
onMuteDefaultAction: 'ask',
onBlockDefaultAction: 'ask',
modalMobileCenter: false,
playVideosInModal: false,
- useOneClickNsfw: false,
useContainFit: true,
disableStickyHeaders: false,
showScrollbars: false,
userPopoverAvatarAction: 'open',
userPopoverOverlay: false,
userCardLeftJustify: false,
userCardHidePersonalMarks: false,
- sidebarColumnWidth: '25rem',
- contentColumnWidth: '45rem',
- notifsColumnWidth: '25rem',
- themeEditorMinWidth: '0rem',
- emojiReactionsScale: 0.5,
- textSize: '1rem',
- emojiSize: '2.2rem',
- navbarSize: '3.5rem',
- panelHeaderSize: '3.2rem',
forcedRoundness: -1,
- navbarColumnStretch: false,
greentext: false,
- mentionLinkDisplay: 'short',
mentionLinkShowTooltip: true,
mentionLinkShowAvatar: false,
mentionLinkFadeDomain: true,
mentionLinkShowYous: false,
mentionLinkBoldenYou: true,
hidePostStats: false,
hideBotIndication: false,
hideUserStats: false,
virtualScrolling: true,
sensitiveByDefault: false,
conversationDisplay: 'linear',
conversationTreeAdvanced: false,
conversationOtherRepliesButton: 'below',
conversationTreeFadeAncestors: false,
showExtraNotifications: true,
showExtraNotificationsTip: true,
showChatsInExtraNotifications: true,
showAnnouncementsInExtraNotifications: true,
showFollowRequestsInExtraNotifications: true,
maxDepthInThread: 6,
autocompleteSelect: false,
closingDrawerMarksAsSeen: true,
unseenAtTop: false,
ignoreInactionableSeen: false,
unsavedPostAction: 'confirm',
autoSaveDraft: false,
useAbsoluteTimeFormat: false,
absoluteTimeFormatMinAge: '0d',
absoluteTime12h: '24h',
- imageCompression: true,
- alwaysUseJpeg: false,
-}
-
-export const makeUndefined = (c) =>
- Object.fromEntries(Object.keys(c).map((key) => [key, undefined]))
-
-/// For properties with special processing or properties that does not
-/// make sense to be overriden on a instance-wide level.
-export const defaultState = {
- // Set these to undefined so it does not interfere with default settings check
- ...makeUndefined(instanceDefaultConfig),
-
+ theme3hacks: {
+ // Hacks, user overrides that are independent of theme used
+ underlay: 'none',
+ fonts: {
+ interface: undefined,
+ input: undefined,
+ post: undefined,
+ monospace: undefined,
+ },
+ },
// Special processing
// Theme stuff
theme: undefined, // Very old theme store, stores preset name, still in use
// V1
colors: {}, // VERY old theme store, just colors of V1, probably not even used anymore
// V2
customTheme: undefined, // "snapshot", previously was used as actual theme store for V2 so it's still used in case of PleromaFE downgrade event.
customThemeSource: undefined, // "source", stores original theme data
// V3
style: null,
styleCustomData: null,
palette: null,
paletteCustomData: null,
themeDebug: false, // debug mode that uses computed backgrounds instead of real ones to debug contrast functions
forceThemeRecompilation: false, // flag that forces recompilation on boot even if cache exists
- theme3hacks: {
- // Hacks, user overrides that are independent of theme used
- underlay: 'none',
- fonts: {
- interface: undefined,
- input: undefined,
- post: undefined,
- monospace: undefined,
- },
- },
- // Special handling: These fields are not of a primitive type, and
- // might cause problems with current code because it specifically checks
- // them in state.config (not getters.mergedConfig).
-
- // Specifically, muteWords is now deprecated in favour of a server-side configuration.
- muteWords: [],
- highlight: {},
+ // Those are handled outside config now
+ // muteWords: [],
+ // highlight: {},
+}
- // If there are any configurations that does not make sense to
- // have instance-wide default, put it here and explain why.
+export const defaultConfigLocal = {
+ hideAttachments: false,
+ hideAttachmentsInConv: false,
+ hideNsfw: true,
+ useOneClickNsfw: false,
+ preloadImage: true,
+ sidebarColumnWidth: '25rem',
+ contentColumnWidth: '45rem',
+ notifsColumnWidth: '25rem',
+ themeEditorMinWidth: '0rem',
+ emojiReactionsScale: 0.5,
+ textSize: '1rem',
+ emojiSize: '2.2rem',
+ navbarSize: '3.5rem',
+ panelHeaderSize: '3.2rem',
+ navbarColumnStretch: false,
+ mentionLinkDisplay: 'short',
+ imageCompression: true,
+ alwaysUseJpeg: false,
}
diff --git a/src/modules/users.js b/src/modules/users.js
index b0febcd3a5..db5b16ccca 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -1,844 +1,832 @@
import {
compact,
concat,
each,
isArray,
last,
map,
mergeWith,
uniq,
} from 'lodash'
import apiService from '../services/api/api.service.js'
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import oauthApi from '../services/new_api/oauth.js'
import {
registerPushNotifications,
unregisterPushNotifications,
} from '../services/sw/sw.js'
import {
windowHeight,
windowWidth,
} from '../services/window_utils/window_utils'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
-import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
-
-import { declarations } from 'src/modules/config_declaration'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
+import { useUserHighlightStore } from 'src/stores/user_highlight.js'
// TODO: Unify with mergeOrAdd in statuses.js
export const mergeOrAdd = (arr, obj, item) => {
if (!item) {
return false
}
const oldItem = obj[item.id]
if (oldItem) {
// We already have this, so only merge the new info.
mergeWith(oldItem, item, mergeArrayLength)
return { item: oldItem, new: false }
} else {
// This is a new item, prepare it
arr.push(item)
obj[item.id] = item
return { item, new: true }
}
}
const mergeArrayLength = (oldValue, newValue) => {
if (isArray(oldValue) && isArray(newValue)) {
oldValue.length = newValue.length
return mergeWith(oldValue, newValue, mergeArrayLength)
}
}
const getNotificationPermission = () => {
const Notification = window.Notification
if (!Notification) return Promise.resolve(null)
if (Notification.permission === 'default')
return Notification.requestPermission()
return Promise.resolve(Notification.permission)
}
const blockUser = (store, args) => {
const id = args.id
const expiresIn = typeof args === 'object' ? args.expiresIn : 0
const predictedRelationship = store.state.relationships[id] || { id }
store.commit('updateUserRelationship', [predictedRelationship])
store.commit('addBlockId', id)
return store.rootState.api.backendInteractor
.blockUser({ id, expiresIn })
.then((relationship) => {
store.commit('updateUserRelationship', [relationship])
store.commit('addBlockId', id)
store.commit('removeStatus', { timeline: 'friends', userId: id })
store.commit('removeStatus', { timeline: 'public', userId: id })
store.commit('removeStatus', {
timeline: 'publicAndExternal',
userId: id,
})
})
}
const unblockUser = (store, id) => {
return store.rootState.api.backendInteractor
.unblockUser({ id })
.then((relationship) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const removeUserFromFollowers = (store, id) => {
return store.rootState.api.backendInteractor
.removeUserFromFollowers({ id })
.then((relationship) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const editUserNote = (store, { id, comment }) => {
return store.rootState.api.backendInteractor
.editUserNote({ id, comment })
.then((relationship) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const muteUser = (store, args) => {
const id = typeof args === 'object' ? args.id : args
const expiresIn = typeof args === 'object' ? args.expiresIn : 0
const predictedRelationship = store.state.relationships[id] || { id }
store.commit('updateUserRelationship', [predictedRelationship])
store.commit('addMuteId', id)
return store.rootState.api.backendInteractor
.muteUser({ id, expiresIn })
.then((relationship) => {
store.commit('updateUserRelationship', [relationship])
store.commit('addMuteId', id)
})
}
const unmuteUser = (store, id) => {
const predictedRelationship = store.state.relationships[id] || { id }
predictedRelationship.muting = false
store.commit('updateUserRelationship', [predictedRelationship])
return store.rootState.api.backendInteractor
.unmuteUser({ id })
.then((relationship) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const hideReblogs = (store, userId) => {
return store.rootState.api.backendInteractor
.followUser({ id: userId, reblogs: false })
.then((relationship) => {
store.commit('updateUserRelationship', [relationship])
})
}
const showReblogs = (store, userId) => {
return store.rootState.api.backendInteractor
.followUser({ id: userId, reblogs: true })
.then((relationship) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const muteDomain = (store, domain) => {
return store.rootState.api.backendInteractor
.muteDomain({ domain })
.then(() => store.commit('addDomainMute', domain))
}
const unmuteDomain = (store, domain) => {
return store.rootState.api.backendInteractor
.unmuteDomain({ domain })
.then(() => store.commit('removeDomainMute', domain))
}
export const mutations = {
tagUser(state, { user: { id }, tag }) {
const user = state.usersObject[id]
const tags = user.tags || []
const newTags = tags.concat([tag])
user.tags = newTags
},
untagUser(state, { user: { id }, tag }) {
const user = state.usersObject[id]
const tags = user.tags || []
const newTags = tags.filter((t) => t !== tag)
user.tags = newTags
},
updateRight(state, { user: { id }, right, value }) {
const user = state.usersObject[id]
const newRights = user.rights
newRights[right] = value
user.rights = newRights
},
updateActivationStatus(state, { user: { id }, deactivated }) {
const user = state.usersObject[id]
user.deactivated = deactivated
},
setCurrentUser(state, user) {
state.lastLoginName = user.screen_name
state.currentUser = mergeWith(
state.currentUser || {},
user,
mergeArrayLength,
)
},
clearCurrentUser(state) {
state.currentUser = false
state.lastLoginName = false
},
beginLogin(state) {
state.loggingIn = true
},
endLogin(state) {
state.loggingIn = false
},
saveFriendIds(state, { id, friendIds }) {
const user = state.usersObject[id]
user.friendIds = uniq(concat(user.friendIds || [], friendIds))
},
saveFollowerIds(state, { id, followerIds }) {
const user = state.usersObject[id]
user.followerIds = uniq(concat(user.followerIds || [], followerIds))
},
// Because frontend doesn't have a reason to keep these stuff in memory
// outside of viewing someones user profile.
clearFriends(state, userId) {
const user = state.usersObject[userId]
if (user) {
user.friendIds = []
}
},
clearFollowers(state, userId) {
const user = state.usersObject[userId]
if (user) {
user.followerIds = []
}
},
addNewUsers(state, users) {
each(users, (user) => {
if (user.relationship) {
state.relationships[user.relationship.id] = user.relationship
}
const res = mergeOrAdd(state.users, state.usersObject, user)
const item = res.item
if (res.new && item.screen_name && !item.screen_name.includes('@')) {
state.usersByNameObject[item.screen_name.toLowerCase()] = item
}
})
},
updateUserRelationship(state, relationships) {
relationships.forEach((relationship) => {
state.relationships[relationship.id] = relationship
})
},
updateUserInLists(state, { id, inLists }) {
state.usersObject[id].inLists = inLists
},
saveBlockIds(state, blockIds) {
state.currentUser.blockIds = blockIds
},
addBlockId(state, blockId) {
if (state.currentUser.blockIds.indexOf(blockId) === -1) {
state.currentUser.blockIds.push(blockId)
}
},
setBlockIdsMaxId(state, blockIdsMaxId) {
state.currentUser.blockIdsMaxId = blockIdsMaxId
},
saveMuteIds(state, muteIds) {
state.currentUser.muteIds = muteIds
},
setMuteIdsMaxId(state, muteIdsMaxId) {
state.currentUser.muteIdsMaxId = muteIdsMaxId
},
addMuteId(state, muteId) {
if (state.currentUser.muteIds.indexOf(muteId) === -1) {
state.currentUser.muteIds.push(muteId)
}
},
saveDomainMutes(state, domainMutes) {
state.currentUser.domainMutes = domainMutes
},
addDomainMute(state, domain) {
if (state.currentUser.domainMutes.indexOf(domain) === -1) {
state.currentUser.domainMutes.push(domain)
}
},
removeDomainMute(state, domain) {
const index = state.currentUser.domainMutes.indexOf(domain)
if (index !== -1) {
state.currentUser.domainMutes.splice(index, 1)
}
},
setPinnedToUser(state, status) {
const user = state.usersObject[status.user.id]
user.pinnedStatusIds = user.pinnedStatusIds || []
const index = user.pinnedStatusIds.indexOf(status.id)
if (status.pinned && index === -1) {
user.pinnedStatusIds.push(status.id)
} else if (!status.pinned && index !== -1) {
user.pinnedStatusIds.splice(index, 1)
}
},
setUserForStatus(state, status) {
status.user = state.usersObject[status.user.id]
},
setUserForNotification(state, notification) {
if (notification.type !== 'follow') {
notification.action.user = state.usersObject[notification.action.user.id]
}
notification.from_profile = state.usersObject[notification.from_profile.id]
},
setColor(state, { user: { id }, highlighted }) {
const user = state.usersObject[id]
user.highlight = highlighted
},
signUpPending(state) {
state.signUpPending = true
state.signUpErrors = []
state.signUpNotice = {}
},
signUpSuccess(state) {
state.signUpPending = false
},
signUpFailure(state, errors) {
state.signUpPending = false
state.signUpErrors = errors
state.signUpNotice = {}
},
signUpNotice(state, notice) {
state.signUpPending = false
state.signUpErrors = []
state.signUpNotice = notice
},
}
export const getters = {
findUser: (state) => (query) => {
return state.usersObject[query]
},
findUserByName: (state) => (query) => {
return state.usersByNameObject[query.toLowerCase()]
},
findUserByUrl: (state) => (query) => {
return state.users.find(
(u) =>
u.statusnet_profile_url &&
u.statusnet_profile_url.toLowerCase() === query.toLowerCase(),
)
},
relationship: (state) => (id) => {
const rel = id && state.relationships[id]
return rel || { id, loading: true }
},
}
export const defaultState = {
loggingIn: false,
lastLoginName: false,
currentUser: false,
users: [],
usersObject: {},
usersByNameObject: {},
signUpPending: false,
signUpErrors: [],
signUpNotice: {},
relationships: {},
}
const users = {
state: defaultState,
mutations,
getters,
actions: {
fetchUserIfMissing(store, id) {
if (!store.getters.findUser(id)) {
store.dispatch('fetchUser', id)
}
},
fetchUser(store, id) {
return store.rootState.api.backendInteractor
.fetchUser({ id })
.then((user) => {
store.commit('addNewUsers', [user])
return user
})
},
fetchUserByName(store, name) {
return store.rootState.api.backendInteractor
.fetchUserByName({ name })
.then((user) => {
store.commit('addNewUsers', [user])
return user
})
},
fetchUserRelationship(store, id) {
if (store.state.currentUser) {
store.rootState.api.backendInteractor
.fetchUserRelationship({ id })
.then((relationships) =>
store.commit('updateUserRelationship', relationships),
)
}
},
fetchUserInLists(store, id) {
if (store.state.currentUser) {
store.rootState.api.backendInteractor
.fetchUserInLists({ id })
.then((inLists) => store.commit('updateUserInLists', { id, inLists }))
}
},
fetchBlocks(store, args) {
const { reset } = args || {}
const maxId = store.state.currentUser.blockIdsMaxId
return store.rootState.api.backendInteractor
.fetchBlocks({ maxId })
.then((blocks) => {
if (reset) {
store.commit('saveBlockIds', map(blocks, 'id'))
} else {
map(blocks, 'id').map((id) => store.commit('addBlockId', id))
}
if (blocks.length) {
store.commit('setBlockIdsMaxId', last(blocks).id)
}
store.commit('addNewUsers', blocks)
return blocks
})
},
blockUser(store, data) {
return blockUser(store, data)
},
unblockUser(store, data) {
return unblockUser(store, data)
},
removeUserFromFollowers(store, id) {
return removeUserFromFollowers(store, id)
},
blockUsers(store, data = []) {
return Promise.all(data.map((d) => blockUser(store, d)))
},
unblockUsers(store, data = []) {
return Promise.all(data.map((d) => unblockUser(store, d)))
},
editUserNote(store, args) {
return editUserNote(store, args)
},
fetchMutes(store, args) {
const { reset } = args || {}
const maxId = store.state.currentUser.muteIdsMaxId
return store.rootState.api.backendInteractor
.fetchMutes({ maxId })
.then((mutes) => {
if (reset) {
store.commit('saveMuteIds', map(mutes, 'id'))
} else {
map(mutes, 'id').map((id) => store.commit('addMuteId', id))
}
if (mutes.length) {
store.commit('setMuteIdsMaxId', last(mutes).id)
}
store.commit('addNewUsers', mutes)
return mutes
})
},
muteUser(store, data) {
return muteUser(store, data)
},
unmuteUser(store, id) {
return unmuteUser(store, id)
},
hideReblogs(store, id) {
return hideReblogs(store, id)
},
showReblogs(store, id) {
return showReblogs(store, id)
},
muteUsers(store, data = []) {
return Promise.all(data.map((d) => muteUser(store, d)))
},
unmuteUsers(store, ids = []) {
return Promise.all(ids.map((d) => unmuteUser(store, d)))
},
fetchDomainMutes(store) {
return store.rootState.api.backendInteractor
.fetchDomainMutes()
.then((domainMutes) => {
store.commit('saveDomainMutes', domainMutes)
return domainMutes
})
},
muteDomain(store, domain) {
return muteDomain(store, domain)
},
unmuteDomain(store, domain) {
return unmuteDomain(store, domain)
},
muteDomains(store, domains = []) {
return Promise.all(domains.map((domain) => muteDomain(store, domain)))
},
unmuteDomains(store, domain = []) {
return Promise.all(domain.map((domain) => unmuteDomain(store, domain)))
},
fetchFriends({ rootState, commit }, id) {
const user = rootState.users.usersObject[id]
const maxId = last(user.friendIds)
return rootState.api.backendInteractor
.fetchFriends({ id, maxId })
.then((friends) => {
commit('addNewUsers', friends)
commit('saveFriendIds', { id, friendIds: map(friends, 'id') })
return friends
})
},
fetchFollowers({ rootState, commit }, id) {
const user = rootState.users.usersObject[id]
const maxId = last(user.followerIds)
return rootState.api.backendInteractor
.fetchFollowers({ id, maxId })
.then((followers) => {
commit('addNewUsers', followers)
commit('saveFollowerIds', { id, followerIds: map(followers, 'id') })
return followers
})
},
clearFriends({ commit }, userId) {
commit('clearFriends', userId)
},
clearFollowers({ commit }, userId) {
commit('clearFollowers', userId)
},
subscribeUser({ rootState, commit }, id) {
return rootState.api.backendInteractor
.followUser({ id, notify: true })
.then((relationship) =>
commit('updateUserRelationship', [relationship]),
)
},
unsubscribeUser({ rootState, commit }, id) {
return rootState.api.backendInteractor
.followUser({ id, notify: false })
.then((relationship) =>
commit('updateUserRelationship', [relationship]),
)
},
toggleActivationStatus({ rootState, commit }, { user }) {
const api = user.deactivated
? rootState.api.backendInteractor.activateUser
: rootState.api.backendInteractor.deactivateUser
api({ user }).then((user) => {
const deactivated = !user.is_active
commit('updateActivationStatus', { user, deactivated })
})
},
registerPushNotifications(store) {
const token = store.state.currentUser.credentials
const vapidPublicKey = useInstanceStore().vapidPublicKey
- const isEnabled = store.rootState.config.webPushNotifications
+ const isEnabled = useMergedConfigStore().mergedConfig.webPushNotifications
const notificationVisibility =
- store.rootState.config.notificationVisibility
+ useMergedConfigStore().mergedConfig.notificationVisibility
registerPushNotifications(
isEnabled,
vapidPublicKey,
token,
notificationVisibility,
)
},
unregisterPushNotifications(store) {
const token = store.state.currentUser.credentials
unregisterPushNotifications(token)
},
addNewUsers({ commit }, users) {
commit('addNewUsers', users)
},
addNewStatuses(store, { statuses }) {
const users = map(statuses, 'user')
const retweetedUsers = compact(map(statuses, 'retweeted_status.user'))
store.commit('addNewUsers', users)
store.commit('addNewUsers', retweetedUsers)
each(statuses, (status) => {
// Reconnect users to statuses
store.commit('setUserForStatus', status)
// Set pinned statuses to user
store.commit('setPinnedToUser', status)
})
each(compact(map(statuses, 'retweeted_status')), (status) => {
// Reconnect users to retweets
store.commit('setUserForStatus', status)
// Set pinned retweets to user
store.commit('setPinnedToUser', status)
})
},
addNewNotifications(store, { notifications }) {
const users = map(notifications, 'from_profile')
const targetUsers = map(notifications, 'target').filter((_) => _)
const notificationIds = notifications.map((_) => _.id)
store.commit('addNewUsers', users)
store.commit('addNewUsers', targetUsers)
const notificationsObject = store.rootState.notifications.idStore
const relevantNotifications = Object.entries(notificationsObject)
.filter(([k]) => notificationIds.includes(k))
.map(([, val]) => val)
// Reconnect users to notifications
each(relevantNotifications, (notification) => {
store.commit('setUserForNotification', notification)
})
},
searchUsers({ rootState, commit }, { query }) {
return rootState.api.backendInteractor
.searchUsers({ query })
.then((users) => {
commit('addNewUsers', users)
return users
})
},
async signUp(store, userInfo) {
const oauthStore = useOAuthStore()
store.commit('signUpPending')
try {
const token = await oauthStore.ensureAppToken()
const data = await apiService.register({
credentials: token,
params: { ...userInfo },
})
if (data.access_token) {
store.commit('signUpSuccess')
oauthStore.setToken(data.access_token)
await store.dispatch('loginUser', data.access_token)
return 'ok'
} else {
// Request succeeded, but user cannot login yet.
store.commit('signUpNotice', data)
return 'request_sent'
}
} catch (e) {
const errors = e.message
store.commit('signUpFailure', errors)
throw e
}
},
async getCaptcha(store) {
return store.rootState.api.backendInteractor.getCaptcha()
},
logout(store) {
const oauth = useOAuthStore()
// NOTE: No need to verify the app still exists, because if it doesn't,
// the token will be invalid too
return oauth
.ensureApp()
.then((app) => {
const params = {
app,
instance: useInstanceStore().server,
token: oauth.userToken,
}
return oauthApi.revokeToken(params)
})
.then(() => {
store.commit('clearCurrentUser')
store.dispatch('disconnectFromSocket')
oauth.clearToken()
store.dispatch('stopFetchingTimeline', 'friends')
store.commit(
'setBackendInteractor',
backendInteractorService(oauth.getToken),
)
store.dispatch('stopFetchingNotifications')
store.dispatch('stopFetchingLists')
store.dispatch('stopFetchingBookmarkFolders')
store.dispatch('stopFetchingFollowRequests')
store.commit('clearNotifications')
store.commit('resetStatuses')
store.dispatch('resetChats')
useInterfaceStore().setLastTimeline('public-timeline')
useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight())
- store.commit('clearServerSideStorage')
})
},
loginUser(store, accessToken) {
return new Promise((resolve, reject) => {
const commit = store.commit
const dispatch = store.dispatch
commit('beginLogin')
store.rootState.api.backendInteractor
.verifyCredentials(accessToken)
.then((data) => {
if (!data.error) {
const user = data
// user.credentials = userCredentials
user.credentials = accessToken
user.blockIds = []
user.muteIds = []
user.domainMutes = []
commit('setCurrentUser', user)
- useServerSideStorageStore().setServerSideStorage(user)
+ useSyncConfigStore()
+ .initSyncConfig(user)
+ .then(() => {
+ useInterfaceStore()
+ .applyTheme()
+ .catch((e) => {
+ console.error('Error setting theme', e)
+ })
+ })
+ useUserHighlightStore().initUserHighlight(user)
commit('addNewUsers', [user])
useEmojiStore().fetchEmoji()
getNotificationPermission().then((permission) =>
useInterfaceStore().setNotificationPermission(permission),
)
// Set our new backend interactor
commit(
'setBackendInteractor',
backendInteractorService(accessToken),
)
// Do server-side storage migrations
// Debug snippet to clean up storage and reset migrations
/*
// Reset wordfilter
Object.keys(
- useServerSideStorageStore().prefsStorage.simple.muteFilters
+ useSyncConfigStore().prefsStorage.simple.muteFilters
).forEach(key => {
- useServerSideStorageStore().unsetPreference({ path: 'simple.muteFilters.' + key, value: null })
+ useSyncConfigStore().unsetSimplePrefAndSave({ path: 'muteFilters.' + key, value: null })
})
// Reset flag to 0 to re-run migrations
- useServerSideStorageStore().setFlag({ flag: 'configMigration', value: 0 })
+ useSyncConfigStore().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)
// Initialize the shout socket.
dispatch('initializeSocket')
}
const startPolling = () => {
// Start getting fresh posts.
dispatch('startFetchingTimeline', { timeline: 'friends' })
// Start fetching notifications
dispatch('startFetchingNotifications')
if (
useInstanceCapabilitiesStore().pleromaChatMessagesAvailable
) {
// Start fetching chats
dispatch('startFetchingChats')
}
}
dispatch('startFetchingLists')
dispatch('startFetchingBookmarkFolders')
if (user.locked) {
dispatch('startFetchingFollowRequests')
}
- if (store.getters.mergedConfig.useStreamingApi) {
+ if (useMergedConfigStore().mergedConfig.useStreamingApi) {
dispatch('fetchTimeline', { timeline: 'friends', since: null })
dispatch('fetchNotifications', { since: null })
dispatch('enableMastoSockets', true)
.catch((error) => {
console.error(
'Failed initializing MastoAPI Streaming socket',
error,
)
})
.then(() => {
dispatch('fetchChats', { latest: true })
setTimeout(
() => dispatch('setNotificationsSilence', false),
10000,
)
})
} else {
startPolling()
}
// Get user mutes
dispatch('fetchMutes')
useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight())
// Fetch our friends
store.rootState.api.backendInteractor
.fetchFriends({ id: user.id })
.then((friends) => commit('addNewUsers', friends))
} else {
const response = data.error
// Authentication failed
commit('endLogin')
// remove authentication token on client/authentication errors
if ([400, 401, 403, 422].includes(response.status)) {
useOAuthStore().clearToken()
}
if (response.status === 401) {
reject(new Error('Wrong username or password'))
} else {
reject(new Error('An error occurred, please try again'))
}
}
commit('endLogin')
resolve()
})
.catch((error) => {
console.error(error)
commit('endLogin')
reject(new Error('Failed to connect to server, try again'))
})
})
},
},
}
export default users
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 909a014ce2..8fb98bc9be 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -1,576 +1,577 @@
import { parseLinkHeader } from '@web3-storage/parse-link-header'
import escapeHtml from 'escape-html'
-import fileTypeService from '../file_type/file_type.service.js'
import punycode from 'punycode.js'
+import fileTypeService from '../file_type/file_type.service.js'
import { isStatusNotification } from '../notification_utils/notification_utils.js'
/** NOTICE! **
* Do not initialize UI-generated data here.
* It will override existing data.
*
* i.e. user.pinnedStatusIds was set to [] here
* UI code would update it with data but upon next user fetch
* it would be reverted back to []
*/
const qvitterStatusType = (status) => {
if (status.is_post_verb) {
return 'status'
}
if (status.retweeted_status) {
return 'retweet'
}
if (
(typeof status.uri === 'string' &&
status.uri.match(/(fave|objectType=Favourite)/)) ||
(typeof status.text === 'string' && status.text.match(/favorited/))
) {
return 'favorite'
}
if (
status.text.match(/deleted notice {{tag/) ||
status.qvitter_delete_notice
) {
return 'deletion'
}
if (
status.text.match(/started following/) ||
status.activity_type === 'follow'
) {
return 'follow'
}
return 'unknown'
}
export const parseUser = (data) => {
const output = {}
const masto = Object.hasOwn(data, 'acct')
// case for users in "mentions" property for statuses in MastoAPI
const mastoShort = masto && !Object.hasOwn(data, 'avatar')
output.inLists = null
output.id = String(data.id)
output._original = data // used for server-side settings
if (masto) {
output.screen_name = data.acct
output.fqn = data.fqn
output.statusnet_profile_url = data.url
if (Object.hasOwn(data, 'mute_expires_at')) {
output.mute_expires_at =
data.mute_expires_at == null ? false : data.mute_expires_at
}
if (Object.hasOwn(data, 'block_expires_at')) {
output.block_expires_at =
data.block_expires_at == null ? false : data.block_expires_at
}
// There's nothing else to get
if (mastoShort) {
return output
}
output.emoji = data.emojis
output.name = escapeHtml(data.display_name)
output.name_html = output.name
output.name_unescaped = data.display_name
output.description = data.note
// TODO cleanup this shit, output.description is overriden with source data
output.description_html = data.note
output.fields = data.fields
output.fields_html = data.fields.map((field) => {
return {
name: escapeHtml(field.name),
value: field.value,
}
})
output.fields_text = data.fields.map((field) => {
return {
name: unescape(field.name.replace(/<[^>]*>/g, '')),
value: unescape(field.value.replace(/<[^>]*>/g, '')),
}
})
// Utilize avatar_static for gif avatars?
output.profile_image_url = data.avatar
output.profile_image_url_original = data.avatar
// Same, utilize header_static?
output.cover_photo = data.header
output.friends_count = data.following_count
output.bot = data.bot
output.privileges = []
if (data.pleroma) {
if (data.pleroma.settings_store) {
output.storage = data.pleroma.settings_store['pleroma-fe']
+ output.user_highlight = data.pleroma.settings_store['user_highlight']
}
const relationship = data.pleroma.relationship
output.background_image = data.pleroma.background_image
output.favicon = data.pleroma.favicon
output.token = data.pleroma.chat_token
if (relationship) {
output.relationship = relationship
}
output.allow_following_move = data.pleroma.allow_following_move
output.hide_favorites = data.pleroma.hide_favorites
output.hide_follows = data.pleroma.hide_follows
output.hide_followers = data.pleroma.hide_followers
output.hide_follows_count = data.pleroma.hide_follows_count
output.hide_followers_count = data.pleroma.hide_followers_count
output.rights = {
moderator: data.pleroma.is_moderator,
admin: data.pleroma.is_admin,
}
// TODO: Clean up in UI? This is duplication from what BE does for qvitterapi
if (output.rights.admin) {
output.role = 'admin'
} else if (output.rights.moderator) {
output.role = 'moderator'
} else {
output.role = 'member'
}
output.birthday = data.pleroma.birthday
if (data.pleroma.privileges) {
output.privileges = data.pleroma.privileges
} else if (data.pleroma.is_admin) {
output.privileges = [
'users_read',
'users_manage_invites',
'users_manage_activation_state',
'users_manage_tags',
'users_manage_credentials',
'users_delete',
'messages_read',
'messages_delete',
'instances_delete',
'reports_manage_reports',
'moderation_log_read',
'announcements_manage_announcements',
'emoji_manage_emoji',
'statistics_read',
]
} else if (data.pleroma.is_moderator) {
output.privileges = ['messages_delete', 'reports_manage_reports']
} else {
output.privileges = []
}
}
if (data.source) {
output.description = data.source.note
output.default_scope = data.source.privacy
output.fields = data.source.fields
if (data.source.pleroma) {
output.no_rich_text = data.source.pleroma.no_rich_text
output.show_role = data.source.pleroma.show_role
output.discoverable = data.source.pleroma.discoverable
output.show_birthday = data.pleroma.show_birthday
output.actor_type = data.source.pleroma.actor_type
}
}
// TODO: handle is_local
output.is_local = !output.screen_name.includes('@')
} else {
output.screen_name = data.screen_name
output.name = data.name
output.name_html = data.name_html
output.description = data.description
output.description_html = data.description_html
output.profile_image_url = data.profile_image_url
output.profile_image_url_original = data.profile_image_url_original
output.cover_photo = data.cover_photo
output.friends_count = data.friends_count
// output.bot = ??? missing
output.statusnet_profile_url = data.statusnet_profile_url
output.is_local = data.is_local
output.role = data.role
output.show_role = data.show_role
if (data.rights) {
output.rights = {
moderator: data.rights.delete_others_notice,
admin: data.rights.admin,
}
}
output.no_rich_text = data.no_rich_text
output.default_scope = data.default_scope
output.hide_follows = data.hide_follows
output.hide_followers = data.hide_followers
output.hide_follows_count = data.hide_follows_count
output.hide_followers_count = data.hide_followers_count
output.background_image = data.background_image
// Websocket token
output.token = data.token
// Convert relationsip data to expected format
output.relationship = {
muting: data.muted,
blocking: data.statusnet_blocking,
followed_by: data.follows_you,
following: data.following,
}
}
output.created_at = new Date(data.created_at)
output.locked = data.locked
output.followers_count = data.followers_count
output.statuses_count = data.statuses_count
if (data.pleroma) {
output.follow_request_count = data.pleroma.follow_request_count
output.tags = data.pleroma.tags
// deactivated was changed to is_active in Pleroma 2.3.0
// so check if is_active is present
output.deactivated =
typeof data.pleroma.is_active !== 'undefined'
? !data.pleroma.is_active // new backend
: data.pleroma.deactivated // old backend
output.notification_settings = data.pleroma.notification_settings
output.unread_chat_count = data.pleroma.unread_chat_count
}
output.tags = output.tags || []
output.rights = output.rights || {}
output.notification_settings = output.notification_settings || {}
// Convert punycode to unicode for UI
output.screen_name_ui = output.screen_name
if (output.screen_name && output.screen_name.includes('@')) {
const parts = output.screen_name.split('@')
const unicodeDomain = punycode.toUnicode(parts[1])
if (unicodeDomain !== parts[1]) {
// Add some identifier so users can potentially spot spoofing attempts:
// lain.com and xn--lin-6cd.com would appear identical otherwise.
output.screen_name_ui_contains_non_ascii = true
output.screen_name_ui = [parts[0], unicodeDomain].join('@')
} else {
output.screen_name_ui_contains_non_ascii = false
}
}
return output
}
export const parseAttachment = (data) => {
const output = {}
const masto = !Object.hasOwn(data, 'oembed')
if (masto) {
// Not exactly same...
output.mimetype = data.pleroma ? data.pleroma.mime_type : data.type
output.meta = data.meta // not present in BE yet
output.id = data.id
} else {
output.mimetype = data.mimetype
// output.meta = ??? missing
}
if (data.type !== 'unknown') {
output.type = data.type
} else {
output.type = fileTypeService.fileType(output.mimetype)
}
output.url = data.url
output.large_thumb_url = data.preview_url
output.description = data.description
return output
}
export const parseSource = (data) => {
const output = {}
output.text = data.text
output.spoiler_text = data.spoiler_text
output.content_type = data.content_type
return output
}
export const parseStatus = (data) => {
const output = {}
const masto = Object.hasOwn(data, 'account')
if (masto) {
output.favorited = data.favourited
output.fave_num = data.favourites_count
output.repeated = data.reblogged
output.repeat_num = data.reblogs_count
output.bookmarked = data.bookmarked
output.type = data.reblog ? 'retweet' : 'status'
output.nsfw = data.sensitive
output.raw_html = data.content
output.emojis = data.emojis
output.tags = data.tags
output.edited_at = data.edited_at
const { pleroma } = data
if (data.pleroma) {
output.text = pleroma.content
? data.pleroma.content['text/plain']
: data.content
output.summary = pleroma.spoiler_text
? data.pleroma.spoiler_text['text/plain']
: data.spoiler_text
output.statusnet_conversation_id = data.pleroma.conversation_id
output.is_local = pleroma.local
output.in_reply_to_screen_name = pleroma.in_reply_to_account_acct
output.thread_muted = pleroma.thread_muted
output.emoji_reactions = pleroma.emoji_reactions
output.parent_visible =
pleroma.parent_visible === undefined ? true : pleroma.parent_visible
output.quote_visible = pleroma.quote_visible || true
output.quotes_count = pleroma.quotes_count
output.bookmark_folder_id = pleroma.bookmark_folder
} else {
output.text = data.content
output.summary = data.spoiler_text
}
const quoteRaw = pleroma?.quote || data.quote
const quoteData = quoteRaw ? parseStatus(quoteRaw) : undefined
output.quote = quoteData
output.quote_id =
- data.quote?.id ?? data.quote_id ?? quoteData?.id ?? pleroma.quote_id
- output.quote_url = data.quote?.url ?? quoteData?.url ?? pleroma.quote_url
+ data.quote?.id ?? data.quote_id ?? quoteData?.id ?? pleroma?.quote_id
+ output.quote_url = data.quote?.url ?? quoteData?.url ?? pleroma?.quote_url
output.in_reply_to_status_id = data.in_reply_to_id
output.in_reply_to_user_id = data.in_reply_to_account_id
output.replies_count = data.replies_count
if (output.type === 'retweet') {
output.retweeted_status = parseStatus(data.reblog)
}
output.summary_raw_html = escapeHtml(data.spoiler_text)
output.external_url = data.uri || data.url
output.poll = data.poll
if (output.poll) {
output.poll.options = (output.poll.options || []).map((field) => ({
...field,
title_html: escapeHtml(field.title),
}))
}
output.pinned = data.pinned
output.muted = data.muted
} else {
output.favorited = data.favorited
output.fave_num = data.fave_num
output.repeated = data.repeated
output.repeat_num = data.repeat_num
// catchall, temporary
// Object.assign(output, data)
output.type = qvitterStatusType(data)
if (data.nsfw === undefined) {
output.nsfw = isNsfw(data)
if (data.retweeted_status) {
output.nsfw = data.retweeted_status.nsfw
}
} else {
output.nsfw = data.nsfw
}
output.raw_html = data.statusnet_html
output.text = data.text
output.in_reply_to_status_id = data.in_reply_to_status_id
output.in_reply_to_user_id = data.in_reply_to_user_id
output.in_reply_to_screen_name = data.in_reply_to_screen_name
output.statusnet_conversation_id = data.statusnet_conversation_id
if (output.type === 'retweet') {
output.retweeted_status = parseStatus(data.retweeted_status)
}
output.summary = data.summary
output.summary_html = data.summary_html
output.external_url = data.external_url
output.is_local = data.is_local
}
output.id = String(data.id)
output.visibility = data.visibility
output.card = data.card
output.created_at = new Date(data.created_at)
// Converting to string, the right way.
output.in_reply_to_status_id = output.in_reply_to_status_id
? String(output.in_reply_to_status_id)
: null
output.in_reply_to_user_id = output.in_reply_to_user_id
? String(output.in_reply_to_user_id)
: null
output.user = parseUser(masto ? data.account : data.user)
output.attentions = ((masto ? data.mentions : data.attentions) || []).map(
parseUser,
)
output.attachments = (
(masto ? data.media_attachments : data.attachments) || []
).map(parseAttachment)
const retweetedStatus = masto ? data.reblog : data.retweeted_status
if (retweetedStatus) {
output.retweeted_status = parseStatus(retweetedStatus)
}
output.favoritedBy = []
output.rebloggedBy = []
if (Object.hasOwn(data, 'originalStatus')) {
Object.assign(output, data.originalStatus)
}
return output
}
export const parseNotification = (data) => {
const mastoDict = {
favourite: 'like',
reblog: 'repeat',
}
const masto = !Object.hasOwn(data, 'ntype')
const output = {}
if (masto) {
output.type = mastoDict[data.type] || data.type
output.seen = data.pleroma.is_seen
// TODO: null check should be a temporary fix, I guess.
// Investigate why backend does this.
output.status =
isStatusNotification(output.type) && data.status !== null
? parseStatus(data.status)
: null
output.target = output.type !== 'move' ? null : parseUser(data.target)
output.from_profile = parseUser(data.account)
output.emoji = data.emoji
output.emoji_url = data.emoji_url
if (data.report) {
output.report = data.report
output.report.content = data.report.content
output.report.acct = parseUser(data.report.account)
output.report.actor = parseUser(data.report.actor)
output.report.statuses = data.report.statuses.map(parseStatus)
}
} else {
const parsedNotice = parseStatus(data.notice)
output.type = data.ntype
output.seen = Boolean(data.is_seen)
output.status =
output.type === 'like'
? parseStatus(data.notice.favorited_status)
: parsedNotice
output.action = parsedNotice
output.from_profile =
output.type === 'pleroma:chat_mention'
? parseUser(data.account)
: parseUser(data.from_profile)
}
output.created_at = new Date(data.created_at)
output.id = parseInt(data.id)
return output
}
const isNsfw = (status) => {
const nsfwRegex = /#nsfw/i
return (
(status.tags || []).includes('nsfw') ||
!!(status.text || '').match(nsfwRegex)
)
}
export const parseLinkHeaderPagination = (linkHeader, opts = {}) => {
const flakeId = opts.flakeId
const parsedLinkHeader = parseLinkHeader(linkHeader)
if (!parsedLinkHeader) return
const maxId = parsedLinkHeader.next?.max_id
const minId = parsedLinkHeader.prev?.min_id
return {
maxId: flakeId ? maxId : parseInt(maxId, 10),
minId: flakeId ? minId : parseInt(minId, 10),
}
}
export const parseChat = (chat) => {
const output = {}
output.id = chat.id
output.account = parseUser(chat.account)
output.unread = chat.unread
output.lastMessage = parseChatMessage(chat.last_message)
output.updated_at = new Date(chat.updated_at)
return output
}
export const parseChatMessage = (message) => {
if (!message) {
return
}
if (message.isNormalized) {
return message
}
const output = message
output.id = message.id
output.created_at = new Date(message.created_at)
output.chat_id = message.chat_id
output.emojis = message.emojis
output.content = message.content
if (message.attachment) {
output.attachments = [parseAttachment(message.attachment)]
} else {
output.attachments = []
}
output.pending = !!message.pending
output.error = false
output.idempotency_key = message.idempotency_key
output.isNormalized = true
return output
}
diff --git a/src/services/html_converter/utility.service.js b/src/services/html_converter/utility.service.js
index 4c36eda2fc..684f96ca10 100644
--- a/src/services/html_converter/utility.service.js
+++ b/src/services/html_converter/utility.service.js
@@ -1,86 +1,88 @@
/**
* Extract tag name from tag opener/closer.
*
* @param {String} tag - tag string, i.e. '<a href="...">'
* @return {String} - tagname, i.e. "div"
*/
export const getTagName = (tag) => {
const result = /(?:<\/(\w+)>|<(\w+)\s?.*?\/?>)/gis.exec(tag)
return result && (result[1] || result[2])
}
/**
* Extract attributes from tag opener.
*
* @param {String} tag - tag string, i.e. '<a href="...">'
* @return {Object} - map of attributes key = attribute name, value = attribute value
* attributes without values represented as boolean true
*/
export const getAttrs = (tag, filter) => {
const innertag = tag
.substring(1, tag.length - 1)
.replace(new RegExp('^' + getTagName(tag)), '')
.replace(/\/?$/, '')
.trim()
const attrs = Array.from(
- innertag.matchAll(/([a-z]+[a-z0-9-]*)(?:=((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')))?/gi),
+ innertag.matchAll(
+ /([a-z]+[a-z0-9-]*)(?:=((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')))?/gi,
+ ),
)
.map(([, key, value]) => [key, value])
.map(([k, v]) => {
if (!v) return [k, true]
return [k, v.substring(1, v.length - 1)]
})
const defaultFilter = ([k, v]) => {
const attrKey = k.toLowerCase()
if (attrKey === 'style') return false
if (attrKey === 'class') {
return v === 'greentext' || v === 'cyantext'
}
return true
}
return Object.fromEntries(attrs.filter(filter || defaultFilter))
}
/**
* Finds shortcodes in text
*
* @param {String} text - original text to find emojis in
* @param {{ url: String, shortcode: Sring }[]} emoji - list of shortcodes to find
* @param {Function} processor - function to call on each encountered emoji,
* function is passed single object containing matching emoji ({ url, shortcode })
* return value will be inserted into resulting array instead of :shortcode:
* @return {Array} resulting array with non-emoji parts of text and whatever {processor}
* returned for emoji
*/
export const processTextForEmoji = (text, emojis, processor) => {
const buffer = []
let textBuffer = ''
for (let i = 0; i < text.length; i++) {
const char = text[i]
if (char === ':') {
const next = text.slice(i + 1)
let found = false
for (const emoji of emojis) {
if (
next.slice(0, emoji.shortcode.length + 1) ===
emoji.shortcode + ':'
) {
found = emoji
break
}
}
if (found) {
buffer.push(textBuffer)
textBuffer = ''
buffer.push(processor(found))
i += found.shortcode.length + 1
} else {
textBuffer += char
}
} else {
textBuffer += char
}
}
if (textBuffer) buffer.push(textBuffer)
return buffer
}
diff --git a/src/services/new_api/oauth.js b/src/services/new_api/oauth.js
index ead2153a67..b803e21465 100644
--- a/src/services/new_api/oauth.js
+++ b/src/services/new_api/oauth.js
@@ -1,200 +1,198 @@
import { reduce } from 'lodash'
import { StatusCodeError } from 'src/services/errors/errors.js'
const REDIRECT_URI = `${window.location.origin}/oauth-callback`
export const getJsonOrError = async (response) => {
if (response.ok) {
return response.json().catch((error) => {
throw new StatusCodeError(response.status, error, {}, response)
})
} else {
throw new StatusCodeError(
response.status,
await response.text(),
{},
response,
)
}
}
export const createApp = (instance) => {
- console.log('NAP', instance)
const url = `${instance}/api/v1/apps`
const form = new window.FormData()
- console.log(url)
form.append('client_name', 'PleromaFE')
form.append('website', 'https://pleroma.social')
form.append('redirect_uris', REDIRECT_URI)
form.append('scopes', 'read write follow push admin')
return window
.fetch(url, {
method: 'POST',
body: form,
})
.then(getJsonOrError)
.then((app) => ({
clientId: app.client_id,
clientSecret: app.client_secret,
}))
}
export const verifyAppToken = ({ instance, appToken }) => {
return window
.fetch(`${instance}/api/v1/apps/verify_credentials`, {
method: 'GET',
headers: { Authorization: `Bearer ${appToken}` },
})
.then(getJsonOrError)
}
const login = ({ instance, clientId }) => {
const data = {
response_type: 'code',
client_id: clientId,
redirect_uri: REDIRECT_URI,
scope: 'read write follow push admin',
}
const dataString = reduce(
data,
(acc, v, k) => {
const encoded = `${k}=${encodeURIComponent(v)}`
if (!acc) {
return encoded
} else {
return `${acc}&${encoded}`
}
},
false,
)
// Do the redirect...
const url = `${instance}/oauth/authorize?${dataString}`
window.location.href = url
}
const getTokenWithCredentials = ({
clientId,
clientSecret,
instance,
username,
password,
}) => {
const url = `${instance}/oauth/token`
const form = new window.FormData()
form.append('client_id', clientId)
form.append('client_secret', clientSecret)
form.append('grant_type', 'password')
form.append('username', username)
form.append('password', password)
return window
.fetch(url, {
method: 'POST',
body: form,
})
.then((data) => data.json())
}
const getToken = ({ clientId, clientSecret, instance, code }) => {
const url = `${instance}/oauth/token`
const form = new window.FormData()
form.append('client_id', clientId)
form.append('client_secret', clientSecret)
form.append('grant_type', 'authorization_code')
form.append('code', code)
form.append('redirect_uri', `${window.location.origin}/oauth-callback`)
return window
.fetch(url, {
method: 'POST',
body: form,
})
.then((data) => data.json())
}
export const getClientToken = ({ clientId, clientSecret, instance }) => {
const url = `${instance}/oauth/token`
const form = new window.FormData()
form.append('client_id', clientId)
form.append('client_secret', clientSecret)
form.append('grant_type', 'client_credentials')
form.append('redirect_uri', `${window.location.origin}/oauth-callback`)
return window
.fetch(url, {
method: 'POST',
body: form,
})
.then(getJsonOrError)
}
const verifyOTPCode = ({ app, instance, mfaToken, code }) => {
const url = `${instance}/oauth/mfa/challenge`
const form = new window.FormData()
form.append('client_id', app.client_id)
form.append('client_secret', app.client_secret)
form.append('mfa_token', mfaToken)
form.append('code', code)
form.append('challenge_type', 'totp')
return window
.fetch(url, {
method: 'POST',
body: form,
})
.then((data) => data.json())
}
const verifyRecoveryCode = ({ app, instance, mfaToken, code }) => {
const url = `${instance}/oauth/mfa/challenge`
const form = new window.FormData()
form.append('client_id', app.client_id)
form.append('client_secret', app.client_secret)
form.append('mfa_token', mfaToken)
form.append('code', code)
form.append('challenge_type', 'recovery')
return window
.fetch(url, {
method: 'POST',
body: form,
})
.then((data) => data.json())
}
const revokeToken = ({ app, instance, token }) => {
const url = `${instance}/oauth/revoke`
const form = new window.FormData()
form.append('client_id', app.clientId)
form.append('client_secret', app.clientSecret)
form.append('token', token)
return window
.fetch(url, {
method: 'POST',
body: form,
})
.then((data) => data.json())
}
const oauth = {
login,
getToken,
getTokenWithCredentials,
verifyOTPCode,
verifyRecoveryCode,
revokeToken,
}
export default oauth
diff --git a/src/services/notification_utils/notification_utils.js b/src/services/notification_utils/notification_utils.js
index 14785a685b..1fbaf2a2c8 100644
--- a/src/services/notification_utils/notification_utils.js
+++ b/src/services/notification_utils/notification_utils.js
@@ -1,205 +1,211 @@
import { showDesktopNotification } from '../desktop_notification_utils/desktop_notification_utils.js'
import { muteFilterHits } from '../status_parser/status_parser.js'
-import { useAnnouncementsStore } from 'src/stores/announcements'
+import { useAnnouncementsStore } from 'src/stores/announcements.js'
import { useI18nStore } from 'src/stores/i18n.js'
import FaviconService from 'src/services/favicon_service/favicon_service.js'
export const ACTIONABLE_NOTIFICATION_TYPES = new Set([
'mention',
'pleroma:report',
'follow_request',
])
let cachedBadgeUrl = null
export const notificationsFromStore = (store) => store.state.notifications.data
-export const visibleTypes = (store) => {
- // When called from within a module we need rootGetters to access wider scope
- // however when called from a component (i.e. this.$store) we already have wider scope
- const rootGetters = store.rootGetters || store.getters
- const { notificationVisibility } = rootGetters.mergedConfig
-
+const visibleTypes = (notificationVisibility) => {
return [
notificationVisibility.likes && 'like',
notificationVisibility.mentions && 'mention',
notificationVisibility.statuses && 'status',
notificationVisibility.repeats && 'repeat',
notificationVisibility.follows && 'follow',
notificationVisibility.followRequest && 'follow_request',
notificationVisibility.moves && 'move',
notificationVisibility.emojiReactions && 'pleroma:emoji_reaction',
notificationVisibility.reports && 'pleroma:report',
notificationVisibility.polls && 'poll',
].filter((_) => _)
}
const statusNotifications = new Set([
'like',
'mention',
'status',
'repeat',
'pleroma:emoji_reaction',
'poll',
])
export const isStatusNotification = (type) => statusNotifications.has(type)
export const isValidNotification = (notification) => {
if (isStatusNotification(notification.type) && !notification.status) {
return false
}
return true
}
const sortById = (a, b) => {
const seqA = Number(a.id)
const seqB = Number(b.id)
const isSeqA = !Number.isNaN(seqA)
const isSeqB = !Number.isNaN(seqB)
if (isSeqA && isSeqB) {
return seqA > seqB ? -1 : 1
} else if (isSeqA && !isSeqB) {
return 1
} else if (!isSeqA && isSeqB) {
return -1
} else {
return a.id > b.id ? -1 : 1
}
}
-const isMutedNotification = (notification) => {
+const isMutedNotification = (muteFilters, notification) => {
if (!notification.status) return false
if (notification.status.muted) return true
- return muteFilterHits(notification.status).length > 0
+ return muteFilterHits(muteFilters, notification.status).length > 0
}
-export const maybeShowNotification = (store, notification) => {
+export const maybeShowNotification = (
+ store,
+ notificationVisibility,
+ muteFilters,
+ notification,
+) => {
const rootState = store.rootState || store.state
if (notification.seen) return
- if (!visibleTypes(store).includes(notification.type)) return
- if (notification.type === 'mention' && isMutedNotification(notification))
+ if (!visibleTypes(notificationVisibility).includes(notification.type)) return
+ if (notification.type === 'mention' && isMutedNotification(muteFilters, notification))
return
const notificationObject = prepareNotificationObject(
notification,
useI18nStore().i18n,
)
showDesktopNotification(rootState, notificationObject)
}
-export const filteredNotificationsFromStore = (store, types) => {
+export const filteredNotificationsFromStore = (
+ store,
+ notificationVisibility,
+ types,
+) => {
// map is just to clone the array since sort mutates it and it causes some issues
const sortedNotifications = notificationsFromStore(store)
.map((_) => _)
.sort(sortById)
// TODO implement sorting elsewhere and make it optional
return sortedNotifications.filter((notification) =>
- (types || visibleTypes(store)).includes(notification.type),
+ (types || visibleTypes(notificationVisibility)).includes(notification.type),
)
}
-export const unseenNotificationsFromStore = (store) => {
- const rootGetters = store.rootGetters || store.getters
- const ignoreInactionableSeen = rootGetters.mergedConfig.ignoreInactionableSeen
-
- return filteredNotificationsFromStore(store).filter(({ seen, type }) => {
- if (!ignoreInactionableSeen) return !seen
- if (seen) return false
- return ACTIONABLE_NOTIFICATION_TYPES.has(type)
- })
+export const unseenNotificationsFromStore = (
+ store,
+ notificationVisibility,
+ ignoreInactionableSeen,
+) => {
+ return filteredNotificationsFromStore(store, notificationVisibility).filter(
+ ({ seen, type }) => {
+ if (!ignoreInactionableSeen) return !seen
+ if (seen) return false
+ return ACTIONABLE_NOTIFICATION_TYPES.has(type)
+ },
+ )
}
export const prepareNotificationObject = (notification, i18n) => {
if (cachedBadgeUrl === null) {
const favicons = FaviconService.getOriginalFavicons()
const favicon = favicons[favicons.length - 1]
if (!favicon) {
cachedBadgeUrl = 'about:blank'
} else {
cachedBadgeUrl = favicon.favimg.src
}
}
const notifObj = {
tag: notification.id,
type: notification.type,
badge: cachedBadgeUrl,
}
const status = notification.status
const title = notification.from_profile.name
notifObj.title = title
notifObj.icon = notification.from_profile.profile_image_url
let i18nString
switch (notification.type) {
case 'like':
i18nString = 'favorited_you'
break
case 'status':
i18nString = 'subscribed_status'
break
case 'repeat':
i18nString = 'repeated_you'
break
case 'follow':
i18nString = 'followed_you'
break
case 'move':
i18nString = 'migrated_to'
break
case 'follow_request':
i18nString = 'follow_request'
break
case 'pleroma:report':
i18nString = 'submitted_report'
break
case 'poll':
i18nString = 'poll_ended'
break
}
if (notification.type === 'pleroma:emoji_reaction') {
notifObj.body = i18n.t('notifications.reacted_with', [notification.emoji])
} else if (i18nString) {
notifObj.body = i18n.t('notifications.' + i18nString)
} else if (isStatusNotification(notification.type)) {
notifObj.body = notification.status.text
}
// Shows first attached non-nsfw image, if any. Should add configuration for this somehow...
if (
status &&
status.attachments &&
status.attachments.length > 0 &&
!status.nsfw &&
status.attachments[0].mimetype.startsWith('image/')
) {
notifObj.image = status.attachments[0].url
}
return notifObj
}
-export const countExtraNotifications = (store) => {
+export const countExtraNotifications = (store, mergedConfig) => {
const rootGetters = store.rootGetters || store.getters
- const mergedConfig = rootGetters.mergedConfig
if (!mergedConfig.showExtraNotifications) {
return 0
}
return [
mergedConfig.showChatsInExtraNotifications
? rootGetters.unreadChatCount
: 0,
mergedConfig.showAnnouncementsInExtraNotifications
? useAnnouncementsStore().unreadAnnouncementCount
: 0,
mergedConfig.showFollowRequestsInExtraNotifications
? rootGetters.followRequestCount
: 0,
].reduce((a, c) => a + c, 0)
}
diff --git a/src/services/notifications_fetcher/notifications_fetcher.service.js b/src/services/notifications_fetcher/notifications_fetcher.service.js
index 73411b6a53..c1a9e1a2f1 100644
--- a/src/services/notifications_fetcher/notifications_fetcher.service.js
+++ b/src/services/notifications_fetcher/notifications_fetcher.service.js
@@ -1,131 +1,131 @@
import apiService from '../api/api.service.js'
import { promiseInterval } from '../promise_interval/promise_interval.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
const update = ({ store, notifications, older }) => {
store.dispatch('addNewNotifications', { notifications, older })
}
//
// For using include_types when fetching notifications.
// Note: chat_mention excluded as pleroma-fe polls them separately
const mastoApiNotificationTypes = new Set([
'mention',
'status',
'favourite',
'reblog',
'follow',
'follow_request',
'move',
'poll',
'pleroma:emoji_reaction',
'pleroma:report',
])
const fetchAndUpdate = ({ store, credentials, older = false, since }) => {
const args = { credentials }
- const { getters } = store
const rootState = store.rootState || store.state
const timelineData = rootState.notifications
- const hideMutedPosts = getters.mergedConfig.hideMutedPosts
+ const hideMutedPosts = useMergedConfigStore().mergedConfig.hideMutedPosts
if (useInstanceCapabilitiesStore().pleromaChatMessagesAvailable) {
mastoApiNotificationTypes.add('pleroma:chat_mention')
}
args.includeTypes = mastoApiNotificationTypes
args.withMuted = !hideMutedPosts
args.timeline = 'notifications'
if (older) {
if (timelineData.minId !== Number.POSITIVE_INFINITY) {
args.until = timelineData.minId
}
return fetchNotifications({ store, args, older })
} else {
// fetch new notifications
if (
since === undefined &&
timelineData.maxId !== Number.POSITIVE_INFINITY
) {
args.since = timelineData.maxId
} else if (since !== null) {
args.since = since
}
const result = fetchNotifications({ store, args, older })
// If there's any unread notifications, try fetch notifications since
// the newest read notification to check if any of the unread notifs
// have changed their 'seen' state (marked as read in another session), so
// we can update the state in this session to mark them as read as well.
// The normal maxId-check does not tell if older notifications have changed
const notifications = timelineData.data
const readNotifsIds = notifications.filter((n) => n.seen).map((n) => n.id)
const unreadNotifsIds = notifications
.filter((n) => !n.seen)
.map((n) => n.id)
if (readNotifsIds.length > 0 && readNotifsIds.length > 0) {
const minId = Math.min(...unreadNotifsIds) // Oldest known unread notification
if (minId !== Infinity) {
args.since = false // Don't use since_id since it sorta conflicts with min_id
args.minId = minId - 1 // go beyond
fetchNotifications({ store, args, older })
}
}
return result
}
}
const fetchNotifications = ({ store, args, older }) => {
return apiService
.fetchTimeline(args)
.then((response) => {
if (response.errors) {
if (
response.status === 400 &&
response.statusText.includes('Invalid value for enum')
) {
response.statusText
.matchAll(/(\w+) - Invalid value for enum./g)
.toArray()
.map((x) => x[1])
.forEach((x) => mastoApiNotificationTypes.delete(x))
return fetchNotifications({ store, args, older })
} else {
throw new Error(`${response.status} ${response.statusText}`)
}
}
const notifications = response.data
update({ store, notifications, older })
return notifications
})
.catch((error) => {
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'notifications.error',
messageArgs: [error.message],
timeout: 5000,
})
console.error(error)
})
}
const startFetching = ({ credentials, store }) => {
// Initially there's set flag to silence all desktop notifications so
// that there won't spam of them when user just opened up the FE we
// reset that flag after a while to show new notifications once again.
setTimeout(() => store.dispatch('setNotificationsSilence', false), 10000)
const boundFetchAndUpdate = () => fetchAndUpdate({ credentials, store })
boundFetchAndUpdate()
return promiseInterval(boundFetchAndUpdate, 10000)
}
const notificationsFetcher = {
fetchAndUpdate,
startFetching,
}
export default notificationsFetcher
diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js
index 495070b0df..c8e90c0a66 100644
--- a/src/services/style_setter/style_setter.js
+++ b/src/services/style_setter/style_setter.js
@@ -1,342 +1,357 @@
+import sum from 'hash-sum'
import localforage from 'localforage'
import { chunk, throttle } from 'lodash'
import { getCssRules } from '../theme_data/css_utils.js'
import { getEngineChecksum, init } from '../theme_data/theme_data_3.service.js'
-import { defaultState } from 'src/modules/default_config_state.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
+
+import { ROOT_CONFIG } from 'src/modules/default_config_state.js'
// On platforms where this is not supported, it will return undefined
// Otherwise it will return an array
const supportsAdoptedStyleSheets = !!document.adoptedStyleSheets
const stylesheets = {}
export const createStyleSheet = (id, priority = 1000) => {
if (stylesheets[id]) return stylesheets[id]
const newStyleSheet = {
rules: [],
ready: false,
priority,
clear() {
this.rules = []
},
addRule(rule) {
let newRule = rule
if (!CSS.supports?.('backdrop-filter', 'blur()')) {
newRule = newRule.replace(/backdrop-filter:[^;]+;/g, '') // Remove backdrop-filter
}
// firefox doesn't like invalid selectors
if (
!CSS.supports?.('selector(::-webkit-scrollbar)') &&
newRule.startsWith('::-webkit')
) {
return
}
this.rules.push(
newRule.replace(/var\(--shadowFilter\)[^;]*;/g, ''), // Remove shadowFilter references
)
},
}
stylesheets[id] = newStyleSheet
return newStyleSheet
}
export const adoptStyleSheets = throttle(() => {
if (supportsAdoptedStyleSheets) {
document.adoptedStyleSheets = Object.values(stylesheets)
.filter((x) => x.ready)
.sort((a, b) => a.priority - b.priority)
.map((sheet) => {
const css = new CSSStyleSheet()
sheet.rules.forEach((r) => css.insertRule(r))
return css
})
} else {
const holder = document.getElementById('custom-styles-holder')
for (let i = holder.sheet.cssRules.length - 1; i >= 0; --i) {
holder.sheet.deleteRule(i)
}
Object.values(stylesheets)
.filter((x) => x.ready)
.sort((a, b) => a.priority - b.priority)
.forEach((sheet) => {
sheet.rules.forEach((r) => holder.sheet.insertRule(r))
})
}
// Some older browsers do not support document.adoptedStyleSheets.
// In this case, we use the <style> elements.
// Since the <style> elements we need are already in the DOM, there
// is nothing to do here.
}, 500)
const EAGER_STYLE_ID = 'pleroma-eager-styles'
const LAZY_STYLE_ID = 'pleroma-lazy-styles'
-export const generateTheme = (inputRuleset, callbacks, debug) => {
+const generateTheme = (inputRuleset, callbacks, debug) => {
const {
onNewRule = () => {
/* no-op */
},
onLazyFinished = () => {
/* no-op */
},
onEagerFinished = () => {
/* no-op */
},
} = callbacks
const themes3 = init({
inputRuleset,
debug,
})
getCssRules(themes3.eager, debug).forEach((rule) => {
// Hacks to support multiple selectors on same component
onNewRule(rule, false)
})
onEagerFinished()
// Optimization - instead of processing all lazy rules in one go, process them in small chunks
// so that UI can do other things and be somewhat responsive while less important rules are being
// processed
let counter = 0
const chunks = chunk(themes3.lazy, 200)
// let t0 = performance.now()
const processChunk = () => {
const chunk = chunks[counter]
Promise.all(chunk.map((x) => x())).then((result) => {
getCssRules(
result.filter((x) => x),
debug,
).forEach((rule) => {
onNewRule(rule, true)
})
// const t1 = performance.now()
// console.debug('Chunk ' + counter + ' took ' + (t1 - t0) + 'ms')
// t0 = t1
counter += 1
if (counter < chunks.length) {
setTimeout(processChunk, 0)
} else {
onLazyFinished()
}
})
}
return { lazyProcessFunc: processChunk }
}
export const tryLoadCache = async () => {
console.info('Trying to load compiled theme data from cache')
const cache = await localforage.getItem('pleromafe-theme-cache')
if (!cache) return null
try {
- if (cache.engineChecksum === getEngineChecksum()) {
+ if (
+ cache.engineChecksum === getEngineChecksum() &&
+ cache.checksum !== undefined &&
+ cache.checksum === useMergedConfigStore().mergedConfig.themeChecksum
+ ) {
const eagerStyles = createStyleSheet(EAGER_STYLE_ID, 10)
const lazyStyles = createStyleSheet(LAZY_STYLE_ID, 20)
cache.data[0].forEach((rule) => eagerStyles.addRule(rule))
cache.data[1].forEach((rule) => lazyStyles.addRule(rule))
eagerStyles.ready = true
lazyStyles.ready = true
console.info(`Loaded theme from cache`)
return true
} else {
- console.warn("Engine checksum doesn't match, cache not usable, clearing")
+ console.warn("Checksums don't match, cache not usable, clearing")
localStorage.removeItem('pleroma-fe-theme-cache')
}
} catch (e) {
console.error('Failed to load theme cache:', e)
return false
}
}
export const applyTheme = (
input,
onEagerFinish = () => {
/* no-op */
},
onFinish = () => {
/* no-op */
},
debug,
) => {
const eagerStyles = createStyleSheet(EAGER_STYLE_ID, 10)
const lazyStyles = createStyleSheet(LAZY_STYLE_ID, 20)
eagerStyles.clear()
lazyStyles.clear()
const { lazyProcessFunc } = generateTheme(
input,
{
onNewRule(rule, isLazy) {
if (isLazy) {
lazyStyles.addRule(rule)
} else {
eagerStyles.addRule(rule)
}
},
onEagerFinished() {
eagerStyles.ready = true
adoptStyleSheets()
onEagerFinish()
console.info(
'Eager part of theme finished, waiting for lazy part to finish to store cache',
)
},
onLazyFinished() {
lazyStyles.ready = true
adoptStyleSheets()
+ const data = [eagerStyles.rules, lazyStyles.rules]
+ const checksum = sum(data)
const cache = {
+ checksum,
engineChecksum: getEngineChecksum(),
- data: [eagerStyles.rules, lazyStyles.rules],
+ data,
}
+ useSyncConfigStore().setSimplePrefAndSave({
+ path: 'themeChecksum',
+ value: checksum,
+ })
onFinish(cache)
localforage.setItem('pleromafe-theme-cache', cache)
console.info('Theme cache stored')
},
},
debug,
)
setTimeout(lazyProcessFunc, 0)
}
const extractStyleConfig = ({
sidebarColumnWidth,
contentColumnWidth,
notifsColumnWidth,
themeEditorMinWidth,
emojiReactionsScale,
emojiSize,
navbarSize,
panelHeaderSize,
textSize,
forcedRoundness,
}) => {
const result = {
sidebarColumnWidth,
contentColumnWidth,
notifsColumnWidth,
themeEditorMinWidth:
parseInt(themeEditorMinWidth) === 0 ? 'fit-content' : themeEditorMinWidth,
emojiReactionsScale,
emojiSize,
navbarSize,
panelHeaderSize,
textSize,
}
switch (forcedRoundness) {
case 'disable':
break
case '0':
result.forcedRoundness = '0'
break
case '1':
result.forcedRoundness = '1px'
break
case '2':
result.forcedRoundness = '0.4rem'
break
default:
}
return result
}
-const defaultStyleConfig = extractStyleConfig(defaultState)
+const defaultStyleConfig = extractStyleConfig(ROOT_CONFIG)
export const applyStyleConfig = (input) => {
const config = extractStyleConfig(input)
if (config === defaultStyleConfig) {
return
}
const rules = Object.entries(config)
.filter(([, v]) => v)
.map(([k, v]) => `--${k}: ${v}`)
.join(';')
const styleSheet = createStyleSheet('theme-holder', 30)
styleSheet.clear()
styleSheet.addRule(`:root { ${rules} }`)
// TODO find a way to make this not apply to theme previews
if (Object.hasOwn(config, 'forcedRoundness')) {
styleSheet.addRule(` *:not(.preview-block) {
--roundness: var(--forcedRoundness) !important;
}`)
}
styleSheet.ready = true
adoptStyleSheets()
}
export const getResourcesIndex = async (url, parser = JSON.parse) => {
const cache = 'no-store'
const customUrl = url.replace(/\.(\w+)$/, '.custom.$1')
let builtin
let custom
const resourceTransform = (resources) => {
return Object.entries(resources).map(([k, v]) => {
if (typeof v === 'object') {
return [k, () => Promise.resolve(v)]
} else if (typeof v === 'string') {
return [
k,
() =>
window
.fetch(v, { cache })
.then((data) => data.text())
.then((text) => parser(text))
.catch((e) => {
console.error(e)
return null
}),
]
} else {
console.error(`Unknown resource format - ${k} is a ${typeof v}`)
return [k, null]
}
})
}
try {
const builtinData = await window.fetch(url, { cache })
const builtinResources = await builtinData.json()
builtin = resourceTransform(builtinResources)
} catch {
builtin = []
console.warn(`Builtin resources at ${url} unavailable`)
}
try {
const customData = await window.fetch(customUrl, { cache })
const customResources = await customData.json()
custom = resourceTransform(customResources)
} catch {
custom = []
console.warn(`Custom resources at ${customUrl} unavailable`)
}
const total = [...custom, ...builtin]
if (total.length === 0) {
return Promise.reject(
new Error(
`Resource at ${url} and ${customUrl} completely unavailable. Panicking`,
),
)
}
return Promise.resolve(Object.fromEntries(total))
}
diff --git a/src/services/sw/sw.js b/src/services/sw/sw.js
index 72d78384c8..e744e37aac 100644
--- a/src/services/sw/sw.js
+++ b/src/services/sw/sw.js
@@ -1,187 +1,187 @@
/* global process */
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)))
}
export function isSWSupported() {
return 'serviceWorker' in navigator
}
function isPushSupported() {
return 'PushManager' in window
}
function getOrCreateServiceWorker() {
if (!isSWSupported()) return
const swType = process.env.HAS_MODULE_SERVICE_WORKER ? 'module' : 'classic'
return navigator.serviceWorker
.register('/sw-pleroma.js', { type: swType })
.catch((err) =>
console.error('Unable to get or create a service worker.', err),
)
}
function subscribePush(registration, isEnabled, vapidPublicKey) {
if (!isEnabled)
return Promise.reject(new Error('Web Push is disabled in config'))
if (!vapidPublicKey)
return Promise.reject(new Error('VAPID public key is not found'))
const subscribeOptions = {
userVisibleOnly: false,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
}
return registration.pushManager.subscribe(subscribeOptions)
}
function unsubscribePush(registration) {
return registration.pushManager.getSubscription().then((subscription) => {
if (subscription === null) {
- return
+ return Promise.resolve('No subscription')
}
return subscription.unsubscribe()
})
}
function deleteSubscriptionFromBackEnd(token) {
return fetch('/api/v1/push/subscription/', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
}).then((response) => {
if (!response.ok) throw new Error('Bad status code from server.')
return response
})
}
function sendSubscriptionToBackEnd(
subscription,
token,
notificationVisibility,
) {
return window
.fetch('/api/v1/push/subscription/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
subscription,
data: {
alerts: {
follow: notificationVisibility.follows,
favourite: notificationVisibility.likes,
mention: notificationVisibility.mentions,
reblog: notificationVisibility.repeats,
move: notificationVisibility.moves,
},
},
}),
})
.then((response) => {
if (!response.ok) throw new Error('Bad status code from server.')
return response.json()
})
.then((responseData) => {
if (!responseData.id) throw new Error('Bad response from server.')
return responseData
})
}
export async function initServiceWorker(store) {
if (!isSWSupported()) return
await getOrCreateServiceWorker()
navigator.serviceWorker.addEventListener('message', (event) => {
const { dispatch } = store
const { type, ...rest } = event.data
switch (type) {
case 'notificationClicked':
dispatch('notificationClicked', { id: rest.id })
}
})
}
export async function showDesktopNotification(content) {
if (!isSWSupported) return
const { active: sw } =
(await window.navigator.serviceWorker.getRegistration()) || {}
if (!sw) return console.error('No serviceworker found!')
sw.postMessage({ type: 'desktopNotification', content })
}
export async function closeDesktopNotification({ id }) {
if (!isSWSupported) return
const { active: sw } =
(await window.navigator.serviceWorker.getRegistration()) || {}
if (!sw) return console.error('No serviceworker found!')
if (id >= 0) {
sw.postMessage({ type: 'desktopNotificationClose', content: { id } })
} else {
sw.postMessage({ type: 'desktopNotificationClose', content: { all: true } })
}
}
export async function updateFocus() {
if (!isSWSupported) return
const { active: sw } =
(await window.navigator.serviceWorker.getRegistration()) || {}
if (!sw) return console.error('No serviceworker found!')
sw.postMessage({ type: 'updateFocus' })
}
export function registerPushNotifications(
isEnabled,
vapidPublicKey,
token,
notificationVisibility,
) {
if (isPushSupported()) {
getOrCreateServiceWorker()
.then((registration) =>
subscribePush(registration, isEnabled, vapidPublicKey),
)
.then((subscription) =>
sendSubscriptionToBackEnd(subscription, token, notificationVisibility),
)
.catch((e) =>
console.warn(`Failed to setup Web Push Notifications: ${e.message}`),
)
}
}
export function unregisterPushNotifications(token) {
if (isPushSupported()) {
- Promise.all([
- deleteSubscriptionFromBackEnd(token),
- getOrCreateServiceWorker()
- .then((registration) => {
- return unsubscribePush(registration).then((result) => [
- registration,
- result,
- ])
- })
- .then(([, unsubResult]) => {
- if (!unsubResult) {
- console.warn("Push subscription cancellation wasn't successful")
- }
- }),
- ]).catch((e) =>
- console.warn(`Failed to disable Web Push Notifications: ${e.message}`),
- )
+ getOrCreateServiceWorker()
+ .then((registration) => {
+ return unsubscribePush(registration).then((result) => [
+ registration,
+ result,
+ ])
+ })
+ .then(([, unsubResult]) => {
+ if (unsubResult === 'No subscription') return
+ if (!unsubResult) {
+ console.warn("Push subscription cancellation wasn't successful")
+ }
+ return deleteSubscriptionFromBackEnd(token)
+ })
+ .catch((e) => {
+ console.warn(`Failed to disable Web Push Notifications: ${e.message}`)
+ })
}
}
export const shouldCache = process.env.NODE_ENV === 'production'
export const cacheKey = 'pleroma-fe'
export const emojiCacheKey = 'pleroma-fe-emoji'
export const clearCache = (key) => caches.delete(key)
export { getOrCreateServiceWorker }
diff --git a/src/services/timeline_fetcher/timeline_fetcher.service.js b/src/services/timeline_fetcher/timeline_fetcher.service.js
index da62f8b37b..68991addf0 100644
--- a/src/services/timeline_fetcher/timeline_fetcher.service.js
+++ b/src/services/timeline_fetcher/timeline_fetcher.service.js
@@ -1,163 +1,164 @@
import { camelCase } from 'lodash'
import apiService from '../api/api.service.js'
import { promiseInterval } from '../promise_interval/promise_interval.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
const update = ({
store,
statuses,
timeline,
showImmediately,
userId,
listId,
pagination,
}) => {
const ccTimeline = camelCase(timeline)
store.dispatch('addNewStatuses', {
timeline: ccTimeline,
userId,
listId,
statuses,
showImmediately,
pagination,
})
}
const fetchAndUpdate = ({
store,
credentials,
timeline = 'friends',
older = false,
showImmediately = false,
userId = false,
listId = false,
statusId = false,
bookmarkFolderId = false,
tag = false,
until,
since,
}) => {
const args = { timeline, credentials }
const rootState = store.rootState || store.state
- const { getters } = store
const timelineData = rootState.statuses.timelines[camelCase(timeline)]
- const { hideMutedPosts, replyVisibility } = getters.mergedConfig
+ const { hideMutedPosts, replyVisibility } =
+ useMergedConfigStore().mergedConfig
const loggedIn = !!rootState.users.currentUser
if (older) {
args.until = until || timelineData.minId
} else {
if (since === undefined) {
args.since = timelineData.maxId
} else if (since !== null) {
args.since = since
}
}
args.userId = userId
args.listId = listId
args.statusId = statusId
args.bookmarkFolderId = bookmarkFolderId
args.tag = tag
args.withMuted = !hideMutedPosts
if (
loggedIn &&
['friends', 'public', 'publicAndExternal', 'bubble'].includes(timeline)
) {
args.replyVisibility = replyVisibility
}
const numStatusesBeforeFetch = timelineData.statuses.length
return apiService
.fetchTimeline(args)
.then((response) => {
if (response.errors) {
if (timeline === 'favorites') {
useInstanceCapabilitiesStore().pleromaPublicFavouritesAvailable = false
return
}
throw new Error(`${response.status} ${response.statusText}`)
}
const { data: statuses, pagination } = response
if (
!older &&
statuses.length >= 20 &&
!timelineData.loading &&
numStatusesBeforeFetch > 0
) {
store.dispatch('queueFlush', { timeline, id: timelineData.maxId })
}
update({
store,
statuses,
timeline,
showImmediately,
userId,
listId,
pagination,
})
return { statuses, pagination }
})
.catch((error) => {
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'timeline.error',
messageArgs: [error.message],
timeout: 5000,
})
})
}
const startFetching = ({
timeline = 'friends',
credentials,
store,
userId = false,
listId = false,
statusId = false,
bookmarkFolderId = false,
tag = false,
}) => {
const rootState = store.rootState || store.state
const timelineData = rootState.statuses.timelines[camelCase(timeline)]
const showImmediately = timelineData.visibleStatuses.length === 0
timelineData.userId = userId
timelineData.listId = listId
timelineData.bookmarkFolderId = bookmarkFolderId
fetchAndUpdate({
timeline,
credentials,
store,
showImmediately,
userId,
listId,
statusId,
bookmarkFolderId,
tag,
})
const boundFetchAndUpdate = () =>
fetchAndUpdate({
timeline,
credentials,
store,
userId,
listId,
statusId,
bookmarkFolderId,
tag,
})
return promiseInterval(boundFetchAndUpdate, 10000)
}
const timelineFetcher = {
fetchAndUpdate,
startFetching,
}
export default timelineFetcher
diff --git a/src/services/user_highlighter/user_highlighter.js b/src/services/user_highlighter/user_highlighter.js
index 45fbffec2e..697496c918 100644
--- a/src/services/user_highlighter/user_highlighter.js
+++ b/src/services/user_highlighter/user_highlighter.js
@@ -1,54 +1,54 @@
import { hex2rgb } from '../color_convert/color_convert.js'
const highlightStyle = (prefs) => {
if (prefs === undefined) return
- const { color, type } = prefs
+ const { color = '#FFFFFF', type } = prefs
if (typeof color !== 'string') return
const rgb = hex2rgb(color)
if (rgb == null) return
const solidColor = `rgb(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)})`
const tintColor = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .1)`
const tintColor2 = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .2)`
const customProps = {
'--____highlight-solidColor': solidColor,
'--____highlight-tintColor': tintColor,
'--____highlight-tintColor2': tintColor2,
}
if (type === 'striped') {
return {
backgroundImage: [
'repeating-linear-gradient(135deg,',
`${tintColor} ,`,
`${tintColor} 20px,`,
`${tintColor2} 20px,`,
`${tintColor2} 40px`,
].join(' '),
backgroundPosition: '0 0',
...customProps,
}
} else if (type === 'solid') {
return {
backgroundColor: tintColor2,
...customProps,
}
} else if (type === 'side') {
return {
backgroundImage: [
'linear-gradient(to right,',
`${solidColor} ,`,
`${solidColor} 2px,`,
'transparent 6px',
].join(' '),
backgroundPosition: '0 0',
...customProps,
}
}
}
const highlightClass = (user) => {
return (
'USER____' + user.screen_name?.replace(/\./g, '_').replace(/@/g, '_AT_')
)
}
export { highlightClass, highlightStyle }
diff --git a/src/stores/i18n.js b/src/stores/i18n.js
index 4e7c7c5d3d..78706af17e 100644
--- a/src/stores/i18n.js
+++ b/src/stores/i18n.js
@@ -1,14 +1,27 @@
+import Cookies from 'js-cookie'
import { defineStore } from 'pinia'
+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(value) {
+ messages.setLanguage(this.i18n, value)
+ Cookies.set(
+ BACKEND_LANGUAGE_COOKIE_NAME,
+ localeService.internalToBackendLocaleMulti(value),
+ )
+ },
},
})
diff --git a/src/stores/instance.js b/src/stores/instance.js
index 76ad22fb5f..54b3cf43ce 100644
--- a/src/stores/instance.js
+++ b/src/stores/instance.js
@@ -1,123 +1,233 @@
import { get, set } from 'lodash'
import { defineStore } from 'pinia'
-import { instanceDefaultProperties } from '../modules/config.js'
import {
- instanceDefaultConfig,
- staticOrApiConfigDefault,
+ convertDefinitions,
+ INSTANCE_DEFAULT_CONFIG,
+ INSTANCE_DEFAULT_CONFIG_DEFINITIONS,
+ INSTANCE_IDENTITY_DEFAULT,
+ INSTANCE_IDENTITY_DEFAULT_DEFINITIONS,
+ LOCAL_DEFAULT_CONFIG,
+ LOCAL_DEFAULT_CONFIG_DEFINITIONS,
+ validateSetting,
} from '../modules/default_config_state.js'
import apiService from '../services/api/api.service.js'
import { useInterfaceStore } from 'src/stores/interface.js'
-import { ensureFinalFallback } from 'src/i18n/languages.js'
-
const REMOTE_INTERACTION_URL = '/main/ostatus'
-const defaultState = {
- // Stuff from apiConfig
- name: 'Pleroma FE',
- registrationOpen: true,
- server: 'http://localhost:4040/',
- textlimit: 5000,
- privateMode: false,
- federating: true,
- federationPolicy: null,
- themesIndex: null,
- stylesIndex: null,
- palettesIndex: null,
- themeData: null, // used for theme editor v2
- vapidPublicKey: null,
+const ROOT_STATE_DEFINITIONS = {
+ name: {
+ type: 'string',
+ default: 'PleromaFE',
+ },
+ registrationOpen: {
+ required: true,
+ type: 'boolean',
+ },
+ server: {
+ description: 'Server URL',
+ required: true,
+ type: 'string',
+ },
+ privateMode: {
+ description: 'Private instance?',
+ required: true,
+ type: 'boolean',
+ },
+ federating: {
+ description: 'Is federation enabled?',
+ required: true,
+ type: 'boolean',
+ },
+ federationPolicy: {
+ description: '????',
+ required: true,
+ type: 'object',
+ },
+
+ // Indexes of themes/styles/palettes
+ themesIndex: {
+ required: true,
+ type: 'object',
+ },
+ stylesIndex: {
+ required: true,
+ type: 'object',
+ },
+ palettesIndex: {
+ required: true,
+ type: 'object',
+ },
+
+ themeData: {
+ description: 'Used for theme v2 editor',
+ required: true,
+ type: 'object',
+ },
+ vapidPublicKey: {
+ description: 'Used for WebPush',
+ required: true,
+ type: 'string',
+ },
// Stuff from static/config.json
- loginMethod: 'password',
- disableUpdateNotification: false,
+ loginMethod: {
+ description: 'Login method (token/password)',
+ default: 'password',
+ },
+ disableUpdateNotification: {
+ description: 'Disable update notification (pleroma-tan one)',
+ default: false,
+ },
+ knownDomains: {
+ description: 'List of known domains; used for domain list for domain mutes',
+ default: [],
+ },
+
+ // Moderation stuff
+ staffAccounts: {
+ description: 'List of staff accounts',
+ default: [],
+ },
+ accountActivationRequired: {
+ description:
+ 'Account activation (by email or admin) required after registration',
+ required: true,
+ type: 'boolean',
+ },
+ accountApprovalRequired: {
+ description: 'Admin approval required after registration',
+ required: true,
+ type: 'boolean',
+ },
+ birthdayRequired: {
+ description: 'Require birthday entry when registering',
+ required: true,
+ type: 'boolean',
+ },
+ birthdayMinAge: {
+ description: 'Minimum age required for registration',
+ default: 0,
+ },
+ restrictedNicknames: {
+ description: 'List of nicknames that are not allowed',
+ default: [],
+ },
+ localBubbleInstances: {
+ description: "Akkoma's bubble feature, list of instances",
+ default: [],
+ }, // Akkoma
+
+ // Version Information
+ backendVersion: {
+ required: true,
+ type: 'string',
+ },
+ backendRepository: {
+ required: true,
+ type: 'string',
+ },
+ frontendVersion: {
+ required: true,
+ type: 'string',
+ },
+}
+const ROOT_STATE = convertDefinitions(ROOT_STATE_DEFINITIONS)
+const DEFAULT_STATE = {
+ ...ROOT_STATE,
// Instance-wide configurations that should not be changed by individual users
instanceIdentity: {
- ...staticOrApiConfigDefault,
+ ...INSTANCE_IDENTITY_DEFAULT,
},
limits: {
bannerlimit: null,
avatarlimit: null,
backgroundlimit: null,
uploadlimit: null,
fieldsLimits: null,
+ textLimit: null,
pollLimits: {
max_options: 4,
max_option_chars: 255,
min_expiration: 60,
max_expiration: 60 * 60 * 24,
},
},
// Instance admins can override default settings for the whole instance
prefsStorage: {
- ...instanceDefaultConfig,
+ ...INSTANCE_DEFAULT_CONFIG,
+ ...LOCAL_DEFAULT_CONFIG,
},
-
- // Known domains list for user's domain-muting
- knownDomains: [],
-
- // Moderation stuff
- staffAccounts: [],
- accountActivationRequired: null,
- accountApprovalRequired: null,
- birthdayRequired: false,
- birthdayMinAge: 0,
- restrictedNicknames: [],
- localBubbleInstances: [], // Akkoma
-
- // Version Information
- backendVersion: '',
- backendRepository: '',
- frontendVersion: '',
}
export const useInstanceStore = defineStore('instance', {
- state: () => ({ ...defaultState }),
+ state: () => ({ ...DEFAULT_STATE }),
getters: {
- instanceDefaultConfig(state) {
- return instanceDefaultProperties
- .map((key) => [key, state[key]])
- .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {})
- },
instanceDomain(state) {
return new URL(this.server).hostname
},
},
actions: {
- set({ path, name, value }) {
- if (get(defaultState, path ?? name) === undefined)
- console.error(
- `Unknown instance option ${path ?? name}, value: ${value}`,
- )
+ set({ path, value }) {
+ let definition
+ const pathArray = path.split('.')
+ const subpath = pathArray[1] ?? pathArray[0]
+
+ if (path.startsWith('instanceIdentity.')) {
+ definition = INSTANCE_IDENTITY_DEFAULT_DEFINITIONS[subpath]
+ } else if (path.startsWith('prefsStorage.')) {
+ definition = {
+ ...LOCAL_DEFAULT_CONFIG_DEFINITIONS,
+ ...INSTANCE_DEFAULT_CONFIG_DEFINITIONS,
+ }[subpath]
+ } else if (path.startsWith('limits.')) {
+ definition =
+ path === 'limits.pollLimits' || path === 'limits.fieldsLimits'
+ ? { required: true, type: 'object' }
+ : { required: true, type: 'number' }
+ } else {
+ const definitions = ROOT_STATE_DEFINITIONS
+ definition = definitions[subpath]
+ }
+
+ const finalValue = validateSetting({
+ path,
+ value,
+ definition,
+ throwError: true,
+ defaultState: DEFAULT_STATE,
+ validateObjects: false,
+ })
- set(this, path ?? name, value)
+ set(this, path, finalValue)
- if ((path ?? name) === 'name') useInterfaceStore().setPageTitle()
+ if (path === 'name') useInterfaceStore().setPageTitle()
},
async getKnownDomains() {
try {
this.knownDomains = await apiService.fetchKnownDomains({
credentials: window.vuex.state.users.currentUser.credentials,
})
} catch (e) {
console.warn("Can't load known domains\n", e)
}
},
getRemoteInteractionLink({ statusId, nickname }) {
const server = this.server.endsWith('/')
? this.server.slice(0, -1)
: this.server
const link = server + REMOTE_INTERACTION_URL
if (statusId) {
return `${link}?status_id=${statusId}`
} else {
return `${link}?nickname=${nickname}`
}
},
},
})
diff --git a/src/stores/interface.js b/src/stores/interface.js
index 8d05ce44bc..3da844a3b5 100644
--- a/src/stores/interface.js
+++ b/src/stores/interface.js
@@ -1,742 +1,742 @@
import { defineStore } from 'pinia'
import {
applyTheme,
getResourcesIndex,
tryLoadCache,
} from '../services/style_setter/style_setter.js'
import { deserialize } from '../services/theme_data/iss_deserializer.js'
import { useInstanceStore } from 'src/stores/instance.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
import {
CURRENT_VERSION,
generatePreset,
} from 'src/services/theme_data/theme_data.service.js'
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js'
export const useInterfaceStore = defineStore('interface', {
state: () => ({
localFonts: null,
themeApplied: false,
themeChangeInProgress: false,
themeVersion: 'v3',
styleNameUsed: null,
styleDataUsed: null,
useStylePalette: false, // hack for applying styles from appearance tab
paletteNameUsed: null,
paletteDataUsed: null,
themeNameUsed: null,
themeDataUsed: null,
temporaryChangesTimeoutId: null,
temporaryChangesCountdown: -1, // used for temporary options that revert after a timeout
temporaryChangesConfirm: () => {
/* no-op */
}, // used for applying temporary options
temporaryChangesRevert: () => {
/* no-op */
}, // used for reverting temporary options
settingsModalState: 'hidden',
settingsModalLoadedUser: false,
settingsModalLoadedAdmin: false,
settingsModalTargetTab: null,
settingsModalMode: 'user',
settings: {
currentSaveStateNotice: null,
noticeClearTimeout: null,
notificationPermission: null,
},
browserSupport: {
cssFilter:
window.CSS &&
window.CSS.supports &&
(window.CSS.supports('filter', 'drop-shadow(0 0)') ||
window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)')),
localFonts: typeof window.queryLocalFonts === 'function',
},
layoutType: 'normal',
globalNotices: [],
layoutHeight: 0,
lastTimeline: null,
}),
actions: {
setTemporaryChanges({ confirm, revert }) {
this.temporaryChangesCountdown = 10
this.temporaryChangesConfirm = confirm
this.temporaryChangesRevert = revert
const countdownFunc = () => {
- if (this.temporaryChangesCountdown === 1) {
+ if (this.temporaryChangesCountdown <= 1) {
this.temporaryChangesRevert()
this.clearTemporaryChanges()
} else {
this.temporaryChangesCountdown--
this.temporaryChangesTimeoutId = setTimeout(countdownFunc, 1000)
}
}
this.temporaryChangesTimeoutId = setTimeout(countdownFunc, 1000)
},
clearTemporaryChanges() {
this.temporaryChangesTimeoutId ??
clearTimeout(this.temporaryChangesTimeoutId)
this.temporaryChangesTimeoutId = null
this.temporaryChangesCountdown = -1
this.temporaryChangesConfirm = () => {
/* no-op */
}
this.temporaryChangesRevert = () => {
/* no-op */
}
},
setPageTitle(option = '') {
try {
- document.title = `${option} ${useInstanceStore().name}`
+ document.title = `${option} ${useInstanceStore().instanceIdentity.name}`
} catch (error) {
console.error(`${error}`)
}
},
settingsSaved({ success, error }) {
if (success) {
if (this.noticeClearTimeout) {
clearTimeout(this.noticeClearTimeout)
}
this.settings.currentSaveStateNotice = { error: false, data: success }
this.settings.noticeClearTimeout = setTimeout(
() => delete this.settings.currentSaveStateNotice,
2000,
)
} else {
this.settings.currentSaveStateNotice = { error: true, errorData: error }
}
},
setNotificationPermission(permission) {
this.notificationPermission = permission
},
closeSettingsModal() {
this.settingsModalState = 'hidden'
},
openSettingsModal(value) {
this.settingsModalMode = value
this.settingsModalState = 'visible'
if (value === 'user') {
if (!this.settingsModalLoadedUser) {
this.settingsModalLoadedUser = true
}
} else if (value === 'admin') {
if (!this.settingsModalLoadedAdmin) {
this.settingsModalLoadedAdmin = true
}
}
},
togglePeekSettingsModal() {
switch (this.settingsModalState) {
case 'minimized':
this.settingsModalState = 'visible'
return
case 'visible':
this.settingsModalState = 'minimized'
return
default:
throw new Error('Illegal minimization state of settings modal')
}
},
clearSettingsModalTargetTab() {
this.settingsModalTargetTab = null
},
openSettingsModalTab(value, mode = 'user') {
this.settingsModalTargetTab = value
this.openSettingsModal(mode)
},
removeGlobalNotice(notice) {
this.globalNotices = this.globalNotices.filter((n) => n !== notice)
},
pushGlobalNotice({
messageKey,
messageArgs = {},
level = 'error',
timeout = 0,
}) {
const notice = {
messageKey,
messageArgs,
level,
}
this.globalNotices.push(notice)
// Adding a new element to array wraps it in a Proxy, which breaks the comparison
// TODO: Generate UUID or something instead or relying on !== operator?
const newNotice = this.globalNotices[this.globalNotices.length - 1]
if (timeout) {
setTimeout(() => this.removeGlobalNotice(newNotice), timeout)
}
return newNotice
},
setLayoutHeight(value) {
this.layoutHeight = value
},
setLayoutWidth(value) {
let width = value
if (value !== undefined) {
this.layoutWidth = value
} else {
width = this.layoutWidth
}
const mobileLayout = width <= 800
const normalOrMobile = mobileLayout ? 'mobile' : 'normal'
- const { thirdColumnMode } = window.vuex.getters.mergedConfig
+ const { thirdColumnMode } = useMergedConfigStore().mergedConfig
if (thirdColumnMode === 'none' || !window.vuex.state.users.currentUser) {
this.layoutType = normalOrMobile
} else {
const wideLayout = width >= 1300
this.layoutType = wideLayout ? 'wide' : normalOrMobile
}
},
setFontsList(value) {
this.localFonts = [...new Set(value.map((font) => font.family)).values()]
},
queryLocalFonts() {
if (this.localFonts !== null) return
this.setFontsList([])
if (!this.browserSupport.localFonts) {
return
}
window
.queryLocalFonts()
.then((fonts) => {
this.setFontsList(fonts)
})
.catch((e) => {
this.pushGlobalNotice({
messageKey: 'settings.style.themes3.font.font_list_unavailable',
messageArgs: {
error: e,
},
level: 'error',
})
})
},
setLastTimeline(value) {
this.lastTimeline = value
},
async fetchPalettesIndex() {
try {
const value = await getResourcesIndex('/static/palettes/index.json')
useInstanceStore().set({
- name: 'palettesIndex',
+ path: 'palettesIndex',
value,
})
return value
} catch (e) {
console.error('Could not fetch palettes index', e)
useInstanceStore().set({
- name: 'palettesIndex',
+ path: 'palettesIndex',
value: { _error: e },
})
return Promise.resolve({})
}
},
setPalette(value) {
this.resetThemeV3Palette()
this.resetThemeV2()
- window.vuex.commit('setOption', { name: 'palette', value })
+ useSyncConfigStore().setPreference({ path: 'simple.palette', value })
+ useSyncConfigStore().pushSyncConfig()
this.applyTheme({ recompile: true })
},
setPaletteCustom(value) {
this.resetThemeV3Palette()
this.resetThemeV2()
- window.vuex.commit('setOption', { name: 'paletteCustomData', value })
+ useSyncConfigStore().setPreference({
+ path: 'simple.paletteCustomData',
+ value,
+ })
+ useSyncConfigStore().pushSyncConfig()
this.applyTheme({ recompile: true })
},
async fetchStylesIndex() {
try {
const value = await getResourcesIndex(
'/static/styles/index.json',
deserialize,
)
- useInstanceStore().set({ name: 'stylesIndex', value })
+ useInstanceStore().set({ path: 'stylesIndex', value })
return value
} catch (e) {
console.error('Could not fetch styles index', e)
useInstanceStore().set({
- name: 'stylesIndex',
+ path: 'simple.stylesIndex',
value: { _error: e },
})
return Promise.resolve({})
}
},
setStyle(value) {
this.resetThemeV3()
this.resetThemeV2()
this.resetThemeV3Palette()
- window.vuex.commit('setOption', { name: 'style', value })
+ useSyncConfigStore().setPreference({ path: 'simple.style', value })
+ useSyncConfigStore().pushSyncConfig()
this.useStylePalette = true
this.applyTheme({ recompile: true }).then(() => {
this.useStylePalette = false
})
},
setStyleCustom(value) {
this.resetThemeV3()
this.resetThemeV2()
this.resetThemeV3Palette()
- window.vuex.commit('setOption', { name: 'styleCustomData', value })
+ useSyncConfigStore().setPreference({
+ path: 'simple.styleCustomData',
+ value,
+ })
+ useSyncConfigStore().pushSyncConfig()
this.useStylePalette = true
this.applyTheme({ recompile: true }).then(() => {
this.useStylePalette = false
})
},
async fetchThemesIndex() {
try {
const value = await getResourcesIndex('/static/styles.json')
- useInstanceStore().set({ name: 'themesIndex', value })
+ useInstanceStore().set({ path: 'themesIndex', value })
return value
} catch (e) {
console.error('Could not fetch themes index', e)
useInstanceStore().set({
- name: 'themesIndex',
+ path: 'themesIndex',
value: { _error: e },
})
return Promise.resolve({})
}
},
setTheme(value) {
this.resetThemeV3()
this.resetThemeV3Palette()
this.resetThemeV2()
- window.vuex.commit('setOption', { name: 'theme', value })
+ useSyncConfigStore().setPreference({ path: 'simple.theme', value })
+ useSyncConfigStore().pushSyncConfig()
this.applyTheme({ recompile: true })
},
setThemeCustom(value) {
this.resetThemeV3()
this.resetThemeV3Palette()
this.resetThemeV2()
- window.vuex.commit('setOption', { name: 'customTheme', value })
- window.vuex.commit('setOption', { name: 'customThemeSource', value })
+ useSyncConfigStore().setPreference({ path: 'simple.customTheme', value })
+ useSyncConfigStore().setPreference({
+ path: 'simple.customThemeSource',
+ value,
+ })
+ useSyncConfigStore().pushSyncConfig()
this.applyTheme({ recompile: true })
},
resetThemeV3() {
- window.vuex.commit('setOption', { name: 'style', value: null })
- window.vuex.commit('setOption', { name: 'styleCustomData', value: null })
+ useSyncConfigStore().setPreference({ path: 'simple.style', value: null })
+ useSyncConfigStore().setPreference({
+ path: 'simple.styleCustomData',
+ value: null,
+ })
},
resetThemeV3Palette() {
- window.vuex.commit('setOption', { name: 'palette', value: null })
- window.vuex.commit('setOption', {
- name: 'paletteCustomData',
+ useSyncConfigStore().setPreference({
+ path: 'simple.palette',
+ value: null,
+ })
+ useSyncConfigStore().setPreference({
+ path: 'simple.paletteCustomData',
value: null,
})
},
resetThemeV2() {
- window.vuex.commit('setOption', { name: 'theme', value: null })
- window.vuex.commit('setOption', { name: 'customTheme', value: null })
- window.vuex.commit('setOption', {
- name: 'customThemeSource',
+ useSyncConfigStore().setPreference({ path: 'simple.theme', value: null })
+ useSyncConfigStore().setPreference({
+ path: 'simple.customTheme',
+ value: null,
+ })
+ useSyncConfigStore().setPreference({
+ path: 'simple.customThemeSource',
value: null,
})
},
async getThemeData() {
const getData = async (resource, index, customData, name) => {
const capitalizedResource =
resource[0].toUpperCase() + resource.slice(1)
const result = {}
if (customData) {
result.nameUsed = 'custom' // custom data overrides name
result.dataUsed = customData
} else {
result.nameUsed = name
if (result.nameUsed == null) {
result.dataUsed = null
return result
}
let fetchFunc = index[result.nameUsed]
// Fallbacks
if (!fetchFunc) {
if (resource === 'style' || resource === 'palette') {
return result
}
const newName = Object.keys(index)[0]
fetchFunc = index[newName]
console.warn(
`${capitalizedResource} with id '${this.styleNameUsed}' not found, trying back to '${newName}'`,
)
if (!fetchFunc) {
console.warn(
`${capitalizedResource} doesn't have a fallback, defaulting to stock.`,
)
fetchFunc = () => Promise.resolve(null)
}
}
result.dataUsed = await fetchFunc()
}
return result
}
- const { style: instanceStyleName, palette: instancePaletteName } =
- useInstanceStore()
-
let {
- theme: instanceThemeV2Name,
- themesIndex,
- stylesIndex,
- palettesIndex,
- } = useInstanceStore()
+ theme: instanceThemeName,
+ style: instanceStyleName,
+ palette: instancePaletteName,
+ } = useInstanceStore().instanceIdentity
+
+ let { themesIndex, stylesIndex, palettesIndex } = useInstanceStore()
const {
style: userStyleName,
styleCustomData: userStyleCustomData,
palette: userPaletteName,
paletteCustomData: userPaletteCustomData,
- } = window.vuex.state.config
+ } = useMergedConfigStore().mergedConfig
let {
theme: userThemeV2Name,
customTheme: userThemeV2Snapshot,
customThemeSource: userThemeV2Source,
- } = window.vuex.state.config
+ } = useMergedConfigStore().mergedConfig
let majorVersionUsed
console.debug(
`User V3 palette: ${userPaletteName}, style: ${userStyleName} , custom: ${!!userStyleCustomData}`,
)
console.debug(
`User V2 name: ${userThemeV2Name}, source: ${!!userThemeV2Source}, snapshot: ${!!userThemeV2Snapshot}`,
)
console.debug(
`Instance V3 palette: ${instancePaletteName}, style: ${instanceStyleName}`,
)
- console.debug('Instance V2 theme: ' + instanceThemeV2Name)
+ console.debug('Instance V2 theme: ' + instanceThemeName)
if (
userPaletteName ||
userPaletteCustomData ||
userStyleName ||
userStyleCustomData ||
// User V2 overrides instance V3
((instancePaletteName || instanceStyleName) &&
- instanceThemeV2Name == null &&
+ instanceThemeName == null &&
userThemeV2Name == null)
) {
// Palette and/or style overrides V2 themes
- instanceThemeV2Name = null
+ instanceThemeName = null
userThemeV2Name = null
userThemeV2Source = null
userThemeV2Snapshot = null
majorVersionUsed = 'v3'
} else if (
userThemeV2Name ||
userThemeV2Snapshot ||
userThemeV2Source ||
- instanceThemeV2Name
+ instanceThemeName
) {
majorVersionUsed = 'v2'
} else {
// if all fails fallback to v3
majorVersionUsed = 'v3'
}
if (majorVersionUsed === 'v3') {
const result = await Promise.all([
this.fetchPalettesIndex(),
this.fetchStylesIndex(),
])
palettesIndex = result[0]
stylesIndex = result[1]
} else {
// Promise.all just to be uniform with v3
const result = await Promise.all([this.fetchThemesIndex()])
themesIndex = result[0]
}
this.themeVersion = majorVersionUsed
console.debug('Version used', majorVersionUsed)
if (majorVersionUsed === 'v3') {
this.themeDataUsed = null
this.themeNameUsed = null
const style = await getData(
'style',
stylesIndex,
userStyleCustomData,
userStyleName || instanceStyleName,
)
this.styleNameUsed = style.nameUsed
this.styleDataUsed = style.dataUsed
let firstStylePaletteName = null
style.dataUsed
?.filter((x) => x.component === '@palette')
.map((x) => {
const cleanDirectives = Object.fromEntries(
Object.entries(x.directives).filter(([k]) => k),
)
return { name: x.variant, ...cleanDirectives }
})
.forEach((palette) => {
const key = 'style.' + palette.name.toLowerCase().replace(/ /g, '_')
if (!firstStylePaletteName) firstStylePaletteName = key
palettesIndex[key] = () => Promise.resolve(palette)
})
const palette = await getData(
'palette',
palettesIndex,
userPaletteCustomData,
this.useStylePalette
? firstStylePaletteName
: userPaletteName || instancePaletteName,
)
if (this.useStylePalette) {
- window.vuex.commit('setOption', {
- name: 'palette',
+ useSyncConfigStore().setPreference({
+ path: 'simple.palette',
value: firstStylePaletteName,
})
+ useSyncConfigStore().pushSyncConfig()
}
this.paletteNameUsed = palette.nameUsed
this.paletteDataUsed = palette.dataUsed
if (this.paletteDataUsed) {
this.paletteDataUsed.link =
this.paletteDataUsed.link || this.paletteDataUsed.accent
this.paletteDataUsed.accent =
this.paletteDataUsed.accent || this.paletteDataUsed.link
}
if (Array.isArray(this.paletteDataUsed)) {
const [
name,
bg,
fg,
text,
link,
cRed = '#FF0000',
cGreen = '#00FF00',
cBlue = '#0000FF',
cOrange = '#E3FF00',
] = palette.dataUsed
this.paletteDataUsed = {
name,
bg,
fg,
text,
link,
accent: link,
cRed,
cBlue,
cGreen,
cOrange,
}
}
console.debug('Palette data used', palette.dataUsed)
} else {
this.styleNameUsed = null
this.styleDataUsed = null
this.paletteNameUsed = null
this.paletteDataUsed = null
const theme = await getData(
'theme',
themesIndex,
userThemeV2Source || userThemeV2Snapshot,
- userThemeV2Name || instanceThemeV2Name,
+ userThemeV2Name || instanceThemeName,
)
this.themeNameUsed = theme.nameUsed
this.themeDataUsed = theme.dataUsed
}
},
async setThemeApplied() {
this.themeApplied = true
},
async applyTheme({ recompile = false } = {}) {
- const { forceThemeRecompilation, themeDebug, theme3hacks } =
- window.vuex.state.config
+ const { mergedConfig } = useMergedConfigStore()
+ const { forceThemeRecompilation, themeDebug } = mergedConfig
+
this.themeChangeInProgress = true
// If we're not forced to recompile try using
// cache (tryLoadCache return true if load successful)
const forceRecompile = forceThemeRecompilation || recompile
await this.getThemeData()
if (!forceRecompile && !themeDebug && (await tryLoadCache())) {
this.themeChangeInProgress = false
return this.setThemeApplied()
}
window.splashUpdate('splash.theme')
try {
const paletteIss = (() => {
if (!this.paletteDataUsed) return null
const result = {
component: 'Root',
directives: {},
}
Object.entries(this.paletteDataUsed)
.filter(([k]) => k !== 'name')
.forEach(([k, v]) => {
let issRootDirectiveName
switch (k) {
case 'background':
issRootDirectiveName = 'bg'
break
case 'foreground':
issRootDirectiveName = 'fg'
break
default:
issRootDirectiveName = k
}
result.directives['--' + issRootDirectiveName] = 'color | ' + v
})
return result
})()
const theme2ruleset =
this.themeDataUsed &&
convertTheme2To3(normalizeThemeData(this.themeDataUsed))
const hacks = []
- Object.entries(theme3hacks).forEach(([key, value]) => {
- switch (key) {
- case 'fonts': {
- Object.entries(theme3hacks.fonts).forEach(([fontKey, font]) => {
- if (!font?.family) return
- switch (fontKey) {
- case 'interface':
- hacks.push({
- component: 'Root',
- directives: {
- '--font': 'generic | ' + font.family,
- },
- })
- break
- case 'input':
- hacks.push({
- component: 'Input',
- directives: {
- '--font': 'generic | ' + font.family,
- },
- })
- break
- case 'post':
- hacks.push({
- component: 'RichContent',
- directives: {
- '--font': 'generic | ' + font.family,
- },
- })
- break
- case 'monospace':
- hacks.push({
- component: 'Root',
- directives: {
- '--monoFont': 'generic | ' + font.family,
- },
- })
- break
- }
- })
- break
- }
- case 'underlay': {
- if (value !== 'none') {
- const newRule = {
- component: 'Underlay',
- directives: {},
- }
- if (value === 'opaque') {
- newRule.directives.opacity = 1
- newRule.directives.background = '--wallpaper'
- }
- if (value === 'transparent') {
- newRule.directives.opacity = 0
- }
- hacks.push(newRule)
- }
- break
- }
+ const fontMap = {
+ Interface: 'Root',
+ Input: 'Input',
+ Posts: 'Post',
+ Monospace: 'Root',
+ }
+
+ Object.entries(fontMap).forEach(([font, component]) => {
+ const family = mergedConfig[`font${font}`]?.family
+ const variable = font === 'Monospace' ? '--monoFont' : '--font'
+ if (family) {
+ hacks.push({
+ component,
+ directives: {
+ [variable]: `generic | "${family}"`,
+ },
+ })
}
})
+ if (mergedConfig.underlay !== 'none') {
+ const newRule = {
+ component: 'Underlay',
+ directives: {},
+ }
+ if (mergedConfig.underlay === 'opaque') {
+ newRule.directives.opacity = 1
+ newRule.directives.background = '--wallpaper'
+ }
+ if (mergedConfig.underlay === 'transparent') {
+ newRule.directives.opacity = 0
+ }
+ hacks.push(newRule)
+ }
+
const rulesetArray = [
theme2ruleset,
this.styleDataUsed,
paletteIss,
hacks,
].filter((x) => x)
return applyTheme(
rulesetArray.flat(),
() => this.setThemeApplied(),
() => {
this.themeChangeInProgress = false
},
themeDebug,
)
} catch (e) {
+ console.error(e)
window.splashError(e)
}
},
},
})
export const normalizeThemeData = (input) => {
let themeData, themeSource
if (input.themeFileVerison === 1) {
// this might not be even used at all, some leftover of unimplemented code in V2 editor
return generatePreset(input).theme
} else if (
Object.hasOwn(input, '_pleroma_theme_version') ||
Object.hasOwn(input, 'source') ||
Object.hasOwn(input, 'theme')
) {
// We got passed a full theme file
themeData = input.theme
themeSource = input.source
} else if (
Object.hasOwn(input, 'themeEngineVersion') ||
Object.hasOwn(input, 'colors')
) {
// We got passed a source/snapshot
themeData = input
themeSource = input
}
// New theme presets don't have 'theme' property, they use 'source'
let out // shout, shout let it all out
if (themeSource && themeSource.themeEngineVersion === CURRENT_VERSION) {
// There are some themes in wild that have completely broken source
out = { ...(themeData || {}), ...themeSource }
} else {
out = themeData
}
// generatePreset here basically creates/updates "snapshot",
// while also fixing the 2.2 -> 2.3 colors/shadows/etc
return generatePreset(out).theme
}
diff --git a/src/stores/local_config.js b/src/stores/local_config.js
new file mode 100644
index 0000000000..5b15c6ff1e
--- /dev/null
+++ b/src/stores/local_config.js
@@ -0,0 +1,67 @@
+import { cloneDeep, set } from 'lodash'
+import { defineStore } from 'pinia'
+import { toRaw } from 'vue'
+
+import { useInstanceStore } from 'src/stores/instance'
+
+import {
+ LOCAL_DEFAULT_CONFIG,
+ LOCAL_DEFAULT_CONFIG_DEFINITIONS,
+ makeUndefined,
+ validateSetting,
+} from 'src/modules/default_config_state'
+
+export const defaultState = {
+ prefsStorage: {
+ ...makeUndefined(LOCAL_DEFAULT_CONFIG),
+ },
+ tempStorage: {
+ ...makeUndefined(LOCAL_DEFAULT_CONFIG),
+ },
+}
+
+export const useLocalConfigStore = defineStore('local_config', {
+ state() {
+ return cloneDeep(defaultState)
+ },
+ actions: {
+ set({ path, value }) {
+ const definition = LOCAL_DEFAULT_CONFIG_DEFINITIONS[path]
+
+ const finalValue = validateSetting({
+ path,
+ value,
+ definition,
+ throwError: false,
+ defaultState: LOCAL_DEFAULT_CONFIG,
+ })
+
+ set(this.prefsStorage, path, finalValue)
+ },
+ setTemporarily({ path, value }) {
+ set(this.tempStorage, path, value)
+ },
+ unsetTemporarily({ path, value }) {
+ set(this.tempStorage, path, undefined)
+ },
+ unset({ path, value }) {
+ set(this.prefsStorage, path, undefined)
+ },
+ clearLocalConfig() {
+ Object.keys(this).forEach((k) => {
+ this.prefsStorage[k] = undefined
+ this.tempStorage[k] = undefined
+ })
+ },
+ },
+ persist: {
+ afterLoad(state) {
+ return {
+ prefsStorage: state.prefsStorage ?? {
+ ...makeUndefined(LOCAL_DEFAULT_CONFIG),
+ },
+ tempStorage: { ...makeUndefined(LOCAL_DEFAULT_CONFIG) },
+ }
+ },
+ },
+})
diff --git a/src/stores/merged_config.js b/src/stores/merged_config.js
new file mode 100644
index 0000000000..334db6c4f4
--- /dev/null
+++ b/src/stores/merged_config.js
@@ -0,0 +1,58 @@
+import { defineStore } from 'pinia'
+
+import { useInstanceStore } from 'src/stores/instance.js'
+import { useLocalConfigStore } from 'src/stores/local_config.js'
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
+
+import {
+ LOCAL_ONLY_KEYS,
+ ROOT_CONFIG,
+} from 'src/modules/default_config_state.js'
+
+export const useMergedConfigStore = defineStore('merged_config', {
+ getters: {
+ mergedConfig: () => {
+ const instancePrefs = useInstanceStore().prefsStorage
+ const tempPrefs = useLocalConfigStore().tempStorage
+ const localPrefs = useLocalConfigStore().prefsStorage
+ const syncPrefs = useSyncConfigStore().prefsStorage
+
+ const getValue = (k) => {
+ if (LOCAL_ONLY_KEYS.has(k)) {
+ return tempPrefs[k] ?? localPrefs[k] ?? syncPrefs.simple[k]
+ } else {
+ return tempPrefs[k] ?? syncPrefs.simple[k]
+ }
+ }
+ const getDefault = (k) => instancePrefs[k] ?? ROOT_CONFIG[k]
+
+ const result = Object.fromEntries(
+ Object.keys(ROOT_CONFIG).map((k) => [k, getValue(k) ?? getDefault(k)]),
+ )
+ return result
+ },
+ mergedConfigDefault: () => {
+ const instancePrefs = useInstanceStore().prefsStorage
+
+ const getDefault = (k) => instancePrefs[k] ?? ROOT_CONFIG[k]
+
+ const result = Object.fromEntries(
+ Object.keys(ROOT_CONFIG).map((k) => [k, getDefault(k)]),
+ )
+ return result
+ },
+ mergedConfigWithoutDefaults: () => {
+ const tempPrefs = useLocalConfigStore().tempStorage
+ const localPrefs = useLocalConfigStore().prefsStorage
+ const syncPrefs = useSyncConfigStore().prefsStorage
+
+ const getValue = (k) =>
+ tempPrefs[k] ?? localPrefs[k] ?? syncPrefs.simple[k]
+
+ const result = Object.fromEntries(
+ Object.keys(ROOT_CONFIG).map((k) => [k, getValue(k)]),
+ )
+ return result
+ },
+ },
+})
diff --git a/src/stores/oauth.js b/src/stores/oauth.js
index ef0f5ba35a..2a79c2fa9f 100644
--- a/src/stores/oauth.js
+++ b/src/stores/oauth.js
@@ -1,160 +1,159 @@
import { defineStore } from 'pinia'
import { useInstanceStore } from 'src/stores/instance.js'
import {
createApp,
getClientToken,
verifyAppToken,
} from 'src/services/new_api/oauth.js'
// status codes about verifyAppToken (GET /api/v1/apps/verify_credentials)
const isAppTokenRejected = (error) =>
// Pleroma API docs say it returns 422 when unauthorized
error.statusCode === 422 ||
// but it actually returns 400 (as of 2.9.0)
// NOTE: don't try to match against the error message, it is translatable
error.statusCode === 400 ||
// and Mastodon docs say it returns 401
error.statusCode === 401
// status codes about getAppToken (GET /oauth/token)
const isClientDataRejected = (error) =>
// Mastodon docs say it returns 401
error.statusCode === 401 ||
// but Pleroma actually returns 400 (as of 2.9.0)
// NOTE: don't try to match against the error message, it is translatable
error.statusCode === 400
export const useOAuthStore = defineStore('oauth', {
state: () => ({
clientId: false,
clientSecret: false,
/* App token is authentication for app without any user, used mostly for
* MastoAPI's registration of new users, stored so that we can fall back to
* it on logout
*/
appToken: false,
/* User token is authentication for app with user, this is for every calls
* that need authorized user to be successful (i.e. posting, liking etc)
*/
userToken: false,
}),
getters: {
getToken() {
return this.userToken || this.appToken
},
getUserToken() {
return this.userToken
},
},
actions: {
setClientData({ clientId, clientSecret }) {
this.clientId = clientId
this.clientSecret = clientSecret
},
setAppToken(token) {
this.appToken = token
},
setToken(token) {
this.userToken = token
},
clearToken() {
this.userToken = false
},
async createApp() {
const instance = useInstanceStore().server
const app = await createApp(instance)
this.setClientData(app)
return app
},
/// Use this if you want to get the client id and secret but are not interested
/// in whether they are valid.
/// @return {{ clientId: string, clientSecret: string }} An object representing the app
async ensureApp() {
if (this.clientId && this.clientSecret) {
return {
clientId: this.clientId,
clientSecret: this.clientSecret,
}
} else {
return this.createApp()
}
},
async getAppToken() {
const instance = useInstanceStore().server
- console.log(this.clientId)
const res = await getClientToken({
clientId: this.clientId,
clientSecret: this.clientSecret,
instance,
})
this.setAppToken(res.access_token)
return res.access_token
},
/// Use this if you want to ensure the app is still valid to use.
/// @return {string} The access token to the app (not attached to any user)
async ensureAppToken() {
if (this.appToken) {
try {
await verifyAppToken({
instance: useInstanceStore().server,
appToken: this.appToken,
})
return this.appToken
} catch (e) {
if (!isAppTokenRejected(e)) {
// The server did not reject our token, but we encountered other problems. Maybe the server is down.
throw e
} else {
// The app token is rejected, so it is no longer useful.
this.setAppToken(false)
}
}
}
// appToken is not available, or is rejected: try to get a new one.
// First, make sure the client id and client secret are filled.
try {
await this.ensureApp()
} catch (e) {
console.error('Cannot create app', e)
throw e
}
// Note that at this step, the client id and secret may be invalid
// (because the backend may have already deleted the app due to no user login)
try {
return await this.getAppToken()
} catch (e) {
if (!isClientDataRejected(e)) {
// Non-credentials problem, fail fast
console.error('Cannot get app token', e)
throw e
} else {
// the client id and secret are invalid, so we should clear them
// and re-create our app
this.setClientData({
clientId: false,
clientSecret: false,
})
await this.createApp()
// try once again to get the token
return await this.getAppToken()
}
}
},
},
persist: {
afterLoad(state) {
// state.token is userToken with older name, coming from persistent state
if (state.token && !state.userToken) {
state.userToken = state.token
}
if ('token' in state) {
delete state.token
}
return state
},
},
})
diff --git a/src/stores/serverSideStorage.js b/src/stores/sync_config.js
similarity index 57%
rename from src/stores/serverSideStorage.js
rename to src/stores/sync_config.js
index 99f5045f78..722dd7f163 100644
--- a/src/stores/serverSideStorage.js
+++ b/src/stores/sync_config.js
@@ -1,643 +1,822 @@
+import sum from 'hash-sum'
import {
merge as _merge,
clamp,
cloneDeep,
findLastIndex,
flatten,
get,
groupBy,
isEqual,
set,
+ take,
takeRight,
uniqWith,
unset,
} from 'lodash'
+import { v4 as uuidv4 } from 'uuid'
import { defineStore } from 'pinia'
import { toRaw } from 'vue'
import { CURRENT_UPDATE_COUNTER } from 'src/components/update_notification/update_notification.js'
-export const VERSION = 1
-export const NEW_USER_DATE = new Date('2022-08-04') // date of writing this, basically
+import { useInstanceStore } from 'src/stores/instance.js'
+import { useLocalConfigStore } from 'src/stores/local_config.js'
+
+import { storage } from 'src/lib/storage.js'
+import {
+ makeUndefined,
+ ROOT_CONFIG,
+ ROOT_CONFIG_DEFINITIONS,
+ validateSetting,
+} from 'src/modules/default_config_state.js'
+import { oldDefaultConfigSync } from 'src/modules/old_default_config_state.js'
+
+export const VERSION = 2
+export const NEW_USER_DATE = new Date('2026-03-16') // date of writing this, basically
export const COMMAND_TRIM_FLAGS = 1000
export const COMMAND_TRIM_FLAGS_AND_RESET = 1001
+export const COMMAND_WIPE_JOURNAL = 1010
+export const COMMAND_WIPE_JOURNAL_AND_STORAGE = 1011
export const defaultState = {
// do we need to update data on server?
dirty: false,
// storage of flags - stuff that can only be set and incremented
flagStorage: {
updateCounter: 0, // Counter for most recent update notification seen
- configMigration: 0, // Counter for config -> server-side migrations
- reset: 0, // special flag that can be used to force-reset all flags, debug purposes only
+ reset: 0, // special flag that can be used to force-reset all data, debug purposes only
// special reset codes:
// 1000: trim keys to those known by currently running FE
// 1001: same as above + reset everything to 0
},
prefsStorage: {
_journal: [],
simple: {
- dontShowUpdateNotifs: false,
- collapseNav: false,
muteFilters: {},
+
+ ...makeUndefined({ ...ROOT_CONFIG }),
},
collections: {
pinnedStatusActions: ['reply', 'retweet', 'favorite', 'emoji'],
pinnedNavItems: ['home', 'dms', 'chats'],
},
},
// raw data
raw: null,
// local cache
cache: null,
}
export const newUserFlags = {
...defaultState.flagStorage,
updateCounter: CURRENT_UPDATE_COUNTER, // new users don't need to see update notification
}
export const _moveItemInArray = (array, value, movement) => {
const oldIndex = array.indexOf(value)
const newIndex = oldIndex + movement
const newArray = [...array]
// remove old
newArray.splice(oldIndex, 1)
// add new
newArray.splice(clamp(newIndex, 0, newArray.length + 1), 0, value)
return newArray
}
-const _wrapData = (data, userName) => ({
- ...data,
- _user: userName,
- _timestamp: Date.now(),
- _version: VERSION,
-})
+const _wrapData = (data, userName) => {
+ return {
+ ...data,
+ _user: userName,
+ _timestamp: Date.now(),
+ _version: VERSION,
+ }
+}
const _checkValidity = (data) => data._timestamp > 0 && data._version > 0
const _verifyPrefs = (state) => {
state.prefsStorage = state.prefsStorage || {
simple: {},
collections: {},
}
// Simple
Object.entries(defaultState.prefsStorage.simple).forEach(([k, v]) => {
+ if (typeof v === 'undefined') return
if (typeof v === 'number' || typeof v === 'boolean') return
- if (typeof v === 'object' && v != null) return
- console.warn(`Preference simple.${k} as invalid type, reinitializing`)
+ if (typeof v === 'object') return
+ console.warn(
+ `Preference simple.${k} as invalid type ${typeof v}, reinitializing`,
+ )
set(state.prefsStorage.simple, k, defaultState.prefsStorage.simple[k])
})
// Collections
Object.entries(defaultState.prefsStorage.collections).forEach(([k, v]) => {
if (Array.isArray(v)) return
- console.warn(`Preference collections.${k} as invalid type, reinitializing`)
+ console.warn(
+ `Preference collections.${k} as invalid type ${typeof v}, reinitializing`,
+ )
set(
state.prefsStorage.collections,
k,
defaultState.prefsStorage.collections[k],
)
})
}
export const _getRecentData = (cache, live, isTest) => {
const result = { recent: null, stale: null, needUpload: false }
const cacheValid = _checkValidity(cache || {})
const liveValid = _checkValidity(live || {})
if (!liveValid && cacheValid) {
result.needUpload = true
console.debug(
'Nothing valid stored on server, assuming cache to be source of truth',
)
result.recent = cache
result.stale = live
} else if (!cacheValid && liveValid) {
console.debug(
'Valid storage on server found, no local cache found, using live as source of truth',
)
result.recent = live
result.stale = cache
} else if (cacheValid && liveValid) {
console.debug('Both sources have valid data, figuring things out...')
if (
live._timestamp === cache._timestamp &&
live._version === cache._version
) {
console.debug(
- 'Same version/timestamp on both source, source of truth irrelevant',
+ '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',
+ 'Different timestamp or version, figuring out which one is more recent',
)
if (live._timestamp < cache._timestamp) {
result.recent = cache
result.stale = live
} else {
result.recent = live
result.stale = cache
}
}
} else {
console.debug('Both sources are invalid, start from scratch')
result.needUpload = true
}
- const merge = (a, b) => ({
- _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),
- })
+ const merge = (a, b) => {
+ return {
+ _user: a._user ?? b._user,
+ _version: a._version ?? b._version,
+ _timestamp: a._timestamp ?? b._timestamp,
+ needUpload: b.needUpload ?? a.needUpload,
+ prefsStorage: _merge(cloneDeep(a.prefsStorage), b.prefsStorage),
+ flagStorage: _merge(a.flagStorage, b.flagStorage),
+ }
+ }
+
result.recent = isTest
? result.recent
: result.recent && merge(defaultState, result.recent)
+
result.stale = isTest
? result.stale
: result.stale && merge(defaultState, result.stale)
return result
}
export const _getAllFlags = (recent, stale) => {
return Array.from(
new Set([
...Object.keys(toRaw((recent || {}).flagStorage || {})),
...Object.keys(toRaw((stale || {}).flagStorage || {})),
]),
)
}
export const _mergeFlags = (recent, stale, allFlagKeys) => {
if (!stale.flagStorage) return recent.flagStorage
if (!recent.flagStorage) return stale.flagStorage
return Object.fromEntries(
allFlagKeys.map((flag) => {
const recentFlag = recent.flagStorage[flag]
const staleFlag = stale.flagStorage[flag]
// use flag that is of higher value
return [
flag,
Number((recentFlag > staleFlag ? recentFlag : staleFlag) || 0),
]
}),
)
}
-const _mergeJournal = (...journals) => {
+export const _mergeJournal = (...journals) => {
// Ignore invalid journal entries
const allJournals = flatten(
journals.map((j) => (Array.isArray(j) ? j : [])),
).filter(
(entry) =>
Object.hasOwn(entry, 'path') &&
Object.hasOwn(entry, 'operation') &&
Object.hasOwn(entry, 'args') &&
Object.hasOwn(entry, 'timestamp'),
)
const grouped = groupBy(allJournals, 'path')
const trimmedGrouped = Object.entries(grouped).map(([path, journal]) => {
// side effect
journal.sort((a, b) => (a.timestamp > b.timestamp ? 1 : -1))
if (path.startsWith('collections')) {
const lastRemoveIndex = findLastIndex(
journal,
({ operation }) => operation === 'removeFromCollection',
)
// everything before last remove is unimportant
let remainder
if (lastRemoveIndex > 0) {
remainder = journal.slice(lastRemoveIndex)
} else {
// everything else doesn't need trimming
remainder = journal
}
return uniqWith(remainder, (a, b) => {
if (a.path !== b.path) {
return false
}
if (a.operation !== b.operation) {
return false
}
if (a.operation === 'addToCollection') {
return a.args[0] === b.args[0]
}
return false
})
} else if (path.startsWith('simple')) {
// Only the last record is important
return takeRight(journal)
} else {
return journal
}
})
- return flatten(trimmedGrouped).sort((a, b) =>
+
+ const flat = flatten(trimmedGrouped).sort((a, b) =>
a.timestamp > b.timestamp ? 1 : -1,
)
+ return take(flat, 500)
}
export const _mergePrefs = (recent, stale) => {
if (!stale) return recent
if (!recent) return stale
const { _journal: recentJournal, ...recentData } = recent
const { _journal: staleJournal } = stale
/** Journal entry format:
* path: path to entry in prefsStorage
* timestamp: timestamp of the change
* operation: operation type
* arguments: array of arguments, depends on operation type
*
* currently only supported operation type is "set" which just sets the value
* to requested one. Intended only to be used with simple preferences (boolean, number)
* shouldn't be used with collections!
*/
const resultOutput = { ...recentData }
const totalJournal = _mergeJournal(staleJournal, recentJournal)
- totalJournal.forEach(({ path, operation, args }) => {
- if (path.startsWith('_')) {
- throw new Error(
- `journal contains entry to edit internal (starts with _) field '${path}', something is incorrect here, ignoring.`,
- )
- }
- switch (operation) {
- case 'set':
- if (
- path.startsWith('collections') ||
- path.startsWith('objectCollections')
- ) {
- throw new Error('Illegal operation "set" on a collection')
+ totalJournal
+ .filter(({ path, operation, args }) => {
+ const entry = path.split('.')[1]
+ if (operation === 'unset') return ROOT_CONFIG[entry] !== undefined
+
+ if (operation !== 'set') return true
+
+ const definition = path.startsWith('simple.muteFilters')
+ ? { default: {} }
+ : ROOT_CONFIG_DEFINITIONS[entry]
+
+ const finalValue = validateSetting({
+ path,
+ value: args[0],
+ definition,
+ throwError: false,
+ defaultState: ROOT_CONFIG,
+ })
+
+ return finalValue !== undefined
+ })
+ .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')) {
+ return console.error('Illegal operation "set" on a collection')
+ }
+ if (path.split(/\./g).length <= 1) {
+ return console.error(
+ `Calling set on depth <= 1 (path: ${path}) is not allowed`,
+ )
+ }
+ set(resultOutput, path, args[0])
+ break
}
- if (path.split(/\./g).length <= 1) {
- throw new Error(
- `Calling set on depth <= 1 (path: ${path}) is not allowed`,
+ case 'unset':
+ if (path.startsWith('collections')) {
+ return console.error('Illegal operation "unset" on a collection')
+ }
+ if (path.split(/\./g).length <= 2) {
+ return console.error(
+ `Calling unset on depth <= 2 (path: ${path}) is not allowed`,
+ )
+ }
+ unset(resultOutput, path)
+ break
+ case 'addToCollection':
+ if (!path.startsWith('collections')) {
+ return console.error(
+ 'Illegal operation "addToCollection" on a non-collection',
+ )
+ }
+ set(
+ resultOutput,
+ path,
+ Array.from(new Set(get(resultOutput, path)).add(args[0])),
)
+ break
+ case 'removeFromCollection': {
+ if (!path.startsWith('collections')) {
+ return console.error(
+ 'Illegal operation "removeFromCollection" on a non-collection',
+ )
+ }
+ const newSet = new Set(get(resultOutput, path))
+ newSet.delete(args[0])
+ set(resultOutput, path, Array.from(newSet))
+ break
}
- 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`,
+ case 'reorderCollection': {
+ const [value, movement] = args
+ set(
+ resultOutput,
+ path,
+ _moveItemInArray(get(resultOutput, path), value, movement),
)
+ break
}
- 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:
+ return console.error(
+ `Unknown journal operation: '${operation}', did we forget to run reverse migrations beforehand?`,
+ )
}
- 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) => {
- if (!cache) return cache
+const _resetPrefs = (
+ totalPrefs,
+ totalFlags,
+ knownKeys = defaultState.flagStorage,
+) => {
+ // prefs reset functionality
+ if (
+ totalFlags.reset >= COMMAND_WIPE_JOURNAL &&
+ totalFlags.reset <= COMMAND_WIPE_JOURNAL_AND_STORAGE
+ ) {
+ console.debug('Received command to reset journals')
+ this.flagStorage.reset = COMMAND_WIPE_JOURNAL
+ this.prefsStorage._journal = []
+ this.cache.prefsStorage._journal = []
+ this.raw.prefsStorage._journal = []
+ this.pushSyncConfig()
+ if (totalFlags.reset === COMMAND_WIPE_JOURNAL_AND_STORAGE) {
+ console.debug('Received command to reset storage')
+ return cloneDeep(defaultState)
+ }
+ }
+ return totalPrefs
+}
- if (cache._version < VERSION) {
+const _doMigrations = async (data, setPreference) => {
+ if (data._version < VERSION) {
console.debug(
- 'Local cached data has older version, seeing if there any migrations that can be applied',
+ '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 (cache._version > VERSION) {
+ if (data._version > VERSION) {
console.debug(
- 'Local cached data has newer version, seeing if there any reverse migrations that can be applied',
+ '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(
{},
- 'serverSideStorage',
- { from: cache._version, to: VERSION },
- cache,
+ 'syncConfigStore',
+ { from: data._version, to: VERSION },
+ data,
)
}
}
}
- return cache
+ return data
}
-export const useServerSideStorageStore = defineStore('serverSideStorage', {
+export const useSyncConfigStore = defineStore('sync_config', {
state() {
return cloneDeep(defaultState)
},
actions: {
setFlag({ flag, value }) {
this.flagStorage[flag] = value
this.dirty = true
},
+ setSimplePrefAndSave({ path, value }) {
+ this.setPreference({ path: `simple.${path}`, value })
+ this.pushSyncConfig()
+ },
+ unsetSimplePrefAndSave({ path }) {
+ this.unsetPreference({ path: `simple.${path}` })
+ this.pushSyncConfig()
+ },
setPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`Tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
- if (
- path.startsWith('collections') ||
- path.startsWith('objectCollections')
- ) {
+ if (path.startsWith('collections')) {
throw new Error(
`Invalid operation 'set' for collection field '${path}', ignoring.`,
)
}
if (path.split(/\./g).length <= 1) {
throw new Error(
`Calling set on depth <= 1 (path: ${path}) is not allowed`,
)
}
if (path.split(/\./g).length > 3) {
throw new Error(
`Calling set on depth > 3 (path: ${path}) is not allowed`,
)
}
- set(this.prefsStorage, path, value)
+
+ if (path.startsWith('collections.')) return value
+
+ const definition = path.startsWith('simple.muteFilters')
+ ? { default: {} }
+ : ROOT_CONFIG_DEFINITIONS[path.split('.')[1]]
+
+ const finalValue = validateSetting({
+ path,
+ value,
+ definition,
+ throwError: false,
+ defaultState: ROOT_CONFIG,
+ })
+
+ if (finalValue !== undefined) set(this.prefsStorage, path, finalValue)
+
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{ operation: 'set', path, args: [value], timestamp: Date.now() },
]
this.dirty = true
},
unsetPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`Tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
- if (
- path.startsWith('collections') ||
- path.startsWith('objectCollections')
- ) {
+ if (path.startsWith('collections')) {
throw new Error(
`Invalid operation 'unset' for collection field '${path}', ignoring.`,
)
}
if (path.split(/\./g).length <= 2) {
throw new Error(
`Calling unset on depth <= 2 (path: ${path}) is not allowed`,
)
}
if (path.split(/\./g).length > 3) {
throw new Error(
`Calling unset on depth > 3 (path: ${path}) is not allowed`,
)
}
- unset(this.prefsStorage, path, value)
+ unset(this.prefsStorage, path)
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{ operation: 'unset', path, args: [], timestamp: Date.now() },
]
this.dirty = true
},
addCollectionPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`tried to edit internal (starts with _) field '${path}'`,
)
}
if (path.startsWith('collections')) {
const collection = new Set(get(this.prefsStorage, path))
collection.add(value)
set(this.prefsStorage, path, [...collection])
- } 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
+
+ const { _key } = value
+ if (path.startsWith('collection')) {
+ const collection = new Set(get(this.prefsStorage, path))
+ collection.delete(value)
+ set(this.prefsStorage, path, [...collection])
+
+ this.prefsStorage._journal = [
+ ...this.prefsStorage._journal,
+ {
+ operation: 'removeFromCollection',
+ path,
+ args: [value],
+ timestamp: Date.now(),
+ },
+ ]
+ this.dirty = true
+ }
},
reorderCollectionPreference({ path, value, movement }) {
if (path.startsWith('_')) {
throw new Error(
`tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
const collection = get(this.prefsStorage, path)
const newCollection = _moveItemInArray(collection, value, movement)
set(this.prefsStorage, path, newCollection)
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{
operation: 'arrangeCollection',
path,
args: [value],
timestamp: Date.now(),
},
]
this.dirty = true
},
updateCache({ username }) {
this.prefsStorage._journal = _mergeJournal(this.prefsStorage._journal)
this.cache = _wrapData(
{
flagStorage: toRaw(this.flagStorage),
prefsStorage: toRaw(this.prefsStorage),
},
username,
)
},
- clearServerSideStorage() {
+ clearSyncConfig() {
const blankState = { ...cloneDeep(defaultState) }
Object.keys(this).forEach((k) => {
this[k] = blankState[k]
})
+ this.flagStorage.reset = COMMAND_WIPE_JOURNAL_AND_STORAGE
},
- setServerSideStorage(userData) {
+ async initSyncConfig(userData) {
const live = userData.storage
this.raw = live
let cache = this.cache
- if (cache && cache._user !== userData.fqn) {
+ if (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 },
})
}
+ recent = recent && (await _doMigrations(recent, this.setPreference))
+ stale = stale && (await _doMigrations(stale, this.setPreference))
+
+ // Various migrations
+ console.debug('Migrating from old config')
+ const vuexState = (await storage.getItem('vuex-lz')) ?? {}
+ const config = vuexState.config ?? {}
+
+ const migratedEntries = new Set(config._syncMigration ?? [])
+ console.debug(
+ `Already migrated Values: ${[...migratedEntries].join() || '[none]'}`,
+ )
+
+ const { configMigration } = useSyncConfigStore().flagStorage
+
+ Object.entries(oldDefaultConfigSync).forEach(([key, value]) => {
+ const oldValue = config[key]
+ const defaultValue = value
+
+ const present = oldValue !== undefined
+ const migrated = migratedEntries.has(key)
+ const different = !isEqual(oldValue, defaultValue)
+
+ if (present && !migrated && different) {
+ console.debug(`Migrating config ${key}: ${oldValue}`)
+ if (key === 'theme3hacks') {
+ useLocalConfigStore().set({
+ path: 'fontInterface',
+ value: oldValue.fonts.interface,
+ })
+ useLocalConfigStore().set({
+ path: 'fontInput',
+ value: oldValue.fonts.input,
+ })
+ useLocalConfigStore().set({
+ path: 'fontPost',
+ value: oldValue.fonts.post,
+ })
+ useLocalConfigStore().set({
+ path: 'fontMonospace',
+ value: oldValue.fonts.monospace,
+ })
+ useSyncConfigStore().setSimplePrefAndSave({
+ path: 'underlay',
+ value: oldValue.underlay,
+ })
+ } else if (key == 'muteWords') {
+ oldValue.forEach((word, order) => {
+ const uniqueId = uuidv4()
+
+ useSyncConfigStore().setPreference({
+ path: 'simple.muteFilters.' + uniqueId,
+ value: {
+ type: 'word',
+ value: word,
+ name: word,
+ enabled: true,
+ expires: null,
+ hide: false,
+ order,
+ },
+ })
+ })
+ } else {
+ this.setPreference({ path: `simple.${key}`, value: oldValue })
+ }
+ migratedEntries.add(key)
+ needUpload = true
+ }
+ })
+
+ config._syncMigration = [...migratedEntries]
+ vuexState.config = config
+ storage.setItem('vuex-lz', vuexState)
+
if (!needUpload && recent && stale) {
console.debug('Checking if data needs merging...')
// discarding timestamps and versions
const { _timestamp: _0, _version: _1, ...recentData } = recent
const { _timestamp: _2, _version: _3, ...staleData } = stale
- dirty = !isEqual(recentData, staleData)
+ dirty = sum(recentData) !== sum(staleData)
console.debug(`Data ${dirty ? 'needs' : "doesn't need"} merging`)
}
const allFlagKeys = _getAllFlags(recent, stale)
let totalFlags
let totalPrefs
if (dirty) {
// Merge the flags
console.debug('Merging the data...')
totalFlags = _mergeFlags(recent, stale, allFlagKeys)
_verifyPrefs(recent)
_verifyPrefs(stale)
totalPrefs = _mergePrefs(recent.prefsStorage, stale.prefsStorage)
} else {
totalFlags = recent.flagStorage
totalPrefs = recent.prefsStorage
}
+ totalPrefs = _resetPrefs(totalPrefs, totalFlags)
totalFlags = _resetFlags(totalFlags)
recent.flagStorage = { ...flagsTemplate, ...totalFlags }
recent.prefsStorage = { ...defaultState.prefsStorage, ...totalPrefs }
this.dirty = dirty || needUpload
this.cache = recent
// set local timestamp to smaller one if we don't have any changes
if (stale && recent && !this.dirty) {
this.cache._timestamp = Math.min(stale._timestamp, recent._timestamp)
}
this.flagStorage = this.cache.flagStorage
this.prefsStorage = this.cache.prefsStorage
+ this.pushSyncConfig()
},
- pushServerSideStorage({ force = false } = {}) {
+ 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.setServerSideStorage(user)
- this.dirty = false
+ window.vuex.state.api.backendInteractor.updateProfileJSON({ params })
+ },
+ },
+ persist: {
+ afterLoad(state) {
+ console.debug('Validating persisted state of SyncConfig')
+ const newState = { ...state }
+ const newEntries = Object.entries(ROOT_CONFIG).map(([path, value]) => {
+ const definition = ROOT_CONFIG_DEFINITIONS[path]
+ const finalValue = validateSetting({
+ path,
+ value: newState.prefsStorage.simple[path],
+ definition,
+ throwError: false,
+ validateObjects: false,
+ defaultState: ROOT_CONFIG,
})
+
+ return finalValue === undefined
+ ? [path, definition.default]
+ : [path, finalValue]
+ })
+ newState.prefsStorage.simple = Object.fromEntries(
+ newEntries.filter((_) => _),
+ )
+ return newState
},
},
})
diff --git a/src/stores/user_highlight.js b/src/stores/user_highlight.js
new file mode 100644
index 0000000000..5ca13b6a91
--- /dev/null
+++ b/src/stores/user_highlight.js
@@ -0,0 +1,357 @@
+import {
+ merge as _merge,
+ clamp,
+ clone,
+ cloneDeep,
+ findLastIndex,
+ flatten,
+ get,
+ groupBy,
+ isEqual,
+ takeRight,
+ uniqWith,
+} from 'lodash'
+import { defineStore } from 'pinia'
+import { toRaw } from 'vue'
+
+import { storage } from 'src/lib/storage.js'
+
+export const NEW_USER_DATE = new Date('2022-08-04') // date of writing this, basically
+
+export const defaultState = {
+ // do we need to update data on server?
+ dirty: false,
+ highlight: {
+ _journal: [],
+ },
+ // raw data
+ raw: null,
+ // local cache
+ cache: null,
+}
+
+export const _moveItemInArray = (array, value, movement) => {
+ const oldIndex = array.indexOf(value)
+ const newIndex = oldIndex + movement
+ const newArray = [...array]
+ // remove old
+ newArray.splice(oldIndex, 1)
+ // add new
+ newArray.splice(clamp(newIndex, 0, newArray.length + 1), 0, value)
+ return newArray
+}
+
+const _wrapData = (data, userName) => {
+ return {
+ ...data,
+ _user: userName,
+ _timestamp: Date.now(),
+ }
+}
+
+const _checkValidity = (data) => data._timestamp > 0
+
+const _verifyHighlights = (state) => {
+ state.highlight = state.highlight || {}
+
+ // Simple
+ Object.entries(defaultState.highlight).forEach(([k, v]) => {
+ if (typeof v === 'undefined') return
+ if (typeof v === 'object') return
+ console.warn(`User highlight ${k} is invalid type ${typeof v}, unsetting`)
+ delete state.highlight[k]
+ })
+}
+
+export const _getRecentData = (cache, live, isTest) => {
+ const result = { recent: null, stale: null, needUpload: false }
+ const cacheValid = _checkValidity(cache || {})
+ const liveValid = _checkValidity(live || {})
+ if (!liveValid && cacheValid) {
+ result.needUpload = true
+ console.debug(
+ '[HIGHLIGHT] Nothing valid stored on server, assuming cache to be source of truth',
+ )
+ result.recent = cache
+ result.stale = live
+ } else if (!cacheValid && liveValid) {
+ console.debug(
+ '[HIGHLIGHT] Valid storage on server found, no local cache found, using live as source of truth',
+ )
+ result.recent = live
+ result.stale = cache
+ } else if (cacheValid && liveValid) {
+ console.debug(
+ '[HIGHLIGHT] Both sources have valid data, figuring things out...',
+ )
+ if (live._timestamp === cache._timestamp) {
+ console.debug(
+ '[HIGHLIGHT] Same timestamp on both sources, source of truth irrelevant',
+ )
+ result.recent = cache
+ result.stale = live
+ } else {
+ console.debug(
+ '[HIGHLIGHT] Different timestamp, figuring out which one is more recent',
+ )
+ if (live._timestamp < cache._timestamp) {
+ result.recent = cache
+ result.stale = live
+ } else {
+ result.recent = live
+ result.stale = cache
+ }
+ }
+ } else {
+ console.debug('[HIGHLIGHT] Both sources are invalid, start from scratch')
+ result.needUpload = true
+ }
+
+ const merge = (a, b) => {
+ return {
+ _user: a._user ?? b._user,
+ _timestamp: a._timestamp ?? b._timestamp,
+ needUpload: b.needUpload ?? a.needUpload,
+ highlight: _merge(cloneDeep(a.highlight), b.highlight),
+ }
+ }
+
+ result.recent = isTest
+ ? result.recent
+ : result.recent && merge(defaultState, result.recent)
+
+ result.stale = isTest
+ ? result.stale
+ : result.stale && merge(defaultState, result.stale)
+
+ return result
+}
+
+const _mergeJournal = (...journals) => {
+ // Ignore invalid journal entries
+ const allJournals = flatten(
+ journals.map((j) => (Array.isArray(j) ? j : [])),
+ ).filter(
+ (entry) =>
+ Object.hasOwn(entry, 'user') &&
+ Object.hasOwn(entry, 'operation') &&
+ Object.hasOwn(entry, 'args') &&
+ Object.hasOwn(entry, 'timestamp'),
+ )
+ const grouped = groupBy(allJournals, 'user')
+ const trimmedGrouped = Object.entries(grouped).map(([user, journal]) => {
+ // side effect
+ journal.sort((a, b) => (a.timestamp > b.timestamp ? 1 : -1))
+
+ return takeRight(journal)
+ })
+ return flatten(trimmedGrouped).sort((a, b) =>
+ a.timestamp > b.timestamp ? 1 : -1,
+ )
+}
+
+export const _mergeHighlights = (recent, stale) => {
+ if (!stale) return recent
+ if (!recent) return stale
+ const { _journal: recentJournal, ...recentHighlight } = recent
+ const { _journal: staleJournal } = stale
+ /** Journal entry format:
+ * user: user to entry in highlight storage
+ * timestamp: timestamp of the change
+ * operation: operation type
+ * arguments: array of arguments, depends on operation type
+ *
+ * currently only supported operation type is "set" which just sets the value
+ * to requested one. Intended only to be used with simple preferences (boolean, number)
+ */
+ const resultHighlight = { ...recentHighlight }
+ const Journal = _mergeJournal(staleJournal, recentJournal)
+ Journal.forEach(({ user, operation, args }) => {
+ if (user.startsWith('_')) {
+ throw new Error(
+ `journal contains entry to edit internal (starts with _) field '${user}', something is incorrect here, ignoring.`,
+ )
+ }
+ switch (operation) {
+ case 'set':
+ resultHighlight[user] = args[0]
+ break
+ case 'unset':
+ delete resultHighlight[user]
+ break
+ default:
+ return console.error(`Unknown journal operation: '${operation}'`)
+ }
+ })
+ return { ...resultHighlight, _journal: Journal }
+}
+
+export const useUserHighlightStore = defineStore('user_highlight', {
+ state() {
+ return cloneDeep(defaultState)
+ },
+ actions: {
+ setAndSave({ user, value }) {
+ this.set({ user, value })
+ this.pushHighlight()
+ },
+ unsetAndSave({ user }) {
+ this.unset({ user })
+ this.pushHighlight()
+ },
+ get(user) {
+ const present = this.highlight[user] || {}
+ return {
+ user,
+ type: 'disabled',
+ color: '#FFFFFF',
+ ...present,
+ }
+ },
+ set({ user, value }) {
+ if (user.startsWith('_')) {
+ throw new Error(
+ `Tried to edit internal (starts with _) field '${user}', ignoring.`,
+ )
+ }
+ const oldValue = this.highlight[user]
+ const newValue = {
+ user,
+ ...oldValue,
+ ...value,
+ }
+ this.highlight[user] = newValue
+ this.highlight._journal = [
+ ...this.highlight._journal,
+ { operation: 'set', user, args: [newValue], timestamp: Date.now() },
+ ]
+ this.dirty = true
+ },
+ unset({ user, value }) {
+ if (user.startsWith('_')) {
+ throw new Error(
+ `Tried to edit internal (starts with _) field '${user}', ignoring.`,
+ )
+ }
+ delete this.highlight[user]
+ this.highlight._journal = [
+ ...this.highlight._journal,
+ { operation: 'unset', user, args: [], timestamp: Date.now() },
+ ]
+ this.dirty = true
+ },
+ updateCache({ username }) {
+ this.highlight._journal = _mergeJournal(this.highlight._journal)
+ this.cache = _wrapData(
+ {
+ highlight: toRaw(this.highlight),
+ },
+ username,
+ )
+ },
+ clearSyncConfig() {
+ const blankState = { ...cloneDeep(defaultState) }
+ Object.keys(this).forEach((k) => {
+ this[k] = blankState[k]
+ })
+ },
+ clearJournals() {
+ this.highlight._journal = []
+ this.cache.highlight._journal = []
+ this.raw.highlight._journal = []
+ this.pushSyncConfig()
+ },
+ async initUserHighlight(userData) {
+ const live = userData.user_highlight
+ this.raw = live
+ let cache = this.cache
+ if (cache?._user !== userData.fqn) {
+ console.warn(
+ 'Cache belongs to another user! reinitializing local cache!',
+ )
+ cache = null
+ }
+
+ let { recent, stale, needUpload } = _getRecentData(cache, live)
+
+ const userNew = userData.created_at > NEW_USER_DATE
+ let dirty = false
+
+ const vuexState = (await storage.getItem('vuex-lz')) ?? {}
+ const config = vuexState.config ?? {}
+ const highlight = config.highlight ?? {}
+
+ Object.entries(highlight).forEach(([user, value]) => {
+ if ((highlight[user]._migrated || 0) < 1) {
+ dirty = true
+ needUpload = true
+ this.set({ user, value: clone(value) })
+ highlight[user]._migrated = 1
+ console.debug(
+ `[HIGHLIGHT] Migrating user ${user}: ${JSON.stringify(value)}`,
+ )
+ }
+ })
+ storage.setItem('vuex-lz', { ...vuexState, config: { ...config, highlight } })
+
+ if (recent === null) {
+ console.debug(
+ `[HIGHLIGHT] Data is empty, initializing for ${userNew ? 'new' : 'existing'} user`,
+ )
+ recent = _wrapData({
+ highlight: { ...defaultState.highlight },
+ })
+ }
+
+ if (!needUpload && recent && stale) {
+ console.debug('[HIGHLIGHT] Checking if data needs merging...')
+ // discarding timestamps
+ const { _timestamp: _0, ...recentData } = recent
+ const { _timestamp: _2, ...staleData } = stale
+ dirty = !isEqual(recentData, staleData)
+ console.debug(
+ `[HIGHLIGHT] Data ${dirty ? 'needs' : "doesn't need"} merging`,
+ )
+ }
+
+ let highlights
+ if (dirty) {
+ console.debug('[HIGHLIGHT] Merging the data...')
+ _verifyHighlights(recent)
+ _verifyHighlights(stale)
+ highlights = _mergeHighlights(recent.highlight, stale.highlight)
+ } else {
+ highlights = recent.highlight
+ }
+
+ recent.highlight = { ...defaultState.highlight, ...highlights }
+
+ this.dirty = dirty || needUpload
+ this.cache = recent
+ // set local timestamp to smaller one if we don't have any changes
+ if (stale && recent && !this.dirty) {
+ this.cache._timestamp = Math.min(stale._timestamp, recent._timestamp)
+ }
+ this.highlight = this.cache.highlight
+ },
+ pushHighlight({ force = false } = {}) {
+ const needPush = this.dirty || force
+ if (!needPush) return
+ this.updateCache({ username: window.vuex.state.users.currentUser.fqn })
+ const params = {
+ pleroma_settings_store: { user_highlight: this.cache },
+ }
+ window.vuex.state.api.backendInteractor
+ .updateProfileJSON({ params })
+ .then((user) => {
+ this.initUserHighlight(user)
+ this.dirty = false
+ })
+ },
+ },
+ persist: {
+ afterLoad(state) {
+ return state
+ },
+ },
+})
diff --git a/src/sw.js b/src/sw.js
index 38a2fe7b2e..1e7abd3de4 100644
--- a/src/sw.js
+++ b/src/sw.js
@@ -1,297 +1,298 @@
/* eslint-env serviceworker */
import 'virtual:pleroma-fe/service_worker_env'
import { createI18n } from 'vue-i18n'
-import { parseNotification } from './services/entity_normalizer/entity_normalizer.service.js'
-import { prepareNotificationObject } from './services/notification_utils/notification_utils.js'
-import { cacheKey, emojiCacheKey, shouldCache } from './services/sw/sw.js'
-
import { storage } from 'src/lib/storage.js'
+import { INSTANCE_DEFAULT_CONFIG } from 'src/modules/default_config_state.js'
+import { parseNotification } from 'src/services/entity_normalizer/entity_normalizer.service.js'
+import { prepareNotificationObject } from 'src/services/notification_utils/notification_utils.js'
+import { cacheKey, emojiCacheKey, shouldCache } from 'src/services/sw/sw.js'
// Collects all messages for service workers
// Needed because service workers cannot use dynamic imports
// See /build/sw_plugin.js for more information
import messages from 'virtual:pleroma-fe/service_worker_messages'
const i18n = createI18n({
// By default, use the browser locale, we will update it if neccessary
locale: 'en',
fallbackLocale: 'en',
messages,
})
const state = {
lastFocused: null,
notificationIds: new Set(),
allowedNotificationTypes: null,
}
function getWindowClients() {
return clients
.matchAll({ includeUncontrolled: true })
.then((clientList) => clientList.filter(({ type }) => type === 'window'))
}
const setSettings = async () => {
- const vuexState = await storage.getItem('vuex-lz')
- const locale = vuexState.config.interfaceLanguage || 'en'
+ const piniaState = await storage.getItem('pinia-local-sync_config')
+ const locale = piniaState.prefsStorage.simple.interfaceLanguage || 'en'
i18n.locale = locale
const notificationsNativeArray = Object.entries(
- vuexState.config.notificationNative,
+ piniaState.prefsStorage.simple.notificationNative ||
+ INSTANCE_DEFAULT_CONFIG.notificationNative,
)
state.webPushAlwaysShowNotifications =
- vuexState.config.webPushAlwaysShowNotifications
+ piniaState.prefsStorage.simple.webPushAlwaysShowNotifications
state.allowedNotificationTypes = new Set(
notificationsNativeArray
.filter(([, v]) => v)
.map(([k]) => {
switch (k) {
case 'mentions':
return 'mention'
case 'statuses':
return 'status'
case 'likes':
return 'like'
case 'repeats':
return 'repeat'
case 'emojiReactions':
return 'pleroma:emoji_reaction'
case 'reports':
return 'pleroma:report'
case 'followRequest':
return 'follow_request'
case 'follows':
return 'follow'
case 'polls':
return 'poll'
default:
return k
}
}),
)
}
const showPushNotification = async (event) => {
const activeClients = await getWindowClients()
await setSettings()
// Only show push notifications if all tabs/windows are closed
if (state.webPushAlwaysShowNotifications || activeClients.length === 0) {
const data = event.data.json()
const url = `${self.registration.scope}api/v1/notifications/${data.notification_id}`
const notification = await fetch(url, {
headers: { Authorization: 'Bearer ' + data.access_token },
})
const notificationJson = await notification.json()
const parsedNotification = parseNotification(notificationJson)
const res = prepareNotificationObject(parsedNotification, i18n)
if (
state.webPushAlwaysShowNotifications ||
state.allowedNotificationTypes.has(parsedNotification.type)
) {
return self.registration.showNotification(res.title, res)
}
}
return Promise.resolve()
}
const cacheFiles = self.serviceWorkerOption.assets
const isEmoji = (req) => {
if (req.method !== 'GET') {
return false
}
const url = new URL(req.url)
return url.pathname.startsWith('/emoji/')
}
const isNotMedia = (req) => {
if (req.method !== 'GET') {
return false
}
const url = new URL(req.url)
return !url.pathname.startsWith('/media/')
}
const isAsset = (req) => {
const url = new URL(req.url)
return cacheFiles.includes(url.pathname)
}
const isSuccessful = (resp) => {
if (!resp.ok) {
return false
}
if (new URL(resp.url).pathname === '/index.html') {
// For index.html itself, there is no fallback possible.
return true
}
const type = resp.headers.get('Content-Type')
// Backend will revert to index.html if the file does not exist, so text/html for emojis and assets is a failure
return type && !type.includes('text/html')
}
self.addEventListener('install', async (event) => {
if (shouldCache) {
event.waitUntil(
(async () => {
// Do not preload i18n and emoji annotations to speed up loading
const shouldPreload = (route) => {
return (
!route.startsWith('/static/js/i18n/') &&
!route.startsWith('/static/js/emoji-annotations/')
)
}
const cache = await caches.open(cacheKey)
await Promise.allSettled(
cacheFiles.filter(shouldPreload).map(async (route) => {
// https://developer.mozilla.org/en-US/docs/Web/API/Cache/add
// originally we used addAll() but it will raise a problem in one edge case:
// when the file for the route is not found, backend will return index.html with code 200
// but it's wrong, and it's cached, so we end up with a bad cache.
// this can happen when you refresh when you are in the process of upgrading
// the frontend.
const resp = await fetch(route)
if (isSuccessful(resp)) {
await cache.put(route, resp)
}
}),
)
})(),
)
}
})
self.addEventListener('activate', async (event) => {
if (shouldCache) {
event.waitUntil(
(async () => {
const cache = await caches.open(cacheKey)
const keys = await cache.keys()
await Promise.all(
keys
.filter((request) => {
const url = new URL(request.url)
const shouldKeep = cacheFiles.includes(url.pathname)
return !shouldKeep
})
.map((k) => cache.delete(k)),
)
})(),
)
}
})
self.addEventListener('push', async (event) => {
if (event.data) {
// Supposedly, we HAVE to return a promise inside waitUntil otherwise it will
// show (extra) notification that website is updated in background
event.waitUntil(showPushNotification(event))
}
})
self.addEventListener('message', async (event) => {
await setSettings()
const { type, content } = event.data
if (type === 'desktopNotification') {
const { title, ...rest } = content
const { tag, type } = rest
if (state.notificationIds.has(tag)) return
state.notificationIds.add(tag)
setTimeout(() => state.notificationIds.delete(tag), 10000)
if (state.allowedNotificationTypes.has(type)) {
self.registration.showNotification(title, rest)
}
}
if (type === 'desktopNotificationClose') {
const { id, all } = content
const search = all ? null : { tag: id }
const notifications = await self.registration.getNotifications(search)
notifications.forEach((n) => n.close())
}
if (type === 'updateFocus') {
state.lastFocused = event.source.id
const notifications = await self.registration.getNotifications()
notifications.forEach((n) => n.close())
}
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
event.waitUntil(
getWindowClients().then((list) => {
for (let i = 0; i < list.length; i++) {
const client = list[i]
client.postMessage({
type: 'notificationClicked',
id: event.notification.tag,
})
}
for (let i = 0; i < list.length; i++) {
const client = list[i]
if (state.lastFocused === null || client.id === state.lastFocused) {
if ('focus' in client) return client.focus()
}
}
if (clients.openWindow) return clients.openWindow('/')
}),
)
})
self.addEventListener('fetch', (event) => {
// Do not mess up with remote things
const isSameOrigin =
new URL(event.request.url).origin === self.location.origin
if (
shouldCache &&
event.request.method === 'GET' &&
isSameOrigin &&
isNotMedia(event.request)
) {
// this is a bit spammy
// console.debug('[Service worker] fetch:', event.request.url)
event.respondWith(
(async () => {
const r = await caches.match(event.request)
const isEmojiReq = isEmoji(event.request)
if (r && isSuccessful(r)) {
console.debug('[Service worker] already cached:', event.request.url)
return r
}
try {
const response = await fetch(event.request)
if (
response.ok &&
isSuccessful(response) &&
(isEmojiReq || isAsset(event.request))
) {
console.debug(
`[Service worker] caching ${isEmojiReq ? 'emoji' : 'asset'}:`,
event.request.url,
)
const cache = await caches.open(
isEmojiReq ? emojiCacheKey : cacheKey,
)
await cache.put(event.request.clone(), response.clone())
}
return response
} catch (e) {
console.error('[Service worker] error when caching emoji:', e)
throw e
}
})(),
)
}
})
diff --git a/test/unit/specs/components/draft.spec.js b/test/unit/specs/components/draft.spec.js
index 7bc053f524..6abf2d75b4 100644
--- a/test/unit/specs/components/draft.spec.js
+++ b/test/unit/specs/components/draft.spec.js
@@ -1,184 +1,180 @@
import { createTestingPinia } from '@pinia/testing'
import { flushPromises, mount } from '@vue/test-utils'
+import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import { $t, mountOpts, waitForEvent } from '../../../fixtures/setup_test'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+
const autoSaveOrNot = (caseFn, caseTitle, runFn) => {
caseFn(`${caseTitle} with auto-save`, function () {
return runFn.bind(this)(true)
})
caseFn(`${caseTitle} with no auto-save`, function () {
return runFn.bind(this)(false)
})
}
const saveManually = async (wrapper) => {
const morePostActions = wrapper.findByText(
'button',
$t('post_status.more_post_actions'),
)
await morePostActions.trigger('click')
const btn = wrapper.findByText(
'button',
$t('post_status.save_to_drafts_button'),
)
await btn.trigger('click')
}
const waitSaveTime = 4000
afterEach(() => {
vi.useRealTimers()
})
describe('Draft saving', () => {
- createTestingPinia()
+ beforeEach(() => {
+ setActivePinia(createTestingPinia())
+ })
autoSaveOrNot(
it,
'should save when the button is clicked',
async (autoSave) => {
const wrapper = mount(PostStatusForm, mountOpts())
- await wrapper.vm.$store.dispatch('setOption', {
- name: 'autoSaveDraft',
- value: autoSave,
- })
+ const store = useMergedConfigStore()
+ store.mergedConfig = {
+ autoSaveDraft: autoSave,
+ }
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
const textarea = wrapper.get('textarea')
await textarea.setValue('mew mew')
await saveManually(wrapper)
expect(wrapper.vm.$store.getters.draftCount).to.equal(1)
expect(wrapper.vm.$store.getters.draftsArray[0].status).to.equal(
'mew mew',
)
},
)
it('should auto-save if it is enabled', async function () {
vi.useFakeTimers()
const wrapper = mount(PostStatusForm, mountOpts())
- await wrapper.vm.$store.dispatch('setOption', {
- name: 'autoSaveDraft',
- value: true,
- })
+ const store = useMergedConfigStore()
+ store.mergedConfig = {
+ autoSaveDraft: true,
+ }
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
const textarea = wrapper.get('textarea')
await textarea.setValue('mew mew')
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
await vi.advanceTimersByTimeAsync(waitSaveTime)
expect(wrapper.vm.$store.getters.draftCount).to.equal(1)
expect(wrapper.vm.$store.getters.draftsArray[0].status).to.equal('mew mew')
})
it('should auto-save when close if auto-save is on', async () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
closeable: true,
},
}),
)
- await wrapper.vm.$store.dispatch('setOption', {
- name: 'autoSaveDraft',
- value: true,
- })
+ const store = useMergedConfigStore()
+ store.mergedConfig = {
+ autoSaveDraft: true,
+ }
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
const textarea = wrapper.get('textarea')
await textarea.setValue('mew mew')
wrapper.vm.requestClose()
expect(wrapper.vm.$store.getters.draftCount).to.equal(1)
await waitForEvent(wrapper, 'can-close')
})
it('should save when close if auto-save is off, and unsavedPostAction is save', async () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
closeable: true,
},
}),
)
- await wrapper.vm.$store.dispatch('setOption', {
- name: 'autoSaveDraft',
- value: false,
- })
- await wrapper.vm.$store.dispatch('setOption', {
- name: 'unsavedPostAction',
- value: 'save',
- })
+ const store = useMergedConfigStore()
+ store.mergedConfig = {
+ autoSaveDraft: false,
+ unsavedPostAction: 'save',
+ }
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
const textarea = wrapper.get('textarea')
await textarea.setValue('mew mew')
wrapper.vm.requestClose()
expect(wrapper.vm.$store.getters.draftCount).to.equal(1)
await waitForEvent(wrapper, 'can-close')
})
it('should discard when close if auto-save is off, and unsavedPostAction is discard', async () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
closeable: true,
},
}),
)
- await wrapper.vm.$store.dispatch('setOption', {
- name: 'autoSaveDraft',
- value: false,
- })
- await wrapper.vm.$store.dispatch('setOption', {
- name: 'unsavedPostAction',
- value: 'discard',
- })
+ const store = useMergedConfigStore()
+ store.mergedConfig = {
+ autoSaveDraft: false,
+ unsavedPostAction: 'discard',
+ }
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
const textarea = wrapper.get('textarea')
await textarea.setValue('mew mew')
wrapper.vm.requestClose()
await waitForEvent(wrapper, 'can-close')
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
})
it('should confirm when close if auto-save is off, and unsavedPostAction is confirm', async () => {
const wrapper = mount(
PostStatusForm,
mountOpts({
props: {
closeable: true,
},
}),
)
- await wrapper.vm.$store.dispatch('setOption', {
- name: 'autoSaveDraft',
- value: false,
- })
- await wrapper.vm.$store.dispatch('setOption', {
- name: 'unsavedPostAction',
- value: 'confirm',
- })
+ const store = useMergedConfigStore(createTestingPinia())
+ store.mergedConfig = {
+ autoSaveDraft: false,
+ unsavedPostAction: 'confirm',
+ }
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
const textarea = wrapper.get('textarea')
await textarea.setValue('mew mew')
wrapper.vm.requestClose()
await nextTick()
const saveButton = wrapper.findByText(
'button',
$t('post_status.close_confirm_save_button'),
)
expect(saveButton).to.be.ok
await saveButton.trigger('click')
console.info('clicked')
expect(wrapper.vm.$store.getters.draftCount).to.equal(1)
await flushPromises()
await waitForEvent(wrapper, 'can-close')
})
})
diff --git a/test/unit/specs/components/emoji_input.spec.js b/test/unit/specs/components/emoji_input.spec.js
index 032e7e9b6d..6a7c67fdbd 100644
--- a/test/unit/specs/components/emoji_input.spec.js
+++ b/test/unit/specs/components/emoji_input.spec.js
@@ -1,158 +1,167 @@
import { createTestingPinia } from '@pinia/testing'
import { shallowMount } from '@vue/test-utils'
import vClickOutside from 'click-outside-vue3'
import { h } from 'vue'
createTestingPinia()
import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+
const generateInput = (value, padEmoji = true) => {
const wrapper = shallowMount(EmojiInput, {
global: {
renderStubDefaultSlot: true,
mocks: {
- $store: {
- getters: {
- mergedConfig: {
- padEmoji,
- },
- },
- },
$t: (msg) => msg,
},
stubs: {
FAIcon: true,
Popover: {
template: `<div><slot trigger /></div>`,
methods: {
updateStyles() {
/* no-op */
},
},
},
},
directives: {
'click-outside': vClickOutside,
},
},
props: {
suggest: () => [],
enableEmojiPicker: true,
modelValue: value,
},
slots: {
default: () => h('input', ''),
},
})
return wrapper
}
describe('EmojiInput', () => {
+ beforeEach(() => {
+ const store = useMergedConfigStore(createTestingPinia())
+ store.mergedConfig = {
+ padEmoji: true,
+ }
+ })
describe('insertion mechanism', () => {
it('inserts string at the end with trailing space', () => {
const initialString = 'Testing'
const wrapper = generateInput(initialString)
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: initialString.length })
wrapper.vm.insert({ insertion: '(test)', keepOpen: false })
const inputEvents = wrapper.emitted()['update:modelValue']
expect(inputEvents[inputEvents.length - 1][0]).to.eql('Testing (test) ')
})
it('inserts string at the end with trailing space (source has a trailing space)', () => {
const initialString = 'Testing '
const wrapper = generateInput(initialString)
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: initialString.length })
wrapper.vm.insert({ insertion: '(test)', keepOpen: false })
const inputEvents = wrapper.emitted()['update:modelValue']
expect(inputEvents[inputEvents.length - 1][0]).to.eql('Testing (test) ')
})
it('inserts string at the begginning without leading space', () => {
const initialString = 'Testing'
const wrapper = generateInput(initialString)
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: 0 })
wrapper.vm.insert({ insertion: '(test)', keepOpen: false })
const inputEvents = wrapper.emitted()['update:modelValue']
expect(inputEvents[inputEvents.length - 1][0]).to.eql('(test) Testing')
})
it('inserts string between words without creating extra spaces', () => {
const initialString = 'Spurdo Sparde'
const wrapper = generateInput(initialString)
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: 6 })
wrapper.vm.insert({ insertion: ':ebin:', keepOpen: false })
const inputEvents = wrapper.emitted()['update:modelValue']
expect(inputEvents[inputEvents.length - 1][0]).to.eql(
'Spurdo :ebin: Sparde',
)
})
it('inserts string between words without creating extra spaces (other caret)', () => {
const initialString = 'Spurdo Sparde'
const wrapper = generateInput(initialString)
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: 7 })
wrapper.vm.insert({ insertion: ':ebin:', keepOpen: false })
const inputEvents = wrapper.emitted()['update:modelValue']
expect(inputEvents[inputEvents.length - 1][0]).to.eql(
'Spurdo :ebin: Sparde',
)
})
it('inserts string without any padding if padEmoji setting is set to false', () => {
const initialString = 'Eat some spam!'
const wrapper = generateInput(initialString, false)
+ const store = useMergedConfigStore(createTestingPinia())
+ store.mergedConfig = {
+ padEmoji: false,
+ }
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: initialString.length, keepOpen: false })
wrapper.vm.insert({ insertion: ':spam:' })
const inputEvents = wrapper.emitted()['update:modelValue']
expect(inputEvents[inputEvents.length - 1][0]).to.eql(
'Eat some spam!:spam:',
)
})
it('correctly sets caret after insertion at beginning', async () => {
const initialString = '1234'
const wrapper = generateInput(initialString)
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: 0 })
wrapper.vm.insert({ insertion: '1234', keepOpen: false })
await wrapper.vm.$nextTick()
expect(wrapper.vm.caret).to.eql(5)
})
it('correctly sets caret after insertion at end', async () => {
const initialString = '1234'
const wrapper = generateInput(initialString)
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: initialString.length })
wrapper.vm.insert({ insertion: '1234', keepOpen: false })
await wrapper.vm.$nextTick()
expect(wrapper.vm.caret).to.eql(10)
})
it('correctly sets caret after insertion if padEmoji setting is set to false', async () => {
const initialString = '1234'
const wrapper = generateInput(initialString, false)
+ const store = useMergedConfigStore(createTestingPinia())
+ store.mergedConfig = {
+ padEmoji: false,
+ }
const input = wrapper.find('input')
input.setValue(initialString)
wrapper.setData({ caret: initialString.length })
wrapper.vm.insert({ insertion: '1234', keepOpen: false })
await wrapper.vm.$nextTick()
expect(wrapper.vm.caret).to.eql(8)
})
})
})
diff --git a/test/unit/specs/components/gallery.spec.js b/test/unit/specs/components/gallery.spec.js
index 941110e78c..367bab0b4a 100644
--- a/test/unit/specs/components/gallery.spec.js
+++ b/test/unit/specs/components/gallery.spec.js
@@ -1,303 +1,283 @@
import Gallery from 'src/components/gallery/gallery.vue'
describe('Gallery', () => {
let local
it('attachments is falsey', () => {
local = { attachments: false }
expect(Gallery.computed.rows.call(local)).to.eql([])
local = { attachments: null }
expect(Gallery.computed.rows.call(local)).to.eql([])
local = { attachments: undefined }
expect(Gallery.computed.rows.call(local)).to.eql([])
})
it('no attachments', () => {
local = { attachments: [] }
expect(Gallery.computed.rows.call(local)).to.eql([])
})
it('one audio attachment', () => {
local = {
- attachments: [{ mimetype: 'audio/mpeg' }],
+ attachments: [{ type: 'audio' }],
}
expect(Gallery.computed.rows.call(local)).to.eql([
- { audio: true, items: [{ mimetype: 'audio/mpeg' }] },
+ { audio: true, items: [{ type: 'audio' }] },
])
})
it('one image attachment', () => {
local = {
- attachments: [{ mimetype: 'image/png' }],
+ attachments: [{ type: 'image' }],
}
expect(Gallery.computed.rows.call(local)).to.eql([
- { items: [{ mimetype: 'image/png' }] },
+ { items: [{ type: 'image' }] },
])
})
it('one audio attachment and one image attachment', () => {
local = {
- attachments: [{ mimetype: 'audio/mpeg' }, { mimetype: 'image/png' }],
+ attachments: [{ type: 'audio' }, { type: 'image' }],
}
expect(Gallery.computed.rows.call(local)).to.eql([
- { audio: true, items: [{ mimetype: 'audio/mpeg' }] },
- { items: [{ mimetype: 'image/png' }] },
+ { audio: true, items: [{ type: 'audio' }] },
+ { items: [{ type: 'image' }] },
])
})
it('has "size" key set to "hide"', () => {
let local
local = {
- attachments: [{ mimetype: 'audio/mpeg' }],
+ attachments: [{ type: 'audio' }],
size: 'hide',
}
expect(Gallery.computed.rows.call(local)).to.eql([
- { minimal: true, items: [{ mimetype: 'audio/mpeg' }] },
+ { minimal: true, items: [{ type: 'audio' }] },
])
local = {
attachments: [
- { mimetype: 'image/jpg' },
- { mimetype: 'image/png' },
- { mimetype: 'image/jpg' },
- { mimetype: 'audio/mpeg' },
- { mimetype: 'image/png' },
- { mimetype: 'audio/mpeg' },
- { mimetype: 'image/jpg' },
- { mimetype: 'image/png' },
- { mimetype: 'image/jpg' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'audio' },
+ { type: 'image' },
+ { type: 'audio' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'image' },
],
size: 'hide',
}
// When defining `size: hide`, the `items` aren't
// grouped and `audio` isn't set
expect(Gallery.computed.rows.call(local)).to.eql([
- { minimal: true, items: [{ mimetype: 'image/jpg' }] },
- { minimal: true, items: [{ mimetype: 'image/png' }] },
- { minimal: true, items: [{ mimetype: 'image/jpg' }] },
- { minimal: true, items: [{ mimetype: 'audio/mpeg' }] },
- { minimal: true, items: [{ mimetype: 'image/png' }] },
- { minimal: true, items: [{ mimetype: 'audio/mpeg' }] },
- { minimal: true, items: [{ mimetype: 'image/jpg' }] },
- { minimal: true, items: [{ mimetype: 'image/png' }] },
- { minimal: true, items: [{ mimetype: 'image/jpg' }] },
+ { minimal: true, items: [{ type: 'image' }] },
+ { minimal: true, items: [{ type: 'image' }] },
+ { minimal: true, items: [{ type: 'image' }] },
+ { minimal: true, items: [{ type: 'audio' }] },
+ { minimal: true, items: [{ type: 'image' }] },
+ { minimal: true, items: [{ type: 'audio' }] },
+ { minimal: true, items: [{ type: 'image' }] },
+ { minimal: true, items: [{ type: 'image' }] },
+ { minimal: true, items: [{ type: 'image' }] },
])
})
// types other than image or audio should be `minimal`
- it('non-image/audio', () => {
+ it('non-image', () => {
let local
local = {
- attachments: [{ mimetype: 'plain/text' }],
+ attachments: [{ type: 'plain' }],
}
expect(Gallery.computed.rows.call(local)).to.eql([
- { minimal: true, items: [{ mimetype: 'plain/text' }] },
+ { minimal: true, items: [{ type: 'plain' }] },
])
- // No grouping of non-image/audio items
+ // No grouping of non-image items
local = {
- attachments: [
- { mimetype: 'plain/text' },
- { mimetype: 'plain/text' },
- { mimetype: 'plain/text' },
- ],
+ attachments: [{ type: 'plain' }, { type: 'plain' }, { type: 'plain' }],
}
expect(Gallery.computed.rows.call(local)).to.eql([
- { minimal: true, items: [{ mimetype: 'plain/text' }] },
- { minimal: true, items: [{ mimetype: 'plain/text' }] },
- { minimal: true, items: [{ mimetype: 'plain/text' }] },
+ { minimal: true, items: [{ type: 'plain' }] },
+ { minimal: true, items: [{ type: 'plain' }] },
+ { minimal: true, items: [{ type: 'plain' }] },
])
local = {
attachments: [
- { mimetype: 'image/png' },
- { mimetype: 'plain/text' },
- { mimetype: 'image/jpg' },
- { mimetype: 'audio/mpeg' },
+ { type: 'image' },
+ { type: 'plain' },
+ { type: 'image' },
+ { type: 'audio' },
],
}
// NOTE / TODO: When defining `size: hide`, the `items` aren't
// grouped and `audio` isn't set
expect(Gallery.computed.rows.call(local)).to.eql([
- { items: [{ mimetype: 'image/png' }] },
- { minimal: true, items: [{ mimetype: 'plain/text' }] },
- { items: [{ mimetype: 'image/jpg' }] },
- { audio: true, items: [{ mimetype: 'audio/mpeg' }] },
+ { items: [{ type: 'image' }] },
+ { minimal: true, items: [{ type: 'plain' }] },
+ { items: [{ type: 'image' }] },
+ { audio: true, items: [{ type: 'audio' }] },
])
})
it('mixed attachments', () => {
local = {
attachments: [
- { mimetype: 'audio/mpeg' },
- { mimetype: 'image/png' },
- { mimetype: 'audio/mpeg' },
- { mimetype: 'image/jpg' },
- { mimetype: 'image/png' },
- { mimetype: 'image/jpg' },
- { mimetype: 'image/jpg' },
+ { type: 'audio' },
+ { type: 'image' },
+ { type: 'audio' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'image' },
],
}
expect(Gallery.computed.rows.call(local)).to.eql([
- { audio: true, items: [{ mimetype: 'audio/mpeg' }] },
- { items: [{ mimetype: 'image/png' }] },
- { audio: true, items: [{ mimetype: 'audio/mpeg' }] },
+ { audio: true, items: [{ type: 'audio' }] },
+ { items: [{ type: 'image' }] },
+ { audio: true, items: [{ type: 'audio' }] },
{
items: [
- { mimetype: 'image/jpg' },
- { mimetype: 'image/png' },
- { mimetype: 'image/jpg' },
- { mimetype: 'image/jpg' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'image' },
],
},
])
local = {
attachments: [
- { mimetype: 'image/jpg' },
- { mimetype: 'image/png' },
- { mimetype: 'image/jpg' },
- { mimetype: 'image/jpg' },
- { mimetype: 'audio/mpeg' },
- { mimetype: 'image/png' },
- { mimetype: 'audio/mpeg' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'audio' },
+ { type: 'image' },
+ { type: 'audio' },
],
}
expect(Gallery.computed.rows.call(local)).to.eql([
{
- items: [
- { mimetype: 'image/jpg' },
- { mimetype: 'image/png' },
- { mimetype: 'image/jpg' },
- ],
+ items: [{ type: 'image' }, { type: 'image' }, { type: 'image' }],
},
- { items: [{ mimetype: 'image/jpg' }] },
- { audio: true, items: [{ mimetype: 'audio/mpeg' }] },
- { items: [{ mimetype: 'image/png' }] },
- { audio: true, items: [{ mimetype: 'audio/mpeg' }] },
+ { items: [{ type: 'image' }] },
+ { audio: true, items: [{ type: 'audio' }] },
+ { items: [{ type: 'image' }] },
+ { audio: true, items: [{ type: 'audio' }] },
])
local = {
attachments: [
- { mimetype: 'image/jpg' },
- { mimetype: 'image/png' },
- { mimetype: 'image/jpg' },
- { mimetype: 'image/jpg' },
- { mimetype: 'image/png' },
- { mimetype: 'image/png' },
- { mimetype: 'image/jpg' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'image' },
],
}
// Group by three-per-row, unless there's one dangling, then stick it on the end of the last row
- // https://git.pleroma.social/pleroma/pleroma-fe/-/merge_requests/1785#note_98514
+ // https:/.pleroma.social-fe/-_requests#note_98514
expect(Gallery.computed.rows.call(local)).to.eql([
{
- items: [
- { mimetype: 'image/jpg' },
- { mimetype: 'image/png' },
- { mimetype: 'image/jpg' },
- ],
+ items: [{ type: 'image' }, { type: 'image' }, { type: 'image' }],
},
{
items: [
- { mimetype: 'image/jpg' },
- { mimetype: 'image/png' },
- { mimetype: 'image/png' },
- { mimetype: 'image/jpg' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'image' },
],
},
])
local = {
attachments: [
- { mimetype: 'image/jpg' },
- { mimetype: 'image/png' },
- { mimetype: 'image/jpg' },
- { mimetype: 'image/jpg' },
- { mimetype: 'image/png' },
- { mimetype: 'image/png' },
- { mimetype: 'image/jpg' },
- { mimetype: 'image/png' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'image' },
],
}
expect(Gallery.computed.rows.call(local)).to.eql([
{
- items: [
- { mimetype: 'image/jpg' },
- { mimetype: 'image/png' },
- { mimetype: 'image/jpg' },
- ],
+ items: [{ type: 'image' }, { type: 'image' }, { type: 'image' }],
},
{
- items: [
- { mimetype: 'image/jpg' },
- { mimetype: 'image/png' },
- { mimetype: 'image/png' },
- ],
+ items: [{ type: 'image' }, { type: 'image' }, { type: 'image' }],
},
- { items: [{ mimetype: 'image/jpg' }, { mimetype: 'image/png' }] },
+ { items: [{ type: 'image' }, { type: 'image' }] },
])
})
it('does not do grouping when grid is set', () => {
const attachments = [
- { mimetype: 'audio/mpeg' },
- { mimetype: 'image/png' },
- { mimetype: 'audio/mpeg' },
- { mimetype: 'image/jpg' },
- { mimetype: 'image/png' },
- { mimetype: 'image/jpg' },
- { mimetype: 'image/jpg' },
+ { type: 'audio' },
+ { type: 'image' },
+ { type: 'audio' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'image' },
]
local = { grid: true, attachments }
expect(Gallery.computed.rows.call(local)).to.eql([
{ grid: true, items: attachments },
])
})
it('limit is set', () => {
const attachments = [
- { mimetype: 'audio/mpeg' },
- { mimetype: 'image/png' },
- { mimetype: 'image/jpg' },
- { mimetype: 'audio/mpeg' },
- { mimetype: 'image/jpg' },
+ { type: 'audio' },
+ { type: 'image' },
+ { type: 'image' },
+ { type: 'audio' },
+ { type: 'image' },
]
let local
local = { attachments, limit: 2 }
expect(Gallery.computed.rows.call(local)).to.eql([
- { audio: true, items: [{ mimetype: 'audio/mpeg' }] },
- { items: [{ mimetype: 'image/png' }] },
+ { audio: true, items: [{ type: 'audio' }] },
+ { items: [{ type: 'image' }] },
])
local = { attachments, limit: 3 }
expect(Gallery.computed.rows.call(local)).to.eql([
- { audio: true, items: [{ mimetype: 'audio/mpeg' }] },
- { items: [{ mimetype: 'image/png' }, { mimetype: 'image/jpg' }] },
+ { audio: true, items: [{ type: 'audio' }] },
+ { items: [{ type: 'image' }, { type: 'image' }] },
])
local = { attachments, limit: 4 }
expect(Gallery.computed.rows.call(local)).to.eql([
- { audio: true, items: [{ mimetype: 'audio/mpeg' }] },
- { items: [{ mimetype: 'image/png' }, { mimetype: 'image/jpg' }] },
- { audio: true, items: [{ mimetype: 'audio/mpeg' }] },
+ { audio: true, items: [{ type: 'audio' }] },
+ { items: [{ type: 'image' }, { type: 'image' }] },
+ { audio: true, items: [{ type: 'audio' }] },
])
})
})
diff --git a/test/unit/specs/services/notification_utils/notification_utils.spec.js b/test/unit/specs/services/notification_utils/notification_utils.spec.js
index baafd89614..20c9379c69 100644
--- a/test/unit/specs/services/notification_utils/notification_utils.spec.js
+++ b/test/unit/specs/services/notification_utils/notification_utils.spec.js
@@ -1,97 +1,102 @@
+import { createTestingPinia } from '@pinia/testing'
+
+import { useSyncConfigStore } from 'src/stores/sync_config.js'
+
import * as NotificationUtils from 'src/services/notification_utils/notification_utils.js'
describe('NotificationUtils', () => {
+ beforeEach(() => {
+ const store = useSyncConfigStore(createTestingPinia())
+ store.mergedConfig = {
+ notificationVisibility: {
+ likes: true,
+ repeats: true,
+ mentions: false,
+ },
+ }
+ })
+
describe('filteredNotificationsFromStore', () => {
it('should return sorted notifications with configured types', () => {
const store = {
state: {
notifications: {
data: [
{
id: 1,
action: { id: '1' },
type: 'like',
},
{
id: 2,
action: { id: '2' },
type: 'mention',
},
{
id: 3,
action: { id: '3' },
type: 'repeat',
},
],
},
},
- getters: {
- mergedConfig: {
- notificationVisibility: {
- likes: true,
- repeats: true,
- mentions: false,
- },
- },
- },
}
const expected = [
{
action: { id: '3' },
id: 3,
type: 'repeat',
},
{
action: { id: '1' },
id: 1,
type: 'like',
},
]
- expect(NotificationUtils.filteredNotificationsFromStore(store)).to.eql(
- expected,
- )
+ expect(
+ NotificationUtils.filteredNotificationsFromStore(store, {
+ mentions: false,
+ likes: true,
+ repeats: true,
+ }),
+ ).to.eql(expected)
})
})
describe('unseenNotificationsFromStore', () => {
it('should return only notifications not marked as seen', () => {
const store = {
state: {
notifications: {
data: [
{
action: { id: '1' },
type: 'like',
seen: false,
},
{
action: { id: '2' },
type: 'mention',
seen: true,
},
],
},
},
- getters: {
- mergedConfig: {
- notificationVisibility: {
- likes: true,
- repeats: true,
- mentions: false,
- },
- },
- },
}
const expected = [
{
action: { id: '1' },
type: 'like',
seen: false,
},
]
- expect(NotificationUtils.unseenNotificationsFromStore(store)).to.eql(
- expected,
- )
+ expect(
+ NotificationUtils.unseenNotificationsFromStore(store, {
+ likes: true,
+ repeats: true,
+ mentions: false,
+ }),
+ ).to.eql(expected)
})
})
})
diff --git a/test/unit/specs/modules/serverSideStorage.spec.js b/test/unit/specs/stores/sync_config.spec.js
similarity index 58%
rename from test/unit/specs/modules/serverSideStorage.spec.js
rename to test/unit/specs/stores/sync_config.spec.js
index bd2028ea34..83324f62b3 100644
--- a/test/unit/specs/modules/serverSideStorage.spec.js
+++ b/test/unit/specs/stores/sync_config.spec.js
@@ -1,526 +1,673 @@
import { cloneDeep } from 'lodash'
import { createPinia, setActivePinia } from 'pinia'
import {
_getAllFlags,
_getRecentData,
_mergeFlags,
_mergePrefs,
_moveItemInArray,
_resetFlags,
COMMAND_TRIM_FLAGS,
COMMAND_TRIM_FLAGS_AND_RESET,
defaultState,
newUserFlags,
- useServerSideStorageStore,
+ useSyncConfigStore,
VERSION,
-} from 'src/stores/serverSideStorage.js'
+} from 'src/stores/sync_config.js'
-describe('The serverSideStorage module', () => {
+describe('The SyncConfig store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('mutations', () => {
- describe('setServerSideStorage', () => {
+ describe('initSyncConfig', () => {
const user = {
created_at: new Date('1999-02-09'),
storage: {},
}
- it('should initialize storage if none present', () => {
- const store = useServerSideStorageStore()
- store.setServerSideStorage(store, user)
+ it('should initialize storage if none present', async () => {
+ const store = useSyncConfigStore()
+ // PushSyncConfig is very simple but uses vuex to push data
+ store.pushSyncConfig = () => {
+ /* no-op */
+ }
+ await store.initSyncConfig({ ...user })
expect(store.cache._version).to.eql(VERSION)
expect(store.cache._timestamp).to.be.a('number')
expect(store.cache.flagStorage).to.eql(defaultState.flagStorage)
expect(store.cache.prefsStorage).to.eql(defaultState.prefsStorage)
})
- it('should initialize storage with proper flags for new users if none present', () => {
- const store = useServerSideStorageStore()
- store.setServerSideStorage({ ...user, created_at: new Date() })
+ it('should initialize storage with proper flags for new users if none present', async () => {
+ const store = useSyncConfigStore()
+ // PushSyncConfig is very simple but uses vuex to push data
+ store.pushSyncConfig = () => {
+ /* no-op */
+ }
+ await store.initSyncConfig({ ...user, created_at: new Date() })
expect(store.cache._version).to.eql(VERSION)
expect(store.cache._timestamp).to.be.a('number')
expect(store.cache.flagStorage).to.eql(newUserFlags)
expect(store.cache.prefsStorage).to.eql(defaultState.prefsStorage)
})
- it('should merge flags even if remote timestamp is older', () => {
- const store = useServerSideStorageStore()
+ it('should merge flags even if remote timestamp is older', async () => {
+ const store = useSyncConfigStore()
+ // PushSyncConfig is very simple but uses vuex to push data
+ store.pushSyncConfig = () => {
+ /* no-op */
+ }
store.cache = {
_timestamp: Date.now(),
_version: VERSION,
...cloneDeep(defaultState),
}
- store.setServerSideStorage({
+ await store.initSyncConfig({
...user,
storage: {
_timestamp: 123,
_version: VERSION,
flagStorage: {
...defaultState.flagStorage,
updateCounter: 1,
},
prefsStorage: {
...defaultState.prefsStorage,
},
},
})
- expect(store.cache.flagStorage).to.eql({
+ expect(store.flagStorage).to.eql({
...defaultState.flagStorage,
updateCounter: 1,
})
})
- it('should reset local timestamp to remote if contents are the same', () => {
- const store = useServerSideStorageStore()
+ it('should trim journal to 500 entries', async () => {
+ const store = useSyncConfigStore()
+ // PushSyncConfig is very simple but uses vuex to push data
+ store.pushSyncConfig = () => {
+ /* no-op */
+ }
+ store.cache = {
+ _timestamp: Date.now(),
+ _version: VERSION,
+ ...cloneDeep(defaultState),
+ }
+ const largeJournal = []
+ for (let value = 0; value < 1000; value++) {
+ largeJournal.push({
+ path: 'simple.palette' + value,
+ operation: 'set',
+ args: [value],
+ // should have A timestamp, we don't really care what it is
+ timestamp: 123456,
+ })
+ }
+
+ await store.initSyncConfig({
+ ...user,
+ storage: {
+ _timestamp: 123,
+ _version: VERSION,
+ flagStorage: {
+ ...defaultState.flagStorage,
+ updateCounter: 1,
+ },
+ prefsStorage: {
+ ...defaultState.prefsStorage,
+ _journal: largeJournal,
+ },
+ },
+ })
+
+ expect(store.prefsStorage._journal.length).to.eql(500)
+ })
+
+ it('should reset local timestamp to remote if contents are the same', async () => {
+ const store = useSyncConfigStore()
store.cache = null
+ // PushSyncConfig is very simple but uses vuex to push data
+ store.pushSyncConfig = () => {
+ /* no-op */
+ }
- store.setServerSideStorage({
+ await store.initSyncConfig({
...user,
storage: {
_timestamp: 123,
_version: VERSION,
flagStorage: {
...defaultState.flagStorage,
updateCounter: 999,
},
},
})
expect(store.cache._timestamp).to.eql(123)
expect(store.flagStorage.updateCounter).to.eql(999)
expect(store.cache.flagStorage.updateCounter).to.eql(999)
})
- it('should remote version if local missing', () => {
- const store = useServerSideStorageStore()
- store.setServerSideStorage(store, user)
+ it('should use remote version if local missing', async () => {
+ const store = useSyncConfigStore()
+ // PushSyncConfig is very simple but uses vuex to push data
+ store.pushSyncConfig = () => {
+ /* no-op */
+ }
+ await store.initSyncConfig(store, user)
expect(store.cache._version).to.eql(VERSION)
expect(store.cache._timestamp).to.be.a('number')
expect(store.cache.flagStorage).to.eql(defaultState.flagStorage)
})
})
describe('setPreference', () => {
it('should set preference and update journal log accordingly', () => {
- const store = useServerSideStorageStore()
- store.setPreference({ path: 'simple.testing', value: 1 })
- expect(store.prefsStorage.simple.testing).to.eql(1)
+ const store = useSyncConfigStore()
+ // PushSyncConfig is very simple but uses vuex to push data
+ store.pushSyncConfig = () => {
+ /* no-op */
+ }
+ store.setPreference({ path: 'simple.palette', value: '1' })
+ expect(store.prefsStorage.simple.palette).to.eql('1')
expect(store.prefsStorage._journal.length).to.eql(1)
expect(store.prefsStorage._journal[0]).to.eql({
- path: 'simple.testing',
+ path: 'simple.palette',
operation: 'set',
- args: [1],
+ args: ['1'],
// should have A timestamp, we don't really care what it is
timestamp: store.prefsStorage._journal[0].timestamp,
})
})
it('should keep journal to a minimum', () => {
- const store = useServerSideStorageStore()
- store.setPreference({ path: 'simple.testing', value: 1 })
- store.setPreference({ path: 'simple.testing', value: 2 })
- store.addCollectionPreference({ path: 'collections.testing', value: 2 })
+ const store = useSyncConfigStore()
+ // PushSyncConfig is very simple but uses vuex to push data
+ store.pushSyncConfig = () => {
+ /* no-op */
+ }
+ store.setPreference({ path: 'simple.palette', value: 1 })
+ store.setPreference({ path: 'simple.palette', value: 2 })
+ store.addCollectionPreference({ path: 'collections.palette', value: 2 })
store.removeCollectionPreference({
- path: 'collections.testing',
+ path: 'collections.palette',
value: 2,
})
store.updateCache({ username: 'test' })
- expect(store.prefsStorage.simple.testing).to.eql(2)
- expect(store.prefsStorage.collections.testing).to.eql([])
+ expect(store.prefsStorage.simple.palette).to.eql(2)
+ expect(store.prefsStorage.collections.palette).to.eql([])
expect(store.prefsStorage._journal.length).to.eql(2)
expect(store.prefsStorage._journal[0]).to.eql({
- path: 'simple.testing',
+ path: 'simple.palette',
operation: 'set',
args: [2],
// should have A timestamp, we don't really care what it is
timestamp: store.prefsStorage._journal[0].timestamp,
})
expect(store.prefsStorage._journal[1]).to.eql({
- path: 'collections.testing',
+ path: 'collections.palette',
operation: 'removeFromCollection',
args: [2],
// should have A timestamp, we don't really care what it is
timestamp: store.prefsStorage._journal[1].timestamp,
})
})
it('should remove duplicate entries from journal', () => {
- const store = 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 })
+ const store = useSyncConfigStore()
+ // PushSyncConfig is very simple but uses vuex to push data
+ store.pushSyncConfig = () => {
+ /* no-op */
+ }
+ store.setPreference({ path: 'simple.palette', value: 1 })
+ store.setPreference({ path: 'simple.palette', value: 1 })
+ store.addCollectionPreference({ path: 'collections.palette', value: 2 })
+ store.addCollectionPreference({ path: 'collections.palette', 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.simple.palette).to.eql(1)
+ expect(store.prefsStorage.collections.palette).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' })
+ const store = useSyncConfigStore()
+ // PushSyncConfig is very simple but uses vuex to push data
+ store.pushSyncConfig = () => {
+ /* no-op */
+ }
+ store.setPreference({ path: 'simple.fontInput.family', value: 'test' })
+ store.unsetPreference({ path: 'simple.fontInput.family' })
store.updateCache(store, { username: 'test' })
- expect(store.prefsStorage.simple.object).to.not.have.property('foo')
+ expect(store.prefsStorage.simple.fontInput).to.not.have.property(
+ 'family',
+ )
expect(store.prefsStorage._journal.length).to.eql(1)
})
it('should not allow unsetting depth <= 2', () => {
- const store = useServerSideStorageStore()
+ const store = useSyncConfigStore()
+ // PushSyncConfig is very simple but uses vuex to push data
+ store.pushSyncConfig = () => {
+ /* no-op */
+ }
store.setPreference({ path: 'simple.object.foo', value: 1 })
expect(() => store.unsetPreference({ path: 'simple' })).to.throw()
expect(() =>
store.unsetPreference({ path: 'simple.object' }),
).to.throw()
})
it('should not allow (un)setting depth > 3', () => {
- const store = useServerSideStorageStore()
+ const store = useSyncConfigStore()
+ // PushSyncConfig is very simple but uses vuex to push data
+ store.pushSyncConfig = () => {
+ /* no-op */
+ }
store.setPreference({ path: 'simple.object', value: {} })
expect(() =>
store.setPreference({ path: 'simple.object.lv3', value: 1 }),
).to.not.throw()
expect(() =>
store.setPreference({ path: 'simple.object.lv3.lv4', value: 1 }),
).to.throw()
expect(() =>
store.unsetPreference({ path: 'simple.object.lv3', value: 1 }),
).to.not.throw()
expect(() =>
store.unsetPreference({ path: 'simple.object.lv3.lv4', value: 1 }),
).to.throw()
})
})
})
describe('helper functions', () => {
describe('_moveItemInArray', () => {
it('should move item according to movement value', () => {
expect(_moveItemInArray([1, 2, 3, 4], 4, -1)).to.eql([1, 2, 4, 3])
expect(_moveItemInArray([1, 2, 3, 4], 1, 2)).to.eql([2, 3, 1, 4])
})
it('should clamp movement to within array', () => {
expect(_moveItemInArray([1, 2, 3, 4], 4, -10)).to.eql([4, 1, 2, 3])
expect(_moveItemInArray([1, 2, 3, 4], 3, 99)).to.eql([1, 2, 4, 3])
})
})
describe('_getRecentData', () => {
it('should handle nulls correctly', () => {
expect(_getRecentData(null, null, true)).to.eql({
recent: null,
stale: null,
needUpload: true,
})
})
it("doesn't choke on invalid data", () => {
expect(_getRecentData({ a: 1 }, { b: 2 }, true)).to.eql({
recent: null,
stale: null,
needUpload: true,
})
})
it('should prefer the valid non-null correctly, needUpload works properly', () => {
const nonNull = { _version: VERSION, _timestamp: 1 }
expect(_getRecentData(nonNull, null, true)).to.eql({
recent: nonNull,
stale: null,
needUpload: true,
})
expect(_getRecentData(null, nonNull, true)).to.eql({
recent: nonNull,
stale: null,
needUpload: false,
})
})
it('should prefer the one with higher timestamp', () => {
const a = { _version: VERSION, _timestamp: 1 }
const b = { _version: VERSION, _timestamp: 2 }
expect(_getRecentData(a, b, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
expect(_getRecentData(b, a, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
})
it('case where both are same', () => {
const a = { _version: VERSION, _timestamp: 3 }
const b = { _version: VERSION, _timestamp: 3 }
expect(_getRecentData(a, b, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
expect(_getRecentData(b, a, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
})
})
describe('_getAllFlags', () => {
it('should handle nulls properly', () => {
expect(_getAllFlags(null, null)).to.eql([])
})
it('should output list of keys if passed single object', () => {
expect(
_getAllFlags({ flagStorage: { a: 1, b: 1, c: 1 } }, null),
).to.eql(['a', 'b', 'c'])
})
it('should union keys of both objects', () => {
expect(
_getAllFlags(
{ flagStorage: { a: 1, b: 1, c: 1 } },
{ flagStorage: { c: 1, d: 1 } },
),
).to.eql(['a', 'b', 'c', 'd'])
})
})
describe('_mergeFlags', () => {
it('should handle merge two flag sets correctly picking higher numbers', () => {
expect(
_mergeFlags(
{ flagStorage: { a: 0, b: 3 } },
{ flagStorage: { b: 1, c: 4, d: 9 } },
['a', 'b', 'c', 'd'],
),
).to.eql({ a: 0, b: 3, c: 4, d: 9 })
})
})
describe('_mergePrefs', () => {
it('should prefer recent and apply journal to it', () => {
expect(
_mergePrefs(
// RECENT
{
- simple: { a: 1, b: 0, c: true },
+ simple: { theme: '1', style: '0', hideISP: true },
_journal: [
- { path: 'simple.b', operation: 'set', args: [0], timestamp: 2 },
{
- path: 'simple.c',
+ path: 'simple.style',
+ operation: 'set',
+ args: ['0'],
+ timestamp: 2,
+ },
+ {
+ path: 'simple.hideISP',
operation: 'set',
args: [true],
timestamp: 4,
},
],
},
// STALE
{
- simple: { a: 1, b: 1, c: false },
+ simple: { theme: '1', style: '1', hideISP: false },
_journal: [
- { path: 'simple.a', operation: 'set', args: [1], timestamp: 1 },
- { path: 'simple.b', operation: 'set', args: [1], timestamp: 3 },
+ {
+ path: 'simple.theme',
+ operation: 'set',
+ args: ['1'],
+ timestamp: 1,
+ },
+ {
+ path: 'simple.style',
+ operation: 'set',
+ args: ['1'],
+ timestamp: 3,
+ },
],
},
),
).to.eql({
- simple: { a: 1, b: 1, c: true },
+ simple: { theme: '1', style: '1', hideISP: true },
_journal: [
- { path: 'simple.a', operation: 'set', args: [1], timestamp: 1 },
- { path: 'simple.b', operation: 'set', args: [1], timestamp: 3 },
- { path: 'simple.c', operation: 'set', args: [true], timestamp: 4 },
+ {
+ path: 'simple.theme',
+ operation: 'set',
+ args: ['1'],
+ timestamp: 1,
+ },
+ {
+ path: 'simple.style',
+ operation: 'set',
+ args: ['1'],
+ timestamp: 3,
+ },
+ {
+ path: 'simple.hideISP',
+ operation: 'set',
+ args: [true],
+ timestamp: 4,
+ },
],
})
})
it('should allow setting falsy values', () => {
expect(
_mergePrefs(
// RECENT
{
- simple: { a: 1, b: 0, c: false },
+ simple: { theme: '1', style: '0', hideISP: false },
_journal: [
- { path: 'simple.b', operation: 'set', args: [0], timestamp: 2 },
{
- path: 'simple.c',
+ path: 'simple.style',
+ operation: 'set',
+ args: ['0'],
+ timestamp: 2,
+ },
+ {
+ path: 'simple.hideISP',
operation: 'set',
args: [false],
timestamp: 4,
},
],
},
// STALE
{
- simple: { a: 0, b: 0, c: true },
+ simple: { theme: '0', style: '0', hideISP: true },
_journal: [
- { path: 'simple.a', operation: 'set', args: [0], timestamp: 1 },
- { path: 'simple.b', operation: 'set', args: [0], timestamp: 3 },
+ {
+ path: 'simple.theme',
+ operation: 'set',
+ args: ['0'],
+ timestamp: 1,
+ },
+ {
+ path: 'simple.style',
+ operation: 'set',
+ args: ['0'],
+ timestamp: 3,
+ },
],
},
),
).to.eql({
- simple: { a: 0, b: 0, c: false },
+ simple: { theme: '0', style: '0', hideISP: false },
_journal: [
- { path: 'simple.a', operation: 'set', args: [0], timestamp: 1 },
- { path: 'simple.b', operation: 'set', args: [0], timestamp: 3 },
- { path: 'simple.c', operation: 'set', args: [false], timestamp: 4 },
+ {
+ path: 'simple.theme',
+ operation: 'set',
+ args: ['0'],
+ timestamp: 1,
+ },
+ {
+ path: 'simple.style',
+ operation: 'set',
+ args: ['0'],
+ timestamp: 3,
+ },
+ {
+ path: 'simple.hideISP',
+ operation: 'set',
+ args: [false],
+ timestamp: 4,
+ },
],
})
})
it('should work with strings', () => {
expect(
_mergePrefs(
// RECENT
{
- simple: { a: 'foo' },
+ simple: { theme: 'foo' },
_journal: [
{
- path: 'simple.a',
+ path: 'simple.theme',
operation: 'set',
args: ['foo'],
timestamp: 2,
},
],
},
// STALE
{
- simple: { a: 'bar' },
+ simple: { theme: 'bar' },
_journal: [
{
- path: 'simple.a',
+ path: 'simple.theme',
operation: 'set',
args: ['bar'],
timestamp: 4,
},
],
},
),
).to.eql({
- simple: { a: 'bar' },
+ simple: { theme: 'bar' },
_journal: [
- { path: 'simple.a', operation: 'set', args: ['bar'], timestamp: 4 },
+ {
+ path: 'simple.theme',
+ operation: 'set',
+ args: ['bar'],
+ timestamp: 4,
+ },
],
})
})
it('should work with objects', () => {
expect(
_mergePrefs(
// RECENT
{
- simple: { lv2: { lv3: 'foo' } },
+ simple: { fontInput: { lv3: 'foo' } },
_journal: [
{
- path: 'simple.lv2.lv3',
+ path: 'simple.fontInput.lv3',
operation: 'set',
args: ['foo'],
timestamp: 2,
},
],
},
// STALE
{
- simple: { lv2: { lv3: 'bar' } },
+ simple: { fontInput: { lv3: 'bar' } },
_journal: [
{
- path: 'simple.lv2.lv3',
+ path: 'simple.fontInput.lv3',
operation: 'set',
args: ['bar'],
timestamp: 4,
},
],
},
),
).to.eql({
- simple: { lv2: { lv3: 'bar' } },
+ simple: { fontInput: { lv3: 'bar' } },
_journal: [
{
- path: 'simple.lv2.lv3',
+ path: 'simple.fontInput.lv3',
operation: 'set',
args: ['bar'],
timestamp: 4,
},
],
})
})
it('should work with unset', () => {
expect(
_mergePrefs(
// RECENT
{
- simple: { lv2: { lv3: 'foo' } },
+ simple: { fontInput: { lv3: 'foo' } },
_journal: [
{
- path: 'simple.lv2.lv3',
+ path: 'simple.fontInput.lv3',
operation: 'set',
args: ['foo'],
timestamp: 2,
},
],
},
// STALE
{
- simple: { lv2: {} },
+ simple: { fontInput: {} },
_journal: [
{
- path: 'simple.lv2.lv3',
+ path: 'simple.fontInput.lv3',
operation: 'unset',
args: [],
timestamp: 4,
},
],
},
),
).to.eql({
- simple: { lv2: {} },
+ simple: { fontInput: {} },
_journal: [
{
- path: 'simple.lv2.lv3',
+ path: 'simple.fontInput.lv3',
operation: 'unset',
args: [],
timestamp: 4,
},
],
})
})
})
describe('_resetFlags', () => {
- it('should reset all known flags to 0 when reset flag is set to > 0 and < 9000', () => {
- const totalFlags = { a: 0, b: 3, reset: 1 }
-
- expect(_resetFlags(totalFlags)).to.eql({ a: 0, b: 0, reset: 0 })
- })
it('should trim all flags to known when reset is set to 1000', () => {
const totalFlags = { a: 0, b: 3, c: 33, reset: COMMAND_TRIM_FLAGS }
expect(_resetFlags(totalFlags, { a: 0, b: 0, reset: 0 })).to.eql({
a: 0,
b: 3,
reset: 0,
})
})
it('should trim all flags to known and reset when reset is set to 1001', () => {
const totalFlags = {
a: 0,
b: 3,
c: 33,
reset: COMMAND_TRIM_FLAGS_AND_RESET,
}
expect(_resetFlags(totalFlags, { a: 0, b: 0, reset: 0 })).to.eql({
a: 0,
b: 0,
reset: 0,
})
})
})
})
})
diff --git a/test/unit/specs/stores/user_highlight.spec.js b/test/unit/specs/stores/user_highlight.spec.js
new file mode 100644
index 0000000000..c468522560
--- /dev/null
+++ b/test/unit/specs/stores/user_highlight.spec.js
@@ -0,0 +1,316 @@
+import { cloneDeep } from 'lodash'
+import { createPinia, setActivePinia } from 'pinia'
+
+import {
+ _getRecentData,
+ _mergeHighlights,
+ _moveItemInArray,
+ useUserHighlightStore,
+} from 'src/stores/user_highlight.js'
+
+describe('The UserHighlight store', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ window.vuex = {
+ state: {
+ users: {
+ currentUser: {
+ fqn: 'foo@bar.tld',
+ },
+ },
+ },
+ }
+ })
+
+ describe('mutations', () => {
+ describe('initUserHighlight', () => {
+ const user = {
+ created_at: new Date('1999-02-09'),
+ storage: {},
+ }
+
+ it('should initialize storage if none present', async () => {
+ const store = useUserHighlightStore()
+ await store.initUserHighlight({ ...user })
+ expect(store.cache._timestamp).to.be.a('number')
+ expect(store.cache.highlight).to.eql({ _journal: [] })
+ })
+
+ it('should initialize storage for new users if none present', async () => {
+ const store = useUserHighlightStore()
+ await store.initUserHighlight({ ...user, created_at: new Date() })
+ expect(store.cache._timestamp).to.be.a('number')
+ expect(store.cache.highlight).to.eql({ _journal: [] })
+ })
+
+ it('should use remote version if local missing', async () => {
+ const store = useUserHighlightStore()
+ await store.initUserHighlight(store, user)
+ expect(store.cache._timestamp).to.be.a('number')
+ })
+ })
+ describe('set', () => {
+ it('should set preference and update journal log accordingly', () => {
+ const store = useUserHighlightStore()
+ store.set({ user: 'highlight@testing', value: { type: 'test' } })
+ expect(store.highlight['highlight@testing']).to.eql({
+ user: 'highlight@testing',
+ type: 'test',
+ })
+ expect(store.highlight._journal.length).to.eql(1)
+ expect(store.highlight._journal[0]).to.eql({
+ user: 'highlight@testing',
+ operation: 'set',
+ args: [{ user: 'highlight@testing', type: 'test' }],
+ // should have A timestamp, we don't really care what it is
+ timestamp: store.highlight._journal[0].timestamp,
+ })
+ })
+
+ it('should keep journal to a minimum', () => {
+ const store = useUserHighlightStore()
+ store.set({ user: 'highlight@testing.xyz', value: { type: 'test' } })
+ store.set({ user: 'highlight@testing.xyz', value: { type: 'test' } })
+ store.updateCache({ username: 'test' })
+ expect(store.highlight['highlight@testing.xyz']).to.eql({
+ user: 'highlight@testing.xyz',
+ type: 'test',
+ })
+ expect(store.highlight._journal.length).to.eql(1)
+ expect(store.highlight._journal[0]).to.eql({
+ user: 'highlight@testing.xyz',
+ operation: 'set',
+ args: [
+ {
+ user: 'highlight@testing.xyz',
+ type: 'test',
+ },
+ ],
+ // should have A timestamp, we don't really care what it is
+ timestamp: store.highlight._journal[0].timestamp,
+ })
+ })
+
+ it('should remove duplicate entries from journal', () => {
+ const store = useUserHighlightStore()
+ store.set({ user: 'a@test.xyz', value: { type: 'foo' } })
+ store.set({ user: 'a@test.xyz', value: { type: 'foo' } })
+ store.updateCache({ username: 'test' })
+ expect(store.highlight['a@test.xyz']).to.eql({
+ user: 'a@test.xyz',
+ type: 'foo',
+ })
+ expect(store.highlight._journal.length).to.eql(1)
+ })
+ })
+ })
+
+ describe('helper functions', () => {
+ describe('_getRecentData', () => {
+ it('should handle nulls correctly', () => {
+ expect(_getRecentData(null, null, true)).to.eql({
+ recent: null,
+ stale: null,
+ needUpload: true,
+ })
+ })
+
+ it("doesn't choke on invalid data", () => {
+ expect(_getRecentData({ a: 1 }, { b: 2 }, true)).to.eql({
+ recent: null,
+ stale: null,
+ needUpload: true,
+ })
+ })
+
+ it('should prefer the valid non-null correctly, needUpload works properly', () => {
+ const nonNull = { _timestamp: 1 }
+ expect(_getRecentData(nonNull, null, true)).to.eql({
+ recent: nonNull,
+ stale: null,
+ needUpload: true,
+ })
+ expect(_getRecentData(null, nonNull, true)).to.eql({
+ recent: nonNull,
+ stale: null,
+ needUpload: false,
+ })
+ })
+
+ it('should prefer the one with higher timestamp', () => {
+ const a = { _timestamp: 1 }
+ const b = { _timestamp: 2 }
+
+ expect(_getRecentData(a, b, true)).to.eql({
+ recent: b,
+ stale: a,
+ needUpload: false,
+ })
+ expect(_getRecentData(b, a, true)).to.eql({
+ recent: b,
+ stale: a,
+ needUpload: false,
+ })
+ })
+
+ it('case where both are same', () => {
+ const a = { _timestamp: 3 }
+ const b = { _timestamp: 3 }
+
+ expect(_getRecentData(a, b, true)).to.eql({
+ recent: b,
+ stale: a,
+ needUpload: false,
+ })
+ expect(_getRecentData(b, a, true)).to.eql({
+ recent: b,
+ stale: a,
+ needUpload: false,
+ })
+ })
+ })
+
+ describe('_mergeHighlights', () => {
+ it('should prefer recent and apply journal to it', () => {
+ expect(
+ _mergeHighlights(
+ {
+ // RECENT
+ 'a@test.xyz': 1,
+ 'b@test.xyz': 0,
+ 'c@test.xyz': true,
+ _journal: [
+ {
+ user: 'b@test.xyz',
+ operation: 'set',
+ args: [0],
+ timestamp: 2,
+ },
+ {
+ user: 'c@test.xyz',
+ operation: 'set',
+ args: [true],
+ timestamp: 4,
+ },
+ ],
+ },
+ {
+ // STALE
+ 'a@test.xyz': 1,
+ 'b@test.xyz': 1,
+ 'c@test.xyz': false,
+ _journal: [
+ {
+ user: 'a@test.xyz',
+ operation: 'set',
+ args: [1],
+ timestamp: 1,
+ },
+ {
+ user: 'b@test.xyz',
+ operation: 'set',
+ args: [1],
+ timestamp: 3,
+ },
+ ],
+ },
+ ),
+ ).to.eql({
+ 'a@test.xyz': 1,
+ 'b@test.xyz': 1,
+ 'c@test.xyz': true,
+ _journal: [
+ { user: 'a@test.xyz', operation: 'set', args: [1], timestamp: 1 },
+ { user: 'b@test.xyz', operation: 'set', args: [1], timestamp: 3 },
+ {
+ user: 'c@test.xyz',
+ operation: 'set',
+ args: [true],
+ timestamp: 4,
+ },
+ ],
+ })
+ })
+
+ it('should work with objects', () => {
+ expect(
+ _mergeHighlights(
+ // RECENT
+ {
+ 'a@test.xyz': { type: 'foo' },
+ _journal: [
+ {
+ user: 'a@test.xyz',
+ operation: 'set',
+ args: [{ type: 'foo' }],
+ timestamp: 2,
+ },
+ ],
+ },
+ // STALE
+ {
+ 'a@test.xyz': { type: 'bar' },
+ _journal: [
+ {
+ user: 'a@test.xyz',
+ operation: 'set',
+ args: [{ type: 'bar' }],
+ timestamp: 4,
+ },
+ ],
+ },
+ ),
+ ).to.eql({
+ 'a@test.xyz': { type: 'bar' },
+ _journal: [
+ {
+ user: 'a@test.xyz',
+ operation: 'set',
+ args: [{ type: 'bar' }],
+ timestamp: 4,
+ },
+ ],
+ })
+ })
+
+ it('should work with unset', () => {
+ expect(
+ _mergeHighlights(
+ // RECENT
+ {
+ 'a@test.xyz': { type: 'foo' },
+ _journal: [
+ {
+ user: 'a@test.xyz',
+ operation: 'set',
+ args: [{ type: 'foo' }],
+ timestamp: 2,
+ },
+ ],
+ },
+ // STALE
+ {
+ _journal: [
+ {
+ user: 'a@test.xyz',
+ operation: 'unset',
+ args: [],
+ timestamp: 4,
+ },
+ ],
+ },
+ ),
+ ).to.eql({
+ _journal: [
+ {
+ user: 'a@test.xyz',
+ operation: 'unset',
+ args: [],
+ timestamp: 4,
+ },
+ ],
+ })
+ })
+ })
+ })
+})

File Metadata

Mime Type
text/x-diff
Expires
Mon, Oct 12, 4:10 AM (1 d, 19 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1786042
Default Alt Text
(1 MB)

Event Timeline