Page MenuHomePhorge

No OneTemporary

Size
648 KB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/build/check-versions.mjs b/build/check-versions.mjs
deleted file mode 100644
index c22004b00b..0000000000
--- a/build/check-versions.mjs
+++ /dev/null
@@ -1,41 +0,0 @@
-import chalk from 'chalk'
-import semver from 'semver'
-
-import packageConfig from '../package.json' with { type: 'json' }
-
-var versionRequirements = [
- {
- name: 'node',
- currentVersion: semver.clean(process.version),
- versionRequirement: packageConfig.engines.node,
- },
-]
-
-export default function () {
- const warnings = []
- for (let i = 0; i < versionRequirements.length; i++) {
- const mod = versionRequirements[i]
- if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) {
- warnings.push(
- mod.name +
- ': ' +
- chalk.red(mod.currentVersion) +
- ' should be ' +
- chalk.green(mod.versionRequirement),
- )
- }
- }
-
- if (warnings.length) {
- console.warn(
- chalk.yellow(
- '\nTo use this template, you must update following to modules:\n',
- ),
- )
- for (let i = 0; i < warnings.length; i++) {
- const warning = warnings[i]
- console.warn(' ' + warning)
- }
- process.exit(1)
- }
-}
diff --git a/build/commit_hash.js b/build/commit_hash.js
index c603558044..8225817ee0 100644
--- a/build/commit_hash.js
+++ b/build/commit_hash.js
@@ -1,18 +1,18 @@
-import childProcess from 'child_process'
+import childProcess from 'node:child_process'
export const getCommitHash = () => {
const subst = '$Format:%h$'
- if (!subst.match(/Format:/)) {
+ if (!/Format:/.exec(subst)) {
return subst
} else {
try {
return childProcess
.execSync('git rev-parse --short HEAD')
.toString()
.trim()
} catch (e) {
console.error('Failed run git:', e)
return 'UNKNOWN'
}
}
}
diff --git a/build/sw_plugin.js b/build/sw_plugin.js
index 2f0a4819d8..e6be7a7385 100644
--- a/build/sw_plugin.js
+++ b/build/sw_plugin.js
@@ -1,151 +1,150 @@
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { exactRegex } from '@rolldown/pluginutils'
import { build } from 'vite'
import {
generateServiceWorkerMessages,
i18nFiles,
} from './service_worker_messages.js'
const getSWMessagesAsText = async () => {
const messages = await generateServiceWorkerMessages()
return `export default ${JSON.stringify(messages, undefined, 2)}`
}
const projectRoot = dirname(dirname(fileURLToPath(import.meta.url)))
// Idea taken from
// https://github.com/vite-pwa/vite-plugin-pwa/blob/main/src/plugins/build.ts
// rollup does not support compiling to iife if we want to code-split;
// however, we must compile the service worker to iife because of browser support.
// Run another vite build just for the service worker targeting iife at
// the end of the build.
export const buildSwPlugin = ({ swSrc, swDest }) => {
const swFullSrc = resolve(projectRoot, swSrc)
const swEnvName = 'virtual:pleroma-fe/service_worker_env'
const swEnvNameResolved = '\0' + swEnvName
let config
return {
name: 'build-sw-plugin',
enforce: 'post',
configResolved(resolvedConfig) {
- resolvedConfig
config = {
define: resolvedConfig.define,
resolve: resolvedConfig.resolve,
plugins: [swMessagesPlugin()],
publicDir: false,
build: {
...resolvedConfig.build,
emptyOutDir: false,
rolldownOptions: {
input: {
main: swSrc,
},
context: 'self',
output: {
entryFileNames: swDest,
codeSplitting: false,
format: 'iife',
},
},
},
configFile: false,
}
},
generateBundle: {
order: 'post',
sequential: true,
async handler(_, bundle) {
const assets = Object.keys(bundle)
- .filter((name) => !/\.map$/.test(name))
+ .filter((name) => !name.endsWith('.map'))
.map((name) => '/' + name)
config.plugins.push({
name: 'build-sw-env-plugin',
mode: 'production',
resolveId: {
filter: { id: exactRegex(swEnvName) },
handler: () => swEnvNameResolved,
},
load: {
filter: { id: exactRegex(swEnvNameResolved) },
handler() {
return `self.serviceWorkerOption = { assets: ${JSON.stringify(assets)} };`
},
},
})
},
},
resolveId: {
filter: { id: new RegExp(swDest) },
handler() {
return swFullSrc
},
},
load: {
filter: { id: new RegExp(swFullSrc) },
async handler() {
config.plugins.push({
name: 'dummy-sw-env',
mode: 'development',
resolveId: {
filter: { id: exactRegex(swEnvName) },
handler: () => swEnvNameResolved,
},
load: {
filter: { id: exactRegex(swEnvNameResolved) },
handler: () => 'self.serviceWorkerOption = { assets: [] }',
},
})
try {
const swBundle = await build(config)
return swBundle.output[0]
} catch (e) {
console.error('Error building ServiceWorker:', e)
}
},
},
closeBundle: {
order: 'post',
sequential: true,
async handler() {
if (process.env.VITEST) return
console.info('Building service worker for production')
try {
await build(config)
} catch (e) {
console.error('Error building ServiceWorker:', e)
}
},
},
}
}
const swMessagesName = 'virtual:pleroma-fe/service_worker_messages'
const swMessagesNameResolved = '\0' + swMessagesName
export const swMessagesPlugin = () => {
return {
name: 'sw-messages-plugin',
resolveId(id) {
if (id === swMessagesName) {
Object.values(i18nFiles).forEach((f) => {
this.addWatchFile(f)
})
return swMessagesNameResolved
} else {
return null
}
},
async load(id) {
if (id === swMessagesNameResolved) {
return await getSWMessagesAsText()
}
return null
},
}
}
diff --git a/build/update-emoji.js b/build/update-emoji.js
index 4ff7e1de80..dd965cf662 100644
--- a/build/update-emoji.js
+++ b/build/update-emoji.js
@@ -1,22 +1,26 @@
+import fs from 'node:fs'
import emojis from '@kazvmoe-infra/unicode-emoji-json/data-by-group.json' with {
type: 'json',
}
-import fs from 'fs'
Object.keys(emojis).map((k) => {
- emojis[k].map((e) => {
+ emojis[k].forEach((e) => {
delete e.unicode_version
delete e.emoji_version
delete e.skin_tone_support_unicode_version
})
})
const res = {}
-Object.keys(emojis).map((k) => {
- const groupId = k.replace('&', 'and').replace(/ /g, '-').toLowerCase()
+Object.keys(emojis).forEach((k) => {
+ const groupId = k.replace('&', 'and').replaceAll(' ', '-').toLowerCase()
res[groupId] = emojis[k]
})
console.info('Updating emojis...')
-fs.writeFileSync('src/assets/emoji.json', JSON.stringify(res))
-console.info('Done.')
+try {
+ fs.writeFileSync('src/assets/emoji.json', JSON.stringify(res))
+ console.info('Done.')
+} catch (e) {
+ console.error('Failed updating emoji', e)
+}
diff --git a/docker/e2e/Dockerfile.e2e b/docker/e2e/Dockerfile.e2e
index 7e3fbfbf11..e0b93d83dc 100644
--- a/docker/e2e/Dockerfile.e2e
+++ b/docker/e2e/Dockerfile.e2e
@@ -1,16 +1,20 @@
FROM mcr.microsoft.com/playwright:v1.61.0-jammy
+RUN npm install -g yarn@1.22.22
+
+RUN adduser --system playwright --group --shell=/bin/bash
+
+USER playwright
+
WORKDIR /app
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
-RUN npm install -g yarn@1.22.22
-
COPY package.json yarn.lock ./
RUN yarn --frozen-lockfile
-COPY . .
+COPY --chown=playwright ./build ./src ./test ./public ./static ./yarn.lock ./package.json .
ENV CI=1
CMD ["yarn", "e2e:pw"]
diff --git a/index.html b/index.html
index 26eeee19bb..346b9f06ca 100644
--- a/index.html
+++ b/index.html
@@ -1,51 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1,user-scalable=no">
<link rel="preload" href="/static/config.json" as="fetch" crossorigin />
<link rel="preload" href="/api/pleroma/frontend_configurations" as="fetch" crossorigin />
<link rel="preload" href="/nodeinfo/2.0.json" as="fetch" crossorigin />
<link rel="preload" href="/nodeinfo/2.1.json" as="fetch" crossorigin />
<link rel="preload" href="/api/v1/instance" as="fetch" crossorigin />
<link rel="preload" href="/static/pleromatan_apology_fox_small.webp" as="image" />
<!-- putting styles here to avoid having to wait for styles to load up -->
<link rel="stylesheet" id="splashscreen" href="/static/splash.css" />
<link rel="stylesheet" id="custom-styles-holder" type="text/css" href="/static/empty.css" />
<!--server-generated-meta-->
</head>
<body>
<noscript>To use Pleroma, please enable JavaScript.</noscript>
<div id="splash" class="initial-hidden">
<!-- we are hiding entire graphic so no point showing credit -->
<div aria-hidden="true" id="splash-credit">
Art by pipivovott
</div>
<div id="splash-container">
<div aria-hidden="true" id="mascot-container">
<div id="throbber">
<div class="chunk" id="chunk-P">
</div>
<div class="chunk" id="chunk-L">
</div>
<div class="chunk" id="chunk-E">
</div>
</div>
- <img id="mascot" src="/static/pleromatan_apology_small.webp">
+ <img id="mascot" alt="A cute mascot" src="/static/pleromatan_apology_small.webp">
</div>
<div id="status" class="css-ok">
<!-- (。>﹏<) -->
<!-- it's a pseudographic, don't want screenreader read out nonsense -->
<span aria-hidden="true" class="initial-text">(。&gt;﹏&lt;)</span>
</div>
<code id="statusError"></code>
<pre id="statusStack"></pre>
</div>
</div>
<div id="app" class="hidden"></div>
<div id="modal"></div>
<!-- built files will be auto injected -->
<div id="popovers"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
diff --git a/public/static/terms-of-service.html b/public/static/terms-of-service.html
index 2b7bf7697d..19db6408e9 100644
--- a/public/static/terms-of-service.html
+++ b/public/static/terms-of-service.html
@@ -1,9 +1,9 @@
<h4>Terms of Service</h4>
<p>This is the default placeholder ToS. You should copy it over to your static folder and edit it to fit the needs of your instance.</p>
<p>To do so, place a file at <code>"/instance/static/static/terms-of-service.html"</code> in your
Pleroma install containing the real ToS for your instance.</p>
<p>See the <a href='https://docs.pleroma.social/backend/configuration/static_dir/'>Pleroma documentation</a> for more information.</p>
<br>
-<img src="/static/logo.svg" style="display: block; margin: auto; max-width: 100%; height: 50px; object-fit: contain;" />
+<img src="/static/logo.svg" alt="Pleroma Logo" style="display: block; margin: auto; max-width: 100%; height: 50px; object-fit: contain;" />
diff --git a/src/App.scss b/src/App.scss
index 0de5a35771..ccdf636bbe 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -1,1099 +1,1099 @@
-// 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';
+// stylelint-disable rscss/class-format
+/* stylelint-disable no-descending-specificity */
+
: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(--icon) 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(--icon) var(--wallpaper);
background: var(--wallpaper);
}
}
a {
text-decoration: none;
color: var(--link);
}
h4 {
margin: 0;
}
code {
background: var(--background);
border: 1px solid var(--border);
border-radius: var(--roundness);
padding: 0 0.2em;
& pre,
pre & {
display: block;
overflow-x: auto;
padding: 0.2em;
}
&.pre {
white-space: pre;
display: block;
}
}
.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%;
transition: background-image 1s;
}
.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%;
text-align: initial;
padding: 0;
background: none;
border: none;
outline: none;
display: inline;
font-family: inherit;
line-height: unset;
}
&.disabled {
cursor: not-allowed;
}
}
.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;
}
.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);
&.-dismissible {
display: flex;
padding-left: 0.5em;
margin: 0;
align-items: baseline;
line-height: 2;
span {
display: block;
flex: 1 0 auto;
}
}
}
.faint {
--text: var(--textFaint);
--link: var(--linkFaint);
color: var(--text);
}
.notice-dismissible {
display: flex;
padding: 0.75em 1em;
align-items: baseline;
line-height: 1.5;
p,
span {
display: block;
flex: 1 1 auto;
margin: 0;
}
.dismiss {
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/api/admin.js b/src/api/admin.js
index c33f863a74..d4a9e8ff96 100644
--- a/src/api/admin.js
+++ b/src/api/admin.js
@@ -1,491 +1,491 @@
import { promisedRequest } from './helpers.js'
const REPORTS = '/api/v1/pleroma/admin/reports'
const CONFIG_URL = '/api/v1/pleroma/admin/config'
const DESCRIPTIONS_URL = '/api/v1/pleroma/admin/config/descriptions'
const ANNOUNCEMENTS_URL = (id = '') =>
`/api/v1/pleroma/admin/announcements/${id}`
const FRONTENDS_URL = '/api/v1/pleroma/admin/frontends'
const FRONTENDS_INSTALL_URL = '/api/v1/pleroma/admin/frontends/install'
const USERS_URL = (nickname = '') => `/api/v1/pleroma/admin/users/${nickname}`
const USERS_URL_LIST = ({
page,
pageSize,
filters = {},
query = '',
name = '',
email = '',
}) => {
const {
local = false,
external = false,
active = false,
needApproval = false,
unconfirmed = false,
deactivated = false,
isAdmin = true,
isModerator = true,
} = filters
const filters_str = [
local && 'local',
external && 'external',
active && 'active',
needApproval && 'need_approval',
unconfirmed && 'unconfirmed',
deactivated && 'deactivated',
isAdmin && 'is_admin',
isModerator && 'is_moderator',
]
- .filter((x) => x)
+ .filter(Boolean)
.join(',')
return `/api/v1/pleroma/admin/users?page=${page}&page_size=${pageSize}&filters=${filters_str}&query=${query}&name=${name}&email=${email}`
}
const TAG_USER_URL = '/api/pleroma/admin/users/tag'
const PERMISSION_GROUP_URL = (right) =>
`/api/pleroma/admin/users/permission_group/${right}`
const ACTIVATE_USERS_URL = '/api/pleroma/admin/users/activate'
const DEACTIVATE_USERS_URL = '/api/pleroma/admin/users/deactivate'
const SUGGEST_USERS_URL = '/api/pleroma/admin/users/suggest'
const UNSUGGEST_USERS_URL = '/api/pleroma/admin/users/unsuggest'
const APPROVE_USERS_URL = '/api/v1/pleroma/admin/users/approve'
const CONFIRM_USERS_URL = '/api/v1/pleroma/admin/users/confirm_email'
const RESEND_CONFIRMATION_EMAIL_URL =
'/api/v1/pleroma/admin/users/resend_confirmation_email'
const LIST_STATUSES_URL = ({ id, page, pageSize, godmode, withReblogs }) =>
`/api/v1/pleroma/admin/users/${id}/statuses?page_size=${pageSize}&page=${page}&godmode=${godmode}&with_reblogs=${withReblogs}`
const CHANGE_STATUS_SCOPE_URL = (id) => `/api/v1/pleroma/admin/statuses/${id}`
const REQUIRE_PASSWORD_CHANGE_URL =
'/api/v1/pleroma/admin/users/force_password_reset'
const DISABLE_MFA_URL = '/api/v1/pleroma/admin/users/disable_mfa'
const EMOJI_RELOAD_URL = '/api/pleroma/admin/reload_emoji'
const EMOJI_IMPORT_FS_URL = '/api/pleroma/emoji/packs/import'
const EMOJI_PACK_URL = (name) => `/api/v1/pleroma/emoji/pack?name=${name}`
const EMOJI_PACKS_DL_REMOTE_URL = '/api/v1/pleroma/emoji/packs/download'
const EMOJI_PACKS_DL_REMOTE_ZIP_URL = '/api/v1/pleroma/emoji/packs/download_zip'
const EMOJI_PACKS_LS_REMOTE_URL = (url, page, pageSize) =>
`/api/v1/pleroma/emoji/packs/remote?url=${url}&page=${page}&page_size=${pageSize}`
const EMOJI_UPDATE_FILE_URL = (name) =>
`/api/v1/pleroma/emoji/packs/files?name=${name}`
//
export const setUsersTags = ({
tags,
credentials,
value,
screen_names: nicknames,
}) =>
promisedRequest({
url: TAG_USER_URL,
method: value ? 'PUT' : 'DELETE',
credentials,
payload: {
nicknames,
tags,
},
})
export const setUsersRight = ({
right,
credentials,
value,
screen_names: nicknames,
}) =>
promisedRequest({
url: PERMISSION_GROUP_URL(right),
method: value ? 'POST' : 'DELETE',
credentials,
payload: {
nicknames,
},
})
export const setUsersActivationStatus = ({
credentials,
screen_names: nicknames,
value,
}) =>
promisedRequest({
url: value ? ACTIVATE_USERS_URL : DEACTIVATE_USERS_URL,
method: 'PATCH',
credentials,
payload: {
nicknames,
},
}).then((response) => response.users)
export const setUsersApprovalStatus = ({
credentials,
screen_names: nicknames,
}) =>
promisedRequest({
url: APPROVE_USERS_URL,
method: 'PATCH',
credentials,
payload: {
nicknames,
},
}).then((response) => response.users)
export const setUsersConfirmationStatus = ({
credentials,
screen_names: nicknames,
}) =>
promisedRequest({
url: CONFIRM_USERS_URL,
method: 'PATCH',
credentials,
payload: {
nicknames,
},
}).then((response) => response.users)
export const setUsersSuggestionStatus = ({
credentials,
screen_names: nicknames,
value,
}) =>
promisedRequest({
url: value ? SUGGEST_USERS_URL : UNSUGGEST_USERS_URL,
method: 'PATCH',
credentials,
payload: {
nicknames,
},
}).then((response) => response.users)
export const getUserData = ({ credentials, screen_name: nickname }) =>
promisedRequest({
url: USERS_URL(nickname),
method: 'GET',
credentials,
})
export const deleteAccounts = ({ credentials, screen_names: nicknames }) =>
promisedRequest({
url: USERS_URL(),
method: 'DELETE',
credentials,
payload: {
nicknames,
},
})
export const getAnnouncements = ({ id, credentials }) =>
promisedRequest({ url: ANNOUNCEMENTS_URL(id), credentials })
// the reported list is hardly useful because standards are for dating i guess,
// so make sure to fetchIfMissing right afterward using this call
export const listUsers = ({ opts, credentials }) =>
promisedRequest({
url: USERS_URL_LIST(opts),
credentials,
method: 'GET',
})
export const resendConfirmationEmail = ({
screen_names: nicknames,
credentials,
}) =>
promisedRequest({
url: RESEND_CONFIRMATION_EMAIL_URL,
credentials,
method: 'PATCH',
payload: {
nicknames,
},
})
export const requirePasswordChange = ({
screen_names: nicknames,
credentials,
}) =>
promisedRequest({
url: REQUIRE_PASSWORD_CHANGE_URL,
credentials,
method: 'PATCH',
payload: {
nicknames,
},
})
export const disableMFA = ({ screen_name: nickname, credentials }) =>
promisedRequest({
url: DISABLE_MFA_URL,
credentials,
method: 'PUT',
payload: {
nickname,
},
})
export const listStatuses = ({ opts, credentials }) =>
promisedRequest({
url: LIST_STATUSES_URL(opts),
credentials,
method: 'GET',
})
export const changeStatusScope = ({
opts: { id, sensitive, visibility },
credentials,
}) => {
- var payload = {}
+ const payload = {}
if (typeof sensitive !== 'undefined') {
payload['sensitive'] = sensitive
}
if (typeof visibility !== 'undefined') {
payload['visibility'] = visibility
}
return promisedRequest({
url: CHANGE_STATUS_SCOPE_URL(id),
credentials,
method: 'PUT',
payload,
})
}
export const announcementToPayload = ({
content,
startsAt,
endsAt,
allDay,
}) => {
const payload = { content }
if (typeof startsAt !== 'undefined') {
payload.starts_at = startsAt ? new Date(startsAt).toISOString() : null
}
if (typeof endsAt !== 'undefined') {
payload.ends_at = endsAt ? new Date(endsAt).toISOString() : null
}
if (typeof allDay !== 'undefined') {
payload.all_day = allDay
}
return payload
}
export const postAnnouncement = ({
credentials,
content,
startsAt,
endsAt,
allDay,
}) =>
promisedRequest({
url: ANNOUNCEMENTS_URL(),
credentials,
method: 'POST',
payload: announcementToPayload({ content, startsAt, endsAt, allDay }),
})
export const editAnnouncement = ({
id,
credentials,
content,
startsAt,
endsAt,
allDay,
}) =>
promisedRequest({
url: ANNOUNCEMENTS_URL(id),
credentials,
method: 'PATCH',
payload: announcementToPayload({ content, startsAt, endsAt, allDay }),
})
export const deleteAnnouncement = ({ id, credentials }) =>
promisedRequest({
url: ANNOUNCEMENTS_URL(id),
credentials,
method: 'DELETE',
})
export const setReportState = ({ id, state, credentials }) => {
return promisedRequest({
url: REPORTS,
credentials,
method: 'PATCH',
payload: {
reports: [
{
id,
state,
},
],
},
})
}
export const getInstanceDBConfig = ({ credentials }) =>
promisedRequest({
url: CONFIG_URL,
credentials,
})
export const getInstanceConfigDescriptions = ({ credentials }) =>
promisedRequest({
url: DESCRIPTIONS_URL,
credentials,
})
export const getAvailableFrontends = ({ credentials }) =>
promisedRequest({
url: FRONTENDS_URL,
credentials,
})
export const pushInstanceDBConfig = ({ credentials, payload }) =>
promisedRequest({
url: CONFIG_URL,
method: 'POST',
credentials,
payload,
})
export const installFrontend = ({ credentials, payload }) =>
promisedRequest({
url: FRONTENDS_INSTALL_URL,
credentials,
method: 'POST',
payload,
})
// Emoji packs
export const deleteEmojiPack = ({ name }) =>
promisedRequest({
url: EMOJI_PACK_URL(name),
method: 'DELETE',
})
export const reloadEmoji = ({ credentials }) =>
promisedRequest({
url: EMOJI_RELOAD_URL,
method: 'POST',
credentials,
})
export const importEmojiFromFS = ({ credentials }) =>
promisedRequest({
url: EMOJI_IMPORT_FS_URL,
credentials,
})
export const createEmojiPack = ({ name, credentials }) =>
promisedRequest({
url: EMOJI_PACK_URL(name),
method: 'POST',
credentials,
})
export const listRemoteEmojiPacks = ({
instance,
page,
pageSize,
credentials,
}) => {
if (!instance.startsWith('http')) {
instance = 'https://' + instance
}
return promisedRequest({
url: EMOJI_PACKS_LS_REMOTE_URL(instance, page, pageSize),
credentials,
})
}
export const downloadRemoteEmojiPack = ({
instance,
packName,
as,
credentials,
}) =>
promisedRequest({
url: EMOJI_PACKS_DL_REMOTE_URL,
credentials,
method: 'POST',
payload: {
url: instance,
name: packName,
as,
},
})
export const downloadRemoteEmojiPackZIP = ({
url,
packName,
file,
credentials,
}) => {
const data = new FormData()
if (file) data.set('file', file)
if (url) data.set('url', url)
data.set('name', packName)
return promisedRequest({
url: EMOJI_PACKS_DL_REMOTE_ZIP_URL,
method: 'POST',
formData: data,
})
}
export const saveEmojiPackMetadata = ({ name, newData, credentials }) =>
promisedRequest({
url: EMOJI_PACK_URL(name),
credentials,
method: 'PATCH',
payload: { metadata: newData },
})
export const addNewEmojiFile = ({ packName, file, shortcode, filename }) => {
const data = new FormData()
if (filename.trim() !== '') {
data.set('filename', filename)
}
if (shortcode.trim() !== '') {
data.set('shortcode', shortcode)
}
data.set('file', file)
return promisedRequest({
url: EMOJI_UPDATE_FILE_URL(packName),
method: 'POST',
formData: data,
})
}
export const updateEmojiFile = ({
packName,
shortcode,
newShortcode,
newFilename,
credentials,
force,
}) =>
promisedRequest({
url: EMOJI_UPDATE_FILE_URL(packName),
credentials,
method: 'PATCH',
payload: {
shortcode,
new_shortcode: newShortcode,
new_filename: newFilename,
force,
},
})
export const deleteEmojiFile = ({ packName, shortcode }) =>
promisedRequest({
url: `${EMOJI_UPDATE_FILE_URL(packName)}&shortcode=${shortcode}`,
method: 'DELETE',
})
diff --git a/src/api/helpers.js b/src/api/helpers.js
index 4bf16e0cce..cb2f2bdd5f 100644
--- a/src/api/helpers.js
+++ b/src/api/helpers.js
@@ -1,140 +1,142 @@
import { snakeCase } from 'lodash'
import { StatusCodeError } from 'src/services/errors/errors'
export const paramsString = (params = {}) => {
if (params == null || params === undefined) return ''
if (typeof params !== 'object' || Array.isArray(params)) {
- throw new Error('Params are not an object!')
+ throw new TypeError('Params are not an object!')
}
const entries = (() => {
if (params instanceof Map) {
- return params.entries()
+ return [...params.entries()]
} else {
return Object.entries(params)
}
})()
const arrays = []
const nonArrays = []
entries.forEach(([k, v]) => {
if (v == null) return // Drop nulls
if (
(typeof v === 'object' && !Array.isArray(v)) ||
typeof v === 'function'
) {
- throw new Error('Param cannot be non-primitive!')
+ throw new TypeError('Param cannot be non-primitive!')
}
if (Array.isArray(v)) {
arrays.push([k, v])
} else {
nonArrays.push([k, v])
}
})
arrays.forEach(([k, array]) => {
array.forEach((v) => {
if (
typeof v === 'object' ||
typeof v === 'function' ||
typeof v === 'undefined'
)
- throw new Error('Array param cannot contain non-primitives!')
+ throw new TypeError('Array param cannot contain non-primitives!')
})
})
if (nonArrays.length + arrays.length === 0) return ''
return (
'?' +
[
...nonArrays.map(([k, v]) => [snakeCase(k), v]),
// turning [a,[1,2,3]] into [[a[],1],[a[],2],[a[],3]]
...arrays.reduce(
(acc, [k, arrayValue]) => [
...acc,
...arrayValue.map((v) => [snakeCase(k) + '[]', v]),
],
[],
),
]
.map(([k, v]) => `${k}=${window.encodeURIComponent(v)}`)
.join('&')
)
}
export const promisedRequest = async ({
method,
url,
payload,
formData,
cache,
credentials,
headers = {},
}) => {
const options = {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
...headers,
},
}
if (!formData) {
options.headers['Content-Type'] = 'application/json'
}
if (cache) {
options.cache = cache
}
if (formData || payload) {
options.body = formData || JSON.stringify(payload)
}
if (credentials) {
options.headers = {
...options.headers,
...authHeaders(credentials),
}
}
const response = await fetch(url, options)
const data = await (async () => {
const [contentType] = response.headers
.get('content-type')
.split(';')
.map((x) => x.toLowerCase().trim())
- const contentLength = parseInt(response.headers.get('content-length'))
+ const contentLength = Number.parseInt(
+ response.headers.get('content-length'),
+ )
if (contentLength === 0) return null
switch (contentType) {
case 'text/plain':
return await response.text()
case 'application/json':
return await response.json()
default:
return await response.bytes()
}
})()
const { ok, status } = response
if (ok) {
return { response, status, data }
} else {
throw new StatusCodeError(response.status, data, { url, options }, response)
}
}
const authHeaders = (accessToken) => {
if (accessToken) {
return { Authorization: `Bearer ${accessToken}` }
} else {
return {}
}
}
diff --git a/src/api/public.js b/src/api/public.js
index e001ba7495..78db90bfe8 100644
--- a/src/api/public.js
+++ b/src/api/public.js
@@ -1,279 +1,279 @@
import { paramsString, promisedRequest } from './helpers.js'
import { MASTODON_USER_TIMELINE_URL } from './timelines.js'
import {
parseSource,
parseStatus,
parseUser,
} from 'src/services/entity_normalizer/entity_normalizer.service.js'
const MASTODON_SUGGESTIONS_URL = '/api/v1/suggestions'
const MASTODON_LOGIN_URL = '/api/v1/accounts/verify_credentials'
const MASTODON_REGISTRATION_URL = '/api/v1/accounts'
const MASTODON_PASSWORD_RESET_URL = ({ email }) =>
`/auth/password${paramsString({ email })}`
const MASTODON_FOLLOWING_URL = (
id,
{ minId, maxId, sinceId, limit, withRelationships },
) =>
`/api/v1/accounts/${id}/following${paramsString({ minId, maxId, sinceId, limit, withRelationships })}`
const MASTODON_FOLLOWERS_URL = (
id,
{ minId, maxId, sinceId, limit, withRelationships },
) =>
`/api/v1/accounts/${id}/followers${paramsString({ minId, maxId, sinceId, limit, withRelationships })}`
export const MASTODON_STATUS_URL = (id) => `/api/v1/statuses/${id}`
const MASTODON_STATUS_CONTEXT_URL = (id) => `/api/v1/statuses/${id}/context`
const MASTODON_STATUS_SOURCE_URL = (id) => `/api/v1/statuses/${id}/source`
const MASTODON_STATUS_HISTORY_URL = (id) => `/api/v1/statuses/${id}/history`
const MASTODON_USER_URL = '/api/v1/accounts'
const MASTODON_USER_LOOKUP_URL = ({ acct }) =>
`/api/v1/accounts/lookup${paramsString({ acct })}`
const MASTODON_POLL_URL = (id = '') => `/api/v1/polls/${id}`
const MASTODON_STATUS_FAVORITEDBY_URL = (id) =>
`/api/v1/statuses/${id}/favourited_by`
const MASTODON_STATUS_REBLOGGEDBY_URL = (id) =>
`/api/v1/statuses/${id}/reblogged_by`
const MASTODON_SEARCH_2 = ({
q,
resolve,
limit,
offset,
following,
type,
withRelationships,
accountId,
excludeUnreviewed,
}) =>
`/api/v2/search${paramsString({ q, resolve, limit, offset, following, type, withRelationships, accountId, excludeUnreviewed })}`
const MASTODON_USER_SEARCH_URL = ({ q, resolve }) =>
`/api/v1/accounts/search${paramsString({ q, resolve })}`
const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers'
const PLEROMA_EMOJI_REACTIONS_URL = (id) =>
`/api/v1/pleroma/statuses/${id}/reactions`
const PLEROMA_SCROBBLES_URL = (id, { maxId, sinceId, minId, limit, offset }) =>
`/api/v1/pleroma/accounts/${id}/scrobbles${paramsString({ maxId, sinceId, minId, limit, offset })}`
const EMOJI_PACKS_URL = (page, pageSize) =>
`/api/v1/pleroma/emoji/packs${paramsString({ page, pageSize })}`
// Params needed:
// nickname
// email
// fullname
// password
// password_confirm
//
// Optional
// bio
// homepage
// location
// token
// language
export const register = ({ params, credentials }) => {
const { nickname, ...rest } = params
return promisedRequest({
url: MASTODON_REGISTRATION_URL,
method: 'POST',
credentials,
payload: {
nickname,
locale: 'en_US',
agreement: true,
...rest,
},
})
}
export const getCaptcha = () =>
promisedRequest({
url: '/api/pleroma/captcha',
})
export const fetchUser = ({ id, credentials }) =>
promisedRequest({
url: `${MASTODON_USER_URL}/${id}`,
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseUser(data) }))
export const fetchUserByName = ({ name, credentials }) =>
promisedRequest({
url: MASTODON_USER_LOOKUP_URL({ acct: name }),
credentials,
})
.then(({ data }) => data.id)
.catch((error) => {
- if (error && error.statusCode === 404) {
+ if (error?.statusCode === 404) {
// Either the backend does not support lookup endpoint,
// or there is no user with such name. Fallback and treat name as id.
return name
} else {
throw error
}
})
.then((id) => fetchUser({ id, credentials }))
export const fetchFriends = ({ id, maxId, sinceId, limit = 20, credentials }) =>
promisedRequest({
url: MASTODON_FOLLOWING_URL(id, { maxId, sinceId, limit }),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const fetchFollowers = ({
id,
maxId,
sinceId,
limit = 20,
credentials,
}) =>
promisedRequest({
url: MASTODON_FOLLOWERS_URL(id, {
maxId,
sinceId,
limit,
withRelationships: true,
}),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const fetchConversation = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_STATUS_CONTEXT_URL(id),
credentials,
}).then((result) => ({
...result,
data: {
...result.data,
ancestors: result.data.ancestors.map(parseStatus),
descendants: result.data.descendants.map(parseStatus),
},
}))
export const fetchStatus = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_STATUS_URL(id),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const fetchStatusSource = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_STATUS_SOURCE_URL(id),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseSource(data) }))
export const fetchStatusHistory = ({ status, credentials }) =>
promisedRequest({
url: MASTODON_STATUS_HISTORY_URL(status.id),
credentials,
}).then(({ data, ...rest }) => {
return [...data].reverse().map((item) => {
item.originalStatus = status
return { ...rest, data: parseStatus(item) }
})
})
export const listEmojiPacks = ({ page, pageSize, credentials }) =>
promisedRequest({
url: EMOJI_PACKS_URL(page, pageSize),
})
export const fetchPinnedStatuses = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_USER_TIMELINE_URL(id, { pinned: true }),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseStatus) }))
export const verifyCredentials = ({ credentials }) =>
promisedRequest({
url: MASTODON_LOGIN_URL,
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseUser(data) }))
export const resetPassword = ({ email }) => {
return promisedRequest({
url: MASTODON_PASSWORD_RESET_URL({ email }),
method: 'POST',
})
}
export const suggestions = ({ credentials }) =>
promisedRequest({
url: MASTODON_SUGGESTIONS_URL,
credentials,
})
export const fetchPoll = ({ pollId, credentials }) =>
promisedRequest({
url: MASTODON_POLL_URL(encodeURIComponent(pollId)),
method: 'GET',
credentials,
})
export const fetchFavoritedByUsers = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_STATUS_FAVORITEDBY_URL(id),
method: 'GET',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const fetchRebloggedByUsers = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_STATUS_REBLOGGEDBY_URL(id),
method: 'GET',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const fetchEmojiReactions = ({ id, credentials }) =>
promisedRequest({
url: PLEROMA_EMOJI_REACTIONS_URL(id),
credentials,
}).then(({ data, ...rest }) => ({
...rest,
data: data.map((r) => {
r.accounts = r.accounts.map(parseUser)
return r
}),
}))
export const searchUsers = ({ credentials, query }) =>
promisedRequest({
url: MASTODON_USER_SEARCH_URL({ q: query, resolve: true }),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const search2 = ({
credentials,
q,
resolve,
limit,
offset,
following,
type,
}) => {
return promisedRequest({
url: MASTODON_SEARCH_2({
q,
resolve,
limit,
offset,
following,
type,
withRelationships: true,
}),
credentials,
}).then(({ data, ...rest }) => {
data.accounts = data.accounts.slice(0, limit).map((u) => parseUser(u))
data.statuses = data.statuses.slice(0, limit).map((s) => parseStatus(s))
return { ...rest, data }
})
}
export const fetchKnownDomains = ({ credentials }) =>
promisedRequest({ url: MASTODON_KNOWN_DOMAIN_LIST_URL, credentials })
export const fetchScrobbles = ({ accountId, limit = 1 }) =>
promisedRequest({
url: PLEROMA_SCROBBLES_URL(accountId, { limit }),
})
diff --git a/src/api/user.js b/src/api/user.js
index 9a55a6b936..8086d6d7f3 100644
--- a/src/api/user.js
+++ b/src/api/user.js
@@ -1,921 +1,921 @@
-import { concat, last } from 'lodash'
+import { last } from 'lodash'
import { paramsString, promisedRequest } from './helpers.js'
import { fetchFriends, MASTODON_STATUS_URL } from './public.js'
import {
parseAttachment,
parseStatus,
parseUser,
} from 'src/services/entity_normalizer/entity_normalizer.service.js'
const MUTES_IMPORT_URL = '/api/pleroma/mutes_import'
const BLOCKS_IMPORT_URL = '/api/pleroma/blocks_import'
const FOLLOW_IMPORT_URL = '/api/pleroma/follow_import'
const DELETE_ACCOUNT_URL = '/api/pleroma/delete_account'
const CHANGE_EMAIL_URL = '/api/pleroma/change_email'
const CHANGE_PASSWORD_URL = '/api/pleroma/change_password'
const MOVE_ACCOUNT_URL = '/api/pleroma/move_account'
const ALIASES_URL = '/api/pleroma/aliases'
const NOTIFICATION_SETTINGS_URL = '/api/pleroma/notification_settings'
const NOTIFICATION_READ_URL = '/api/v1/pleroma/notifications/read'
const MFA_SETTINGS_URL = '/api/pleroma/accounts/mfa'
const MFA_BACKUP_CODES_URL = '/api/pleroma/accounts/mfa/backup_codes'
const MFA_SETUP_OTP_URL = '/api/pleroma/accounts/mfa/setup/totp'
const MFA_CONFIRM_OTP_URL = '/api/pleroma/accounts/mfa/confirm/totp'
const MFA_DISABLE_OTP_URL = '/api/pleroma/accounts/mfa/totp'
const MASTODON_DISMISS_NOTIFICATION_URL = (id) =>
`/api/v1/notifications/${id}/dismiss`
const MASTODON_FAVORITE_URL = (id) => `/api/v1/statuses/${id}/favourite`
const MASTODON_UNFAVORITE_URL = (id) => `/api/v1/statuses/${id}/unfavourite`
const MASTODON_RETWEET_URL = (id) => `/api/v1/statuses/${id}/reblog`
const MASTODON_UNRETWEET_URL = (id) => `/api/v1/statuses/${id}/unreblog`
const MASTODON_DELETE_URL = (id) => `/api/v1/statuses/${id}`
const MASTODON_FOLLOW_URL = (id) => `/api/v1/accounts/${id}/follow`
const MASTODON_UNFOLLOW_URL = (id) => `/api/v1/accounts/${id}/unfollow`
const MASTODON_FOLLOW_REQUESTS_URL = '/api/v1/follow_requests'
const MASTODON_APPROVE_USER_URL = (id) =>
`/api/v1/follow_requests/${id}/authorize`
const MASTODON_DENY_USER_URL = (id) => `/api/v1/follow_requests/${id}/reject`
const MASTODON_USER_RELATIONSHIPS_URL = ({ id, withSuspended }) =>
`/api/v1/accounts/relationships/${paramsString({ id, withSuspended })}`
const MASTODON_USER_IN_LISTS = (id) => `/api/v1/accounts/${id}/lists`
export const MASTODON_LIST_URL = (id = '') => `/api/v1/lists/${id}`
export const MASTODON_LIST_ACCOUNTS_URL = (id) => `/api/v1/lists/${id}/accounts`
const MASTODON_USER_BLOCKS_URL = ({
maxId,
sinceId,
limit,
withRelationships,
}) =>
`/api/v1/blocks/${paramsString({ maxId, sinceId, limit, withRelationships })}`
const MASTODON_USER_MUTES_URL = ({
maxId,
sinceId,
limit,
withRelationships,
}) =>
`/api/v1/mutes/${paramsString({ maxId, sinceId, limit, withRelationships })}`
const MASTODON_BLOCK_USER_URL = (id) => `/api/v1/accounts/${id}/block`
const MASTODON_UNBLOCK_USER_URL = (id) => `/api/v1/accounts/${id}/unblock`
const MASTODON_MUTE_USER_URL = (id) => `/api/v1/accounts/${id}/mute`
const MASTODON_UNMUTE_USER_URL = (id) => `/api/v1/accounts/${id}/unmute`
const MASTODON_REMOVE_USER_FROM_FOLLOWERS = (id) =>
`/api/v1/accounts/${id}/remove_from_followers`
const MASTODON_USER_NOTE_URL = (id) => `/api/v1/accounts/${id}/note`
const MASTODON_BOOKMARK_STATUS_URL = (id) => `/api/v1/statuses/${id}/bookmark`
const MASTODON_UNBOOKMARK_STATUS_URL = (id) =>
`/api/v1/statuses/${id}/unbookmark`
const MASTODON_POST_STATUS_URL = '/api/v1/statuses'
const MASTODON_MEDIA_UPLOAD_URL = '/api/v1/media'
const MASTODON_VOTE_URL = (id) => `/api/v1/polls/${id}/votes`
const MASTODON_PROFILE_UPDATE_URL = '/api/v1/accounts/update_credentials'
const MASTODON_REPORT_USER_URL = '/api/v1/reports'
const MASTODON_PIN_OWN_STATUS = (id) => `/api/v1/statuses/${id}/pin`
const MASTODON_UNPIN_OWN_STATUS = (id) => `/api/v1/statuses/${id}/unpin`
const MASTODON_MUTE_CONVERSATION = (id) => `/api/v1/statuses/${id}/mute`
const MASTODON_UNMUTE_CONVERSATION = (id) => `/api/v1/statuses/${id}/unmute`
const MASTODON_DOMAIN_BLOCKS_URL = '/api/v1/domain_blocks'
const MASTODON_ANNOUNCEMENTS_URL = '/api/v1/announcements'
const MASTODON_ANNOUNCEMENTS_DISMISS_URL = (id) =>
`/api/v1/announcements/${id}/dismiss`
const PLEROMA_EMOJI_REACT_URL = (id, emoji) =>
`/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
const PLEROMA_EMOJI_UNREACT_URL = (id, emoji) =>
`/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
const PLEROMA_BACKUP_URL = '/api/v1/pleroma/backups'
const PLEROMA_BOOKMARK_FOLDERS_URL = '/api/v1/pleroma/bookmark_folders'
const PLEROMA_BOOKMARK_FOLDER_URL = (id) =>
`/api/v1/pleroma/bookmark_folders/${id}`
// #Posts
export const favorite = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_FAVORITE_URL(id),
method: 'POST',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const unfavorite = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_UNFAVORITE_URL(id),
method: 'POST',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const retweet = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_RETWEET_URL(id),
method: 'POST',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const unretweet = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_UNRETWEET_URL(id),
method: 'POST',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const reactWithEmoji = ({ id, emoji, credentials }) =>
promisedRequest({
url: PLEROMA_EMOJI_REACT_URL(id, emoji),
method: 'PUT',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const unreactWithEmoji = ({ id, emoji, credentials }) =>
promisedRequest({
url: PLEROMA_EMOJI_UNREACT_URL(id, emoji),
method: 'DELETE',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const bookmarkStatus = ({ id, credentials, ...options }) =>
promisedRequest({
url: MASTODON_BOOKMARK_STATUS_URL(id),
credentials,
method: 'POST',
payload: {
folder_id: options.folder_id,
},
})
export const unbookmarkStatus = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_UNBOOKMARK_STATUS_URL(id),
credentials,
method: 'POST',
})
export const pinOwnStatus = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_PIN_OWN_STATUS(id),
credentials,
method: 'POST',
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const unpinOwnStatus = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_UNPIN_OWN_STATUS(id),
credentials,
method: 'POST',
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const muteConversation = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_MUTE_CONVERSATION(id),
credentials,
method: 'POST',
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const unmuteConversation = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_UNMUTE_CONVERSATION(id),
credentials,
method: 'POST',
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
export const vote = ({ pollId, choices, credentials }) => {
return promisedRequest({
url: MASTODON_VOTE_URL(encodeURIComponent(pollId)),
method: 'POST',
credentials,
payload: {
choices,
},
})
}
// #Posting
export const postStatus = ({
credentials,
status,
spoilerText,
visibility,
sensitive,
poll,
mediaIds = [],
inReplyToStatusId,
quoteId,
contentType,
preview,
idempotencyKey,
}) => {
const form = new FormData()
const pollOptions = poll?.options || []
form.append('status', status)
form.append('source', 'Pleroma FE')
if (spoilerText) form.append('spoiler_text', spoilerText)
if (visibility) form.append('visibility', visibility)
if (sensitive) form.append('sensitive', sensitive)
if (contentType) form.append('content_type', contentType)
mediaIds.forEach((val) => {
form.append('media_ids[]', val)
})
if (pollOptions.some((option) => option !== '')) {
const normalizedPoll = {
- expires_in: parseInt(poll.expiresIn, 10),
+ expires_in: Number.parseInt(poll.expiresIn, 10),
multiple: poll.multiple,
}
Object.keys(normalizedPoll).forEach((key) => {
form.append(`poll[${key}]`, normalizedPoll[key])
})
pollOptions.forEach((option) => {
form.append('poll[options][]', option)
})
}
if (inReplyToStatusId) {
form.append('in_reply_to_id', inReplyToStatusId)
}
if (quoteId) {
form.append('quote_id', quoteId)
}
if (preview) {
form.append('preview', 'true')
}
const headers = {}
if (idempotencyKey) {
headers['idempotency-key'] = idempotencyKey
}
return promisedRequest({
url: MASTODON_POST_STATUS_URL,
formData: form,
method: 'POST',
credentials,
headers,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
}
export const editStatus = ({
id,
credentials,
status,
spoilerText,
sensitive,
poll,
mediaIds = [],
contentType,
}) => {
const form = new FormData()
const pollOptions = poll?.options || []
form.append('status', status)
if (spoilerText) form.append('spoiler_text', spoilerText)
if (sensitive) form.append('sensitive', sensitive)
if (contentType) form.append('content_type', contentType)
mediaIds.forEach((val) => {
form.append('media_ids[]', val)
})
if (pollOptions.some((option) => option !== '')) {
const normalizedPoll = {
- expires_in: parseInt(poll.expiresIn, 10),
+ expires_in: Number.parseInt(poll.expiresIn, 10),
multiple: poll.multiple,
}
Object.keys(normalizedPoll).forEach((key) => {
form.append(`poll[${key}]`, normalizedPoll[key])
})
pollOptions.forEach((option) => {
form.append('poll[options][]', option)
})
}
return promisedRequest({
url: MASTODON_STATUS_URL(id),
formData: form,
method: 'PUT',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
}
export const deleteStatus = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_DELETE_URL(id),
credentials,
method: 'DELETE',
})
export const uploadMedia = ({ formData, credentials }) =>
promisedRequest({
url: MASTODON_MEDIA_UPLOAD_URL,
formData,
method: 'POST',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseAttachment(data) }))
export const setMediaDescription = ({ id, description, credentials }) =>
promisedRequest({
url: `${MASTODON_MEDIA_UPLOAD_URL}/${id}`,
method: 'PUT',
credentials,
payload: {
description,
},
}).then(({ data, ...rest }) => ({ ...rest, data: parseAttachment(data) }))
// #Notifications
export const dismissNotification = ({ credentials, id }) =>
promisedRequest({
url: MASTODON_DISMISS_NOTIFICATION_URL(id),
method: 'POST',
payload: { id },
credentials,
})
export const markNotificationsAsSeen = ({
id,
credentials,
single = false,
}) => {
const formData = new FormData()
if (single) {
formData.append('id', id)
} else {
formData.append('max_id', id)
}
return promisedRequest({
url: NOTIFICATION_READ_URL,
formData,
credentials,
method: 'POST',
})
}
// #Announcements
export const getAnnouncements = ({ credentials }) =>
promisedRequest({ url: MASTODON_ANNOUNCEMENTS_URL, credentials })
export const dismissAnnouncement = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_ANNOUNCEMENTS_DISMISS_URL(id),
credentials,
method: 'POST',
})
// #Imports
export const importMutes = ({ file, credentials }) => {
const formData = new FormData()
formData.append('list', file)
return promisedRequest({
url: MUTES_IMPORT_URL,
formData,
method: 'POST',
credentials,
}).then((response) => response.ok)
}
export const importBlocks = ({ file, credentials }) => {
const formData = new FormData()
formData.append('list', file)
return promisedRequest({
url: BLOCKS_IMPORT_URL,
formData,
method: 'POST',
credentials,
}).then((response) => response.ok)
}
export const importFollows = ({ file, credentials }) => {
const formData = new FormData()
formData.append('list', file)
return promisedRequest({
url: FOLLOW_IMPORT_URL,
formData,
method: 'POST',
credentials,
}).then((response) => response.ok)
}
export const exportFriends = ({ id, credentials }) => {
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: TODO refactor this
return new Promise(async (resolve, reject) => {
try {
let friends = []
let more = true
while (more) {
const maxId = friends.length > 0 ? last(friends).id : undefined
const users = await fetchFriends({
id,
maxId,
credentials,
withRelationships: true,
})
- friends = concat(friends, users)
+ friends = [...friends, ...users]
if (users.length === 0) {
more = false
}
}
resolve(friends)
} catch (err) {
reject(err)
}
})
}
// #Profile settings
export const updateNotificationSettings = ({ credentials, settings }) => {
return promisedRequest({
url: NOTIFICATION_SETTINGS_URL,
credentials,
method: 'PUT',
payload: settings,
})
}
export const updateProfileImages = ({
credentials,
avatar = null,
avatarName = null,
banner = null,
background = null,
}) => {
const form = new FormData()
if (avatar !== null) {
if (avatarName !== null) {
form.append('avatar', avatar, avatarName)
} else {
form.append('avatar', avatar)
}
}
if (banner !== null) form.append('header', banner)
if (background !== null) form.append('pleroma_background_image', background)
return promisedRequest({
url: MASTODON_PROFILE_UPDATE_URL,
credentials,
method: 'PATCH',
formData: form,
}).then(({ data, ...rest }) => ({ ...rest, data: parseUser(data) }))
}
export const updateProfile = ({ credentials, params }) => {
const formData = new FormData()
for (const name in params) {
if (name === 'fields_attributes') {
params[name].forEach((param, i) => {
formData.append(name + `[${i}][name]`, param.name)
formData.append(name + `[${i}][value]`, param.value)
})
} else {
if (typeof params[name] === 'object') {
console.warn(
'Object detected in updateProfile API call. This will not work, use updateProfileJSON instead.',
)
console.warn('Object:\n' + JSON.stringify(params[name], null, 2))
}
formData.append(name, params[name])
}
}
return promisedRequest({
url: MASTODON_PROFILE_UPDATE_URL,
credentials,
method: 'PATCH',
formData,
}).then(({ data, ...rest }) => ({ ...rest, data: parseUser(data) }))
}
export const updateProfileJSON = ({ credentials, params }) =>
promisedRequest({
url: MASTODON_PROFILE_UPDATE_URL,
credentials,
payload: params,
method: 'PATCH',
}).then(({ data, ...rest }) => ({ ...rest, data: parseUser(data) }))
export const changeEmail = ({ credentials, email, password }) => {
const form = new FormData()
form.append('email', email)
form.append('password', password)
return promisedRequest({
url: CHANGE_EMAIL_URL,
formData: form,
method: 'POST',
credentials,
})
}
export const moveAccount = ({ credentials, password, targetAccount }) => {
const form = new FormData()
form.append('password', password)
form.append('target_account', targetAccount)
return promisedRequest({
url: MOVE_ACCOUNT_URL,
formData: form,
method: 'POST',
credentials,
})
}
export const changePassword = ({
credentials,
password,
newPassword,
newPasswordConfirmation,
}) => {
const form = new FormData()
form.append('password', password)
form.append('new_password', newPassword)
form.append('new_password_confirmation', newPasswordConfirmation)
return promisedRequest({
url: CHANGE_PASSWORD_URL,
formData: form,
method: 'POST',
credentials,
})
}
// #MFA
export const settingsMFA = ({ credentials }) =>
promisedRequest({
url: MFA_SETTINGS_URL,
credentials,
method: 'GET',
})
export const mfaDisableOTP = ({ credentials, password }) => {
const form = new FormData()
form.append('password', password)
return promisedRequest({
url: MFA_DISABLE_OTP_URL,
formData: form,
method: 'DELETE',
credentials,
})
}
export const mfaConfirmOTP = ({ credentials, password, token }) => {
const form = new FormData()
form.append('password', password)
form.append('code', token)
return promisedRequest({
url: MFA_CONFIRM_OTP_URL,
formData: form,
credentials,
method: 'POST',
})
}
export const mfaSetupOTP = ({ credentials }) =>
promisedRequest({
url: MFA_SETUP_OTP_URL,
credentials,
method: 'GET',
})
export const generateMfaBackupCodes = ({ credentials }) =>
promisedRequest({
url: MFA_BACKUP_CODES_URL,
credentials,
method: 'GET',
})
// #Aliases
export const addAlias = ({ credentials, alias }) =>
promisedRequest({
url: ALIASES_URL,
method: 'PUT',
credentials,
payload: { alias },
})
export const deleteAlias = ({ credentials, alias }) =>
promisedRequest({
url: ALIASES_URL,
method: 'DELETE',
credentials,
payload: { alias },
})
export const listAliases = ({ credentials }) =>
promisedRequest({
url: ALIASES_URL,
method: 'GET',
credentials,
params: {
- _cacheBooster: new Date().getTime(),
+ _cacheBooster: Date.now(),
},
})
// User manipulation
export const fetchUserRelationship = ({ id, withSuspended, credentials }) =>
promisedRequest({
url: MASTODON_USER_RELATIONSHIPS_URL({ id, withSuspended }),
credentials,
})
export const followUser = ({ id, credentials, ...options }) => {
const payload = {}
if (options.reblogs !== undefined) {
payload.reblogs = options.reblogs
}
if (options.notify !== undefined) {
payload.notify = options.notify
}
return promisedRequest({
url: MASTODON_FOLLOW_URL(id),
payload,
credentials,
method: 'POST',
})
}
export const unfollowUser = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_UNFOLLOW_URL(id),
credentials,
method: 'POST',
})
export const fetchUserInLists = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_USER_IN_LISTS(id),
credentials,
})
export const removeUserFromFollowers = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_REMOVE_USER_FROM_FOLLOWERS(id),
credentials,
method: 'POST',
})
export const fetchFollowRequests = ({ credentials }) =>
promisedRequest({
url: MASTODON_FOLLOW_REQUESTS_URL,
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const approveUser = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_APPROVE_USER_URL(id),
credentials,
method: 'POST',
})
export const denyUser = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_DENY_USER_URL(id),
credentials,
method: 'POST',
})
export const editUserNote = ({ id, credentials, comment }) =>
promisedRequest({
url: MASTODON_USER_NOTE_URL(id),
credentials,
payload: {
comment,
},
method: 'POST',
})
export const fetchMutes = ({ maxId, credentials }) =>
promisedRequest({
url: MASTODON_USER_MUTES_URL({ maxId, withRelationships: true }),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const muteUser = ({ id, expiresIn, credentials }) => {
const payload = {}
if (expiresIn) {
payload.expires_in = expiresIn
}
return promisedRequest({
url: MASTODON_MUTE_USER_URL(id),
credentials,
method: 'POST',
payload,
})
}
export const unmuteUser = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_UNMUTE_USER_URL(id),
credentials,
method: 'POST',
})
export const fetchBlocks = ({ maxId, credentials }) =>
promisedRequest({
url: MASTODON_USER_BLOCKS_URL({ maxId, withRelationships: true }),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const blockUser = ({ id, expiresIn, credentials }) => {
const payload = {}
if (expiresIn) {
payload.duration = expiresIn
}
return promisedRequest({
url: MASTODON_BLOCK_USER_URL(id),
credentials,
method: 'POST',
payload,
})
}
export const unblockUser = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_UNBLOCK_USER_URL(id),
credentials,
method: 'POST',
})
export const reportUser = ({
credentials,
userId,
statusIds,
comment,
forward,
}) =>
promisedRequest({
url: MASTODON_REPORT_USER_URL,
method: 'POST',
payload: {
account_id: userId,
status_ids: statusIds,
comment,
forward,
},
credentials,
})
// #Domain mutes
export const fetchDomainMutes = ({ credentials }) =>
promisedRequest({ url: MASTODON_DOMAIN_BLOCKS_URL, credentials })
export const muteDomain = ({ domain, credentials }) =>
promisedRequest({
url: MASTODON_DOMAIN_BLOCKS_URL,
method: 'POST',
payload: { domain },
credentials,
})
export const unmuteDomain = ({ domain, credentials }) =>
promisedRequest({
url: MASTODON_DOMAIN_BLOCKS_URL,
method: 'DELETE',
payload: { domain },
credentials,
})
// #Backups
export const addBackup = ({ credentials }) =>
promisedRequest({
url: PLEROMA_BACKUP_URL,
method: 'POST',
credentials,
})
export const listBackups = ({ credentials }) =>
promisedRequest({
url: PLEROMA_BACKUP_URL,
method: 'GET',
credentials,
params: {
- _cacheBooster: new Date().getTime(),
+ _cacheBooster: Date.now(),
},
})
// #OAuth
export const fetchOAuthTokens = ({ credentials }) =>
promisedRequest({
url: '/api/oauth_tokens.json',
credentials,
})
export const revokeOAuthToken = ({ id, credentials }) =>
promisedRequest({
url: `/api/oauth_tokens/${id}`,
credentials,
method: 'DELETE',
})
// #Lists
export const fetchLists = ({ credentials }) =>
promisedRequest({
url: MASTODON_LIST_URL(),
credentials,
})
export const createList = ({ title, credentials }) =>
promisedRequest({
url: MASTODON_LIST_URL(),
credentials,
method: 'POST',
payload: { title },
})
export const getList = ({ listId, credentials }) =>
promisedRequest({
url: MASTODON_LIST_URL(listId),
credentials,
})
export const updateList = ({ listId, title, credentials }) =>
promisedRequest({
url: MASTODON_LIST_URL(listId),
credentials,
method: 'PUT',
payload: { title },
})
export const getListAccounts = ({ listId, credentials }) =>
promisedRequest({
url: MASTODON_LIST_ACCOUNTS_URL(listId),
credentials,
}).then((data) => data.map(({ id }) => id))
export const addAccountsToList = ({ listId, accountIds, credentials }) =>
promisedRequest({
url: MASTODON_LIST_ACCOUNTS_URL(listId),
credentials,
method: 'POST',
payload: { account_ids: accountIds },
})
export const removeAccountsFromList = ({ listId, accountIds, credentials }) =>
promisedRequest({
url: MASTODON_LIST_ACCOUNTS_URL(listId),
credentials,
method: 'DELETE',
payload: { account_ids: accountIds },
})
export const deleteList = ({ listId, credentials }) =>
promisedRequest({
url: MASTODON_LIST_URL(listId),
method: 'DELETE',
credentials,
})
// #Bookmarks
export const fetchBookmarkFolders = ({ credentials }) =>
promisedRequest({
url: PLEROMA_BOOKMARK_FOLDERS_URL,
credentials,
})
export const createBookmarkFolder = ({ name, emoji, credentials }) =>
promisedRequest({
url: PLEROMA_BOOKMARK_FOLDERS_URL,
credentials,
method: 'POST',
payload: { name, emoji },
})
export const updateBookmarkFolder = ({ folderId, name, emoji, credentials }) =>
promisedRequest({
url: PLEROMA_BOOKMARK_FOLDER_URL(folderId),
credentials,
method: 'PATCH',
payload: { name, emoji },
})
export const deleteBookmarkFolder = ({ folderId, credentials }) =>
promisedRequest({
url: PLEROMA_BOOKMARK_FOLDER_URL(folderId),
method: 'DELETE',
credentials,
})
// #So long and thanks for all the fish
export const deleteAccount = ({ credentials, password }) => {
const formData = new FormData()
formData.append('password', password)
return promisedRequest({
url: DELETE_ACCOUNT_URL,
formData,
method: 'POST',
credentials,
})
}
diff --git a/src/api/websocket.js b/src/api/websocket.js
index d952372e51..ae2e5358fe 100644
--- a/src/api/websocket.js
+++ b/src/api/websocket.js
@@ -1,176 +1,174 @@
import { paramsString } from './helpers.js'
import {
parseChat,
parseNotification,
parseStatus,
} from 'src/services/entity_normalizer/entity_normalizer.service.js'
const MASTODON_STREAMING = ({ accessToken, stream }) =>
`/api/v1/streaming${paramsString({ accessToken, stream })}`
export const getMastodonSocketURI = ({ credentials, stream }) => {
return MASTODON_STREAMING({ accessToken: credentials, stream })
}
const MASTODON_STREAMING_EVENTS = new Set([
'update',
'notification',
'delete',
'filters_changed',
'status.update',
])
const PLEROMA_STREAMING_EVENTS = new Set([
'pleroma:chat_update',
'pleroma:respond',
])
// A thin wrapper around WebSocket API that allows adding a pre-processor to it
// Uses EventTarget and a CustomEvent to proxy events
export const ProcessedWS = ({
url,
preprocessor = handleMastoWS,
id = 'Unknown',
credentials,
}) => {
const eventTarget = new EventTarget()
const socket = new WebSocket(url)
if (!socket) throw new Error(`Failed to create socket ${id}`)
const proxy = (original, eventName, processor = (a) => a) => {
original.addEventListener(eventName, (eventData) => {
eventTarget.dispatchEvent(
new CustomEvent(eventName, { detail: processor(eventData) }),
)
})
}
socket.addEventListener('open', (wsEvent) => {
console.debug(`[WS][${id}] Socket connected`, wsEvent)
if (credentials) {
socket.send(
JSON.stringify({
type: 'pleroma:authenticate',
token: credentials,
}),
)
}
})
socket.addEventListener('error', (wsEvent) => {
console.debug(`[WS][${id}] Socket errored`, wsEvent)
})
socket.addEventListener('close', (wsEvent) => {
console.debug(
`[WS][${id}] Socket disconnected with code ${wsEvent.code}`,
wsEvent,
)
})
// Commented code reason: very spammy, uncomment to enable message debug logging
/*
socket.addEventListener('message', (wsEvent) => {
console.debug(
`[WS][${id}] Message received`,
wsEvent
)
})
/**/
const onAuthenticated = () => {
eventTarget.dispatchEvent(new CustomEvent('pleroma:authenticated'))
}
proxy(socket, 'open')
proxy(socket, 'close')
proxy(socket, 'message', (event) => preprocessor(event, { onAuthenticated }))
proxy(socket, 'error')
// 1000 = Normal Closure
eventTarget.close = () => {
socket.close(1000, 'Shutting down socket')
}
eventTarget.getState = () => socket.readyState
eventTarget.subscribe = (stream, args = {}) => {
console.debug(`[WS][${id}] Subscribing to stream ${stream} with args`, args)
socket.send(
JSON.stringify({
type: 'subscribe',
stream,
...args,
}),
)
}
eventTarget.unsubscribe = (stream, args = {}) => {
console.debug(
`[WS][${id}] Unsubscribing from stream ${stream} with args`,
args,
)
socket.send(
JSON.stringify({
type: 'unsubscribe',
stream,
...args,
}),
)
}
return eventTarget
}
export const handleMastoWS = (
wsEvent,
{
onAuthenticated = () => {
/* no-op */
},
} = {},
) => {
const { data } = wsEvent
if (!data) return
const parsedEvent = JSON.parse(data)
const { event, payload } = parsedEvent
if (
MASTODON_STREAMING_EVENTS.has(event) ||
PLEROMA_STREAMING_EVENTS.has(event)
) {
// MastoBE and PleromaBE both send payload for delete as a PLAIN string
if (event === 'delete') {
return { event, id: payload }
}
const data = payload ? JSON.parse(payload) : null
if (event === 'pleroma:respond') {
if (data.type === 'pleroma:authenticate') {
if (data.result === 'success') {
console.debug('[WS] Successfully authenticated')
onAuthenticated()
+ } else if (data.error === 'already_authenticated') {
+ onAuthenticated()
} else {
- if (data.error === 'already_authenticated') {
- onAuthenticated()
- } else {
- console.error('[WS] Unable to authenticate:', data.error)
- wsEvent.target.close()
- }
+ console.error('[WS] Unable to authenticate:', data.error)
+ wsEvent.target.close()
}
}
return null
} else if (event === 'update') {
return { event, status: parseStatus(data) }
} else if (event === 'status.update') {
return { event, status: parseStatus(data) }
} else if (event === 'notification') {
return { event, notification: parseNotification(data) }
} else if (event === 'pleroma:chat_update') {
return { event, chatUpdate: parseChat(data) }
}
} else {
console.warn('Unknown event', wsEvent)
return null
}
}
export const WSConnectionStatus = Object.freeze({
JOINED: 1,
CLOSED: 2,
ERROR: 3,
DISABLED: 4,
STARTING: 5,
STARTING_INITIAL: 6,
})
diff --git a/src/boot/after_store.js b/src/boot/after_store.js
index 4ccfa4d41d..986586a80d 100644
--- a/src/boot/after_store.js
+++ b/src/boot/after_store.js
@@ -1,622 +1,620 @@
/* 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 RichContent from 'src/components/rich_content/rich_content.jsx'
import Status from 'src/components/status/status.vue'
import StillImage from 'src/components/still-image/still-image.vue'
import { config } from '@fortawesome/fontawesome-svg-core'
import {
FontAwesomeIcon,
FontAwesomeLayers,
} from '@fortawesome/vue-fontawesome'
config.autoAddCss = false
import App from '../App.vue'
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 { 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.js'
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 {
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 array = Uint8Array.from([...rawData].map((char) => char.codePointAt(0)))
const text = new TextDecoder().decode(array)
return text
}
const preloadFetch = async (request) => {
const data = parsedInitialResults()
- if (!data || !data[request]) {
+ if (!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 vapidPublicKey = data.pleroma.vapid_public_key
useInstanceCapabilitiesStore().set(
'pleromaExtensionsAvailable',
data.pleroma,
)
useInstanceStore().set({
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.',
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)
+ config = { ...apiConfig, ...staticConfig }
} else {
- config = Object.assign({}, staticConfig, apiConfig)
+ config = { ...staticConfig, ...apiConfig }
}
Object.keys(INSTANCE_IDENTITY_DEFAULT_DEFINITIONS).forEach((source) => {
if (source === 'name') return
if (INSTANCE_IDENTIY_EXTERNAL.has(source)) return
useInstanceStore().set({
value:
config[source] ?? INSTANCE_IDENTITY_DEFAULT_DEFINITIONS[source].default,
path: `instanceIdentity.${source}`,
})
})
Object.keys(INSTANCE_DEFAULT_CONFIG_DEFINITIONS).forEach((source) =>
useInstanceStore().set({
value:
config[source] ?? INSTANCE_DEFAULT_CONFIG_DEFINITIONS[source].default,
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({ 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 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: '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),
+ value: Number.parseInt(uploadLimits.general),
})
useInstanceStore().set({
path: 'limits.avatarlimit',
- value: parseInt(uploadLimits.avatar),
+ value: Number.parseInt(uploadLimits.avatar),
})
useInstanceStore().set({
path: 'limits.backgroundlimit',
- value: parseInt(uploadLimits.background),
+ value: Number.parseInt(uploadLimits.background),
})
useInstanceStore().set({
path: 'limits.bannerlimit',
- value: parseInt(uploadLimits.banner),
+ value: Number.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'
+ 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,
+ value: 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', e)
}
}
const setConfig = async ({ store }) => {
// apiConfig, staticConfig
const configInfos = await Promise.all([
- getBackendProvidedConfig({ store }),
+ getBackendProvidedConfig(),
getStaticConfig(),
])
const apiConfig = configInfos[0]
const staticConfig = configInfos[1]
await setSettings({ store, apiConfig, staticConfig })
}
const checkOAuthToken = async ({ store }) => {
const oauth = useOAuthStore()
if (oauth.userToken) {
return store.dispatch('loginUser', oauth.userToken)
}
- return Promise.resolve()
+ return
}
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)
app.config.errorHandler = (error, instance, info) => {
console.error(
'Global Vue Error Handler caught an error:',
error,
instance,
info,
)
useInterfaceStore().setGlobalError({ error, instance, info })
}
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(
+ throw new TypeError(
'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(
+ throw new TypeError(
'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(
+ throw new TypeError(
`${name} store's $persistLoaded is not a Promise. The persist plugin is not applied.`,
)
}
await p
} else {
- throw new Error(
+ throw new TypeError(
`Store module ${name} does not export a 'use...' function`,
)
}
}),
)
}
+ let newStorageError
try {
await waitForAllStoresToLoad()
} catch (e) {
console.error('Cannot load stores:', e)
- storageError = e
+ newStorageError = e
}
- if (storageError) {
+ if (storageError || newStorageError) {
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
+ overrides.target !== undefined ? overrides.target : window.location.origin
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)
+ throw e
}
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))
+ ])
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 {}
}
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)
app.component('Status', Status)
app.component('RichContent', RichContent)
app.component('StillImage', StillImage)
// remove after vue 3.3
app.config.unwrapInjectedRef = true
app.mount('#app')
return app
}
export default afterStoreSetup
diff --git a/src/components/chat_list_item/chat_list_item.js b/src/components/chat_list_item/chat_list_item.js
index c95895def2..8bb2afd93f 100644
--- a/src/components/chat_list_item/chat_list_item.js
+++ b/src/components/chat_list_item/chat_list_item.js
@@ -1,71 +1,71 @@
import { mapState } from 'vuex'
import AvatarList from 'src/components/avatar_list/avatar_list.vue'
import ChatTitle from 'src/components/chat_title/chat_title.vue'
import StatusBody from 'src/components/status_content/status_content.vue'
import Timeago from 'src/components/timeago/timeago.vue'
import UserAvatar from 'src/components/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)
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 isYou = 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,
chatUserId: 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 78be870b47..aabdec31fe 100644
--- a/src/components/chat_message/chat_message.js
+++ b/src/components/chat_message/chat_message.js
@@ -1,223 +1,223 @@
import { mapState as mapPiniaState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import { mapState } from 'vuex'
import Attachment from 'src/components/attachment/attachment.vue'
import ChatMessageDate from 'src/components/chat_message_date/chat_message_date.vue'
import EmojiReactions from 'src/components/emoji_reactions/emoji_reactions.vue'
import Gallery from 'src/components/gallery/gallery.vue'
import LinkPreview from 'src/components/link-preview/link-preview.vue'
import MentionLink from 'src/components/mention_link/mention_link.vue'
import Popover from 'src/components/popover/popover.vue'
import StatusActionButtons from 'src/components/status_action_buttons/status_action_buttons.vue'
import StatusBody from 'src/components/status_body/status_body.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import StatusPopover from 'src/components/status_popover/status_popover.vue'
import Timeago from 'src/components/timeago/timeago.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserPopover from 'src/components/user_popover/user_popover.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 {
faCircleNotch,
faEllipsisH,
faReply,
faRetweet,
faStar,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(faTimes, faEllipsisH, faCircleNotch, faReply, faStar, faRetweet)
const ChatMessage = {
name: 'ChatMessage',
props: [
'edited',
'noHeading',
'previousItem',
'chatItem',
'previousItem',
'hoveredMessageChain',
'focused',
'repliedTo',
],
emits: ['hover', 'replyRequested'],
components: {
Popover,
Attachment,
StatusContent,
StatusBody,
StatusActionButtons,
UserAvatar,
Gallery,
LinkPreview,
ChatMessageDate,
EmojiReactions,
UserPopover,
StatusPopover,
MentionLink,
Quote: defineAsyncComponent(() => import('src/components/quote/quote.vue')),
Timeago,
},
computed: {
isMessage() {
return this.chatItem.type === 'message'
},
message() {
if (!this.isMessage) return null
return this.chatItem.data.retweeted_status ?? this.chatItem.data
},
isStatus() {
// ChatMessage only has account_id while Status has full user data
return !!this.message.user
},
authorId() {
return this.isStatus ? this.message.user.id : this.message.account_id
},
author() {
return this.$store.getters.findUser(this.authorId)
},
isCurrentUser() {
// mini-hack/optimizaiton:
// - current user would always be in memory so if user is missing it's obviously not us
// - if anon views page then "us" pretty much doesn't exist
if (!this.author || !this.currentUser) return false
return this.author.id === this.currentUser.id
},
// Reply stuff
isCustomReply() {
if (!this.previousItem) return false
if (!this.message.in_reply_to_status_id) return false
return this.previousItem.data.id !== this.message.in_reply_to_status_id
},
isBrokenReply() {
if (!this.previousItem) return false
return !this.message.in_reply_to_status_id
},
customReplyTo() {
return this.$store.state.statuses.allStatusesObject[
this.message.in_reply_to_status_id
]
},
replyToName() {
if (this.message.in_reply_to_screen_name) {
return this.message.in_reply_to_screen_name
} else {
const user = this.$store.getters.findUser(
this.message.in_reply_to_user_id,
)
- return user && user.screen_name_ui
+ return user?.screen_name_ui
}
},
replyProfileLink() {
if (this.isCustomReply) {
const user = this.$store.getters.findUser(
this.message.in_reply_to_user_id,
)
// FIXME Why user not found sometimes???
return user ? user.statusnet_profile_url : 'NOT_FOUND'
}
},
// Quote stuff
quoteId() {
return this.message.quote_id
},
quoteUrl() {
return this.message.quote_url
},
quoteVisible() {
return this.message.quote_visible
},
// Content
messageForStatusContent() {
return {
...this.message,
summary: '',
emojis: this.message.emojis,
raw_html: this.message.content || this.message.raw_html || '',
text: this.message.content || '',
}
},
hasAttachment() {
return this.message.attachments.length > 0
},
// Stylistic
classnames() {
return {
'-outgoing': this.isCurrentUser,
'-incoming': !this.isCurrentUser,
'-pending': this.message.pending,
'-focused': this.focused,
}
},
popoverMarginStyle() {
if (this.isCurrentUser) {
return {}
} else {
return { left: 50 }
}
},
// Global stuff
...mapPiniaState(useInterfaceStore, {
betterShadow: (store) => store.browserSupport.cssFilter,
}),
...mapState({
currentUser: (state) => state.users.currentUser,
restrictedNicknames: (state) => useInstanceStore().restrictedNicknames,
}),
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
},
data() {
return {
hovered: false,
menuOpened: false,
}
},
methods: {
onHover(bool) {
this.$emit('hover', {
isHovered: bool,
messageChainId: this.chatItem.messageChainId,
})
},
visibilityIcon(visibility) {
switch (visibility) {
case 'private':
return 'lock'
case 'unlisted':
return 'lock-open'
case 'direct':
return 'envelope'
case 'local':
return 'igloo'
default:
return 'globe'
}
},
visibilityLocalized() {
return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility)
},
async deleteMessage() {
const confirmed = window.confirm(this.$t('chats.delete_confirm'))
if (confirmed) {
await this.$emit('delete', {
messageId: this.message.id,
chatId: this.message.chat_id,
})
}
this.hovered = false
this.menuOpened = false
},
},
}
export default ChatMessage
diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue
index b772a9b20c..394cce4b76 100644
--- a/src/components/chat_message/chat_message.vue
+++ b/src/components/chat_message/chat_message.vue
@@ -1,241 +1,241 @@
<template>
<div
v-if="isMessage"
class="chat-message-wrapper"
:class="[classnames, { 'hovered-message-chain': hoveredMessageChain }]"
:id="`chatmessage-${message.id}`"
@mouseover="onHover(true)"
@mouseleave="onHover(false)"
>
<i18n-t
v-if="isStatus && (isCustomReply || isBrokenReply)"
keypath="status.reply_to_with_arg"
scope="global"
tag="small"
class="reply-to-header faint"
>
<template #replyToWithIcon>
<div class="avatar-spacer" />
<StatusPopover
v-if="!isBrokenReply"
:status-id="customReplyTo?.id"
class="reply-to-popover"
:class="{ '-strikethrough': !message.parent_visible }"
>
<i18n-t
keypath="status.reply_to_with_icon"
scope="global"
>
<template #icon>
<FAIcon
class="fa-scale-110"
icon="reply"
flip="horizontal"
/>
</template>
<template #replyTo>
<span class="reply-label">
{{ $t('status.reply_to') }}
</span>
</template>
</i18n-t>
</StatusPopover>
<span v-else class="reply-label">
{{ $t('status.broken_reply') }}
</span>
</template>
<template #user>
<MentionLink
class="reply-body"
:content="replyToName"
:url="replyProfileLink"
:user-id="message.in_reply_to_user_id"
:user-screen-name="message.in_reply_to_screen_name"
/>
<!-- v-if is there because status might not be loaded yet -->
- <template v-if="customReplyTo && customReplyTo.text.trim().length > 0">
+ <template v-if="customReplyTo?.text.trim().length > 0">
:
<StatusBody
class="reply-body faint"
:status="customReplyTo"
collapse
single-line
ignore-subject
/>
</template>
</template>
</i18n-t>
<div
class="chat-message"
:class="classnames"
>
<div
v-if="!isCurrentUser"
class="avatar-wrapper"
>
<UserPopover
v-if="chatItem.isHead"
:user-id="authorId"
>
<UserAvatar
v-if="author"
:compact="true"
:user="author"
/>
</UserPopover>
<div v-else class="avatar-spacer" />
</div>
<div class="chat-message-inner">
<div class="message-bubble-wrapper">
<div
class="status-body"
:style="{ 'min-width': message.attachment ? '80%' : '' }"
>
<div
class="media status"
:class="{ 'without-attachment': !hasAttachment, 'pending': chatItem.data.pending, 'error': chatItem.data.error }"
style="position: relative;"
@mouseenter="hovered = true"
@mouseleave="hovered = false"
>
<StatusActionButtons
v-if="isStatus"
class="chat-message-toolbar"
:class="{ '-visible': hovered || menuOpened }"
:status="message"
:pinned="new Set(['reply', 'emoji'])"
fixed-pinned
use-default-buttons
hide-labels
in-chat-view
@toggle-replying="$emit('replyRequested', message)"
/>
<div
class="chat-message-toolbar"
:class="{ '-visible': hovered || menuOpened }"
v-else
>
<Popover
trigger="click"
:trigger-attrs="{ 'class': 'button-default menu-icon simple-button', title: $t('chats.more') }"
placement="top"
:margin="popoverMarginStyle"
@show="menuOpened = true"
@close="menuOpened = false"
>
<template #content>
<div class="dropdown-menu">
<div class="menu-item dropdown-item -icon">
<button
class="main-button"
@click="deleteMessage"
>
<FAIcon icon="times" /> {{ $t("chats.delete") }}
</button>
</div>
</div>
</template>
<template #trigger>
<FAIcon icon="ellipsis-h" />
</template>
</Popover>
</div>
<StatusContent
class="message-content"
:class="{ faint: message.pending }"
:status="messageForStatusContent"
:full-content="true"
>
<template #footer>
<EmojiReactions
v-if="isStatus"
:status="message"
/>
<Quote
v-if="isStatus"
class="quoted-post"
:status-id="quoteId"
:status-url="quoteUrl"
:status-visible="quoteVisible"
initially-expanded
/>
<span
class="created-at"
>
<span
v-if="message.favorited"
>
<FAIcon
class="fa-scale-110"
icon="star"
fixed-width
/>
</span>
<span
v-if="message.repeated"
>
<FAIcon
class="fa-scale-110"
icon="retweet"
fixed-width
/>
</span>
<span
v-if="message.visibility"
class="visibility-icon"
:title="visibilityLocalized"
>
<FAIcon
class="fa-scale-110"
:icon="visibilityIcon(message.visibility)"
fixed-width
/>
</span>
<span
v-if="message.pending"
class="loading-spinner"
>
<FAIcon
class="fa-old-padding"
icon="circle-notch"
spin
/>
</span>
{{ ' ' }}
<router-link
class="timeago faint"
:to="{ name: 'conversation2', params: { statusId: message.id } }"
>
<Timeago
:time="message.created_at"
:auto-update="60"
/>
</router-link>
</span>
</template>
</StatusContent>
</div>
</div>
<div
v-if="isStatus && repliedTo"
class="reply-indicator"
>
<FAIcon class="icon" icon="reply" />
</div>
<div class="end-spacer" />
</div>
</div>
</div>
</div>
<div
v-else
class="chat-message-date-separator"
>
<ChatMessageDate :date="chatItem.date" :show-time="chatItem.isTime" />
</div>
</template>
<script src="./chat_message.js"></script>
<style src="./chat_message.scss" lang="scss" />
diff --git a/src/components/chat_title/chat_title.vue b/src/components/chat_title/chat_title.vue
index 764e0de612..905a32d851 100644
--- a/src/components/chat_title/chat_title.vue
+++ b/src/components/chat_title/chat_title.vue
@@ -1,65 +1,65 @@
<template>
<div
class="chat-title"
:title="title"
>
<UserPopover
v-if="withAvatar && user"
class="avatar-container"
:user-id="user.id"
>
<UserAvatar
class="titlebar-avatar"
:user="user"
/>
</UserPopover>
<RichContent
v-if="user"
class="username"
- :title="'@'+(user && user.screen_name_ui)"
+ :title="'@'+(user?.screen_name_ui)"
:html="htmlTitle"
:emoji="user.emoji || []"
:allow-non-square-emoji="allowNonSquareEmoji"
:pause-mfm="pauseMfm"
:scale-mfm="scaleMfm"
:is-local="user.is_local"
/>
</div>
</template>
<script src="./chat_title.js"></script>
<style lang="scss">
.chat-title {
display: flex;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
--emoji-size: 1em;
.username {
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
display: inline;
overflow: hidden;
}
.avatar-container {
align-self: center;
line-height: 1;
}
.titlebar-avatar {
margin-right: 0.5em;
height: 1.5em;
width: 1.5em;
border-radius: var(--roundness);
&.animated::before {
display: none;
}
}
}
</style>
diff --git a/src/components/color_input/color_input.vue b/src/components/color_input/color_input.vue
index 208bdf2dca..d6667c28ff 100644
--- a/src/components/color_input/color_input.vue
+++ b/src/components/color_input/color_input.vue
@@ -1,159 +1,159 @@
<template>
<div
class="color-input style-control"
:class="{ disabled: !present || disabled, '-compact': compact }"
>
<label
:for="name"
class="label"
:class="{ faint: !present || disabled }"
>
{{ label }}
</label>
<Checkbox
v-if="typeof fallback !== 'undefined' && showOptionalCheckbox && !hideOptionalCheckbox"
:model-value="present"
:disabled="disabled"
class="opt"
@update:model-value="updateValue(typeof modelValue === 'undefined' ? fallback : undefined)"
/>
<div
class="input color-input-field"
:class="{ disabled: !present || disabled, unstyled }"
>
<input
:id="name + '-t'"
class="textColor unstyled"
:class="{ disabled: !present || disabled }"
type="text"
:value="modelValue ?? fallback"
:disabled="!present || disabled"
@input="updateValue($event.target.value)"
>
<div
v-if="validColor"
class="validIndicator"
:style="{backgroundColor: modelValue || fallback}"
/>
<div
v-else-if="transparentColor"
class="transparentIndicator"
/>
<div
v-else-if="computedColor"
class="computedIndicator"
:style="{backgroundColor: fallback}"
/>
<div
v-else
class="invalidIndicator"
/>
<label class="nativeColor">
<FAIcon icon="eye-dropper" />
<input
:id="name"
class="unstyled"
type="color"
:value="modelValue || fallback"
:disabled="!present || disabled"
:class="{ disabled: !present || disabled }"
@input="updateValue($event.target.value)"
>
</label>
</div>
</div>
</template>
<script>
import { throttle } from 'lodash'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import { hex2rgb } from '../../services/color_convert/color_convert.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faEyeDropper } from '@fortawesome/free-solid-svg-icons'
library.add(faEyeDropper)
export default {
components: {
Checkbox,
},
props: {
// Name of color, used for identifying
name: {
required: true,
type: String,
},
// Readable label
label: {
required: false,
type: String,
default: '',
},
// use unstyled, uh, style
unstyled: {
required: false,
type: Boolean,
},
// Color value, should be required but vue cannot tell the difference
// between "property missing" and "property set to undefined"
modelValue: {
required: false,
type: String,
default: undefined,
},
// Color fallback to use when value is not defeind
fallback: {
required: false,
type: String,
default: undefined,
},
// Disable the control
disabled: {
required: false,
type: Boolean,
default: false,
},
// Show "optional" tickbox, for when value might become mandatory
showOptionalCheckbox: {
required: false,
type: Boolean,
default: true,
},
// Force "optional" tickbox to hide
hideOptionalCheckbox: {
required: false,
type: Boolean,
default: false,
},
compact: {
required: false,
type: Boolean,
},
},
emits: ['update:modelValue'],
computed: {
present() {
- return typeof this.modelValue !== 'undefined'
+ return this.modelValue !== undefined
},
validColor() {
return hex2rgb(this.modelValue || this.fallback)
},
transparentColor() {
return this.modelValue === 'transparent'
},
computedColor() {
return (
this.modelValue &&
(this.modelValue.startsWith('--') || this.modelValue.startsWith('$'))
)
},
},
methods: {
updateValue: throttle(function (value) {
this.$emit('update:modelValue', value)
}, 100),
},
}
</script>
<style lang="scss" src="./color_input.scss"></style>
diff --git a/src/components/desktop_nav/desktop_nav.js b/src/components/desktop_nav/desktop_nav.js
index df855500ac..fb23299d21 100644
--- a/src/components/desktop_nav/desktop_nav.js
+++ b/src/components/desktop_nav/desktop_nav.js
@@ -1,131 +1,129 @@
import SearchBar from 'components/search_bar/search_bar.vue'
import { mapActions, mapState } from 'pinia'
import { defineAsyncComponent } from '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: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
},
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',
- },
- )
+ const mask = this.enableMask
+ ? {}
+ : { 'background-color': this.enableMask ? '' : 'transparent' }
+
+ return {
+ margin: `${this.logoMargin} 0`,
+ opacity: this.searchBarHidden ? 1 : 0,
+ ...mask,
+ }
},
...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 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/desktop_nav/desktop_nav.vue b/src/components/desktop_nav/desktop_nav.vue
index 477a7634dc..3883b3c6f9 100644
--- a/src/components/desktop_nav/desktop_nav.vue
+++ b/src/components/desktop_nav/desktop_nav.vue
@@ -1,98 +1,98 @@
<template>
<nav
id="nav"
class="DesktopNav"
:class="{ '-logoLeft': logoLeft }"
@click="scrollToTop()"
>
<div class="inner-nav">
<div class="item sitename">
<router-link
v-if="!hideSitename"
class="site-name"
:to="{ name: 'root' }"
active-class="home"
>
{{ sitename }}
</router-link>
</div>
<router-link
class="logo"
:to="{ name: 'root' }"
:style="logoBgStyle"
:title="sitename"
>
<div
class="mask"
:style="logoMaskStyle"
/>
<img
:src="logo"
:style="logoStyle"
>
</router-link>
<div class="item right actions">
<SearchBar
v-if="currentUser || !privateMode"
@toggled="onSearchBarToggled"
@click.stop
/>
<template v-if="searchBarHidden">
<button
class="button-unstyled nav-icon"
:title="$t('nav.preferences')"
@click.stop="openSettingsModal('user')"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="cog"
/>
</button>
<button
- v-if="currentUser && currentUser.role === 'admin'"
+ v-if="currentUser?.role === 'admin'"
class="button-unstyled nav-icon"
target="_blank"
:title="$t('nav.administration')"
@click.stop="openSettingsModal('admin')"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="tachometer-alt"
/>
</button>
<span class="spacer" />
<button
v-if="currentUser"
class="button-unstyled nav-icon"
:title="$t('login.logout')"
@click.stop.prevent="logout"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="sign-out-alt"
/>
</button>
</template>
</div>
</div>
<teleport to="#modal">
<ConfirmModal
v-if="showingConfirmLogout"
:title="$t('login.logout_confirm_title')"
:confirm-danger="true"
:confirm-text="$t('login.logout_confirm_accept_button')"
:cancel-text="$t('login.logout_confirm_cancel_button')"
@accepted="doLogout"
@cancelled="hideConfirmLogout"
>
{{ $t('login.logout_confirm') }}
</ConfirmModal>
</teleport>
</nav>
</template>
<script src="./desktop_nav.js"></script>
<style src="./desktop_nav.scss" lang="scss"></style>
diff --git a/src/components/follow_request_card/follow_request_card.js b/src/components/follow_request_card/follow_request_card.js
index c2e10c2423..294dd24726 100644
--- a/src/components/follow_request_card/follow_request_card.js
+++ b/src/components/follow_request_card/follow_request_card.js
@@ -1,103 +1,103 @@
import { defineAsyncComponent } from 'vue'
import { notificationsFromStore } from '../../services/notification_utils/notification_utils.js'
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { approveUser, denyUser } from 'src/api/user.js'
const FollowRequestCard = {
props: ['user'],
components: {
BasicUserCard,
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
},
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
+ return 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() {
approveUser({
id: this.user.id,
credentials: useOAuthStore().token,
})
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()
denyUser({
id: this.user.id,
credentials: useOAuthStore().token,
}).then(() => {
this.$store.dispatch('dismissNotificationLocal', { id: notifId })
this.$store.dispatch('removeFollowRequest', this.user)
})
this.hideDenyConfirmDialog()
},
},
computed: {
mergedConfig() {
return useMergedConfigStore().mergedConfig
},
shouldConfirmApprove() {
return this.mergedConfig.modalOnApproveFollow
},
shouldConfirmDeny() {
return this.mergedConfig.modalOnDenyFollow
},
},
}
export default FollowRequestCard
diff --git a/src/components/opacity_input/opacity_input.vue b/src/components/opacity_input/opacity_input.vue
index 761aeb2169..e6b736fa33 100644
--- a/src/components/opacity_input/opacity_input.vue
+++ b/src/components/opacity_input/opacity_input.vue
@@ -1,49 +1,49 @@
<template>
<div
class="opacity-control style-control"
:class="{ disabled: !present || disabled }"
>
<label
:for="name"
class="label"
:class="{ faint: !present || disabled }"
>
{{ label || $t('settings.style.themes3.editor.opacity') }}
</label>
<Checkbox
v-if="typeof fallback !== 'undefined'"
:model-value="present"
:disabled="disabled"
class="opt"
@update:model-value="$emit('update:modelValue', !present ? fallback : undefined)"
/>
<input
:id="name"
class="input input-number"
type="number"
:value="modelValue || fallback"
:disabled="!present || disabled"
:class="{ disabled: !present || disabled }"
max="1"
min="0"
step=".05"
@input="$emit('update:modelValue', $event.target.value)"
>
</div>
</template>
<script>
import Checkbox from 'src/components/checkbox/checkbox.vue'
export default {
components: {
Checkbox,
},
props: ['name', 'label', 'modelValue', 'fallback', 'disabled'],
emits: ['update:modelValue'],
computed: {
present() {
- return typeof this.modelValue !== 'undefined'
+ return this.modelValue !== undefined
},
},
}
</script>
diff --git a/src/components/quote/quote_form.js b/src/components/quote/quote_form.js
index cffae123bc..cd6f9a7092 100644
--- a/src/components/quote/quote_form.js
+++ b/src/components/quote/quote_form.js
@@ -1,120 +1,120 @@
import { debounce } from 'lodash'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Quote from './quote.vue'
import { useInstanceStore } from 'src/stores/instance.js'
export default {
components: {
Quote,
Checkbox,
},
name: 'QuoteForm',
props: {
visible: {
type: Boolean,
},
url: {
type: String,
required: false,
default: '',
},
id: {
type: String,
required: false,
default: '',
},
},
data() {
return {
text: this.url,
loading: false,
error: false,
debounceSetQuote: debounce((value) => {
this.fetchStatus(value)
}, 1000),
}
},
created() {
if (this.url && !this.id) {
this.fetchStatus(this.url)
} else if (this.id) {
this.text =
window.location.protocol +
'//' +
this.instanceHost +
'/notice/' +
this.id
}
},
computed: {
instanceHost() {
return new URL(useInstanceStore().server).host
},
noticeRegex() {
return new RegExp(
`^([^/:]*:?//|)(${window.location.host}|${this.instanceHost})/notice/(.*)$`,
)
},
quoteVisible() {
return (!!this.id || this.loading) && !this.error
},
},
watch: {
text(value) {
this.debounceSetQuote(value)
this.$emit('update:url', value)
},
visible(value) {
if (value && this.url) {
this.fetchStatus(this.url)
}
},
},
methods: {
clear() {
this.text = this.url
this.loading = false
this.error = false
},
setLoading(value) {
this.loading = value
},
handleError(error) {
this.id = null
this.error = !!error
},
fetchStatus(value) {
this.error = false
const notice = this.noticeRegex.exec(value)
- if (notice && notice.length === 4) {
+ if (notice?.length === 4) {
this.$emit('update:id', notice[3])
} else if (value) {
this.loading = true
this.$store
.dispatch('search', {
q: value,
resolve: true,
offset: 0,
limit: 1,
type: 'statuses',
})
.then((data) => {
- if (data && data.statuses && data.statuses.length === 1) {
+ if (data?.statuses && data.statuses.length === 1) {
this.$emit('update:id', data.statuses[0].id)
} else {
this.handleError(true)
}
})
.catch(this.handleError)
.finally(() => {
this.loading = false
})
} else {
this.$emit('update:id', null)
}
},
},
}
diff --git a/src/components/range_input/range_input.vue b/src/components/range_input/range_input.vue
index 91d3dcc3bc..40c427784e 100644
--- a/src/components/range_input/range_input.vue
+++ b/src/components/range_input/range_input.vue
@@ -1,75 +1,75 @@
<template>
<div
class="range-control style-control"
:class="{ disabled: !present || disabled }"
>
<label
:id="name + '-label'"
:for="name"
class="label"
>
{{ label }}
</label>
<input
v-if="typeof fallback !== 'undefined'"
:id="name + '-o'"
:aria-labelledby="name + '-label'"
class="input -checkbox opt visible-for-screenreader-only"
type="checkbox"
:checked="present"
@change="$emit('update:modelValue', !present ? fallback : undefined)"
>
<label
v-if="typeof fallback !== 'undefined'"
class="opt-l"
:for="name + '-o'"
:aria-hidden="true"
/>
<input
:id="name"
class="input input-number"
type="range"
:value="modelValue || fallback"
:disabled="!present || disabled"
:max="max || hardMax || 100"
:min="min || hardMin || 0"
:step="step || 1"
@input="$emit('update:modelValue', $event.target.value)"
>
<input
:id="name + '-numeric'"
class="input input-number"
type="number"
:aria-labelledby="name + '-label'"
:value="modelValue || fallback"
:disabled="!present || disabled"
:max="hardMax"
:min="hardMin"
:step="step || 1"
@input="$emit('update:modelValue', $event.target.value)"
>
</div>
</template>
<script>
export default {
props: [
'name',
'modelValue',
'fallback',
'disabled',
'label',
'max',
'min',
'step',
'hardMin',
'hardMax',
],
emits: ['update:modelValue'],
computed: {
present() {
- return typeof this.modelValue !== 'undefined'
+ return this.modelValue !== undefined
},
},
}
</script>
diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index eb73e263cd..646df5bab8 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -1,566 +1,566 @@
import { flattenDeep, unescape as ldUnescape } from 'lodash'
import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue'
import { MENTIONS_LIMIT } from 'src/components/mentions_line/mentions_line.js'
import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import StillImage from 'src/components/still-image/still-image.vue'
import StillImageEmojiPopover from 'src/components/still-image/still-image-emoji-popover.vue'
import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js'
import {
getAttrs,
getTagName,
processTextForEmoji,
} from 'src/services/html_converter/utility.service.js'
import './rich_content.scss'
const MAYBE_LINE_BREAKING_ELEMENTS = [
'blockquote',
'br',
'hr',
'ul',
'ol',
'li',
'p',
'table',
'tbody',
'td',
'th',
'thead',
'tr',
'h1',
'h2',
'h3',
'h4',
'h5',
]
/**
* RichContent, The Über-powered component for rendering Post HTML.
*
* This takes post HTML and does multiple things to it:
* - Groups all mentions into <MentionsLine>, this affects all mentions regardles
* of where they are (beginning/middle/end), even single mentions are converted
* to a <MentionsLine> containing single <MentionLink>.
* - Replaces emoji shortcodes with <StillImage>'d images.
*
* There are two problems with this component's architecture:
* 1. Parsing HTML and rendering are inseparable. Attempts to separate the two
* proven to be a massive overcomplication due to amount of things done here.
* 2. We need to output both render and some extra data, which seems to be imp-
* possible in vue. Current solution is to emit 'parseReady' event when parsing
* is done within render() function.
*
* Apart from that one small hiccup with emit in render this _should_ be vue3-ready
*/
export default {
name: 'RichContent',
components: {
MentionsLine,
HashtagLink,
},
props: {
// Original html content
html: {
required: true,
type: String,
},
attentions: {
required: false,
default: () => [],
},
// Emoji object, as in status.emojis, note the "s" at the end...
emoji: {
required: true,
type: Array,
},
// Whether to handle links or not (posts: yes, everything else: no)
handleLinks: {
required: false,
type: Boolean,
default: false,
},
// Meme arrows
greentext: {
required: false,
type: Boolean,
default: false,
},
// Faint style (for notifs)
faint: {
required: false,
type: Boolean,
default: false,
},
// Collapse newlines
collapse: {
required: false,
type: Boolean,
default: false,
},
/* Content comes from current instance
*
* This is used for emoji stealing popover.
* By default we assume it is, so that steal
* emoji button isn't shown where it probably
* should not be.
*/
isLocal: {
required: false,
type: Boolean,
default: true,
},
// Allow wide emoji (max 3:1 ratio)
allowNonSquareEmoji: {
required: false,
type: Boolean,
default: false,
},
pauseMfm: {
required: false,
type: Boolean,
default: false,
},
scaleMfm: {
required: false,
type: Boolean,
default: false,
},
},
// NEVER EVER TOUCH DATA INSIDE RENDER
render() {
// Pre-process HTML
const { newHtml: html } = preProcessPerLine(this.html, this.greentext)
let currentMentions = null // Current chain of mentions, we group all mentions together
// This is used to recover spacing removed when parsing mentions
let lastSpacing = ''
const lastTags = [] // Tags that appear at the end of post body
const writtenMentions = [] // All mentions that appear in post body
const invisibleMentions = [] // All mentions that go beyond the limiter (see MentionsLine)
// to collapse too many mentions in a row
const writtenTags = [] // All tags that appear in post body
// unique index for vue "tag" property
let mentionIndex = 0
let tagsIndex = 0
const renderImage = (tag) => {
return <StillImage {...getAttrs(tag)} class="img" />
}
const renderHashtag = (attrs, children, encounteredTextReverse) => {
const { index, ...linkData } = getLinkData(attrs, children, tagsIndex++)
writtenTags.push(linkData)
if (!encounteredTextReverse) {
lastTags.push(linkData)
}
const { url, tag, content } = linkData
return <HashtagLink url={url} tag={tag} content={content} />
}
const renderMention = (attrs, children) => {
const linkData = getLinkData(attrs, children, mentionIndex++)
linkData.notifying = this.attentions.some(
(a) => a.statusnet_profile_url === linkData.url,
)
writtenMentions.push(linkData)
if (currentMentions === null) {
currentMentions = []
}
currentMentions.push(linkData)
if (currentMentions.length > MENTIONS_LIMIT) {
invisibleMentions.push(linkData)
}
if (currentMentions.length === 1) {
return <MentionsLine mentions={currentMentions} />
} else {
return ''
}
}
// Processor to use with html_tree_converter
const processItem = (item, index, array, what) => {
// Handle text nodes - just add emoji
if (typeof item === 'string') {
const emptyText = item.trim() === ''
if (item.includes('\n')) {
currentMentions = null
}
if (emptyText) {
// don't include spaces when processing mentions - we'll include them
// in MentionsLine
lastSpacing = item
// Don't remove last space in a container (fixes poast mentions)
return index !== array.length - 1 && currentMentions !== null
? item.trim()
: item
}
currentMentions = null
if (item.includes(':')) {
item = [
'',
processTextForEmoji(item, this.emoji, ({ shortcode, url }) => {
return (
<StillImageEmojiPopover
class="emoji img"
src={url}
title={`:${shortcode}:`}
alt={`:${shortcode}:`}
shortcode={shortcode}
isLocal={this.isLocal}
/>
)
}),
]
}
return item
}
// Handle tag nodes
if (Array.isArray(item)) {
const [opener, children, closer] = item
let Tag = getTagName(opener)
if (Tag.toLowerCase() === 'script') Tag = 'js-exploit'
if (Tag.toLowerCase() === 'style') Tag = 'css-exploit'
const fullAttrs = getAttrs(opener, () => true)
const attrs = getAttrs(opener)
const previouslyMentions = currentMentions !== null
/* During grouping of mentions we trim all the empty text elements
* This padding is added to recover last space removed in case
* we have a tag right next to mentions
*/
const mentionsLinePadding =
// Padding is only needed if we just finished parsing mentions
previouslyMentions &&
// Don't add padding if content is string and has padding already
!(
children &&
typeof children[0] === 'string' &&
children[0].match(/^\s/)
)
? lastSpacing
: ''
if (MAYBE_LINE_BREAKING_ELEMENTS.includes(Tag)) {
// all the elements that can cause a line change
currentMentions = null
} else if (Tag === 'img') {
// replace images with StillImage
return ['', [mentionsLinePadding, renderImage(opener)], '']
} else if (Tag === 'a' && this.handleLinks) {
// replace mentions with MentionLink
if (fullAttrs.class && fullAttrs.class.includes('mention')) {
// Handling mentions here
return renderMention(attrs, children)
} else {
currentMentions = null
}
} else if (Tag === 'span') {
if (
this.handleLinks &&
fullAttrs.class &&
fullAttrs.class.includes('h-card')
) {
return ['', children.map(processItem), '']
}
}
if (children !== undefined) {
return [
'',
[mentionsLinePadding, [opener, children.map(processItem), closer]],
'',
]
} else {
return ['', [mentionsLinePadding, item], '']
}
}
}
// Processor for back direction (for finding "last" stuff, just easier this way)
let encounteredTextReverse = false
const processItemReverse = (item, index, array, what) => {
// Handle text nodes - just add emoji
if (typeof item === 'string') {
const emptyText = item.trim() === ''
if (emptyText) return item
if (!encounteredTextReverse) encounteredTextReverse = true
return ldUnescape(item)
} else if (Array.isArray(item)) {
// Handle tag nodes
const [opener, children] = item
const Tag = opener === '' ? '' : getTagName(opener)
switch (Tag) {
case 'a': {
// replace mentions with MentionLink
if (!this.handleLinks) break
const fullAttrs = getAttrs(opener, () => true)
const attrs = getAttrs(opener, () => true)
// should only be this
if (
(fullAttrs.class && fullAttrs.class.includes('hashtag')) || // Pleroma style
fullAttrs.rel === 'tag' // Mastodon style
) {
return renderHashtag(attrs, children, encounteredTextReverse)
} else {
attrs.target = '_blank'
const newChildren = [...children]
.reverse()
.map(processItemReverse)
.reverse()
return <a {...attrs}>{newChildren}</a>
}
}
case '':
return [...children].reverse().map(processItemReverse).reverse()
}
// Render tag as is
if (children !== undefined) {
const newChildren = Array.isArray(children)
? [...children].reverse().map(processItemReverse).reverse()
: children
const attrs = getAttrs(opener)
const newAttrs = { ...attrs }
const fullAttrs = getAttrs(opener, () => true)
const classname = fullAttrs['class']
const isMFM = classname?.startsWith('mfm-')
if (isMFM) {
const mfmOperator = /^mfm-(\w+)$/.exec(classname)?.[1]
newAttrs['class'] = [
'mfm',
this.pauseMfm ? '-pause' : '',
this.scaleMfm ? '-scale' : '',
]
- .filter((x) => x)
+ .filter(Boolean)
.join(' ')
newAttrs['data-mfm-operator'] = mfmOperator
switch (mfmOperator) {
case 'position': {
const x = Number.parseFloat(fullAttrs['data-mfm-x']) || 0
const y = Number.parseFloat(fullAttrs['data-mfm-y']) || 0
newAttrs.style = [
'transform:',
`translate(calc(${x} * (var(--emoji-size) / 2)), `,
`calc(${y} * (var(--emoji-size) / 2)))`,
].join(' ')
break
}
case 'scale': {
const x = Number.parseFloat(fullAttrs['data-mfm-x']) || 1
const y = Number.parseFloat(fullAttrs['data-mfm-y']) || 1
newAttrs.style = ['transform:', `scale(${x}, ${y})`].join(' ')
break
}
case 'rotate': {
const deg = Number.parseFloat(fullAttrs['data-mfm-deg']) || 0
newAttrs.style = [
`transform: rotate(${deg}deg)`,
'transform-origin: center',
].join(';')
break
}
case 'bg': {
const color = fullAttrs['data-mfm-color'] || 0
newAttrs.style = [`background-color: #${color}`].join(' ')
break
}
case 'fg': {
const color = fullAttrs['data-mfm-color'] || 0
newAttrs.style = [`color: #${color}`].join(';')
break
}
case 'spin': {
const speed = fullAttrs['data-mfm-speed'] || '1s'
const delay = fullAttrs['data-mfm-delay'] || 0
const left = fullAttrs['data-mfm-left'] != null
const alternate = fullAttrs['data-mfm-alternate'] != null
const y = fullAttrs['data-mfm-y'] != null
const x = fullAttrs['data-mfm-x'] != null
const anim = [
x ? 'mfm-spinX' : null,
y ? 'mfm-spinY' : null,
'mfm-spin',
].filter((a) => a)[0]
const direction = [
alternate ? 'alternate' : null,
left ? 'reverse' : null,
'normal',
].filter((a) => a)[0]
newAttrs.style = [
`animation-name: ${anim}`,
`animation-duration: ${speed}`,
'animation-iteration-count: infinite',
`animation-delay: ${delay}`,
`animation-direction: ${direction}`,
'animation-fill-mode: none',
'animation-timing-function: linear',
].join(';')
break
}
case 'flip': {
newAttrs.style = 'transform: scaleX(-1)'
break
}
case 'border': {
const width = fullAttrs['data-mfm-width'] || '0'
const style = fullAttrs['data-mfm-style'] || 'solid'
const color = fullAttrs['data-mfm-color'] || 'transparent'
const radius = fullAttrs['data-mfm-radius'] || '0'
const noclip = fullAttrs['data-mfm-noclip'] || false
newAttrs.style = [
`border: ${width} ${style} ${color}`,
`border-radius: ${radius}`,
`overflow: ${noclip ? 'visible' : 'clip'}`,
].join(';')
break
}
case 'tada':
case 'jelly':
case 'twitch':
case 'shake':
case 'jump':
case 'bounce':
case 'rainbow': {
const speed = fullAttrs['data-mfm-speed'] || '1s'
const delay = fullAttrs['data-mfm-delay'] || 0
const rules = [
`animation-name: mfm-${mfmOperator}`,
`animation-duration: ${speed}`,
'animation-iteration-count: infinite',
`animation-delay: ${delay}`,
'animation-direction: normal',
'animation-fill-mode: none',
'animation-timing-function: linear',
].join(';')
newAttrs.style = rules
break
}
case 'sparkle':
case 'x2':
case 'x3':
case 'x4':
// handled by css
break
default:
console.warn('Unsupported MFM operator:', mfmOperator, opener)
break
}
}
return <Tag {...newAttrs}>{newChildren}</Tag>
} else {
return <Tag />
}
}
return item
}
const pass1 = convertHtmlToTree(html).map(processItem)
const pass2 = [...pass1].reverse().map(processItemReverse).reverse()
// DO NOT USE SLOTS they cause a re-render feedback loop here.
// slots updated -> rerender -> emit -> update up the tree -> rerender -> ...
// at least until vue3?
const result = (
<span
class={[
'RichContent',
this.faint ? '-faint' : '',
this.allowNonSquareEmoji ? '-allow-non-square-emoji' : '',
]}
>
{this.collapse
? pass2.map((x) => {
if (typeof x === 'string') return x.replace(/\n/g, ' ')
if (!Array.isArray(x)) return x
return x.map((y) => (y.type === 'br' ? ' ' : y))
})
: pass2}
</span>
)
const event = {
lastTags,
writtenMentions,
writtenTags,
invisibleMentions,
}
// DO NOT MOVE TO UPDATE. BAD IDEA.
this.$emit('parseReady', event)
return result
},
}
const getLinkData = (attrs, children, index) => {
const stripTags = (item) => {
if (typeof item === 'string') {
return item
} else {
return item[1].map(stripTags).join('')
}
}
const textContent = children.map(stripTags).join('')
return {
index,
url: attrs.href,
tag: attrs['data-tag'],
content: flattenDeep(children).join(''),
textContent,
}
}
/** Pre-processing HTML
*
* Currently this does one thing:
* - add green/cyantexting
*
* @param {String} html - raw HTML to process
* @param {Boolean} greentext - whether to enable greentexting or not
*/
export const preProcessPerLine = (html, greentext) => {
const greentextHandle = new Set(['p', 'div'])
const lines = convertHtmlToLines(html)
const newHtml = lines
.reverse()
.map((item, index, array) => {
if (!item.text) return item
const string = item.text
// Greentext stuff
if (
// Only if greentext is engaged
greentext &&
// Only handle p's and divs. Don't want to affect blockquotes, code etc
item.level.every((l) => greentextHandle.has(l)) &&
// Only if line begins with '>' or '<'
(string.includes('&gt;') || string.includes('&lt;'))
) {
const cleanedString = string
.replace(/<[^>]+?>/gi, '') // remove all tags
.replace(/@\w+/gi, '') // remove mentions (even failed ones)
.trim()
if (cleanedString.startsWith('&gt;')) {
return `<span class='greentext'>${string}</span>`
} else if (cleanedString.startsWith('&lt;')) {
return `<span class='cyantext'>${string}</span>`
}
}
return string
})
.reverse()
.join('')
return { newHtml }
}
diff --git a/src/components/roundness_input/roundness_input.vue b/src/components/roundness_input/roundness_input.vue
index caf21763b2..36fa8fd474 100644
--- a/src/components/roundness_input/roundness_input.vue
+++ b/src/components/roundness_input/roundness_input.vue
@@ -1,49 +1,49 @@
<template>
<div
class="roundness-control style-control"
:class="{ disabled: !present || disabled }"
>
<label
:for="name"
class="label"
:class="{ faint: !present || disabled }"
>
{{ label }}
</label>
<Checkbox
v-if="typeof fallback !== 'undefined'"
:model-value="present"
:disabled="disabled"
class="opt"
@update:model-value="$emit('update:modelValue', !present ? fallback : undefined)"
/>
<input
:id="name"
class="input input-number"
type="number"
:value="modelValue || fallback"
:disabled="!present || disabled"
:class="{ disabled: !present || disabled }"
max="999"
min="0"
step="1"
@input="$emit('update:modelValue', $event.target.value)"
>
</div>
</template>
<script>
import Checkbox from 'src/components/checkbox/checkbox.vue'
export default {
components: {
Checkbox,
},
props: ['name', 'label', 'modelValue', 'fallback', 'disabled'],
emits: ['update:modelValue'],
computed: {
present() {
- return typeof this.modelValue !== 'undefined'
+ return this.modelValue !== undefined
},
},
}
</script>
diff --git a/src/components/settings_modal/admin_tabs/emoji_tab.vue b/src/components/settings_modal/admin_tabs/emoji_tab.vue
index b118e0c52f..98b5012662 100644
--- a/src/components/settings_modal/admin_tabs/emoji_tab.vue
+++ b/src/components/settings_modal/admin_tabs/emoji_tab.vue
@@ -1,443 +1,443 @@
<template>
<div
class="EmojiTab"
:label="$t('admin_dash.tabs.emoji')"
>
<div class="setting-section">
<h3 class="toolbar">
<span class="header-text">
{{ $t('admin_dash.emoji.emoji_packs') }}
</span>
<span class="header-buttons btn-group">
<button
class="button button-default"
type="button"
:title="$t('admin_dash.emoji.reload')"
@click="reloadEmoji"
>
<FAIcon icon="arrows-rotate" />
{{ $t('admin_dash.emoji.reload_short') }}
</button>
<Popover
popover-class="emoji-tab-edit-popover popover-default"
trigger="click"
placement="bottom"
>
<template #trigger>
<button
class="button button-default"
type="button"
:title="$t('admin_dash.emoji.remote_packs')"
>
<FAIcon icon="download" />
{{ $t('admin_dash.emoji.remote_packs_short') }}
</button>
</template>
<template #content>
<div class="emoji-tab-popover-input">
<h3>{{ $t('admin_dash.emoji.remote_pack_instance') }}</h3>
<input
v-model="remotePackInstance"
class="input"
:placeholder="$t('admin_dash.emoji.remote_pack_instance')"
>
<button
class="button button-default emoji-tab-popover-button"
type="button"
@click="listRemotePacks"
>
{{ $t('admin_dash.emoji.do_list') }}
</button>
</div>
</template>
</Popover>
<Popover
ref="additionalRemotePopover"
popover-class="emoji-tab-edit-popover popover-default"
class="button button-default emoji-panel-additional-actions"
:title="$t('admin_dash.emoji.import_pack')"
trigger="click"
placement="bottom"
>
<template #trigger>
<FAIcon icon="folder-open" />
{{ $t('admin_dash.emoji.import_pack_short') }}
</template>
<template #content>
<div class="emoji-tab-popover-input">
<h3>{{ $t('admin_dash.emoji.new_pack_name') }}</h3>
<input
v-model="newPackName"
:placeholder="$t('admin_dash.emoji.new_pack_name')"
class="input"
>
<h3>Import pack from URL</h3>
<input
v-model="remotePackURL"
class="input"
placeholder="Pack .zip URL"
>
<button
class="button button-default btn emoji-tab-popover-button"
type="button"
:disabled="newPackName.trim() === '' || remotePackURL.trim() === ''"
@click="downloadRemoteURLPack"
>
Import
</button>
<h3>Import pack from a file</h3>
<input
type="file"
accept="application/zip"
class="emoji-tab-popover-file input"
@change="remotePackFile = $event.target.files"
>
<button
class="button button-default btn emoji-tab-popover-button"
type="button"
:disabled="newPackName.trim() === '' || remotePackFile === null || remotePackFile.length === 0"
@click="downloadRemoteFilePack"
>
Import
</button>
</div>
</template>
</Popover>
</span>
</h3>
<div class="setting-section">
<h4 class="toolbar">
{{ $t('admin_dash.emoji.edit_pack') }}
</h4>
<div class="setting-item selector-buttons">
<button
:disabled="!pack || pack.remote !== undefined"
class="button button-default btn"
type="button"
@click="deleteModalVisible = true"
>
{{ $t('admin_dash.emoji.delete_pack') }}
<ConfirmModal
v-if="deleteModalVisible"
:title="$t('admin_dash.emoji.delete_title')"
:cancel-text="$t('status.delete_confirm_cancel_button')"
:confirm-text="$t('status.delete_confirm_accept_button')"
@cancelled="deleteModalVisible = false"
@accepted="deleteEmojiPack"
>
{{ $t('admin_dash.emoji.delete_confirm', [packName]) }}
</ConfirmModal>
</button>
<button
:disabled="!pack || pack.remote === undefined"
class="button button-default btn"
type="button"
@click="$refs.downloadPackPopover.showPopover"
>
{{ $t('admin_dash.emoji.download_pack') }}
<Popover
ref="downloadPackPopover"
trigger="click"
placement="bottom"
bound-to-selector=".emoji-tab"
popover-class="emoji-tab-edit-popover popover-default"
:bound-to="{ x: 'container' }"
:offset="{ y: 5 }"
>
<template #content>
<h3>{{ $t('admin_dash.emoji.downloading_pack', [packName]) }}</h3>
<div>
<div>
<div class="emoji-tab-popover-input">
<label>
{{ $t('admin_dash.emoji.download_as_name') }}
<input
v-model="remotePackDownloadAs"
class="emoji-data-input input"
:placeholder="$t('admin_dash.emoji.download_as_name_full')"
>
</label>
<div
v-if="downloadWillReplaceLocal"
class="warning"
>
<em>{{ $t('admin_dash.emoji.replace_warning') }}</em>
</div>
</div>
<button
class="button button-default btn"
type="button"
@click="downloadRemotePack"
>
{{ $t('admin_dash.emoji.download') }}
</button>
</div>
</div>
</template>
</Popover>
</button>
<span class="btn-group">
<Select
v-model="packName"
class="form-control"
>
<option
value=""
disabled
hidden
>
{{ $t('admin_dash.emoji.emoji_pack') }}
</option>
<option
v-for="(pack, listPackName) in knownPacks"
:key="listPackName"
:label="listPackName"
>
{{ listPackName }}
</option>
</Select>
<Popover
ref="createPackPopover"
popover-class="emoji-tab-edit-popover popover-default"
trigger="click"
placement="bottom"
>
<template #trigger>
<button
class="button button-default btn emoji-tab-popover-button"
type="button"
>
{{ $t('admin_dash.emoji.create_pack') }}
</button>
</template>
<template #content>
<div class="emoji-tab-popover-input">
<h3>{{ $t('admin_dash.emoji.new_pack_name') }}</h3>
<input
v-model="newPackName"
:placeholder="$t('admin_dash.emoji.new_pack_name')"
class="input"
>
<button
class="button button-default btn emoji-tab-popover-button"
type="button"
@click="createEmojiPack"
>
{{ $t('admin_dash.emoji.create') }}
</button>
</div>
</template>
</Popover>
</span>
</div>
<h5>
{{ $t('admin_dash.emoji.metadata') }}
<ModifiedIndicator
:changed="$refs.emojiPopovers && $refs.emojiPopovers.some(p => p.isEdited)"
message-key="admin_dash.emoji.emoji_changed"
/>
</h5>
<ul class="setting-list">
<li>
<label
class="setting-item"
:class="{ ['-disabled']: !pack || pack.remote !== undefined }"
>
<span class="setting-label">
<ModifiedIndicator
:changed="metaEdited('description')"
message-key="admin_dash.emoji.metadata_changed"
/>
{{ $t('admin_dash.emoji.description') }}
</span>
<textarea
v-model="packMeta.description"
:disabled="!pack || pack.remote !== undefined"
height="4"
class="bio resize-height input textarea setting-control"
/>
</label>
</li>
<li>
<label
class="setting-item"
:class="{ ['-disabled']: !pack || pack.remote !== undefined }"
>
<span class="setting-label">
<ModifiedIndicator
:changed="metaEdited('homepage')"
message-key="admin_dash.emoji.metadata_changed"
/>
{{ $t('admin_dash.emoji.homepage') }}
</span>
<input
v-model="packMeta.homepage"
class="emoji-info-input input setting-control"
:disabled="!pack || pack.remote !== undefined"
>
</label>
</li>
<li>
<label
class="setting-item"
:class="{ ['-disabled']: !pack || pack.remote !== undefined }"
>
<span class="setting-label">
<ModifiedIndicator
:changed="metaEdited('fallback-src')"
message-key="admin_dash.emoji.metadata_changed"
/>
{{ $t('admin_dash.emoji.fallback_src') }}
</span>
<input
v-model="packMeta['fallback-src']"
class="emoji-info-input input setting-control"
:disabled="!pack || pack.remote !== undefined"
>
</label>
</li>
<li>
<label
class="setting-item"
:class="{ ['-disabled']: !pack || pack.remote !== undefined }"
>
<span class="setting-label">
{{ $t('admin_dash.emoji.fallback_sha256') }}
</span>
<input
v-model="packMeta['fallback-src-sha256']"
:disabled="!pack || pack.remote !== undefined"
class="emoji-info-input input setting-control"
>
</label>
</li>
<li>
<div class="setting-item">
<Checkbox
v-model="packMeta['share-files']"
:disabled="!pack || pack.remote !== undefined"
class="setting-label setting-control"
>
<ModifiedIndicator
:changed="metaEdited('share-files')"
message-key="admin_dash.emoji.metadata_changed"
/>
{{ $t('admin_dash.emoji.share') }}
</Checkbox>
</div>
</li>
<li>
<div class="meta-buttons">
<button
v-if="pack && pack.remote === undefined"
class="button button-default btn"
type="button"
@click="savePackMetadata"
>
{{ $t('admin_dash.emoji.save_meta') }}
</button>
<button
v-if="pack && pack.remote === undefined"
class="button button-default btn"
type="button"
@click="savePackMetadata"
>
{{ $t('admin_dash.emoji.revert_meta') }}
</button>
</div>
</li>
</ul>
<h5>
{{ $t('admin_dash.emoji.files') }}
<ModifiedIndicator
:changed="$refs.emojiPopovers && $refs.emojiPopovers.some(p => p.isEdited)"
message-key="admin_dash.emoji.emoji_changed"
/>
</h5>
<div
class="emoji-list setting-list"
>
<EmojiEditingPopover
- v-if="pack && pack.remote === undefined"
+ v-if="pack?.remote === undefined"
class="emoji-item"
placement="bottom"
new-upload
:title="$t('admin_dash.emoji.adding_new')"
:pack-name="packName"
@update-pack-files="updatePackFiles"
@display-error="displayError"
>
<template #trigger>
<FAIcon
icon="plus"
size="2x"
:title="$t('admin_dash.emoji.add_file')"
/>
</template>
</EmojiEditingPopover>
<template v-if="!pack">
<div
v-for="(_, i) in new Array(20)"
:key="i"
class="placeholder"
/>
</template>
<EmojiEditingPopover
v-for="(file, shortcode) in (pack?.files || [])"
ref="emojiPopovers"
:key="shortcode"
placement="top"
:title="$t(`admin_dash.emoji.${pack?.remote === undefined ? 'editing' : 'copying'}`, [shortcode])"
:shortcode="shortcode"
:file="file"
:pack-name="packName"
:remote="pack?.remote"
:known-local-packs="knownLocalPacks"
@update-pack-files="updatePackFiles"
@display-error="displayError"
>
<template #trigger>
<StillImage
class="emoji"
:src="emojiAddr(file)"
:title="`:${shortcode}:`"
:alt="`:${shortcode}:`"
/>
</template>
</EmojiEditingPopover>
</div>
</div>
<h3>{{ $t('admin_dash.emoji.advanced') }}</h3>
<button
class="button button-default btn"
type="button"
@click="importFromFS"
>
<FAIcon icon="server" />
{{ $t('admin_dash.emoji.importFS') }}
</button>
</div>
</div>
</template>
<script src="./emoji_tab.js"></script>
<style lang="scss" src="./emoji_tab.scss"></style>
diff --git a/src/components/settings_modal/helpers/number_setting.js b/src/components/settings_modal/helpers/number_setting.js
index e037ece28e..d0b23c919d 100644
--- a/src/components/settings_modal/helpers/number_setting.js
+++ b/src/components/settings_modal/helpers/number_setting.js
@@ -1,39 +1,39 @@
import Setting from './setting.js'
export default {
...Setting,
props: {
...Setting.props,
min: {
type: Number,
required: false,
default: 1,
},
max: {
type: Number,
required: false,
default: 1,
},
step: {
type: Number,
required: false,
default: 1,
},
truncate: {
type: Number,
required: false,
default: 1,
},
},
methods: {
...Setting.methods,
getValue(e) {
if (!this.truncate === 1) {
- return parseInt(e.target.value)
+ return Number.parseInt(e.target.value)
} else if (this.truncate > 1) {
return Math.trunc(e.target.value / this.truncate) * this.truncate
}
return parseFloat(e.target.value)
},
},
}
diff --git a/src/components/settings_modal/helpers/setting.js b/src/components/settings_modal/helpers/setting.js
index 3b76708fed..c2f5279f6c 100644
--- a/src/components/settings_modal/helpers/setting.js
+++ b/src/components/settings_modal/helpers/setting.js
@@ -1,420 +1,420 @@
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 { useAdminSettingsStore } from 'src/stores/admin_settings.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,
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(useAdminSettingsStore().draft, this.canonPath)
} else {
return this.localDraft
}
},
set(value) {
if (this.realSource === 'admin' || this.path == null) {
useAdminSettingsStore().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'
+ return this.draftMode === undefined
? this.defaultDraftMode
: this.draftMode
},
backendDescription() {
return get(useAdminSettingsStore().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(useAdminSettingsStore().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 useAdminSettingsStore().config
default:
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) =>
useAdminSettingsStore().pushAdminSetting({ path: k, value: v })
default:
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(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' &&
useAdminSettingsStore().modifiedPaths?.has(this.canonPath.join(' -> '))
)
},
matchesExpertLevel() {
const settingExpertLevel = this.expert || 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(
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/vertical_tab_switcher.jsx b/src/components/settings_modal/helpers/vertical_tab_switcher.jsx
index 61ed6bee11..996939c00b 100644
--- a/src/components/settings_modal/helpers/vertical_tab_switcher.jsx
+++ b/src/components/settings_modal/helpers/vertical_tab_switcher.jsx
@@ -1,214 +1,214 @@
// eslint-disable-next-line no-unused
import { mapState as mapPiniaState } from 'pinia'
import { Fragment } from 'vue'
import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome'
import './vertical_tab_switcher.scss'
import { useInterfaceStore } from 'src/stores/interface.js'
const findFirstUsable = (slots) => slots.findIndex((_) => _.props)
export default {
name: 'VerticalTabSwitcher',
props: {
renderOnlyFocused: {
required: false,
type: Boolean,
default: false,
},
onSwitch: {
required: false,
type: Function,
default: undefined,
},
activeTab: {
required: false,
type: String,
default: undefined,
},
bodyScrollLock: {
required: false,
type: Boolean,
default: false,
},
},
data() {
return {
active: findFirstUsable(this.slots()),
resizeHandler: null,
navSide: 'tabs',
}
},
computed: {
activeIndex() {
// In case of controlled component
if (this.activeTab) {
return this.slots().findIndex(
- (slot) => slot && slot.props && this.activeTab === slot.props.key,
+ (slot) => slot?.props && this.activeTab === slot.props.key,
)
} else {
return this.active
}
},
isActive() {
return (tabName) => {
const isWanted = (slot) =>
slot.props && slot.props['data-tab-name'] === tabName
return this.$slots.default().findIndex(isWanted) === this.activeIndex
}
},
...mapPiniaState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile',
}),
},
beforeUpdate() {
const currentSlot = this.slots()[this.active]
if (!currentSlot.props) {
this.active = findFirstUsable(this.slots())
}
},
methods: {
clickTab(index) {
return (e) => {
e.preventDefault()
this.setTab(index)
}
},
setTab(index) {
if (typeof this.onSwitch === 'function') {
this.onSwitch.call(null, this.slots()[index].key)
}
this.active = index
this.changeNavSide('content')
},
changeNavSide(side) {
if (this.navSide !== side) {
this.navSide = side
}
},
// DO NOT put it to computed, it doesn't work (caching?)
slots() {
if (this.$slots.default()[0].type === Fragment) {
return this.$slots.default()[0].children
}
return this.$slots.default()
},
},
render() {
const tabs = this.slots().map((slot, index) => {
const props = slot.props
if (!props) return
const classesTab = ['vertical-tab', 'menu-item']
if (
this.activeIndex === index &&
useInterfaceStore().layoutType !== 'mobile'
) {
classesTab.push('-active')
}
return (
<button
disabled={props.disabled}
onClick={this.clickTab(index)}
class={classesTab.join(' ')}
type="button"
role="tab"
title={props.label}
>
{!props.icon ? (
''
) : (
<FAIcon class="tab-icon" size="1x" fixed-width icon={props.icon} />
)}
<span class="text">{props.label}</span>
</button>
)
})
const contents = this.slots().map((slot, index) => {
const props = slot.props
if (!props) return
const active = this.activeIndex === index
let delayRender = slot.props['delay-render']
if (delayRender && active) {
slot.props['delay-render'] = false
delayRender = false
}
const renderSlot =
!delayRender && (!this.renderOnlyFocused || active) ? slot : ''
const headerClasses = ['tab-content-label']
const header = (
<h2 class={headerClasses}>
<button
type="button"
onClick={() => this.changeNavSide('tabs')}
title={this.$t('nav.back')}
class="button-unstyled"
>
<FAIcon size="lg" class="back-button-icon" icon="chevron-left" />
</button>
{props.label}
</h2>
)
const wrapperClasses = [
'tab-content-wrapper',
active ? '-active' : '-hidden',
]
const slotWrapperClasses = [
'tab-slot-wrapper',
active ? '-active' : '-hidden',
]
const contentClasses = ['tab-content']
if (props['full-width'] || props['full-width'] != null) {
contentClasses.push('-full-width')
wrapperClasses.push('-full-width')
slotWrapperClasses.push('-full-width')
}
if (props['full-height'] || props['full-width'] != null) {
contentClasses.push('-full-height')
wrapperClasses.push('-full-height')
slotWrapperClasses.push('-full-height')
}
return (
<div class={wrapperClasses}>
<div class="tab-mobile-header">{header}</div>
<div class={slotWrapperClasses}>
<div class={contentClasses}>{renderSlot}</div>
</div>
</div>
)
})
const rootClasses = ['vertical-tab-switcher']
if (useInterfaceStore().layoutType === 'mobile') {
rootClasses.push('-mobile')
}
if (this.navSide === 'tabs') {
rootClasses.push('-nav-tabs')
} else {
rootClasses.push('-nav-contents')
}
return (
<div ref="root" class={rootClasses.join(' ')}>
<div class="tabs" role="tablist" ref="nav">
{tabs}
</div>
<div
role="tabpanel"
class="contents"
v-body-scroll-lock={this.bodyScrollLock}
ref="contents"
>
{contents}
</div>
</div>
)
},
}
diff --git a/src/components/settings_modal/tabs/appearance_tab.js b/src/components/settings_modal/tabs/appearance_tab.js
index c11579e53b..518345d9ea 100644
--- a/src/components/settings_modal/tabs/appearance_tab.js
+++ b/src/components/settings_modal/tabs/appearance_tab.js
@@ -1,509 +1,509 @@
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 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 { useOAuthStore } from 'src/stores/oauth.js'
import { updateProfileImages } from 'src/api/user.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,
backgroundError: null,
backgroundPreview: null,
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
FloatSetting,
UnitSetting,
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
+ if (!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),
+ inputRuleset: [...input, paletteRule].filter(Boolean),
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
},
clearBackgroundError() {
this.backgroundError = null
},
submitBackground(background) {
if (!this.backgroundPreview && background !== '') {
return
}
this.backgroundUploading = true
updateProfileImages({
background,
credentials: useOAuthStore().token,
})
.then(({ data }) => {
this.$store.commit('addNewUsers', [data])
this.$store.commit('setCurrentUser', data)
this.backgroundPreview = null
this.backgroundError = null
})
.catch((e) => {
this.backgroundError = e
})
.finally(() => {
this.backgroundUploading = false
})
},
},
}
export default AppearanceTab
diff --git a/src/components/settings_modal/tabs/data_import_export_tab.js b/src/components/settings_modal/tabs/data_import_export_tab.js
index 4554f4a1f1..7fc6adefd4 100644
--- a/src/components/settings_modal/tabs/data_import_export_tab.js
+++ b/src/components/settings_modal/tabs/data_import_export_tab.js
@@ -1,135 +1,135 @@
import { mapState } from 'vuex'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Exporter from 'src/components/exporter/exporter.vue'
import Importer from 'src/components/importer/importer.vue'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useOAuthTokensStore } from 'src/stores/oauth_tokens.js'
import {
addBackup,
exportFriends,
fetchBlocks,
fetchMutes,
importBlocks,
importFollows,
importMutes,
listBackups,
} from 'src/api/user.js'
const DataImportExportTab = {
data() {
return {
activeTab: 'profile',
newDomainToMute: '',
listBackupsError: false,
addBackupError: false,
addedBackup: false,
backups: [],
}
},
created() {
useOAuthTokensStore().fetchTokens()
this.fetchBackups()
},
components: {
Importer,
Exporter,
Checkbox,
},
computed: {
...mapState({
user: (state) => state.users.currentUser,
}),
},
methods: {
getFollowsContent() {
return exportFriends({
id: this.user.id,
credentials: useOAuthStore().token,
}).then(this.generateExportableUsersContent)
},
getBlocksContent() {
return fetchBlocks({
credentials: useOAuthStore().token,
}).then(this.generateExportableUsersContent)
},
getMutesContent() {
return fetchMutes({
credentials: useOAuthStore().token,
}).then(this.generateExportableUsersContent)
},
importFollows(file) {
return importFollows({
file,
credentials: useOAuthStore().token,
}).then(({ data: status }) => {
if (!status) {
throw new Error('failed')
}
})
},
importBlocks(file) {
return importBlocks({
file,
credentials: useOAuthStore().token,
}).then(({ data: status }) => {
if (!status) {
throw new Error('failed')
}
})
},
importMutes(file) {
return importMutes({
file,
credentials: useOAuthStore().token,
}).then(({ data: status }) => {
if (!status) {
throw new Error('failed')
}
})
},
generateExportableUsersContent({ data: users }) {
// Get addresses
return users
.map((user) => {
// check is it's a local user
- if (user && user.is_local) {
+ if (user?.is_local) {
// append the instance address
return user.screen_name + '@' + location.hostname
}
return user.screen_name
})
.join('\n')
},
addBackup() {
addBackup({
credentials: useOAuthStore().token,
})
.then(() => {
this.addedBackup = true
this.addBackupError = false
})
.catch((error) => {
this.addedBackup = false
this.addBackupError = error
})
.then(() => this.fetchBackups())
},
fetchBackups() {
listBackups({
credentials: useOAuthStore().token,
})
.then(({ data: res }) => {
this.backups = res
this.listBackupsError = false
})
.catch((error) => {
this.listBackupsError = error.error
})
},
},
}
export default DataImportExportTab
diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.js b/src/components/settings_modal/tabs/mutes_and_blocks_tab.js
index c45d6ad763..61d3e42f79 100644
--- a/src/components/settings_modal/tabs/mutes_and_blocks_tab.js
+++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.js
@@ -1,140 +1,140 @@
import { get, map, reject } from 'lodash'
import Autosuggest from 'src/components/autosuggest/autosuggest.vue'
import BlockCard from 'src/components/block_card/block_card.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import DomainMuteCard from 'src/components/domain_mute_card/domain_mute_card.vue'
import List from 'src/components/list/list.vue'
import MuteCard from 'src/components/mute_card/mute_card.vue'
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import { useInstanceStore } from 'src/stores/instance.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useOAuthTokensStore } from 'src/stores/oauth_tokens.js'
import { importBlocks, importFollows } from 'src/api/user.js'
const MutesAndBlocks = {
data() {
return {
activeTab: 'profile',
}
},
created() {
useOAuthTokensStore().fetchTokens()
useInstanceStore().getKnownDomains()
},
components: {
TabSwitcher,
DomainMuteCard,
BlockCard,
List,
MuteCard,
ProgressButton,
Autosuggest,
Checkbox,
},
computed: {
knownDomains() {
return useInstanceStore().knownDomains
},
user() {
return this.$store.state.users.currentUser
},
blocks() {
return get(this.$store.state.users.currentUser, 'blockIds', [])
},
mutes() {
return get(this.$store.state.users.currentUser, 'muteIds', [])
},
domains() {
return get(this.$store.state.users.currentUser, 'domainMutes', [])
},
},
methods: {
fetchItems(group) {
return () => this.$store.dispatch('fetch' + group, this.userId)
},
importFollows(file) {
return importFollows({
file,
credentials: useOAuthStore().token,
}).then(({ data: status }) => {
if (!status) {
throw new Error('failed')
}
})
},
importBlocks(file) {
return importBlocks({
file,
credentials: useOAuthStore().token,
}).then(({ data: status }) => {
if (!status) {
throw new Error('failed')
}
})
},
generateExportableUsersContent(users) {
// Get addresses
return users
.map((user) => {
// check is it's a local user
- if (user && user.is_local) {
+ if (user?.is_local) {
// append the instance address
return user.screen_name + '@' + location.hostname
}
return user.screen_name
})
.join('\n')
},
activateTab(tabName) {
this.activeTab = tabName
},
filterUnblockedUsers(userIds) {
return reject(userIds, (userId) => {
const relationship = this.$store.getters.relationship(this.userId)
return relationship.blocking || userId === this.user.id
})
},
filterUnMutedUsers(userIds) {
return reject(userIds, (userId) => {
const relationship = this.$store.getters.relationship(this.userId)
return relationship.muting || userId === this.user.id
})
},
queryUserIds(query) {
return this.$store
.dispatch('searchUsers', { query })
.then((users) => map(users, 'id'))
},
blockUsers(ids) {
return this.$store.dispatch('blockUsers', ids)
},
unblockUsers(ids) {
return this.$store.dispatch('unblockUsers', ids)
},
muteUsers(ids) {
return this.$store.dispatch('muteUsers', ids)
},
unmuteUsers(ids) {
return this.$store.dispatch('unmuteUsers', ids)
},
filterUnMutedDomains(urls) {
return urls.filter((url) => !this.user.domainMutes.includes(url))
},
queryKnownDomains(query) {
return new Promise((resolve) => {
resolve(
this.knownDomains.filter((url) => url.toLowerCase().includes(query)),
)
})
},
unmuteDomains(domains) {
return this.$store.dispatch('unmuteDomains', domains)
},
},
}
export default MutesAndBlocks
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 83f993c86c..13bcc3ae6c 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,834 +1,828 @@
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: 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') {
+ if (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().setTheme({
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'
- ) {
+ if (colors.text === undefined && colors.fg !== undefined) {
version = 1
}
// New v2 naming: text is text, fg is foreground
- if (
- typeof colors.text !== 'undefined' &&
- typeof colors.fg !== 'undefined'
- ) {
+ if (colors.text !== undefined && 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
+ if (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/style_tab/style_tab.js b/src/components/settings_modal/tabs/style_tab/style_tab.js
index 4d3ed16954..a023b44159 100644
--- a/src/components/settings_modal/tabs/style_tab/style_tab.js
+++ b/src/components/settings_modal/tabs/style_tab/style_tab.js
@@ -1,934 +1,934 @@
import { get, set, throttle, unset } from 'lodash'
import {
computed,
getCurrentInstance,
provide,
reactive,
ref,
watch,
} from 'vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ColorInput from 'src/components/color_input/color_input.vue'
import ComponentPreview from 'src/components/component_preview/component_preview.vue'
import ContrastRatio from 'src/components/contrast_ratio/contrast_ratio.vue'
import OpacityInput from 'src/components/opacity_input/opacity_input.vue'
import PaletteEditor from 'src/components/palette_editor/palette_editor.vue'
import RoundnessInput from 'src/components/roundness_input/roundness_input.vue'
import Select from 'src/components/select/select.vue'
import SelectMotion from 'src/components/select/select_motion.vue'
import ShadowControl from 'src/components/shadow_control/shadow_control.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import Tooltip from 'src/components/tooltip/tooltip.vue'
import StringSetting from '../../helpers/string_setting.vue'
import Preview from '../old_theme_tab/theme_preview.vue'
import VirtualDirectivesTab from './virtual_directives_tab.vue'
import { useInterfaceStore } from 'src/stores/interface'
import {
getContrastRatio,
hex2rgb,
rgb2hex,
} from 'src/services/color_convert/color_convert.js'
import {
newExporter,
newImporter,
} from 'src/services/export_import/export_import.js'
import {
adoptStyleSheets,
createStyleSheet,
} from 'src/services/style_setter/style_setter.js'
import { getCssRules } from 'src/services/theme_data/css_utils.js'
import {
deserialize,
deserializeShadow,
} from 'src/services/theme_data/iss_deserializer.js'
import { serialize } from 'src/services/theme_data/iss_serializer.js'
import {
findColor,
init,
} from 'src/services/theme_data/theme_data_3.service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faArrowsRotate,
faCheck,
faFile,
faFloppyDisk,
faFolderOpen,
} from '@fortawesome/free-solid-svg-icons'
// helper for debugging
// const toValue = (x) => JSON.parse(JSON.stringify(x === undefined ? 'null' : x))
// helper to make states comparable
const normalizeStates = (states) =>
['normal', ...(states?.filter((x) => x !== 'normal') || [])].join(':')
library.add(faFile, faFloppyDisk, faFolderOpen, faArrowsRotate, faCheck)
export default {
components: {
Select,
SelectMotion,
Checkbox,
Tooltip,
StringSetting,
ComponentPreview,
TabSwitcher,
ShadowControl,
ColorInput,
PaletteEditor,
OpacityInput,
RoundnessInput,
ContrastRatio,
Preview,
VirtualDirectivesTab,
},
setup() {
const exports = {}
const interfaceStore = useInterfaceStore()
// All rules that are made by editor
const allEditedRules = ref(interfaceStore.styleDataUsed || {})
const styleDataUsed = computed(() => interfaceStore.styleDataUsed)
watch(
[styleDataUsed],
() => {
onImport(interfaceStore.styleDataUsed)
},
{ once: true },
)
exports.isActive = computed(() => {
const tabSwitcher = getCurrentInstance().parent.ctx
return tabSwitcher ? tabSwitcher.isActive('style') : false
})
// ## Meta stuff
exports.name = ref('')
exports.author = ref('')
exports.license = ref('')
exports.website = ref('')
const metaOut = computed(() => {
return [
'@meta {',
` name: ${exports.name.value};`,
` author: ${exports.author.value};`,
` license: ${exports.license.value};`,
` website: ${exports.website.value};`,
'}',
].join('\n')
})
const metaRule = computed(() => ({
component: '@meta',
directives: {
name: exports.name.value,
author: exports.author.value,
license: exports.license.value,
website: exports.website.value,
},
}))
// ## Palette stuff
const palettes = reactive([
{
name: 'default',
bg: '#121a24',
fg: '#182230',
text: '#b9b9ba',
link: '#d8a070',
accent: '#d8a070',
cRed: '#FF0000',
cBlue: '#0095ff',
cGreen: '#0fa00f',
cOrange: '#ffa500',
},
{
name: 'light',
bg: '#f2f6f9',
fg: '#d6dfed',
text: '#304055',
underlay: '#5d6086',
accent: '#f55b1b',
cBlue: '#0095ff',
cRed: '#d31014',
cGreen: '#0fa00f',
cOrange: '#ffa500',
border: '#d8e6f9',
},
])
exports.palettes = palettes
// This is kinda dumb but you cannot "replace" reactive() object
// and so v-model simply fails when you try to chage (increase only?)
// length of the array. Since linter complains about mutating modelValue
// inside SelectMotion, the next best thing is to just wipe existing array
// and replace it with new one.
const onPalettesUpdate = (e) => {
palettes.splice(0, palettes.length)
palettes.push(...e)
}
exports.onPalettesUpdate = onPalettesUpdate
const selectedPaletteId = ref(0)
const selectedPalette = computed({
get() {
return palettes[selectedPaletteId.value]
},
set(newPalette) {
palettes[selectedPaletteId.value] = newPalette
},
})
exports.selectedPaletteId = selectedPaletteId
exports.selectedPalette = selectedPalette
provide('selectedPalette', selectedPalette)
watch([selectedPalette], () => updateOverallPreview())
exports.getNewPalette = () => ({
name: 'new palette',
bg: '#121a24',
fg: '#182230',
text: '#b9b9ba',
link: '#d8a070',
accent: '#d8a070',
cRed: '#FF0000',
cBlue: '#0095ff',
cGreen: '#0fa00f',
cOrange: '#ffa500',
})
// Raw format
const palettesRule = computed(() => {
return palettes.map((palette) => {
const { name, ...rest } = palette
return {
component: '@palette',
variant: name,
directives: Object.entries(rest)
.filter(([k, v]) => v && k)
.reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {}),
}
})
})
// Text format
const palettesOut = computed(() => {
return palettes
.map(({ name, ...palette }) => {
const entries = Object.entries(palette)
.filter(([k, v]) => v && k)
.map(([slot, data]) => ` ${slot}: ${data};`)
.join('\n')
return `@palette.${name} {\n${entries}\n}`
})
.join('\n\n')
})
// ## Components stuff
// Getting existing components
const componentsContext = import.meta.glob(
['/src/**/*.style.js', '/src/**/*.style.json'],
{ eager: true },
)
const componentKeysAll = Object.keys(componentsContext)
const componentsMap = new Map(
componentKeysAll
.map((key) => [key, componentsContext[key].default])
.filter(
([, component]) => !component.virtual && !component.notEditable,
),
)
exports.componentsMap = componentsMap
const componentKeys = [...componentsMap.keys()]
exports.componentKeys = componentKeys
// Component list and selection
const selectedComponentKey = ref(componentsMap.keys().next().value)
exports.selectedComponentKey = selectedComponentKey
const selectedComponent = computed(() =>
componentsMap.get(selectedComponentKey.value),
)
const selectedComponentName = computed(() => selectedComponent.value.name)
// Selection basis
exports.selectedComponentVariants = computed(() => {
return Object.keys({
normal: null,
...(selectedComponent.value.variants || {}),
})
})
exports.selectedComponentStates = computed(() => {
const all = Object.keys({
normal: null,
...(selectedComponent.value.states || {}),
})
return all.filter((x) => x !== 'normal')
})
// selection
const selectedVariant = ref('normal')
exports.selectedVariant = selectedVariant
const selectedState = reactive(new Set())
exports.selectedState = selectedState
exports.updateSelectedStates = (state, v) => {
if (v) {
selectedState.add(state)
} else {
selectedState.delete(state)
}
}
// Reset variant and state on component change
const updateSelectedComponent = () => {
selectedVariant.value = 'normal'
selectedState.clear()
}
watch(selectedComponentName, updateSelectedComponent)
// ### Rules stuff aka meat and potatoes
// The native structure of separate rules and the child -> parent
// relation isn't very convenient for editor, we replace the array
// and child -> parent structure with map and parent -> child structure
const rulesToEditorFriendly = (rules, root = {}) =>
rules.reduce((acc, rule) => {
const { parent: rParent, component: rComponent } = rule
const parent = rParent ?? rule
const hasChildren = !!rParent
const child = hasChildren ? rule : null
const {
component: pComponent,
variant: pVariant = 'normal',
state: pState = [], // no relation to Intel CPUs whatsoever
} = parent
const pPath = `${hasChildren ? pComponent : rComponent}.${pVariant}.${normalizeStates(pState)}`
let output = get(acc, pPath)
if (!output) {
set(acc, pPath, {})
output = get(acc, pPath)
}
if (hasChildren) {
output._children = output._children ?? {}
const {
component: cComponent,
variant: cVariant = 'normal',
state: cState = [],
directives,
} = child
const cPath = `${cComponent}.${cVariant}.${normalizeStates(cState)}`
set(output._children, cPath, { directives })
} else {
output.directives = parent.directives
}
return acc
}, root)
const editorFriendlyFallbackStructure = computed(() => {
const root = {}
componentKeys.forEach((componentKey) => {
const componentValue = componentsMap.get(componentKey)
const { defaultRules, name } = componentValue
rulesToEditorFriendly(
defaultRules.map((rule) => ({ ...rule, component: name })),
root,
)
})
return root
})
// Checking whether component can support some "directives" which
// are actually virtual subcomponents, i.e. Text, Link etc
exports.componentHas = (subComponent) => {
return !!selectedComponent.value.validInnerComponents?.find(
(x) => x === subComponent,
)
}
// Path for lodash's get and set
const getPath = (component, directive) => {
const pathSuffix = component
? `._children.${component}.normal.normal`
: ''
const path = `${selectedComponentName.value}.${selectedVariant.value}.${normalizeStates([...selectedState])}${pathSuffix}.directives.${directive}`
return path
}
// Templates for directives
const isElementPresent = (component, directive, defaultValue = '') =>
computed({
get() {
return (
get(allEditedRules.value, getPath(component, directive)) != null
)
},
set(value) {
if (value) {
const fallback = get(
editorFriendlyFallbackStructure.value,
getPath(component, directive),
)
set(
allEditedRules.value,
getPath(component, directive),
fallback ?? defaultValue,
)
} else {
unset(allEditedRules.value, getPath(component, directive))
}
exports.updateOverallPreview()
},
})
const getEditedElement = (component, directive, postProcess = (x) => x) =>
computed({
get() {
let usedRule
const fallback = editorFriendlyFallbackStructure.value
const real = allEditedRules.value
const path = getPath(component, directive)
usedRule = get(real, path) // get real
if (usedRule === '') {
return usedRule
}
if (!usedRule) {
usedRule = get(fallback, path)
}
return postProcess(usedRule)
},
set(value) {
if (value != null) {
set(allEditedRules.value, getPath(component, directive), value)
} else {
unset(allEditedRules.value, getPath(component, directive))
}
exports.updateOverallPreview()
},
})
// All the editable stuff for the component
exports.editedBackgroundColor = getEditedElement(null, 'background')
exports.isBackgroundColorPresent = isElementPresent(
null,
'background',
'#FFFFFF',
)
exports.editedOpacity = getEditedElement(null, 'opacity')
exports.isOpacityPresent = isElementPresent(null, 'opacity', 1)
exports.editedRoundness = getEditedElement(null, 'roundness')
exports.isRoundnessPresent = isElementPresent(null, 'roundness', '0')
exports.editedTextColor = getEditedElement('Text', 'textColor')
exports.isTextColorPresent = isElementPresent(
'Text',
'textColor',
'#000000',
)
exports.editedTextAuto = getEditedElement('Text', 'textAuto')
exports.isTextAutoPresent = isElementPresent('Text', 'textAuto', '#000000')
exports.editedLinkColor = getEditedElement('Link', 'textColor')
exports.isLinkColorPresent = isElementPresent(
'Link',
'textColor',
'#000080',
)
exports.editedIconColor = getEditedElement('Icon', 'textColor')
exports.isIconColorPresent = isElementPresent(
'Icon',
'textColor',
'#909090',
)
exports.editedBorderColor = getEditedElement('Border', 'textColor')
exports.isBorderColorPresent = isElementPresent(
'Border',
'textColor',
'#909090',
)
const getContrast = (bg, text) => {
try {
const bgRgb = hex2rgb(bg)
const textRgb = hex2rgb(text)
const ratio = getContrastRatio(bgRgb, textRgb)
return {
// TODO this ideally should be part of <ContractRatio />
ratio,
text: ratio.toPrecision(3) + ':1',
// AA level, AAA level
aa: ratio >= 4.5,
aaa: ratio >= 7,
// same but for 18pt+ texts
laa: ratio >= 3,
laaa: ratio >= 4.5,
}
} catch (e) {
console.warn('Failure computing contrast', e)
return { error: e }
}
}
const normalizeShadows = (shadows) => {
return shadows?.map((shadow) => {
if (typeof shadow === 'object') {
return shadow
}
if (typeof shadow === 'string') {
try {
return deserializeShadow(shadow)
} catch (e) {
console.warn('Failed to deserialize shadow', e)
return shadow
}
}
return null
})
}
provide('normalizeShadows', normalizeShadows)
// Shadow is partially edited outside the ShadowControl
// for better space utilization
const editedShadow = getEditedElement(null, 'shadow', normalizeShadows)
exports.editedShadow = editedShadow
const editedSubShadowId = ref(null)
exports.editedSubShadowId = editedSubShadowId
const editedSubShadow = computed(() => {
if (editedShadow.value == null || editedSubShadowId.value == null)
return null
return editedShadow.value[editedSubShadowId.value]
})
exports.editedSubShadow = editedSubShadow
exports.isShadowPresent = isElementPresent(null, 'shadow', [])
exports.onSubShadow = (id) => {
if (id != null) {
editedSubShadowId.value = id
} else {
editedSubShadow.value = null
}
}
exports.updateSubShadow = (axis, value) => {
if (!editedSubShadow.value || editedSubShadowId.value == null) return
const newEditedShadow = [...editedShadow.value]
newEditedShadow[editedSubShadowId.value] = {
...newEditedShadow[editedSubShadowId.value],
[axis]: value,
}
editedShadow.value = newEditedShadow
}
exports.isShadowTabOpen = ref(false)
exports.onTabSwitch = (tab) => {
exports.isShadowTabOpen.value = tab === 'shadow'
}
// component preview
exports.editorHintStyle = computed(() => {
const editorHint = selectedComponent.value.editor
const styles = []
if (editorHint && Object.keys(editorHint).length > 0) {
if (editorHint.aspect != null) {
styles.push(`aspect-ratio: ${editorHint.aspect} !important;`)
}
if (editorHint.border != null) {
styles.push(`border-width: ${editorHint.border}px !important;`)
}
}
return styles.join('; ')
})
const editorFriendlyToOriginal = computed(() => {
const resultRules = []
const convert = (component, data = {}, parent) => {
const variants = Object.entries(data || {})
variants.forEach(([variant, variantData]) => {
const states = Object.entries(variantData)
states.forEach(([jointState, stateData]) => {
const state = jointState.split(/:/g)
const result = {
component,
variant,
state,
directives: stateData.directives || {},
}
if (parent) {
result.parent = {
component: parent,
}
}
resultRules.push(result)
// Currently we only support single depth for simplicity's sake
if (!parent) {
Object.entries(stateData._children || {}).forEach(
([cName, child]) => convert(cName, child, component),
)
}
})
})
}
;[...componentsMap.values()].forEach(({ name }) => {
convert(name, allEditedRules.value[name])
})
return resultRules
})
const allCustomVirtualDirectives = [...componentsMap.values()]
.map((c) => {
return c.defaultRules
.filter((c) => c.component === 'Root')
.map((x) => Object.entries(x.directives))
.flat()
})
- .filter((x) => x)
+ .filter(Boolean)
.flat()
.map(([name, value]) => {
const [valType, valVal] = value.split('|')
return {
name: name.substring(2),
valType: valType?.trim(),
value: valVal?.trim(),
}
})
const virtualDirectives = ref(allCustomVirtualDirectives)
exports.virtualDirectives = virtualDirectives
exports.updateVirtualDirectives = (value) => {
virtualDirectives.value = value
}
// Raw format
const virtualDirectivesRule = computed(() => ({
component: 'Root',
directives: Object.fromEntries(
virtualDirectives.value.map((vd) => [
`--${vd.name}`,
`${vd.valType} | ${vd.value}`,
]),
),
}))
// Text format
const virtualDirectivesOut = computed(() => {
return [
'Root {',
...virtualDirectives.value
.filter((vd) => vd.name && vd.valType && vd.value)
.map((vd) => ` --${vd.name}: ${vd.valType} | ${vd.value};`),
'}',
].join('\n')
})
exports.computeColor = (color) => {
let computedColor
try {
computedColor = findColor(color, {
dynamicVars: dynamicVars.value,
staticVars: staticVars.value,
})
if (computedColor) {
return rgb2hex(computedColor)
}
} catch (e) {
console.warn('failed to get computed color', e)
}
return null
}
provide('computeColor', exports.computeColor)
exports.contrast = computed(() => {
return getContrast(
exports.computeColor(previewColors.value.background),
exports.computeColor(previewColors.value.text),
)
})
// ## Export and Import
const styleExporter = newExporter({
filename: () => exports.name.value ?? 'pleroma_theme',
mime: 'text/plain',
extension: 'iss',
getExportedObject: () => exportStyleData.value,
})
const onImport = (parsed) => {
const editorComponents = parsed.filter((x) => x.component.startsWith('@'))
const rootComponent = parsed.find((x) => x.component === 'Root')
const rules = parsed.filter(
(x) => !x.component.startsWith('@') && x.component !== 'Root',
)
const metaIn = editorComponents.find(
(x) => x.component === '@meta',
).directives
const palettesIn = editorComponents.filter(
(x) => x.component === '@palette',
)
exports.name.value = metaIn.name
exports.license.value = metaIn.license
exports.author.value = metaIn.author
exports.website.value = metaIn.website
const newVirtualDirectives = Object.entries(rootComponent.directives).map(
([name, value]) => {
const [valType, valVal] = value.split('|').map((x) => x.trim())
return { name: name.substring(2), valType, value: valVal }
},
)
virtualDirectives.value = newVirtualDirectives
onPalettesUpdate(
palettesIn.map((x) => ({ name: x.variant, ...x.directives })),
)
allEditedRules.value = rulesToEditorFriendly(rules)
exports.updateOverallPreview()
}
const styleImporter = newImporter({
accept: '.iss',
parser(string) {
return deserialize(string)
},
onImportFailure(result) {
console.error('Failure importing style:', result)
useInterfaceStore().pushGlobalNotice({
messageKey: 'settings.invalid_theme_imported',
level: 'error',
})
},
onImport,
})
// Raw format
const exportRules = computed(() => [
metaRule.value,
...palettesRule.value,
virtualDirectivesRule.value,
...editorFriendlyToOriginal.value,
])
// Text format
const exportStyleData = computed(() => {
return [
metaOut.value,
palettesOut.value,
virtualDirectivesOut.value,
serialize(editorFriendlyToOriginal.value),
].join('\n\n')
})
exports.clearStyle = () => {
onImport(interfaceStore.styleDataUsed)
}
exports.exportStyle = () => {
styleExporter.exportData()
}
exports.importStyle = () => {
styleImporter.importData()
}
exports.applyStyle = () => {
useInterfaceStore().setStyleCustom(exportRules.value)
}
const overallPreviewRules = ref([])
exports.overallPreviewRules = overallPreviewRules
watch([overallPreviewRules], () => {
let css = null
try {
css = getCssRules(overallPreviewRules.value).map((r) =>
r.replace('html', '&'),
)
} catch (e) {
console.error(e)
return
}
const sheet = createStyleSheet('style-tab-overall-preview', 90)
sheet.clear()
sheet.addRule(
['#edited-style-preview {\n', css.join('\n'), '\n}'].join(''),
)
sheet.ready = true
adoptStyleSheets()
})
const updateOverallPreview = throttle(() => {
try {
overallPreviewRules.value = init({
inputRuleset: [
...exportRules.value,
{
component: 'Root',
directives: Object.fromEntries(
Object.entries(selectedPalette.value)
.filter(([k, v]) => k && v && k !== 'name')
.map(([k, v]) => [`--${k}`, `color | ${v}`]),
),
},
],
ultimateBackgroundColor: '#000000',
debug: true,
}).eager
} catch (e) {
console.error('Could not compile preview theme', e)
return null
}
}, 1000)
//
// Apart from "hover" we can't really show how component looks like in
// certain states, so we have to fake them.
const simulatePseudoSelectors = (css, prefix) =>
css
.replace(prefix, '.preview-block')
.replace(':active', '.preview-active')
.replace(':hover', '.preview-hover')
.replace(':active', '.preview-active')
.replace(':focus', '.preview-focus')
.replace(':focus-within', '.preview-focus-within')
.replace(':disabled', '.preview-disabled')
const previewRules = computed(() => {
const filtered = overallPreviewRules.value.filter((r) => {
const componentMatch = r.component === selectedComponentName.value
const parentComponentMatch =
r.parent?.component === selectedComponentName.value
if (!componentMatch && !parentComponentMatch) return false
const rule = parentComponentMatch ? r.parent : r
if (rule.component !== selectedComponentName.value) return false
if (rule.variant !== selectedVariant.value) return false
const ruleState = new Set(rule.state.filter((x) => x !== 'normal'))
const differenceA = [...ruleState].filter((x) => !selectedState.has(x))
const differenceB = [...selectedState].filter((x) => !ruleState.has(x))
return differenceA.length + differenceB.length === 0
})
const sorted = [...filtered]
.filter((x) => x.component === selectedComponentName.value)
.sort((a, b) => {
const aSelectorLength = a.selector.split(/ /g).length
const bSelectorLength = b.selector.split(/ /g).length
return aSelectorLength - bSelectorLength
})
const prefix = sorted[0].selector
return filtered.filter((x) => x.selector.startsWith(prefix))
})
exports.previewClass = computed(() => {
const selectors = []
if (
!!selectedComponent.value.variants?.normal ||
selectedVariant.value !== 'normal'
) {
selectors.push(selectedComponent.value.variants[selectedVariant.value])
}
if (selectedState.size > 0) {
selectedState.forEach((state) => {
const original = selectedComponent.value.states[state]
selectors.push(simulatePseudoSelectors(original))
})
}
return selectors.map((x) => x.substring(1)).join('')
})
exports.previewCss = computed(() => {
try {
const prefix = previewRules.value[0].selector
const scoped = getCssRules(previewRules.value).map((x) =>
simulatePseudoSelectors(x, prefix),
)
return scoped.join('\n')
} catch (e) {
console.error('Invalid ruleset', e)
return null
}
})
const dynamicVars = computed(() => {
return previewRules.value[0].dynamicVars
})
const staticVars = computed(() => {
const rootComponent = overallPreviewRules.value.find((r) => {
return r.component === 'Root'
})
const rootDirectivesEntries = Object.entries(rootComponent.directives)
const directives = {}
rootDirectivesEntries
.filter(([k, v]) => k.startsWith('--') && v.startsWith('color | '))
.map(([k, v]) => [k.substring(2), v.substring('color | '.length)])
.forEach(([k, v]) => {
directives[k] = findColor(v, {
dynamicVars: {},
staticVars: directives,
})
})
return directives
})
provide('staticVars', staticVars)
exports.staticVars = staticVars
const previewColors = computed(() => {
const stacked = dynamicVars.value.stacked
const background =
typeof stacked === 'string' ? stacked : rgb2hex(stacked)
return {
text: previewRules.value.find((r) => r.component === 'Text')
?.virtualDirectives['--text'],
link: previewRules.value.find((r) => r.component === 'Link')
?.virtualDirectives['--link'],
border: previewRules.value.find((r) => r.component === 'Border')
?.virtualDirectives['--border'],
icon: previewRules.value.find((r) => r.component === 'Icon')
?.virtualDirectives['--icon'],
background,
}
})
exports.previewColors = previewColors
exports.updateOverallPreview = updateOverallPreview
updateOverallPreview()
watch(
[
allEditedRules.value,
palettes,
selectedPalette,
selectedState,
selectedVariant,
],
updateOverallPreview,
)
return exports
},
}
diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue
index e0b7331c6e..c810d93a0e 100644
--- a/src/components/side_drawer/side_drawer.vue
+++ b/src/components/side_drawer/side_drawer.vue
@@ -1,445 +1,445 @@
<template>
<div
class="side-drawer-container mobile-drawer"
:class="{ 'side-drawer-container-closed': closed, 'side-drawer-container-open': !closed }"
>
<div
class="side-drawer-darken"
:class="{ 'side-drawer-darken-closed': closed}"
/>
<div
class="side-drawer"
:class="{'side-drawer-closed': closed}"
@touchstart="touchStart"
@touchmove="touchMove"
>
<div
class="side-drawer-heading"
@click="toggleDrawer"
>
<UserCard
v-if="currentUser"
:user-id="currentUser.id"
:hide-bio="true"
/>
<div
v-else
class="side-drawer-logo-wrapper"
>
<img :src="logo">
<span v-if="!hideSitename">{{ sitename }}</span>
</div>
</div>
<ul>
<li
v-if="!currentUser"
@click="toggleDrawer"
>
<router-link
:to="{ name: 'login' }"
class="menu-item"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="sign-in-alt"
/> {{ $t("login.login") }}
</router-link>
</li>
<li
v-if="currentUser || !privateMode"
@click="toggleDrawer"
>
<router-link
:to="timelinesRoute"
class="menu-item"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="home"
/> {{ $t("nav.timelines") }}
</router-link>
</li>
<li
v-if="currentUser"
@click="toggleDrawer"
>
<router-link
:to="{ name: 'lists' }"
class="menu-item"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="list"
/> {{ $t("nav.lists") }}
</router-link>
</li>
<li
v-if="currentUser"
@click="toggleDrawer"
>
<router-link
:to="{ name: 'bookmarks' }"
class="menu-item"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="bookmark"
/> {{ $t("nav.bookmarks") }}
</router-link>
</li>
<li
v-if="currentUser && pleromaChatMessagesAvailable"
@click="toggleDrawer"
>
<router-link
:to="{ name: 'chats', params: { username: currentUser.screen_name } }"
style="position: relative;"
class="menu-item"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="comments"
/> {{ $t("nav.chats") }}
<span
v-if="unreadChatsCount"
class="badge -notification"
>
{{ unreadChatsCount }}
</span>
</router-link>
</li>
</ul>
<ul v-if="currentUser">
<li @click="toggleDrawer">
<router-link
:to="{ name: 'interactions', params: { username: currentUser.screen_name } }"
class="menu-item"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="bell"
/> {{ $t("nav.interactions") }}
</router-link>
</li>
<li
v-if="currentUser.locked"
@click="toggleDrawer"
>
<router-link
to="/friend-requests"
class="menu-item"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="user-plus"
/> {{ $t("nav.friend_requests") }}
<span
v-if="followRequestCount > 0"
class="badge -notification"
>
{{ followRequestCount }}
</span>
</router-link>
</li>
<li
v-if="shout"
@click="toggleDrawer"
>
<router-link
:to="{ name: 'shout-panel' }"
class="menu-item"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="bullhorn"
/> {{ $t("shoutbox.title") }}
</router-link>
</li>
</ul>
<ul>
<li
v-if="currentUser || !privateMode"
@click="toggleDrawer"
>
<router-link
:to="{ name: 'search' }"
class="menu-item"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="search"
/> {{ $t("nav.search") }}
</router-link>
</li>
<li
v-if="currentUser && suggestionsEnabled"
@click="toggleDrawer"
>
<router-link
:to="{ name: 'who-to-follow' }"
class="menu-item"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="user-plus"
/> {{ $t("nav.who_to_follow") }}
</router-link>
</li>
<li @click="toggleDrawer">
<button
class="menu-item"
@click="openSettingsModal('user')"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="cog"
/> {{ $t("settings.settings") }}
</button>
</li>
<li @click="toggleDrawer">
<router-link
:to="{ name: 'about'}"
class="menu-item"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="info-circle"
/> {{ $t("nav.about") }}
</router-link>
</li>
<li
- v-if="currentUser && currentUser.role === 'admin'"
+ v-if="currentUser?.role === 'admin'"
@click="toggleDrawer"
>
<button
class="menu-item"
@click.stop="openSettingsModal('admin')"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="tachometer-alt"
/> {{ $t("nav.administration") }}
</button>
</li>
<li
v-if="currentUser && supportsAnnouncements"
@click="toggleDrawer"
>
<router-link
:to="{ name: 'announcements' }"
class="menu-item"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="bullhorn"
/> {{ $t("nav.announcements") }}
<span
v-if="unreadAnnouncementCount"
class="badge -notification"
>
{{ unreadAnnouncementCount }}
</span>
</router-link>
</li>
<li
v-if="currentUser"
@click="toggleDrawer"
>
<router-link
:to="{ name: 'drafts' }"
class="menu-item"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="file-pen"
/> {{ $t('nav.drafts') }}
<span
v-if="draftCount"
class="badge -neutral"
>
{{ draftCount }}
</span>
</router-link>
</li>
<li
v-if="currentUser"
@click="toggleDrawer"
>
<router-link
:to="{ name: 'edit-navigation' }"
class="menu-item"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="compass"
/> {{ $t("nav.edit_nav_mobile") }}
</router-link>
</li>
<li
v-if="currentUser"
@click="toggleDrawer"
>
<button
class="menu-item"
@click="doLogout"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="sign-out-alt"
/> {{ $t("login.logout") }}
</button>
</li>
</ul>
</div>
<div
class="side-drawer-click-outside"
:class="{'side-drawer-click-outside-closed': closed}"
@click.stop.prevent="toggleDrawer"
/>
</div>
</template>
<script src="./side_drawer.js"></script>
<style lang="scss">
.side-drawer-container {
position: fixed;
z-index: var(--ZI_navbar);
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: stretch;
transition-duration: 0s;
transition-property: transform;
}
.side-drawer-container-open {
transform: translate(0%);
}
.side-drawer-container-closed {
transition-delay: 0.35s;
transform: translate(-100%);
}
.side-drawer-darken {
top: 0;
left: 0;
width: 100vw;
height: 100vh;
position: fixed;
z-index: -1;
transition: 0.35s;
transition-property: background-color;
background-color: rgb(0 0 0 / 50%);
}
.side-drawer-darken-closed {
background-color: rgb(0 0 0 / 0%);
}
.side-drawer-click-outside {
flex: 1 1 100%;
}
.side-drawer {
overflow-x: hidden;
transition: 0.35s;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
transition-property: transform;
margin: 0 0 0 -100px;
padding: 0 0 1em 100px;
width: 80%;
max-width: 20em;
flex: 0 0 80%;
box-shadow: var(--shadow);
background-color: var(--background);
.badge {
margin-left: 10px;
}
}
.side-drawer-logo-wrapper {
display: flex;
align-items: center;
padding: 0.85em;
img {
flex: none;
height: 50px;
margin-right: 0.85em;
}
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.side-drawer-click-outside-closed {
flex: 0 0 0;
}
.side-drawer-closed {
transform: translate(-100%);
}
.side-drawer-heading {
background: transparent;
flex-direction: column;
align-items: stretch;
display: flex;
padding: 0;
margin: 0;
.user-info {
margin: 1em;
}
}
.side-drawer ul {
list-style: none;
margin: 0;
padding: 0;
border-bottom: 1px solid;
border-color: var(--border);
}
.side-drawer ul:last-child {
border: 0;
}
.side-drawer li {
padding: 0;
a,
button {
box-sizing: border-box;
display: block;
height: 3em;
line-height: 3em;
padding: 0 0.7em;
}
}
</style>
diff --git a/src/components/status/status.js b/src/components/status/status.js
index fb37a7faf6..61a553ef66 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -1,590 +1,590 @@
import { uniqBy } from 'lodash'
import { defineAsyncComponent } from 'vue'
import AvatarList from 'src/components/avatar_list/avatar_list.vue'
import EmojiReactions from 'src/components/emoji_reactions/emoji_reactions.vue'
import MentionLink from 'src/components/mention_link/mention_link.vue'
import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import StatusActionButtons from 'src/components/status_action_buttons/status_action_buttons.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import StatusPopover from 'src/components/status_popover/status_popover.vue'
import Timeago from 'src/components/timeago/timeago.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserLink from 'src/components/user_link/user_link.vue'
import UserListPopover from 'src/components/user_list_popover/user_list_popover.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
import { muteFilterHits } from '../../services/status_parser/status_parser.js'
import {
highlightClass,
highlightStyle,
} from '../../services/user_highlighter/user_highlighter.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 { 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 Status = {
name: 'Status',
components: {
PostStatusForm,
UserAvatar,
AvatarList,
Timeago,
StatusPopover,
UserListPopover,
EmojiReactions,
StatusContent,
MentionLink,
MentionsLine,
UserPopover,
UserLink,
Quote: defineAsyncComponent(() => import('src/components/quote/quote.vue')),
StatusActionButtons,
},
props: {
statusoid: Object,
replies: Array,
expandable: Boolean,
focused: Boolean,
compact: Boolean,
isPreview: Boolean,
noHeading: Boolean,
inlineExpanded: Boolean,
showPinned: Boolean,
inProfile: Boolean,
inConversation: Boolean,
inQuote: Boolean,
profileUserId: String,
simpleTree: Boolean,
showOtherRepliesAsButton: Boolean,
canDive: Boolean,
ignoreMute: Boolean,
threadDisplayStatus: String,
},
emits: ['goto', 'dive', 'toggleExpanded', 'suspendableStateChange'],
data() {
return {
replying: false,
unmuted: false,
userExpanded: false,
mediaPlaying: new Set(),
error: null,
headTailLinks: null,
}
},
computed: {
showReasonMutedThread() {
return (
(this.status.thread_muted ||
(this.status.reblog && this.status.reblog.thread_muted)) &&
!this.inConversation
)
},
allowNonSquareEmoji() {
return this.mergedConfig.nonSquareEmoji
},
pauseMfm() {
return this.mergedConfig.pauseMfm
},
scaleMfm() {
return this.mergedConfig.scaleMfm
},
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
return highlightStyle(useUserHighlightStore().get(user.screen_name))
},
userStyle() {
if (this.noHeading) return
const user = this.retweet
? this.statusoid.retweeted_status.user
: this.statusoid.user
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(
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.ignoreMute) return false
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) ||
+ (reblog?.muted && !reblog.thread_muted) ||
// Muted user
relationship.muting ||
// Muted user of a reprööt
- (relationshipReblog && relationshipReblog.muting)
+ relationshipReblog?.muting
)
},
shouldNotMute() {
if (this.ignoreMute) return true
if (this.focused) 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))) ||
+ 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))
)
},
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
+ return user?.screen_name_ui
}
},
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.focused &&
(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 useMergedConfigStore().mergedConfig
},
isSuspendable() {
return !this.replying && this.mediaPlaying.size === 0
},
inThreadForest() {
return !!this.threadDisplayStatus
},
threadShowing() {
return this.threadDisplayStatus === '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.error = undefined
},
toggleReplyForm() {
if (this.replying) {
// This emits 'close-accepted' if successful
// which in turn callse closeReply()
this.$refs.postStatusForm.requestClose()
} else {
this.replying = true
}
},
closeReplyForm() {
this.replying = false
},
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) {
this.mediaPlaying.add(id)
},
removeMediaPlaying(id) {
this.mediaPlaying.delete(id)
},
setHeadTailLinks(headTailLinks) {
this.headTailLinks = headTailLinks
},
toggleThreadDisplay() {
this.controlledToggleThreadDisplay()
},
scrollIfFocused(focused) {
if (this.$el.getBoundingClientRect == null) return
if (focused) {
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: {
focused: function (id) {
this.scrollIfFocused(id)
},
'status.repeat_num': function (num) {
// refetch repeats when repeat_num is changed in any way
if (
this.focused &&
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.focused &&
this.statusFromGlobalRepository.favoritedBy &&
this.statusFromGlobalRepository.favoritedBy.length !== num
) {
this.$store.dispatch('fetchFavs', this.status.id)
}
},
isSuspendable: function (suspend) {
this.$emit('suspendableStateChange', { id: this.statusoid.id, suspend })
},
},
}
export default Status
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index da9fbe7abf..c1d3409757 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -1,561 +1,561 @@
<template>
<div
v-if="!hideStatus"
ref="root"
class="Status"
:class="[{ '-focused': focused }, { '-conversation': inlineExpanded }]"
>
<div
v-if="error"
class="alert error"
>
{{ error }}
<button
class="fa-scale-110 fa-old-padding"
type="button"
@click="clearError"
>
<FAIcon icon="times" />
</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="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"
:allow-non-square-emoji="allowNonSquareEmoji"
:pause-mfm="pauseMfm"
:scale-mfm="scaleMfm"
:is-local="retweeterUser.is_local"
/>
</router-link>
<router-link
v-else
:to="retweeterProfileLink"
>{{ retweeter }}</router-link>
</bdi>
<div class="repeat-label">
<FAIcon
icon="retweet"
class="repeat-icon"
:title="$t('tool_tip.repeat')"
/>
{{ $t('timeline.repeated') }}
</div>
</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"
:allow-non-square-emoji="allowNonSquareEmoji"
: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">
<span
v-if="showPinned"
class="pin"
>
<FAIcon
icon="thumbtack"
class="faint"
/>
<span class="faint">{{ $t('status.pinned') }}</span>
</span>
<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"
+ v-if="inThreadForest && 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="canDive && !simpleTree"
class="button-unstyled"
:title="$t('status.show_only_conversation_under_this')"
@click.prevent="$emit('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"
:focused="focused"
:in-conversation="inConversation"
@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"
+ v-if="inConversation && !isPreview && 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="$emit('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 || focused) && (!noHeading && !isPreview)"
:status="status"
/>
<StatusActionButtons
v-if="!noHeading && !isPreview"
class="status-action-buttons"
:status="status"
:replying="replying"
@toggle-replying="toggleReplyForm"
/>
</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"
:replied-status="status"
@posted="closeReplyForm"
@draft-done="closeReplyForm"
@close-accepted="closeReplyForm"
/>
</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.js b/src/components/status_action_buttons/action_button.js
index 2434820db2..1a5b853013 100644
--- a/src/components/status_action_buttons/action_button.js
+++ b/src/components/status_action_buttons/action_button.js
@@ -1,175 +1,175 @@
import Popover from 'src/components/popover/popover.vue'
import StatusBookmarkFolderMenu from 'src/components/status_bookmark_folder_menu/status_bookmark_folder_menu.vue'
import EmojiPicker from '../emoji_picker/emoji_picker.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 { library } from '@fortawesome/fontawesome-svg-core'
import {
faBookmark as faBookmarkRegular,
faFaceSmileBeam,
faStar as faStarRegular,
} from '@fortawesome/free-regular-svg-icons'
import {
faBookmark,
faCheck,
faChevronDown,
faChevronRight,
faComments,
faExternalLinkAlt,
faEye,
faEyeSlash,
faHistory,
faList,
faMinus,
faPencil,
faPlus,
faReply,
faRetweet,
faShareAlt,
faStar,
faThumbtack,
faTimes,
faWrench,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faPlus,
faMinus,
faCheck,
faTimes,
faWrench,
faChevronRight,
faChevronDown,
faReply,
faRetweet,
faStar,
faStarRegular,
faFaceSmileBeam,
faBookmark,
faBookmarkRegular,
faEyeSlash,
faEye,
faThumbtack,
faPencil,
faShareAlt,
faComments,
faList,
faExternalLinkAlt,
faHistory,
)
export default {
props: [
'button',
'status',
'extra',
'status',
'funcArg',
'getClass',
'getComponent',
'doAction',
'outerClose',
'defaultButtonStyle',
'hideLabel',
],
components: {
StatusBookmarkFolderMenu,
EmojiPicker,
Popover,
},
data: () => ({
animationState: false,
}),
computed: {
buttonClass() {
return [
this.button.name + '-button',
{
'-with-extra': this.button.name === 'bookmark',
'-extra': this.extra,
'-quick': !this.extra,
},
]
},
userIsMuted() {
return this.$store.getters.relationship(this.status.user.id).muting
},
threadIsMuted() {
return this.status.thread_muted
},
hideCustomEmoji() {
return !useInstanceCapabilitiesStore()
.pleromaCustomEmojiReactionsAvailable
},
hidePostStats() {
return useMergedConfigStore().mergedConfig.hidePostStats
},
buttonInnerClass() {
const buttonStyleClass = this.defaultButtonStyle
? 'button-default'
: 'button-unstyled'
return [
this.button.name + '-button',
{
'main-button': this.extra,
[buttonStyleClass]: !this.extra,
'-active': this.button.active?.(this.funcArg),
disabled: this.button.interactive
? !this.button.interactive(this.funcArg)
: false,
},
]
},
remoteInteractionLink() {
return useInstanceStore().getRemoteInteractionLink({
statusId: this.status.id,
})
},
},
methods: {
addReaction(event) {
const emoji = event.insertion
const existingReaction = this.status.emoji_reactions.find(
(r) => r.name === emoji,
)
- if (existingReaction && existingReaction.me) {
+ if (existingReaction?.me) {
this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji })
} else {
this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji })
}
},
onShowEmojiPicker() {
this.$emit('emojiPickerShown', true)
},
onHideEmojiPicker() {
this.$emit('emojiPickerShown', false)
},
doActionWrap(
button,
close = () => {
/* no-op */
},
) {
if (
this.button.interactive ? !this.button.interactive(this.funcArg) : false
)
return
if (button.name === 'emoji') {
this.$refs.picker.togglePicker()
} else {
this.animationState = true
this.getComponent(button) === 'button' && this.doAction(button)
setTimeout(() => {
this.animationState = false
}, 500)
close()
}
},
},
}
diff --git a/src/components/tab_switcher/tab_switcher.jsx b/src/components/tab_switcher/tab_switcher.jsx
index 07327af27d..2c86983d8a 100644
--- a/src/components/tab_switcher/tab_switcher.jsx
+++ b/src/components/tab_switcher/tab_switcher.jsx
@@ -1,183 +1,183 @@
// eslint-disable-next-line no-unused
import { Fragment } from 'vue'
import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome'
import './tab_switcher.scss'
const findFirstUsable = (slots) => slots.findIndex((_) => _.props)
export default {
name: 'TabSwitcher',
props: {
renderOnlyFocused: {
required: false,
type: Boolean,
default: false,
},
onSwitch: {
required: false,
type: Function,
default: undefined,
},
activeTab: {
required: false,
type: String,
default: undefined,
},
scrollableTabs: {
required: false,
type: Boolean,
default: false,
},
bodyScrollLock: {
required: false,
type: Boolean,
default: false,
},
},
data() {
return {
active: findFirstUsable(this.slots()),
}
},
computed: {
activeIndex() {
// In case of controlled component
if (this.activeTab) {
return this.slots().findIndex(
- (slot) => slot && slot.props && this.activeTab === slot.props.key,
+ (slot) => slot?.props && this.activeTab === slot.props.key,
)
} else {
return this.active
}
},
isActive() {
return (tabName) => {
const isWanted = (slot) =>
slot.props && slot.props['data-tab-name'] === tabName
return this.$slots.default().findIndex(isWanted) === this.activeIndex
}
},
},
beforeUpdate() {
const currentSlot = this.slots()[this.active]
if (!currentSlot.props) {
this.active = findFirstUsable(this.slots())
}
},
methods: {
clickTab(index) {
return (e) => {
e.preventDefault()
this.setTab(index)
}
},
// DO NOT put it to computed, it doesn't work (caching?)
slots() {
if (this.$slots.default()[0].type === Fragment) {
return this.$slots.default()[0].children
}
return this.$slots.default()
},
setTab(index) {
if (typeof this.onSwitch === 'function') {
this.onSwitch.call(null, this.slots()[index].key)
}
this.active = index
if (this.scrollableTabs && this.$refs.contents) {
this.$refs.contents.scrollTop = 0
}
},
},
render() {
const tabs = this.slots().map((slot, index) => {
const props = slot.props
if (!props) return
const classesTab = ['tab']
const classesWrapper = ['tab-wrapper']
if (this.activeIndex === index) {
classesTab.push('active')
classesWrapper.push('active')
}
if (props.image) {
return (
<div class={classesWrapper.join(' ')}>
<button
disabled={props.disabled}
onClick={this.clickTab(index)}
class={classesTab.join(' ')}
type="button"
role="tab"
>
<img src={props.image} title={props['image-tooltip']} />
{props.label ? '' : props.label}
</button>
</div>
)
}
return (
<div class={classesWrapper.join(' ')}>
<button
disabled={props.disabled}
onClick={this.clickTab(index)}
class={classesTab.join(' ')}
type="button"
role="tab"
>
{!props.icon ? (
''
) : (
<FAIcon
class="tab-icon"
size="2x"
fixed-width
icon={props.icon}
/>
)}
<span class="text">{props.label}</span>
</button>
</div>
)
})
const contents = this.slots().map((slot, index) => {
const props = slot.props
if (!props) return
const active = this.activeIndex === index
const classes = [active ? 'active' : 'hidden']
if (props.fullHeight || props['full-height']) {
classes.push('-full-height')
}
if (props.fullWidth || props['full-width']) {
classes.push('-full-width')
}
let delayRender = slot.props['delay-render']
if (delayRender && active) {
slot.props['delay-render'] = false
delayRender = false
}
const renderSlot =
!delayRender && (!this.renderOnlyFocused || active) ? slot : ''
return <div class={classes}>{renderSlot}</div>
})
return (
<div class="tab-switcher top-tabs" ref="root">
<div class="tabs" role="tablist" ref="nav">
{tabs}
</div>
<div
role="tabpanel"
class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')}
v-body-scroll-lock={this.bodyScrollLock}
ref="content"
>
{contents}
</div>
</div>
)
},
}
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index 50b2acd90e..17065a9ef3 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -1,342 +1,342 @@
import { debounce, keyBy, throttle } from 'lodash'
import { mapState } from 'pinia'
import Conversation from 'src/components/conversation/conversation.vue'
import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from 'src/components/quick_view_settings/quick_view_settings.vue'
import ScrollTopButton from 'src/components/scroll_top_button/scroll_top_button.vue'
import TimelineMenu from 'src/components/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: {
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 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') {
+ if (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')
+ if (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) {
+ if (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: {
filteredVisibleStatuses() {
this.determineVisibleStatuses()
},
newStatusCount(count) {
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 &&
useMergedConfigStore().mergedConfig.pauseOnUnfocused
)
) {
this.showNewStatuses()
} else {
this.paused = true
}
}
},
},
}
export default Timeline
diff --git a/src/components/unicode_domain_indicator/unicode_domain_indicator.vue b/src/components/unicode_domain_indicator/unicode_domain_indicator.vue
index a979cc51d8..08125694d8 100644
--- a/src/components/unicode_domain_indicator/unicode_domain_indicator.vue
+++ b/src/components/unicode_domain_indicator/unicode_domain_indicator.vue
@@ -1,22 +1,22 @@
<template>
<FAIcon
- v-if="user && user.screen_name_ui_contains_non_ascii"
+ v-if="user?.screen_name_ui_contains_non_ascii"
icon="code"
:title="$t('unicode_domain_indicator.tooltip')"
/>
</template>
<script>
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCode } from '@fortawesome/free-solid-svg-icons'
library.add(faCode)
const UnicodeDomainIndicator = {
props: {
user: Object,
},
}
export default UnicodeDomainIndicator
</script>
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index 7f8c68fd2a..e6c6485f76 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -1,868 +1,868 @@
<template>
<div class="user-card">
<div class="user-card-inner">
<div class="user-info">
<div
class="user-identity"
:class="{ '-compact': compact }"
>
<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"
+ :disabled="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 && !hideButtons"
: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"
:allow-non-square-emoji="allowNonSquareEmoji"
:pause-mfm="pauseMfm"
:scale-mfm="scaleMfm"
/>
</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>
<span
v-for="tag in user.tags"
:key="tag"
class="alert warning user-role"
>
{{ isKnownTag ? $t('user_card.tags.' + tag) : tag }}
</span>
</template>
</div>
</div>
</div>
<div
v-if="loggedIn && isOtherUser && !hideButtons"
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"
:users="[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) && !hideRemarks"
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>
<div
v-if="user.adminData && !hideBio"
class="admin-data"
>
<details>
<summary>
{{ $t('user_card.admin_data.data') }}
</summary>
<div class="user-profile-fields">
<dl class="user-profile-field">
<dt class="user-profile-field-name">
{{ $t('admin_dash.users.local_id') }}
</dt>
<dd class="user-profile-field-value">
{{ user.adminData.id }}
</dd>
</dl>
<dl
v-if="user.is_local"
class="user-profile-field"
>
<dt class="user-profile-field-name">
{{ $t('admin_dash.users.labels.email') }}
</dt>
<dd
class="user-profile-field-value"
:class="{ faint: user.adminData.email == null }"
>
{{ user.adminData.email == null ? $t('general.not_available') : user.adminData.email }}
</dd>
</dl>
<dl
v-if="user.is_local"
class="user-profile-field"
>
<dt class="user-profile-field-name">
{{ $t('general.role.admin') }}
</dt>
<dd class="user-profile-field-value">
{{ $t('general.' + (user.adminData.roles.admin ? 'yes' : 'no')) }}
</dd>
</dl>
<dl
v-if="user.is_local"
class="user-profile-field"
>
<dt class="user-profile-field-name">
{{ $t('general.role.moderator') }}
</dt>
<dd class="user-profile-field-value">
{{ $t('general.' + (user.adminData.roles.moderator ? 'yes' : 'no')) }}
</dd>
</dl>
<dl
v-if="user.is_local"
class="user-profile-field"
>
<dt class="user-profile-field-name">
{{ $t('admin_dash.users.indicator.confirmed') }}
</dt>
<dd class="user-profile-field-value">
{{ $t('general.' + (user.adminData.is_confirmed ? 'yes' : 'no')) }}
</dd>
</dl>
<dl
v-if="user.is_local"
class="user-profile-field"
>
<dt class="user-profile-field-name">
{{ $t('admin_dash.users.indicator.approved') }}
</dt>
<dd class="user-profile-field-value">
{{ $t('general.' + (user.adminData.is_approved ? 'yes' : 'no')) }}
</dd>
</dl>
<dl class="user-profile-field">
<dt class="user-profile-field-name">
{{ $t('admin_dash.users.indicator.suggested') }}
</dt>
<dd class="user-profile-field-value">
{{ $t('general.' + (user.adminData.is_suggested ? 'yes' : 'no')) }}
</dd>
</dl>
<details
v-if="user.is_local"
open
>
<summary>
{{ $t('user_card.admin_data.registration_reason') }}
</summary>
<span>
{{ user.adminData.registration_reason == null ? $t('general.not_available') : user.adminData.registration_reason }}
</span>
</details>
<details open>
<summary>
{{ $t('user_card.admin_data.tags') }}
</summary>
<ul>
<li v-if="user.adminData.tags.length === 0">
{{ $t('general.none') }}
</li>
<li
v-for="tag in user.adminData.tags"
:key="tag"
>
<code>
{{ tag }}
</code>
{{ ' ' }}
</li>
</ul>
</details>
</div>
</details>
</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"
:allow-non-square-emoji="allowNonSquareEmoji"
:pause-mfm="pauseMfm"
:scale-mfm="scaleMfm"
: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"
:allow-non-square-emoji="allowNonSquareEmoji"
:pause-mfm="pauseMfm"
:scale-mfm="scaleMfm"
/>
</dt>
<dd
:title="field.value"
class="user-profile-field-value"
>
<RichContent
:html="field.value"
:emoji="editable ? emoji : user.emoji"
:allow-non-square-emoji="allowNonSquareEmoji"
:pause-mfm="pauseMfm"
:scale-mfm="scaleMfm"
/>
</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 && !hideUserStats"
class="user-stats"
>
<dl
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>
<span
v-if="!hideUserStats"
class="user-stats"
>
<template v-if="!hideBio">
<dl
v-if="user.birthday && !editable"
class="user-count"
>
<dd>
<FAIcon
class="fa-old-padding"
icon="birthday-cake"
/>
</dd>
{{ ' ' }}
<dt>
{{ $t('user_card.birthday', { birthday: formattedBirthday }) }}
</dt>
</dl>
<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>
<dl
v-if="!editable"
class="user-count"
>
<dd>
{{ $t('user_card.joined') }}
</dd>
{{ ' ' }}
<dt>
{{ formattedJoinDate }}
</dt>
</dl>
</span>
</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
v-if="isOtherUser"
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/video_attachment/video_attachment.js b/src/components/video_attachment/video_attachment.js
index 6904091694..5e702723d2 100644
--- a/src/components/video_attachment/video_attachment.js
+++ b/src/components/video_attachment/video_attachment.js
@@ -1,53 +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 (useMergedConfigStore().mergedConfig.loopVideoSilentOnly) {
return !this.hasAudio
}
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') {
+ if (target.webkitAudioDecodedByteCount !== undefined) {
// non-zero if video has audio track
if (target.webkitAudioDecodedByteCount > 0) return
}
- if (typeof target.mozHasAudio !== 'undefined') {
+ if (target.mozHasAudio !== undefined) {
// true if video has audio track
if (target.mozHasAudio) return
}
- if (typeof target.audioTracks !== 'undefined') {
+ if (target.audioTracks !== undefined) {
if (target.audioTracks.length > 0) return
}
this.hasAudio = false
},
},
}
export default VideoAttachment
diff --git a/src/modules/statuses.js b/src/modules/statuses.js
index 5bec225ce0..ee5e50a393 100644
--- a/src/modules/statuses.js
+++ b/src/modules/statuses.js
@@ -1,903 +1,901 @@
import {
each,
find,
findIndex,
first,
- isArray,
last,
maxBy,
merge,
minBy,
omitBy,
remove,
- slice,
} from 'lodash'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import {
fetchEmojiReactions,
fetchFavoritedByUsers,
fetchPinnedStatuses,
fetchRebloggedByUsers,
fetchScrobbles,
fetchStatus,
fetchStatusHistory,
fetchStatusSource,
search2,
} from 'src/api/public.js'
import {
bookmarkStatus,
deleteStatus,
favorite,
muteConversation,
pinOwnStatus,
reactWithEmoji,
retweet,
unbookmarkStatus,
unfavorite,
unmuteConversation,
unpinOwnStatus,
unreactWithEmoji,
unretweet,
} from 'src/api/user.js'
const emptyTl = (userId = 0) => ({
statuses: [],
statusesObject: {},
faves: [],
visibleStatuses: [],
visibleStatusesObject: {},
newStatusCount: 0,
maxId: 0,
minId: 0,
minVisibleId: 0,
loading: false,
followers: [],
friends: [],
userId,
flushMarker: 0,
})
export const defaultState = () => ({
allStatuses: [],
scrobblesNextFetch: {},
allStatusesObject: {},
conversationsObject: {},
maxId: 0,
favorites: new Set(),
timelines: {
mentions: emptyTl(),
public: emptyTl(),
user: emptyTl(),
favorites: emptyTl(),
media: emptyTl(),
publicAndExternal: emptyTl(),
friends: emptyTl(),
tag: emptyTl(),
dms: emptyTl(),
bookmarks: emptyTl(),
list: emptyTl(),
bubble: emptyTl(),
},
})
export const prepareStatus = (status) => {
// Set deleted flag
status.deleted = false
// To make the array reactive
status.attachments = status.attachments || []
return status
}
const mergeOrAdd = (arr, obj, item) => {
const oldItem = obj[item.id]
if (oldItem) {
// We already have this, so only merge the new info.
// We ignore null values to avoid overwriting existing properties with missing data
// we also skip 'user' because that is handled by users module
merge(
oldItem,
omitBy(item, (v, k) => v === null || k === 'user'),
)
// Reactivity fix.
oldItem.attachments.splice(oldItem.attachments.length)
return { item: oldItem, new: false }
} else {
// This is a new item, prepare it
prepareStatus(item)
arr.push(item)
obj[item.id] = item
return { item, new: 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 sortTimeline = (timeline) => {
timeline.visibleStatuses = timeline.visibleStatuses.sort(sortById)
timeline.statuses = timeline.statuses.sort(sortById)
timeline.minVisibleId = (last(timeline.visibleStatuses) || {}).id
return timeline
}
const getLatestScrobble = (state, user) => {
const scrobblesSupport =
useInstanceCapabilitiesStore().pleromaScrobblesAvailable
if (!scrobblesSupport || !user.name || user.id === 'undefined') {
return
}
if (
state.scrobblesNextFetch[user.id] &&
state.scrobblesNextFetch[user.id] > Date.now()
) {
return
}
state.scrobblesNextFetch[user.id] = Date.now() + 24 * 60 * 60 * 1000
if (!scrobblesSupport) return
fetchScrobbles({ accountId: user.id })
.then(({ data: scrobbles }) => {
if (scrobbles?.error) {
useInstanceCapabilitiesStore().set('pleromaScrobblesAvailable', false)
return
}
if (scrobbles.length > 0) {
user.latestScrobble = scrobbles[0]
state.scrobblesNextFetch[user.id] = Date.now() + 60 * 1000
}
})
.catch((e) => {
console.warn('cannot fetch scrobbles', e)
})
}
// Add status to the global storages (arrays and objects maintaining statuses) except timelines
const addStatusToGlobalStorage = (state, data) => {
getLatestScrobble(state, data.user)
const result = mergeOrAdd(state.allStatuses, state.allStatusesObject, data)
if (result.new) {
// Add to conversation
const status = result.item
const conversationsObject = state.conversationsObject
const conversationId = status.statusnet_conversation_id
if (conversationsObject[conversationId]) {
conversationsObject[conversationId].push(status)
} else {
conversationsObject[conversationId] = [status]
}
}
return result
}
const addNewStatuses = (
state,
{
statuses,
showImmediately = false,
timeline,
user = {},
noIdUpdate = false,
userId,
pagination = {},
},
) => {
// Sanity check
- if (!isArray(statuses)) {
+ if (!Array.isArray(statuses)) {
return false
}
const allStatuses = state.allStatuses
const timelineObject = state.timelines[timeline]
// Mismatch between API pagination and our internal minId/maxId tracking systems:
// pagination.maxId is the oldest of the returned statuses when fetching older,
// and pagination.minId is the newest when fetching newer. The names come directly
// from the arguments they're supposed to be passed as for the next fetch.
const minNew =
pagination.maxId || (statuses.length > 0 ? minBy(statuses, 'id').id : 0)
const maxNew =
pagination.minId || (statuses.length > 0 ? maxBy(statuses, 'id').id : 0)
const newer =
timeline &&
(maxNew > timelineObject.maxId || timelineObject.maxId === 0) &&
statuses.length > 0
const older =
timeline &&
(minNew < timelineObject.minId || timelineObject.minId === 0) &&
statuses.length > 0
if (!noIdUpdate && newer) {
timelineObject.maxId = maxNew
}
if (!noIdUpdate && older) {
timelineObject.minId = minNew
}
// This makes sure that user timeline won't get data meant for other
// user. I.e. opening different user profiles makes request which could
// return data late after user already viewing different user profile
if (
(timeline === 'user' || timeline === 'media') &&
timelineObject.userId !== userId
) {
return
}
const addStatus = (data, showImmediately, addToTimeline = true) => {
const result = addStatusToGlobalStorage(state, data)
const status = result.item
if (result.new) {
// We are mentioned in a post
if (
status.type === 'status' &&
find(status.attentions, { id: user.id })
) {
const mentions = state.timelines.mentions
// Add the mention to the mentions timeline
if (timelineObject !== mentions) {
mergeOrAdd(mentions.statuses, mentions.statusesObject, status)
mentions.newStatusCount += 1
sortTimeline(mentions)
}
}
if (status.visibility === 'direct') {
const dms = state.timelines.dms
mergeOrAdd(dms.statuses, dms.statusesObject, status)
dms.newStatusCount += 1
sortTimeline(dms)
}
}
// Decide if we should treat the status as new for this timeline.
let resultForCurrentTimeline
// Some statuses should only be added to the global status repository.
if (timeline && addToTimeline) {
resultForCurrentTimeline = mergeOrAdd(
timelineObject.statuses,
timelineObject.statusesObject,
status,
)
}
if (timeline && showImmediately) {
// Add it directly to the visibleStatuses, don't change
// newStatusCount
mergeOrAdd(
timelineObject.visibleStatuses,
timelineObject.visibleStatusesObject,
status,
)
} else if (timeline && addToTimeline && resultForCurrentTimeline.new) {
// Just change newStatuscount
timelineObject.newStatusCount += 1
}
if (status.quote) {
addStatus(
status.quote,
/* showImmediately = */ false,
/* addToTimeline = */ false,
)
}
return status
}
const favoriteStatus = (favorite) => {
const status = find(allStatuses, { id: favorite.in_reply_to_status_id })
if (status) {
// This is our favorite, so the relevant bit.
if (favorite.user.id === user.id) {
status.favorited = true
} else {
status.fave_num += 1
}
}
return status
}
const processors = {
status: (status) => {
addStatus(status, showImmediately)
},
edit: (status) => {
addStatus(status, showImmediately)
},
retweet: (status) => {
// RetweetedStatuses are never shown immediately
const retweetedStatus = addStatus(status.retweeted_status, false, false)
let retweet
// If the retweeted status is already there, don't add the retweet
// to the timeline.
if (
timeline &&
find(timelineObject.statuses, (s) => {
if (s.retweeted_status) {
return (
s.id === retweetedStatus.id ||
s.retweeted_status.id === retweetedStatus.id
)
} else {
return s.id === retweetedStatus.id
}
})
) {
// Already have it visible (either as the original or another RT), don't add to timeline, don't show.
retweet = addStatus(status, false, false)
} else {
retweet = addStatus(status, showImmediately)
}
retweet.retweeted_status = retweetedStatus
},
favorite: (favorite) => {
// Only update if this is a new favorite.
// Ignore our own favorites because we get info about likes as response to like request
if (!state.favorites.has(favorite.id)) {
state.favorites.add(favorite.id)
favoriteStatus(favorite)
}
},
follow: () => {
// NOOP, it is known status but we don't do anything about it for now
},
default: (unknown) => {
console.warn('unknown status type', unknown)
},
}
each(statuses, (status) => {
const type = status.type
const processor = processors[type] || processors.default
processor(status)
})
// Keep the visible statuses sorted
if (timeline && !(timeline === 'bookmarks')) {
sortTimeline(timelineObject)
}
}
const removeStatus = (state, { timeline, userId }) => {
const timelineObject = state.timelines[timeline]
if (userId) {
remove(timelineObject.statuses, { user: { id: userId } })
remove(timelineObject.visibleStatuses, { user: { id: userId } })
timelineObject.minVisibleId =
timelineObject.visibleStatuses.length > 0
? last(timelineObject.visibleStatuses).id
: 0
timelineObject.maxId =
timelineObject.statuses.length > 0 ? first(timelineObject.statuses).id : 0
}
}
export const mutations = {
addNewStatuses,
removeStatus,
showNewStatuses(state, { timeline }) {
const oldTimeline = state.timelines[timeline]
oldTimeline.newStatusCount = 0
- oldTimeline.visibleStatuses = slice(oldTimeline.statuses, 0, 50)
+ oldTimeline.visibleStatuses = oldTimeline.statuses.slice(0, 50)
oldTimeline.minVisibleId = last(oldTimeline.visibleStatuses).id
oldTimeline.minId = oldTimeline.minVisibleId
oldTimeline.visibleStatusesObject = {}
each(oldTimeline.visibleStatuses, (status) => {
oldTimeline.visibleStatusesObject[status.id] = status
})
},
resetStatuses(state) {
const emptyState = defaultState()
Object.entries(emptyState).forEach(([key, value]) => {
state[key] = value
})
},
clearTimeline(state, { timeline, excludeUserId = false }) {
const userId = excludeUserId ? state.timelines[timeline].userId : undefined
state.timelines[timeline] = emptyTl(userId)
},
setFavorited(state, { status, value }) {
const newStatus = state.allStatusesObject[status.id]
if (newStatus.favorited !== value) {
if (value) {
newStatus.fave_num++
} else {
newStatus.fave_num--
}
}
newStatus.favorited = value
},
setFavoritedConfirm(state, { status, user }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.favorited = status.favorited
newStatus.fave_num = status.fave_num
const index = findIndex(newStatus.favoritedBy, { id: user.id })
if (index !== -1 && !newStatus.favorited) {
newStatus.favoritedBy.splice(index, 1)
} else if (index === -1 && newStatus.favorited) {
newStatus.favoritedBy.push(user)
}
},
setMutedStatus(state, status) {
const newStatus = state.allStatusesObject[status.id]
newStatus.thread_muted = status.thread_muted
if (newStatus.thread_muted !== undefined) {
state.conversationsObject[newStatus.statusnet_conversation_id].forEach(
(status) => {
status.thread_muted = newStatus.thread_muted
},
)
}
},
setRetweeted(state, { status, value }) {
const newStatus = state.allStatusesObject[status.id]
if (newStatus.repeated !== value) {
if (value) {
newStatus.repeat_num++
} else {
newStatus.repeat_num--
}
}
newStatus.repeated = value
},
setRetweetedConfirm(state, { status, user }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.repeated = status.repeated
newStatus.repeat_num = status.repeat_num
const index = findIndex(newStatus.rebloggedBy, { id: user.id })
if (index !== -1 && !newStatus.repeated) {
newStatus.rebloggedBy.splice(index, 1)
} else if (index === -1 && newStatus.repeated) {
newStatus.rebloggedBy.push(user)
}
},
setBookmarked(state, { status, value }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.bookmarked = value
newStatus.bookmark_folder_id = status.bookmark_folder_id
},
setBookmarkedConfirm(state, { status }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.bookmarked = status.bookmarked
if (status.pleroma)
newStatus.bookmark_folder_id = status.pleroma.bookmark_folder
},
setDeleted(state, { status }) {
const newStatus = state.allStatusesObject[status.id]
if (newStatus) newStatus.deleted = true
},
setManyDeleted(state, condition) {
Object.values(state.allStatusesObject).forEach((status) => {
if (condition(status)) {
status.deleted = true
}
})
},
setLoading(state, { timeline, value }) {
state.timelines[timeline].loading = value
},
setNsfw(state, { id, nsfw }) {
const newStatus = state.allStatusesObject[id]
newStatus.nsfw = nsfw
},
queueFlush(state, { timeline, id }) {
state.timelines[timeline].flushMarker = id
},
queueFlushAll(state) {
Object.keys(state.timelines).forEach((timeline) => {
state.timelines[timeline].flushMarker = state.timelines[timeline].maxId
})
},
addRepeats(state, { id, rebloggedByUsers, currentUser }) {
const newStatus = state.allStatusesObject[id]
newStatus.rebloggedBy = rebloggedByUsers.filter((_) => _)
// repeats stats can be incorrect based on polling condition, let's update them using the most recent data
newStatus.repeat_num = newStatus.rebloggedBy.length
newStatus.repeated = !!newStatus.rebloggedBy.find(
({ id }) => currentUser.id === id,
)
},
addFavs(state, { id, favoritedByUsers, currentUser }) {
const newStatus = state.allStatusesObject[id]
newStatus.favoritedBy = favoritedByUsers.filter((_) => _)
// favorites stats can be incorrect based on polling condition, let's update them using the most recent data
newStatus.fave_num = newStatus.favoritedBy.length
newStatus.favorited = !!newStatus.favoritedBy.find(
({ id }) => currentUser.id === id,
)
},
addEmojiReactionsBy(state, { id, emojiReactions }) {
const status = state.allStatusesObject[id]
status.emoji_reactions = emojiReactions
},
addOwnReaction(state, { id, emoji, currentUser }) {
const status = state.allStatusesObject[id]
const reactionIndex = findIndex(status.emoji_reactions, { name: emoji })
const reaction = status.emoji_reactions[reactionIndex] || {
name: emoji,
count: 0,
accounts: [],
}
const newReaction = {
...reaction,
count: reaction.count + 1,
me: true,
accounts: [...reaction.accounts, currentUser],
}
// Update count of existing reaction if it exists, otherwise append at the end
if (reactionIndex >= 0) {
status.emoji_reactions[reactionIndex] = newReaction
} else {
status.emoji_reactions = [...status.emoji_reactions, newReaction]
}
},
removeOwnReaction(state, { id, emoji, currentUser }) {
const status = state.allStatusesObject[id]
const reactionIndex = findIndex(status.emoji_reactions, { name: emoji })
if (reactionIndex < 0) return
const reaction = status.emoji_reactions[reactionIndex]
const accounts = reaction.accounts || []
const newReaction = {
...reaction,
count: reaction.count - 1,
me: false,
accounts: accounts.filter((acc) => acc.id !== currentUser.id),
}
if (newReaction.count > 0) {
status.emoji_reactions[reactionIndex] = newReaction
} else {
status.emoji_reactions = status.emoji_reactions.filter(
(r) => r.name !== emoji,
)
}
},
updateStatusWithPoll(state, { id, poll }) {
const status = state.allStatusesObject[id]
status.poll = poll
},
setVirtualHeight(state, { statusId, height }) {
state.allStatusesObject[statusId].virtualHeight = height
},
}
const statuses = {
state: defaultState(),
actions: {
addNewStatuses(
{ rootState, commit },
{
statuses,
showImmediately = false,
timeline = false,
noIdUpdate = false,
userId,
pagination,
},
) {
return commit('addNewStatuses', {
statuses,
showImmediately,
timeline,
noIdUpdate,
user: rootState.users.currentUser,
userId,
pagination,
})
},
fetchStatus({ rootState, dispatch }, id) {
return fetchStatus({ id }).then(({ data: status }) =>
dispatch('addNewStatuses', { statuses: [status] }),
)
},
fetchStatusSource({ rootState }, status) {
return fetchStatusSource({
id: status.id,
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
fetchStatusHistory(_, status) {
return fetchStatusHistory({ status }).then(({ data }) => data)
},
deleteStatus({ rootState, commit }, status) {
deleteStatus({
id: status.id,
credentials: useOAuthStore().token,
})
.then(() => {
commit('setDeleted', { status })
})
.catch((e) => {
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'status.delete_error',
messageArgs: [e.message],
timeout: 5000,
})
})
},
deleteStatusById({ rootState, commit }, id) {
const status = rootState.statuses.allStatusesObject[id]
commit('setDeleted', { status })
},
markStatusesAsDeleted({ commit }, condition) {
commit('setManyDeleted', condition)
},
favorite({ rootState, commit }, status) {
// Optimistic favoriting...
commit('setFavorited', { status, value: true })
favorite({
id: status.id,
credentials: useOAuthStore().token,
}).then(({ data: status }) =>
commit('setFavoritedConfirm', {
status,
user: rootState.users.currentUser,
}),
)
},
unfavorite({ rootState, commit }, status) {
// Optimistic unfavoriting...
commit('setFavorited', { status, value: false })
unfavorite({
id: status.id,
credentials: useOAuthStore().token,
}).then(({ data: status }) =>
commit('setFavoritedConfirm', {
status,
user: rootState.users.currentUser,
}),
)
},
fetchPinnedStatuses({ rootState, dispatch }, userId) {
fetchPinnedStatuses({
id: userId,
credentials: useOAuthStore().token,
}).then(({ data: statuses }) =>
dispatch('addNewStatuses', {
statuses,
timeline: 'user',
userId,
showImmediately: true,
noIdUpdate: true,
}),
)
},
pinStatus({ rootState, dispatch }, statusId) {
return pinOwnStatus({
id: statusId,
credentials: useOAuthStore().token,
}).then(({ data: status }) =>
dispatch('addNewStatuses', { statuses: [status] }),
)
},
unpinStatus({ rootState, dispatch }, statusId) {
return unpinOwnStatus({
id: statusId,
credentials: useOAuthStore().token,
}).then(({ data: status }) =>
dispatch('addNewStatuses', { statuses: [status] }),
)
},
muteConversation({ rootState, commit }, { id: statusId }) {
return muteConversation({
id: statusId,
credentials: useOAuthStore().token,
}).then(({ data: status }) => commit('setMutedStatus', status))
},
unmuteConversation({ rootState, commit }, { id: statusId }) {
return unmuteConversation({
id: statusId,
credentials: useOAuthStore().token,
}).then(({ data: status }) => commit('setMutedStatus', status))
},
retweet({ rootState, commit }, status) {
// Optimistic retweeting...
commit('setRetweeted', { status, value: true })
retweet({
id: status.id,
credentials: useOAuthStore().token,
}).then(({ data: status }) =>
commit('setRetweetedConfirm', {
status: status.retweeted_status,
user: rootState.users.currentUser,
}),
)
},
unretweet({ rootState, commit }, status) {
// Optimistic unretweeting...
commit('setRetweeted', { status, value: false })
unretweet({
id: status.id,
credentials: useOAuthStore().token,
}).then(({ data: status }) =>
commit('setRetweetedConfirm', {
status,
user: rootState.users.currentUser,
}),
)
},
bookmark({ rootState, commit }, status) {
commit('setBookmarked', { status, value: true })
bookmarkStatus({
id: status.id,
folder_id: status.bookmark_folder_id,
credentials: useOAuthStore().token,
}).then(({ data: status }) => {
commit('setBookmarkedConfirm', { status })
})
},
unbookmark({ rootState, commit }, status) {
commit('setBookmarked', { status, value: false })
unbookmarkStatus({
id: status.id,
credentials: useOAuthStore().token,
}).then(({ data: status }) => {
commit('setBookmarkedConfirm', { status })
})
},
queueFlush({ commit }, { timeline, id }) {
commit('queueFlush', { timeline, id })
},
queueFlushAll({ commit }) {
commit('queueFlushAll')
},
fetchFavsAndRepeats({ rootState, commit }, id) {
Promise.all([
fetchFavoritedByUsers({
id,
credentials: useOAuthStore().token,
}).then(({ data }) => data),
fetchRebloggedByUsers({
id,
credentials: useOAuthStore().token,
}).then(({ data }) => data),
]).then(([favoritedByUsers, rebloggedByUsers]) => {
commit('addFavs', {
id,
favoritedByUsers,
currentUser: rootState.users.currentUser,
})
commit('addRepeats', {
id,
rebloggedByUsers,
currentUser: rootState.users.currentUser,
})
})
},
reactWithEmoji({ rootState, dispatch, commit }, { id, emoji }) {
const currentUser = rootState.users.currentUser
if (!currentUser) return
commit('addOwnReaction', { id, emoji, currentUser })
reactWithEmoji({
id,
emoji,
credentials: useOAuthStore().token,
}).then(() => {
dispatch('fetchEmojiReactionsBy', id)
})
},
unreactWithEmoji({ rootState, dispatch, commit }, { id, emoji }) {
const currentUser = rootState.users.currentUser
if (!currentUser) return
commit('removeOwnReaction', { id, emoji, currentUser })
unreactWithEmoji({
id,
emoji,
currentUser: rootState.users.currentUser,
}).then(() => {
dispatch('fetchEmojiReactionsBy', id)
})
},
fetchEmojiReactionsBy({ rootState, commit }, id) {
return fetchEmojiReactions({
id,
credentials: useOAuthStore().token,
}).then(({ data: emojiReactions }) => {
commit('addEmojiReactionsBy', {
id,
emojiReactions,
currentUser: rootState.users.currentUser,
})
})
},
fetchFavs({ rootState, commit }, id) {
fetchFavoritedByUsers({
id,
credentials: useOAuthStore().token,
}).then(({ data: favoritedByUsers }) =>
commit('addFavs', {
id,
favoritedByUsers,
currentUser: rootState.users.currentUser,
}),
)
},
fetchRepeats({ rootState, commit }, id) {
fetchRebloggedByUsers({
id,
credentials: useOAuthStore().token,
}).then(({ data: rebloggedByUsers }) =>
commit('addRepeats', {
id,
rebloggedByUsers,
currentUser: rootState.users.currentUser,
}),
)
},
search(store, { q, resolve, limit, offset, following, type }) {
return search2({
q,
resolve,
limit,
offset,
following,
type,
credentials: useOAuthStore().token,
}).then(({ data }) => {
store.commit('addNewUsers', data.accounts)
store.commit(
'addNewUsers',
data.statuses.map((s) => s.user).filter((u) => u),
)
store.commit('addNewStatuses', {
statuses: data.statuses,
})
data.statuses = data.statuses.map(
(s) => store.state.allStatusesObject[s.id],
)
return data
})
},
setVirtualHeight({ commit }, { statusId, height }) {
commit('setVirtualHeight', { statusId, height })
},
},
mutations,
}
export default statuses
diff --git a/src/modules/users.js b/src/modules/users.js
index 8777f18da0..b585668591 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -1,879 +1,870 @@
import Cookies from 'js-cookie'
-import {
- compact,
- concat,
- each,
- isArray,
- last,
- map,
- mergeWith,
- uniq,
-} from 'lodash'
+import { compact, each, last, map, mergeWith } from 'lodash'
import {
registerPushNotifications,
unregisterPushNotifications,
} from '../services/sw/sw.js'
import {
windowHeight,
windowWidth,
} from '../services/window_utils/window_utils'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js'
import { useChatsStore } from 'src/stores/chats.js'
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 { useListsStore } from 'src/stores/lists.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import { revokeToken } from 'src/api/oauth.js'
import {
fetchFollowers,
fetchFriends,
fetchUser,
fetchUserByName,
getCaptcha,
register,
searchUsers,
verifyCredentials,
} from 'src/api/public.js'
import {
blockUser as apiBlockUser,
editUserNote as apiEditUserNote,
muteUser as apiMuteUser,
unblockUser as apiUnblockUser,
unmuteUser as apiUnmuteUser,
fetchBlocks,
fetchDomainMutes,
fetchMutes,
fetchUserInLists,
fetchUserRelationship,
followUser,
} from 'src/api/user.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)) {
+ if (Array.isArray(oldValue) && Array.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 apiBlockUser({ id, expiresIn }).then(({ data: 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 apiUnblockUser({ id }).then(({ data: relationship }) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const removeUserFromFollowers = (store, id) => {
return removeUserFromFollowers({ id }).then((relationship) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const editUserNote = (store, { id, comment }) => {
return apiEditUserNote({ 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 apiMuteUser({
id,
expiresIn,
credentials: useOAuthStore().token,
}).then(({ data: 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 apiUnmuteUser({ id }).then(({ data: relationship }) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const hideReblogs = (store, userId) => {
return followUser({
id: userId,
reblogs: false,
credentials: useOAuthStore().token,
}).then(({ data: relationship }) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const showReblogs = (store, userId) => {
return followUser({
id: userId,
reblogs: true,
credentials: useOAuthStore().token,
}).then(({ data: relationship }) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const muteDomain = (store, domain) => {
return muteDomain({
domain,
credentials: useOAuthStore().token,
}).then(() => store.commit('addDomainMute', domain))
}
const unmuteDomain = (store, domain) => {
return unmuteDomain({
domain,
credentials: useOAuthStore().token,
}).then(() => store.commit('removeDomainMute', domain))
}
export const mutations = {
tagUser(state, { user: { id }, tag }) {
const user = state.usersObject[id]
user.tags.add(tag)
},
untagUser(state, { user: { id }, tag }) {
const user = state.usersObject[id]
user.tags.delete(tag)
},
updateRight(state, { user: { id }, right, value }) {
const user = state.usersObject[id]
const newRights = user.rights
newRights[right] = value
user.rights = newRights
},
updateUserAdminData(state, { user }) {
const { id } = user
const localUser = state.usersObject[id]
localUser.adminData = user
localUser.deactivated = !user.is_active
localUser.tags = new Set(user.tags)
},
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))
+ user.friendIds = [...new Set([...(user.friendIds || []), ...friendIds])]
},
saveFollowerIds(state, { id, followerIds }) {
const user = state.usersObject[id]
- user.followerIds = uniq(concat(user.followerIds || [], followerIds))
+ user.followerIds = [...new Set([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) {
const user = store.getters.findUser(id)
if (!user) {
return store.dispatch('fetchUser', id)
} else {
return Promise.resolve(user)
}
},
updateUserAdminData(store, { userAdminData }) {
return store
.dispatch('fetchUserIfMissing', userAdminData.id)
.then((user) => {
user.adminData = userAdminData
store.commit('addNewUsers', [user])
return user
})
},
fetchUser(store, id) {
return fetchUser({
id,
credentials: useOAuthStore().token,
})
.then(({ data: user }) => {
store.commit('addNewUsers', [user])
return user
})
.catch((error) => {
if (error.statusCode === 404) {
console.warn(`User ${id} not found`)
} else {
throw error
}
})
},
fetchUserByName(store, name) {
return fetchUserByName({
name,
credentials: useOAuthStore().token,
}).then(({ data: user }) => {
store.commit('addNewUsers', [user])
return user
})
},
fetchUserRelationship(store, id) {
if (store.state.currentUser) {
fetchUserRelationship({
id,
credentials: useOAuthStore().token,
}).then(({ data: relationships }) =>
store.commit('updateUserRelationship', relationships),
)
}
},
fetchUserInLists(store, id) {
if (store.state.currentUser) {
fetchUserInLists({
id,
credentials: useOAuthStore().token,
}).then(({ data: inLists }) =>
store.commit('updateUserInLists', { id, inLists }),
)
}
},
fetchBlocks(store, args) {
const { reset } = args || {}
const maxId = store.state.currentUser.blockIdsMaxId
return fetchBlocks({
maxId,
credentials: useOAuthStore().token,
}).then(({ data: 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 fetchMutes({
maxId,
credentials: useOAuthStore().token,
}).then(({ data: 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 fetchDomainMutes({
credentials: useOAuthStore().token,
}).then(({ data: 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 fetchFriends({
id,
maxId,
credentials: useOAuthStore().token,
}).then(({ data: 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 fetchFollowers({
id,
maxId,
credentials: useOAuthStore().token,
}).then(({ data: 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 followUser({
id,
notify: true,
credentials: useOAuthStore().token,
}).then(({ data: relationship }) =>
commit('updateUserRelationship', [relationship]),
)
},
unsubscribeUser({ rootState, commit }, id) {
return followUser({
id,
notify: false,
credentials: useOAuthStore().token,
}).then(({ data: relationship }) =>
commit('updateUserRelationship', [relationship]),
)
},
registerPushNotifications(store) {
const token = store.state.currentUser.credentials
const vapidPublicKey = useInstanceStore().vapidPublicKey
const isEnabled = useMergedConfigStore().mergedConfig.webPushNotifications
const 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 searchUsers({
query,
credentials: useOAuthStore().token,
}).then(({ data: 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 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
}
},
getCaptcha(store) {
return getCaptcha({
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
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 revokeToken(params)
})
.then(() => {
store.commit('clearCurrentUser')
store.dispatch('disconnectFromSocket')
store.dispatch('stopFetchingTimeline', 'friends')
store.dispatch('stopFetchingNotifications')
useListsStore().stopFetching()
useBookmarkFoldersStore().stopFetching()
store.dispatch('stopFetchingFollowRequests')
store.commit('clearNotifications')
store.commit('resetStatuses')
useChatsStore().resetChats()
oauth.clearToken()
Cookies.remove('__Host-pleroma_key', { path: '/' })
useInterfaceStore().setLastTimeline('public-timeline')
useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight())
})
},
loginUser(store, accessToken) {
return new Promise((resolve, reject) => {
const commit = store.commit
const dispatch = store.dispatch
commit('beginLogin')
verifyCredentials({
credentials: useOAuthStore().token,
})
.then(({ data: user }) => {
// user.credentials = userCredentials
user.credentials = accessToken
user.blockIds = []
user.muteIds = []
user.domainMutes = []
commit('setCurrentUser', 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),
)
// Do server-side storage migrations
// Debug snippet to clean up storage and reset migrations
/*
// Reset wordfilter
Object.keys(
useSyncConfigStore().prefsStorage.simple.muteFilters
).forEach(key => {
useSyncConfigStore().unsetSimplePrefAndSave({ path: 'muteFilters.' + key, value: null })
})
// Reset flag to 0 to re-run migrations
useSyncConfigStore().setFlag({ flag: 'configMigration', value: 0 })
/**/
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')
}
}
useListsStore().startFetching()
useBookmarkFoldersStore().startFetching()
if (user.locked) {
dispatch('startFetchingFollowRequests')
}
if (useMergedConfigStore().mergedConfig.useStreamingApi) {
dispatch('fetchTimeline', {
timeline: 'friends',
sinceId: null,
})
dispatch('fetchNotifications', { sinceId: 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()
}
// Start fetching things that don't need to block the UI
useAnnouncementsStore().startFetchingAnnouncements()
dispatch('fetchMutes')
dispatch('loadDrafts')
useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight())
// Fetch our friends
fetchFriends({ id: user.id }).then(({ data: friends }) =>
commit('addNewUsers', friends),
)
commit('endLogin')
resolve()
})
.catch((error) => {
console.error(error)
// Authentication failed
commit('endLogin')
// remove authentication token on client/authentication errors
if ([400, 401, 403, 422].includes(error.statusCode)) {
useOAuthStore().clearToken()
}
commit('endLogin')
if (error.tatusCode === 401) {
throw new Error('Wrong username or password', error)
} else {
throw new Error('An error occurred, please try again', error)
}
})
})
},
},
}
export default users
diff --git a/src/services/color_convert/color_convert.js b/src/services/color_convert/color_convert.js
index 9840f97263..dded73da3e 100644
--- a/src/services/color_convert/color_convert.js
+++ b/src/services/color_convert/color_convert.js
@@ -1,297 +1,297 @@
import { contrastRatio, convert, invertLightness } from 'chromatism'
// useful for visualizing color when debugging
// const consoleColor = (color) => console.debug('%c##########', 'background: ' + color + '; color: ' + color)
/**
* Convert r, g, b values into hex notation. All components are [0-255]
*
* @param {Number|String|Object} r - Either red component, {r,g,b} object, or hex string
* @param {Number} [g] - Green component
* @param {Number} [b] - Blue component
*/
export const rgb2hex = (r, g, b) => {
if (r === null || typeof r === 'undefined') {
return undefined
}
// TODO: clean up this mess
if (r[0] === '#' || r === 'transparent') {
return r
}
if (typeof r === 'object') {
;({ r, g, b } = r)
}
;[r, g, b] = [r, g, b].map((val) => {
val = Math.ceil(val)
val = val < 0 ? 0 : val
val = val > 255 ? 255 : val
return val
})
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`
}
/**
* Converts 8-bit RGB component into linear component
* https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
* https://www.w3.org/TR/2008/REC-WCAG20-20081211/relative-luminance.xml
* https://en.wikipedia.org/wiki/SRGB#The_reverse_transformation
*
* @param {Number} bit - color component [0..255]
* @returns {Number} linear component [0..1]
*/
const c2linear = (bit) => {
// W3C gives 0.03928 while wikipedia states 0.04045
// what those magical numbers mean - I don't know.
// something about gamma-correction, i suppose.
// Sticking with W3C example.
const c = bit / 255
if (c < 0.03928) {
return c / 12.92
} else {
return Math.pow((c + 0.055) / 1.055, 2.4)
}
}
/**
* Calculates relative luminance for given color
* https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
* https://www.w3.org/TR/2008/REC-WCAG20-20081211/relative-luminance.xml
*
* @param {Object} srgb - sRGB color
* @returns {Number} relative luminance
*/
export const relativeLuminance = (srgb) => {
const r = c2linear(srgb.r)
const g = c2linear(srgb.g)
const b = c2linear(srgb.b)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
/**
* Generates color ratio between two colors. Order is unimporant
* https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef
*
* @param {Object} a - sRGB color
* @param {Object} b - sRGB color
* @returns {Number} color ratio
*/
export const getContrastRatio = (a, b) => {
const la = relativeLuminance(a)
const lb = relativeLuminance(b)
const [l1, l2] = la > lb ? [la, lb] : [lb, la]
return (l1 + 0.05) / (l2 + 0.05)
}
/**
* Same as `getContrastRatio` but for multiple layers in-between
*
* @param {Object} text - text color (topmost layer)
* @param {[Object, Number]} layers[] - layers between text and bedrock
* @param {Object} bedrock - layer at the very bottom
*/
export const getContrastRatioLayers = (text, layers, bedrock) => {
return getContrastRatio(alphaBlendLayers(bedrock, layers), text)
}
/**
* Blending of two solid colors with a user-defined operator: origin +- value
*
* @param {Object} origin - base color
* @param {Object} value - modification argument
* @param {string} operator - math operator to use
*/
export const arithmeticBlend = (origin, value, operator) => {
const func = (a, b) => {
switch (operator) {
case '+':
return Math.min(a + b, 255)
case '-':
return Math.max(a - b, 0)
default:
return a
}
}
return {
r: func(origin.r, value.r),
g: func(origin.g, value.g),
b: func(origin.b, value.b),
}
}
/**
* This performs alpha blending between solid background and semi-transparent foreground
*
* @param {Object} fg - top layer color
* @param {Number} fga - top layer's alpha
* @param {Object} bg - bottom layer color
* @returns {Object} sRGB of resulting color
*/
export const alphaBlend = (fg, fga, bg) => {
if (fga === 1 || typeof fga === 'undefined') {
return fg
}
// Simplified https://en.wikipedia.org/wiki/Alpha_compositing#Alpha_blending
// for opaque bg and transparent fg
return {
r: fg.r * fga + bg.r * (1 - fga),
g: fg.g * fga + bg.g * (1 - fga),
b: fg.b * fga + bg.b * (1 - fga),
}
}
/**
* Same as `alphaBlend` but for multiple layers in-between
*
* @param {Object} bedrock - layer at the very bottom
* @param {[Object, Number]} layers[] - layers between text and bedrock
*/
export const alphaBlendLayers = (bedrock, layers) =>
layers.reduce((acc, [color, opacity]) => {
return alphaBlend(color, opacity, acc)
}, bedrock)
export const invert = (rgb) => {
return {
r: 255 - rgb.r,
g: 255 - rgb.g,
b: 255 - rgb.b,
}
}
/**
* Converts #rrggbb hex notation into an {r, g, b} object
*
* @param {String} hex - #rrggbb string
* @returns {Object} rgb representation of the color, values are 0-255
*/
export const hex2rgb = (hex) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result
? {
- r: parseInt(result[1], 16),
- g: parseInt(result[2], 16),
- b: parseInt(result[3], 16),
+ r: Number.parseInt(result[1], 16),
+ g: Number.parseInt(result[2], 16),
+ b: Number.parseInt(result[3], 16),
}
: null
}
/**
* Old somewhat weird function for mixing two colors together
*
* @param {Object} a - one color (rgb)
* @param {Object} b - other color (rgb)
* @returns {Object} result
*/
export const mixrgb = (a, b) => {
return {
r: (a.r + b.r) / 2,
g: (a.g + b.g) / 2,
b: (a.b + b.b) / 2,
}
}
/**
* Converts rgb object into a CSS rgba() color
*
* @param {Object} color - rgb
* @returns {String} CSS rgba() color
*/
export const rgba2css = function (rgba) {
const base = {
r: 0,
g: 0,
b: 0,
a: 1,
}
if (rgba !== null) {
if (rgba.r !== undefined && !isNaN(rgba.r)) {
base.r = rgba.r
}
if (rgba.g !== undefined && !isNaN(rgba.g)) {
base.g = rgba.g
}
if (rgba.b !== undefined && !isNaN(rgba.b)) {
base.b = rgba.b
}
if (rgba.a !== undefined && !isNaN(rgba.a)) {
base.a = rgba.a
}
} else {
base.r = 255
base.g = 255
base.b = 255
}
return `rgba(${Math.floor(base.r)}, ${Math.floor(base.g)}, ${Math.floor(base.b)}, ${base.a})`
}
/**
* Get text color for given background color and intended text color
* This checks if text and background don't have enough color and inverts
* text color's lightness if needed. If text color is still not enough it
* will fall back to black or white
*
* @param {Object} bg - background color
* @param {Object} text - intended text color
* @param {Boolean} preserve - try to preserve intended text color's hue/saturation (i.e. no BW)
*/
export const getTextColor = function (bg, text, preserve) {
const originalContrast = getContrastRatio(bg, text)
if (!preserve) {
if (originalContrast < 4.5) {
// B&W
return contrastRatio(bg, text).rgb
}
}
const originalColor = convert(text).hex
const invertedColor = invertLightness(originalColor).hex
const invertedContrast = getContrastRatio(bg, convert(invertedColor).rgb)
let workColor
if (invertedContrast > originalContrast) {
workColor = invertedColor
} else {
workColor = originalColor
}
let contrast = getContrastRatio(bg, text)
const result = convert(rgb2hex(workColor)).hsl
const delta = result.l >= 50 ? 1 : -1
const multiplier = 1
while (contrast < 4.5 && result.l > 20 && result.l < 80) {
result.l += delta * multiplier
result.l = Math.min(100, Math.max(0, result.l))
contrast = getContrastRatio(bg, convert(result).rgb)
}
- const base = typeof text.a !== 'undefined' ? { a: text.a } : {}
+ const base = text.a !== undefined ? { a: text.a } : {}
return Object.assign(convert(result).rgb, base)
}
/**
* Converts color to CSS Color value
*
* @param {Object|String} input - color
* @param {Number} [a] - alpha value
* @returns {String} a CSS Color value
*/
export const getCssColor = (input, a) => {
let rgb = {}
if (typeof input === 'object') {
rgb = input
} else if (typeof input === 'string') {
if (input.startsWith('#')) {
rgb = hex2rgb(input)
} else {
return input
}
}
return rgba2css({ ...rgb, a })
}
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 94a5681bee..d2df5e8699 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -1,426 +1,426 @@
import { parseLinkHeader } from '@web3-storage/parse-link-header'
import escapeHtml from 'escape-html'
import { unescape as lodashUnescape } from 'lodash'
import punycode from 'punycode.js'
import { fileType } 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 []
*/
export const parseUser = (data) => {
const output = {}
output._original = data // used for server-side settings
// case for users in "mentions" property for statuses in MastoAPI
const mastoShort = !Object.hasOwn(data, 'avatar')
output.inLists = null
output.id = String(data.id)
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 = new Set(data.pleroma.privileges)
} else if (data.pleroma.is_admin) {
output.privileges = new Set([
'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 = new Set(['messages_delete', 'reports_manage_reports'])
} else {
output.privileges = new Set()
}
}
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 =
typeof data.source.pleroma.show_role === 'boolean'
? data.source.pleroma.show_role
: true
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('@')
output.created_at = new Date(data.created_at)
output.locked = data.locked
output.last_status_at = new Date(data.last_status_at)
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 = new Set(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 !== 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 || new Set()
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 = {}
// 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
if (data.type !== 'unknown') {
// treat gifv like it is "video"
output.type = data.type === 'gifv' ? 'video' : data.type
} else {
output.type = fileType(output.mimetype)
}
output.url = data.url
output.large_thumb_url = data.preview_url
output.description = lodashUnescape(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 = {}
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
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
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(data.account)
output.attentions = (data.mentions || []).map(parseUser)
output.attachments = (data.media_attachments || []).map(parseAttachment)
const retweetedStatus = data.reblog
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 output = {}
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)
}
output.created_at = new Date(data.created_at)
- output.id = parseInt(data.id)
+ output.id = Number.parseInt(data.id)
return output
}
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),
+ maxId: flakeId ? maxId : Number.parseInt(maxId, 10),
+ minId: flakeId ? minId : Number.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/errors/errors.js b/src/services/errors/errors.js
index ccbae9b3e0..5fbb8da110 100644
--- a/src/services/errors/errors.js
+++ b/src/services/errors/errors.js
@@ -1,70 +1,70 @@
import { capitalize } from 'lodash'
function humanizeErrors(errors) {
return Object.entries(errors).reduce((errs, [k, val]) => {
const message = val.reduce((acc, message) => {
const key = capitalize(k.replace(/_/g, ' '))
return acc + [key, message].join(' ') + '. '
}, '')
return [...errs, message]
}, [])
}
export function StatusCodeError(statusCode, body, options, response) {
this.name = 'StatusCodeError'
this.statusCode = statusCode
this.statusText = body.error || body.errors || body
- this.details = JSON && JSON.stringify ? JSON.stringify(body) : body
+ this.details = JSON.stringify(body)
this.errorData = body.error || body.errors
this.message = this.statusCode + ' - ' + this.statusText
this.error = body // legacy attribute
this.options = options
this.response = response
if (Error.captureStackTrace) {
// required for non-V8 environments
Error.captureStackTrace(this)
}
}
StatusCodeError.prototype = Object.create(Error.prototype)
StatusCodeError.prototype.constructor = StatusCodeError
export class RegistrationError extends Error {
constructor(error) {
super()
if (Error.captureStackTrace) {
Error.captureStackTrace(this)
}
try {
// the error is probably a JSON object with a single key, "errors", whose value is another JSON object containing the real errors
if (typeof error === 'string') {
error = JSON.parse(error)
// eslint-disable-next-line
if (Object.hasOwn(error, 'error')) {
error = JSON.parse(error.error)
}
}
if (typeof error === 'object') {
const errorContents = JSON.parse(error.error)
// keys will have the property that has the error, for example 'ap_id',
// 'email' or 'captcha', the value will be an array of its error
// like "ap_id": ["has been taken"] or "captcha": ["Invalid CAPTCHA"]
// replace ap_id with username
if (errorContents.ap_id) {
errorContents.username = errorContents.ap_id
delete errorContents.ap_id
}
this.message = humanizeErrors(errorContents)
} else {
this.message = error
}
} catch {
// can't parse it, so just treat it like a string
this.message = error
}
}
}
diff --git a/src/services/poll/poll.service.js b/src/services/poll/poll.service.js
index 468fc6ff9b..84bd793707 100644
--- a/src/services/poll/poll.service.js
+++ b/src/services/poll/poll.service.js
@@ -1,34 +1,32 @@
-import { uniq } from 'lodash'
-
import * as DateUtils from 'src/services/date_utils/date_utils.js'
const pollFallbackValues = {
pollType: 'single',
options: ['', ''],
expiryAmount: 10,
expiryUnit: 'minutes',
}
export const pollFallback = (object, attr) => {
return object[attr] !== undefined ? object[attr] : pollFallbackValues[attr]
}
export const pollFormToMasto = (poll) => {
const expiresIn = DateUtils.unitToSeconds(
pollFallback(poll, 'expiryUnit'),
pollFallback(poll, 'expiryAmount'),
)
- const options = uniq(
- pollFallback(poll, 'options').filter((option) => option !== ''),
- )
+ const options = [
+ ...new Set(pollFallback(poll, 'options').filter((option) => option !== '')),
+ ]
if (options.length < 2) {
return { errorKey: 'polls.not_enough_options' }
}
return {
options,
multiple: pollFallback(poll, 'pollType') === 'multiple',
expiresIn,
}
}
diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js
index 974d75ec05..cb445d2c16 100644
--- a/src/services/style_setter/style_setter.js
+++ b/src/services/style_setter/style_setter.js
@@ -1,370 +1,369 @@
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 { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { promisedRequest } from 'src/api/helpers.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
}
if (newRule.startsWith('::-webkit')) {
if (typeof CSS.supports !== 'function') return
// firefox doesn't like invalid selectors
const fullWebkitScrollbarSupport =
CSS.supports('selector(::-webkit-scrollbar)') &&
CSS.supports('selector(::-webkit-scrollbar-button)') &&
CSS.supports('selector(::-webkit-resizer)') &&
CSS.supports('selector(::-webkit-scrollbar-thumb)')
if (!fullWebkitScrollbarSupport) 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) => {
try {
css.insertRule(r)
} catch (e) {
console.warn('Error inserting rule:', e, 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'
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) => {
+ getCssRules(result.filter(Boolean), 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() &&
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("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,
}
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,
+ Number.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(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 = (x) => x) => {
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,
() =>
promisedRequest({
url: v,
cache,
})
.then(({ data: 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 { data: builtinData } = await promisedRequest({ url, cache })
builtin = resourceTransform(builtinData)
} catch {
builtin = []
console.warn(`Builtin resources at ${url} unavailable`)
}
try {
const { data: customData } = await promisedRequest({
url: customUrl,
cache,
})
custom = resourceTransform(customData)
} 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 e744e37aac..b45409b283 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)))
+ return Uint8Array.from([...rawData].map((char) => char.codePointAt(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 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()) {
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/theme_data/css_utils.js b/src/services/theme_data/css_utils.js
index 2e3758ad52..990db652c5 100644
--- a/src/services/theme_data/css_utils.js
+++ b/src/services/theme_data/css_utils.js
@@ -1,190 +1,190 @@
import { convert } from 'chromatism'
import { hex2rgb, rgba2css } from '../color_convert/color_convert.js'
export const getCssColorString = (color, alpha = 1) =>
rgba2css({ ...convert(color).rgb, a: alpha })
export const getCssShadow = (input, usesDropShadow) => {
if (input.length === 0) {
return 'none'
}
return input
.filter((_) => (usesDropShadow ? _.inset : _))
.map((shad) =>
[shad.x, shad.y, shad.blur, shad.spread]
.map((_) => _ + 'px ')
.concat([
getCssColorString(shad.color, shad.alpha),
shad.inset ? 'inset' : '',
])
.join(' '),
)
.join(', ')
}
export const getCssShadowFilter = (input) => {
if (input.length === 0) {
return 'none'
}
return (
input
// drop-shadow doesn't support inset or spread
.filter((shad) => !shad.inset && Number(shad.spread) === 0)
.map((shad) =>
[
shad.x,
shad.y,
// drop-shadow's blur is twice as strong compared to box-shadow
shad.blur / 2,
]
.map((_) => _ + 'px')
.concat([getCssColorString(shad.color, shad.alpha)])
.join(' '),
)
.map((_) => `drop-shadow(${_})`)
.join(' ')
)
}
// `debug` changes what backgrounds are used to "stacked" solid colors so you can see
// what theme engine "thinks" is actual background color is for purposes of text color
// generation and for when --stacked variable is used
export const getCssRules = (rules, debug) =>
rules
.map((rule) => {
let selector = rule.selector
if (!selector) {
selector = 'html'
}
const header = selector + ' {'
const footer = '}'
const virtualDirectives = Object.entries(rule.virtualDirectives || {})
.map(([k, v]) => {
return ' ' + k + ': ' + v
})
.join(';\n')
const directives = Object.entries(rule.directives)
.map(([k, v]) => {
switch (k) {
case 'roundness': {
return ' ' + ['--roundness: ' + v + 'px'].join(';\n ')
}
case 'shadow': {
if (!rule.dynamicVars.shadow) {
return ''
}
return (
' ' +
[
'--shadow: ' + getCssShadow(rule.dynamicVars.shadow),
'--shadowFilter: ' +
getCssShadowFilter(rule.dynamicVars.shadow),
'--shadowInset: ' +
getCssShadow(rule.dynamicVars.shadow, true),
].join(';\n ')
)
}
case 'background': {
if (debug) {
return `
--background: ${getCssColorString(rule.dynamicVars.stacked)};
background-color: ${getCssColorString(rule.dynamicVars.stacked)};
`
}
if (v === 'transparent') {
if (rule.component === 'Root') return null
return [
rule.directives.backgroundNoCssColor !== 'yes'
? 'background-color: ' + v
: '',
' --background: ' + v,
]
- .filter((x) => x)
+ .filter(Boolean)
.join(';\n')
}
const color = getCssColorString(
rule.dynamicVars.background,
rule.directives.opacity,
)
const cssDirectives = ['--background: ' + color]
if (rule.directives.backgroundNoCssColor !== 'yes') {
cssDirectives.push('background-color: ' + color)
}
- return cssDirectives.filter((x) => x).join(';\n')
+ return cssDirectives.filter(Boolean).join(';\n')
}
case 'blur': {
const cssDirectives = []
if (rule.directives.opacity < 1) {
cssDirectives.push(`--backdrop-filter: blur(${v}) `)
if (rule.directives.backgroundNoCssColor !== 'yes') {
cssDirectives.push(`backdrop-filter: blur(${v}) `)
}
}
return cssDirectives.join(';\n')
}
case 'font': {
return 'font-family: ' + v
}
case 'textColor': {
if (rule.directives.textNoCssColor === 'yes') {
return ''
}
return 'color: ' + v
}
default:
if (k.startsWith('--')) {
const [type, value] = v.split('|').map((x) => x.trim())
switch (type) {
case 'color': {
const color = rule.dynamicVars[k]
if (typeof color === 'string') {
return k + ': ' + rgba2css(hex2rgb(color))
} else {
return k + ': ' + rgba2css(color)
}
}
case 'generic':
return k + ': ' + value
default:
return null
}
}
return null
}
})
- .filter((x) => x)
+ .filter(Boolean)
.map((x) => ' ' + x + ';')
.join('\n')
return [
header,
directives,
rule.component === 'Text' &&
rule.state.indexOf('faint') < 0 &&
rule.directives.textNoCssColor !== 'yes'
? ' color: var(--text);'
: '',
virtualDirectives,
footer,
]
- .filter((x) => x)
+ .filter(Boolean)
.join('\n')
})
- .filter((x) => x)
+ .filter(Boolean)
export const getScopedVersion = (rules, newScope) => {
return rules.map((x) => {
if (x.startsWith('html')) {
return x.replace('html', newScope)
} else if (x.startsWith('#content')) {
return x.replace('#content', newScope)
} else {
return newScope + ' > ' + x
}
})
}
diff --git a/src/services/theme_data/iss_serializer.js b/src/services/theme_data/iss_serializer.js
index a9c3887dd3..5a336f10bf 100644
--- a/src/services/theme_data/iss_serializer.js
+++ b/src/services/theme_data/iss_serializer.js
@@ -1,67 +1,67 @@
import { deserializeShadow } from './iss_deserializer.js'
import { unroll } from './iss_utils.js'
export const serializeShadow = (s) => {
if (typeof s === 'object') {
const inset = s.inset ? 'inset ' : ''
const name = s.name ? ` #${s.name} ` : ''
const result = `${inset}${s.x} ${s.y} ${s.blur} ${s.spread} ${s.color} / ${s.alpha}${name}`
deserializeShadow(result) // Verify that output is valid and parseable
return result
} else {
return s
}
}
export const serialize = (ruleset) => {
return ruleset
.map((rule) => {
if (Object.keys(rule.directives || {}).length === 0) return false
const header = unroll(rule)
.reverse()
.map((rule) => {
const { component } = rule
const newVariant =
rule.variant == null || rule.variant === 'normal'
? ''
: '.' + rule.variant
const newState = (rule.state || []).filter((st) => st !== 'normal')
return `${component}${newVariant}${newState.map((st) => ':' + st).join('')}`
})
.join(' ')
const content = Object.entries(rule.directives).map(
([directive, value]) => {
if (directive.startsWith('--')) {
const [valType, newValue] = value.split('|') // only first one! intentional!
switch (valType) {
case 'shadow':
return ` ${directive}: ${valType.trim()} | ${newValue
.map(serializeShadow)
.map((s) => s.trim())
.join(', ')}`
default:
return ` ${directive}: ${valType.trim()} | ${newValue.trim()}`
}
} else {
switch (directive) {
case 'shadow':
if (value.length > 0) {
return ` ${directive}: ${value.map(serializeShadow).join(', ')}`
} else {
return ` ${directive}: none`
}
default:
return ` ${directive}: ${value}`
}
}
},
)
return `${header} {\n${content.join(';\n')}\n}`
})
- .filter((x) => x)
+ .filter(Boolean)
.join('\n\n')
}
diff --git a/src/services/theme_data/theme2_to_theme3.js b/src/services/theme_data/theme2_to_theme3.js
index aa323ac91f..5bab4818cc 100644
--- a/src/services/theme_data/theme2_to_theme3.js
+++ b/src/services/theme_data/theme2_to_theme3.js
@@ -1,574 +1,574 @@
import { convert } from 'chromatism'
import allKeys from './theme2_keys'
// keys that are meant to be used globally, i.e. what's the rest of the theme is based upon.
export const basePaletteKeys = new Set([
'bg',
'fg',
'text',
'link',
'accent',
'cBlue',
'cRed',
'cGreen',
'cOrange',
'wallpaper',
])
export const fontsKeys = new Set(['interface', 'input', 'post', 'postCode'])
export const opacityKeys = new Set([
'alert',
'alertPopup',
'bg',
'border',
'btn',
'faint',
'input',
'panel',
'popover',
'profileTint',
'underlay',
])
export const shadowsKeys = new Set([
'panel',
'topBar',
'popup',
'avatar',
'avatarStatus',
'panelHeader',
'button',
'buttonHover',
'buttonPressed',
'input',
])
export const radiiKeys = new Set([
'btn',
'input',
'checkbox',
'panel',
'avatar',
'avatarAlt',
'tooltip',
'attachment',
'chatMessage',
])
// Keys that are not available in editor and never meant to be edited
export const hiddenKeys = new Set(['profileBg', 'profileTint'])
export const extendedBasePrefixes = [
'border',
'icon',
'highlight',
'lightText',
'popover',
'panel',
'topBar',
'tab',
'btn',
'input',
'selectedMenu',
'alert',
'alertPopup',
'badge',
'post',
'selectedPost', // wrong nomenclature
'poll',
'chatBg',
'chatMessage',
]
export const nonComponentPrefixes = new Set([
'border',
'icon',
'highlight',
'lightText',
'chatBg',
])
export const extendedBaseKeys = Object.fromEntries(
extendedBasePrefixes.map((prefix) => [
prefix,
allKeys.filter((k) => {
if (prefix === 'alert') {
return k.startsWith(prefix) && !k.startsWith('alertPopup')
}
return k.startsWith(prefix)
}),
]),
)
// Keysets that are only really used intermideately, i.e. to generate other colors
export const temporary = new Set(['', 'highlight'])
export const temporaryColors = {}
export const convertTheme2To3 = (data) => {
data.colors.accent = data.colors.accent || data.colors.link
data.colors.link = data.colors.link || data.colors.accent
const generateRoot = () => {
const directives = {}
basePaletteKeys.forEach((key) => {
directives['--' + key] = 'color | ' + convert(data.colors[key]).hex
})
return {
component: 'Root',
directives,
}
}
const convertOpacity = () => {
const newRules = []
Object.keys(data.opacity || {}).forEach((key) => {
if (!opacityKeys.has(key) || data.opacity[key] === undefined) return null
const originalOpacity = data.opacity[key]
const rule = { source: '2to3' }
switch (key) {
case 'alert':
rule.component = 'Alert'
break
case 'alertPopup':
rule.component = 'Alert'
rule.parent = { component: 'Popover' }
break
case 'bg':
rule.component = 'Panel'
break
case 'border':
rule.component = 'Border'
break
case 'btn':
rule.component = 'Button'
break
case 'faint':
rule.component = 'Text'
rule.state = ['faint']
break
case 'input':
rule.component = 'Input'
break
case 'panel':
rule.component = 'PanelHeader'
break
case 'popover':
rule.component = 'Popover'
break
case 'profileTint':
return null
case 'underlay':
rule.component = 'Underlay'
break
}
switch (key) {
case 'alert':
case 'alertPopup':
case 'bg':
case 'btn':
case 'input':
case 'panel':
case 'popover':
case 'underlay':
rule.directives = { opacity: originalOpacity }
break
case 'faint':
case 'border':
rule.directives = { textOpacity: originalOpacity }
break
}
newRules.push(rule)
if (rule.component === 'Button') {
newRules.push({ ...rule, component: 'ScrollbarElement' })
newRules.push({ ...rule, component: 'Tab' })
newRules.push({
...rule,
component: 'Tab',
state: ['active'],
directives: { opacity: 0 },
})
}
if (rule.component === 'Panel') {
newRules.push({ ...rule, component: 'Post' })
}
})
return newRules
}
const convertRadii = () => {
const newRules = []
Object.keys(data.radii || {}).forEach((key) => {
if (!radiiKeys.has(key) || data.radii[key] === undefined) return null
const originalRadius = data.radii[key]
const rule = { source: '2to3' }
switch (key) {
case 'btn':
rule.component = 'Button'
break
case 'tab':
rule.component = 'Tab'
break
case 'input':
rule.component = 'Input'
break
case 'checkbox':
rule.component = 'Input'
rule.variant = 'checkbox'
break
case 'panel':
rule.component = 'Panel'
break
case 'avatar':
rule.component = 'Avatar'
break
case 'avatarAlt':
rule.component = 'Avatar'
rule.variant = 'compact'
break
case 'tooltip':
rule.component = 'Popover'
break
case 'ChatMessage':
rule.component = 'Button'
break
}
rule.directives = {
roundness: originalRadius,
}
newRules.push(rule)
if (rule.component === 'Button') {
newRules.push({ ...rule, component: 'ScrollbarElement' })
newRules.push({ ...rule, component: 'Tab' })
}
})
return newRules
}
const convertFonts = () => {
const newRules = []
Object.keys(data.fonts || {}).forEach((key) => {
if (!fontsKeys.has(key)) return
if (!data.fonts[key]) return
const originalFont = data.fonts[key]
const rule = { source: '2to3' }
switch (key) {
case 'interface':
case 'postCode':
rule.component = 'Root'
break
case 'input':
rule.component = 'Input'
break
case 'post':
rule.component = 'RichContent'
break
}
switch (key) {
case 'interface':
case 'input':
case 'post':
rule.directives = { '--font': 'generic | ' + originalFont }
break
case 'postCode':
rule.directives = { '--monoFont': 'generic | ' + originalFont }
newRules.push({ ...rule, component: 'RichContent' })
break
}
newRules.push(rule)
})
return newRules
}
const convertShadows = () => {
const newRules = []
Object.keys(data.shadows || {}).forEach((key) => {
if (!shadowsKeys.has(key)) return
const originalShadow = data.shadows[key]
const rule = { source: '2to3' }
switch (key) {
case 'panel':
rule.component = 'Panel'
break
case 'topBar':
rule.component = 'TopBar'
break
case 'popup':
rule.component = 'Popover'
break
case 'avatar':
rule.component = 'Avatar'
break
case 'avatarStatus':
rule.component = 'Avatar'
rule.parent = { component: 'Post' }
break
case 'panelHeader':
rule.component = 'PanelHeader'
break
case 'button':
rule.component = 'Button'
break
case 'buttonHover':
rule.component = 'Button'
rule.state = ['hover']
break
case 'buttonPressed':
rule.component = 'Button'
rule.state = ['pressed']
break
case 'input':
rule.component = 'Input'
break
}
rule.directives = {
shadow: originalShadow,
}
newRules.push(rule)
if (key === 'topBar') {
newRules.push({
...rule,
component: 'PanelHeader',
parent: { component: 'MobileDrawer' },
})
}
if (key === 'avatarStatus') {
newRules.push({ ...rule, parent: { component: 'Notification' } })
}
if (key === 'buttonPressed') {
newRules.push({ ...rule, state: ['toggled'] })
newRules.push({ ...rule, state: ['toggled', 'focus'] })
newRules.push({ ...rule, state: ['pressed', 'focus'] })
newRules.push({ ...rule, state: ['toggled', 'focus', 'hover'] })
newRules.push({ ...rule, state: ['pressed', 'focus', 'hover'] })
}
if (rule.component === 'Button') {
newRules.push({ ...rule, component: 'ScrollbarElement' })
newRules.push({ ...rule, component: 'Tab' })
}
})
return newRules
}
const extendedRules = Object.entries(extendedBaseKeys).map(
([prefix, keys]) => {
if (nonComponentPrefixes.has(prefix)) return null
const rule = { source: '2to3' }
if (prefix === 'alertPopup') {
rule.component = 'Alert'
rule.parent = { component: 'Popover' }
} else if (prefix === 'selectedPost') {
rule.component = 'Post'
rule.state = ['selected']
} else if (prefix === 'selectedMenu') {
rule.component = 'MenuItem'
rule.state = ['hover']
} else if (prefix === 'chatMessageIncoming') {
rule.component = 'ChatMessage'
} else if (prefix === 'chatMessageOutgoing') {
rule.component = 'ChatMessage'
rule.variant = 'outgoing'
} else if (prefix === 'panel') {
rule.component = 'PanelHeader'
} else if (prefix === 'topBar') {
rule.component = 'TopBar'
} else if (prefix === 'chatMessage') {
rule.component = 'ChatMessage'
} else if (prefix === 'poll') {
rule.component = 'PollGraph'
} else if (prefix === 'btn') {
rule.component = 'Button'
} else {
rule.component = prefix[0].toUpperCase() + prefix.slice(1).toLowerCase()
}
return keys.map((key) => {
if (!data.colors[key]) return null
const leftoverKey = key.replace(prefix, '')
const parts = (leftoverKey || 'Bg').match(/[A-Z][a-z]*/g)
const last = parts.slice(-1)[0]
let newRule = { source: '2to3', directives: {} }
let variantArray = []
switch (last) {
case 'Text':
case 'Faint': // typo
case 'Link':
case 'Icon':
case 'Greentext':
case 'Cyantext':
case 'Border':
newRule.parent = rule
newRule.directives.textColor = data.colors[key]
variantArray = parts.slice(0, -1)
break
default:
newRule = { ...rule, directives: {} }
newRule.directives.background = data.colors[key]
variantArray = parts
break
}
if (last === 'Text' || last === 'Link') {
const secondLast = parts.slice(-2)[0]
if (secondLast === 'Light') {
return null // unsupported
} else if (secondLast === 'Faint') {
newRule.state = ['faint']
variantArray = parts.slice(0, -2)
}
}
switch (last) {
case 'Text':
case 'Link':
case 'Icon':
case 'Border':
newRule.component = last
break
case 'Greentext':
case 'Cyantext':
newRule.component = 'FunText'
newRule.variant = last.toLowerCase()
break
case 'Faint':
newRule.component = 'Text'
newRule.state = ['faint']
break
}
variantArray = variantArray.filter((x) => x !== 'Bg')
if (last === 'Link' && prefix === 'selectedPost') {
// selectedPost has typo - duplicate 'Post'
variantArray = variantArray.filter((x) => x !== 'Post')
}
if (prefix === 'popover' && variantArray[0] === 'Post') {
newRule.component = 'Post'
newRule.parent = { source: '2to3hack', component: 'Popover' }
variantArray = variantArray.filter((x) => x !== 'Post')
}
if (prefix === 'selectedMenu' && variantArray[0] === 'Popover') {
newRule.parent = { source: '2to3hack', component: 'Popover' }
variantArray = variantArray.filter((x) => x !== 'Popover')
}
switch (prefix) {
case 'btn':
case 'input':
case 'alert': {
const hasPanel = variantArray.find((x) => x === 'Panel')
if (hasPanel) {
newRule.parent = {
source: '2to3hack',
component: 'PanelHeader',
parent: newRule.parent,
}
variantArray = variantArray.filter((x) => x !== 'Panel')
}
const hasTop = variantArray.find((x) => x === 'Top') // TopBar
if (hasTop) {
newRule.parent = {
source: '2to3hack',
component: 'TopBar',
parent: newRule.parent,
}
variantArray = variantArray.filter(
(x) => x !== 'Top' && x !== 'Bar',
)
}
break
}
}
if (variantArray.length > 0) {
if (prefix === 'btn') {
newRule.state = variantArray.map((x) => x.toLowerCase())
} else {
newRule.variant = variantArray[0].toLowerCase()
}
}
if (newRule.component === 'Panel') {
return [newRule, { ...newRule, component: 'MobileDrawer' }]
} else if (newRule.component === 'Button') {
const rules = [
newRule,
{ ...newRule, component: 'Tab' },
{ ...newRule, component: 'ScrollbarElement' },
]
if (newRule.state?.indexOf('toggled') >= 0) {
rules.push({ ...newRule, state: [...newRule.state, 'focused'] })
rules.push({ ...newRule, state: [...newRule.state, 'hover'] })
rules.push({
...newRule,
state: [...newRule.state, 'hover', 'focused'],
})
}
if (newRule.state?.indexOf('hover') >= 0) {
rules.push({ ...newRule, state: [...newRule.state, 'focused'] })
}
return rules
} else if (newRule.component === 'Badge') {
if (newRule.variant === 'notification') {
return [
newRule,
{
component: 'Root',
directives: {
'--badgeNotification':
'color | ' + newRule.directives.background,
},
},
]
} else if (newRule.variant === 'neutral') {
return [{ ...newRule, variant: 'normal' }]
} else {
return [newRule]
}
} else if (newRule.component === 'TopBar') {
return [
newRule,
{
...newRule,
parent: { component: 'MobileDrawer' },
component: 'PanelHeader',
},
]
} else {
return [newRule]
}
})
},
)
const flatExtRules = extendedRules
- .filter((x) => x)
+ .filter(Boolean)
.reduce((acc, x) => [...acc, ...x], [])
- .filter((x) => x)
+ .filter(Boolean)
.reduce((acc, x) => [...acc, ...x], [])
return [
generateRoot(),
...convertShadows(),
...convertRadii(),
...convertOpacity(),
...convertFonts(),
...flatExtRules,
]
}
diff --git a/src/services/theme_data/theme_data.service.js b/src/services/theme_data/theme_data.service.js
index 7dcb97fed4..cdce2cf574 100644
--- a/src/services/theme_data/theme_data.service.js
+++ b/src/services/theme_data/theme_data.service.js
@@ -1,840 +1,837 @@
import { brightness, contrastRatio, convert } from 'chromatism'
import {
alphaBlendLayers,
getCssColor,
getTextColor,
relativeLuminance,
rgb2hex,
rgba2css,
} from '../color_convert/color_convert.js'
import { DEFAULT_OPACITY, LAYERS, SLOT_INHERITANCE } from './pleromafe.js'
/*
* # What's all this?
* Here be theme engine for pleromafe. All of this supposed to ease look
* and feel customization, making widget styles and make developer's life
* easier when it comes to supporting themes. Like many other theme systems
* it operates on color definitions, or "slots" - for example you define
* "button" color slot and then in UI component Button's CSS you refer to
* it as a CSS3 Variable.
*
* Some applications allow you to customize colors for certain things.
* Some UI toolkits allow you to define colors for each type of widget.
* Most of them are pretty barebones and have no assistance for common
* problems and cases, and in general themes themselves are very hard to
* maintain in all aspects. This theme engine tries to solve all of the
* common problems with themes.
*
* You don't have redefine several similar colors if you just want to
* change one color - all color slots are derived from other ones, so you
* can have at least one or two "basic" colors defined and have all other
* components inherit and modify basic ones.
*
* You don't have to test contrast ratio for colors or pick text color for
* each element even if you have light-on-dark elements in dark-on-light
* theme.
*
* You don't have to maintain order of code for inheriting slots from othet
* slots - dependency graph resolving does it for you.
*/
/* This indicates that this version of code outputs similar theme data and
* should be incremented if output changes - for instance if getTextColor
* function changes and older themes no longer render text colors as
* author intended previously.
*/
export const CURRENT_VERSION = 3
export const getLayersArray = (layer, data = LAYERS) => {
const array = [layer]
let parent = data[layer]
while (parent) {
array.unshift(parent)
parent = data[parent]
}
return array
}
export const getLayers = (
layer,
variant = layer,
opacitySlot,
colors,
opacity,
) => {
return getLayersArray(layer).map((currentLayer) => [
currentLayer === layer ? colors[variant] : colors[currentLayer],
currentLayer === layer ? opacity[opacitySlot] || 1 : opacity[currentLayer],
])
}
const getDependencies = (key, inheritance) => {
const data = inheritance[key]
if (typeof data === 'string' && data.startsWith('--')) {
return [data.substring(2)]
} else {
if (data === null) return []
const { depends, layer, variant } = data
const layerDeps = layer
? getLayersArray(layer).map((currentLayer) => {
return currentLayer === layer ? variant || layer : currentLayer
})
: []
if (Array.isArray(depends)) {
return [...depends, ...layerDeps]
} else {
return [...layerDeps]
}
}
}
/**
* Sorts inheritance object topologically - dependant slots come after
* dependencies
*
* @property {Object} inheritance - object defining the nodes
* @property {Function} getDeps - function that returns dependencies for
* given value and inheritance object.
* @returns {String[]} keys of inheritance object, sorted in topological
* order. Additionally, dependency-less nodes will always be first in line
*/
export const topoSort = (
inheritance = SLOT_INHERITANCE,
getDeps = getDependencies,
) => {
// This is an implementation of https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm
const allKeys = Object.keys(inheritance)
const whites = new Set(allKeys)
const grays = new Set()
const blacks = new Set()
const unprocessed = [...allKeys]
const output = []
const step = (node) => {
if (whites.has(node)) {
// Make node "gray"
whites.delete(node)
grays.add(node)
// Do step for each node connected to it (one way)
getDeps(node, inheritance).forEach(step)
// Make node "black"
grays.delete(node)
blacks.add(node)
// Put it into the output list
output.push(node)
} else if (grays.has(node)) {
output.push(node)
} else if (blacks.has(node)) {
// do nothing
} else {
throw new Error('Unintended condition in topoSort!')
}
}
while (unprocessed.length > 0) {
step(unprocessed.pop())
}
// The index thing is to make sorting stable on browsers
// where Array.sort() isn't stable
return output
.map((data, index) => ({ data, index }))
.sort(({ data: a, index: ai }, { data: b, index: bi }) => {
const depsA = getDeps(a, inheritance).length
const depsB = getDeps(b, inheritance).length
if (depsA === depsB || (depsB !== 0 && depsA !== 0)) return ai - bi
if (depsA === 0 && depsB !== 0) return -1
if (depsB === 0 && depsA !== 0) return 1
return 0 // failsafe, shouldn't happen?
})
.map(({ data }) => data)
}
const expandSlotValue = (value) => {
if (typeof value === 'object') return value
return {
depends: value.startsWith('--') ? [value.substring(2)] : [],
default: value.startsWith('#') ? value : undefined,
}
}
/**
* retrieves opacity slot for given slot. This goes up the depenency graph
* to find which parent has opacity slot defined for it.
* TODO refactor this
*/
export const getOpacitySlot = (
k,
inheritance = SLOT_INHERITANCE,
getDeps = getDependencies,
) => {
const value = expandSlotValue(inheritance[k])
if (value.opacity === null) return
if (value.opacity) return value.opacity
const findInheritedOpacity = (key, visited = [k]) => {
const depSlot = getDeps(key, inheritance)[0]
if (depSlot === undefined) return
const dependency = inheritance[depSlot]
if (dependency === undefined) return
if (dependency.opacity || dependency === null) {
return dependency.opacity
} else if (dependency.depends && visited.includes(depSlot)) {
return findInheritedOpacity(depSlot, [...visited, depSlot])
} else {
return null
}
}
if (value.depends) {
return findInheritedOpacity(k)
}
}
/**
* retrieves layer slot for given slot. This goes up the depenency graph
* to find which parent has opacity slot defined for it.
* this is basically copypaste of getOpacitySlot except it checks if key is
* in LAYERS
* TODO refactor this
*/
export const getLayerSlot = (
k,
inheritance = SLOT_INHERITANCE,
getDeps = getDependencies,
) => {
const value = expandSlotValue(inheritance[k])
if (LAYERS[k]) return k
if (value.layer === null) return
if (value.layer) return value.layer
const findInheritedLayer = (key, visited = [k]) => {
const depSlot = getDeps(key, inheritance)[0]
if (depSlot === undefined) return
const dependency = inheritance[depSlot]
if (dependency === undefined) return
if (dependency.layer || dependency === null) {
return dependency.layer
} else if (dependency.depends) {
return findInheritedLayer(dependency, [...visited, depSlot])
} else {
return null
}
}
if (value.depends) {
return findInheritedLayer(k)
}
}
/**
* topologically sorted SLOT_INHERITANCE
*/
export const SLOT_ORDERED = topoSort(
Object.entries(SLOT_INHERITANCE)
- .sort(
- ([, aV], [, bV]) =>
- ((aV && aV.priority) || 0) - ((bV && bV.priority) || 0),
- )
+ .sort(([, aV], [, bV]) => (aV?.priority || 0) - (bV?.priority || 0))
.reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {}),
)
/**
* All opacity slots used in color slots, their default values and affected
* color slots.
*/
export const OPACITIES = Object.entries(SLOT_INHERITANCE).reduce((acc, [k]) => {
const opacity = getOpacitySlot(k, SLOT_INHERITANCE, getDependencies)
if (opacity) {
return {
...acc,
[opacity]: {
defaultValue: DEFAULT_OPACITY[opacity] || 1,
affectedSlots: [
...((acc[opacity] && acc[opacity].affectedSlots) || []),
k,
],
},
}
} else {
return acc
}
}, {})
/**
* Handle dynamic color
*/
export const computeDynamicColor = (sourceColor, getColor, mod) => {
if (typeof sourceColor !== 'string' || !sourceColor.startsWith('--'))
return sourceColor
let targetColor = null
// Color references other color
const [variable, modifier] = sourceColor.split(/,/g).map((str) => str.trim())
const variableSlot = variable.substring(2)
targetColor = getColor(variableSlot)
if (modifier) {
targetColor = brightness(Number.parseFloat(modifier) * mod, targetColor).rgb
}
return targetColor
}
/**
* THE function you want to use. Takes provided colors and opacities
* value and uses inheritance data to figure out color needed for the slot.
*/
export const getColors = (sourceColors, sourceOpacity) =>
SLOT_ORDERED.reduce(
({ colors, opacity }, key) => {
const sourceColor = sourceColors[key]
const value = expandSlotValue(SLOT_INHERITANCE[key])
const deps = getDependencies(key, SLOT_INHERITANCE)
const isTextColor = !!value.textColor
const variant = value.variant || value.layer
let backgroundColor = null
if (isTextColor) {
backgroundColor = alphaBlendLayers(
{
...(colors[deps[0]] || convert(sourceColors[key] || '#FF00FF').rgb),
},
getLayers(
getLayerSlot(key) || 'bg',
variant || 'bg',
getOpacitySlot(variant),
colors,
opacity,
),
)
} else if (variant && variant !== key) {
backgroundColor = colors[variant] || convert(sourceColors[variant]).rgb
} else {
backgroundColor = colors.bg || convert(sourceColors.bg)
}
const isLightOnDark = relativeLuminance(backgroundColor) < 0.5
const mod = isLightOnDark ? 1 : -1
let outputColor = null
if (sourceColor) {
// Color is defined in source color
let targetColor = sourceColor
if (targetColor === 'transparent') {
// We take only layers below current one
const layers = getLayers(
getLayerSlot(key),
key,
getOpacitySlot(key) || key,
colors,
opacity,
).slice(0, -1)
targetColor = {
...alphaBlendLayers(convert('#FF00FF').rgb, layers),
a: 0,
}
} else if (
typeof sourceColor === 'string' &&
sourceColor.startsWith('--')
) {
targetColor = computeDynamicColor(
sourceColor,
(variableSlot) =>
colors[variableSlot] || sourceColors[variableSlot],
mod,
)
} else if (
typeof sourceColor === 'string' &&
sourceColor.startsWith('#')
) {
targetColor = convert(targetColor).rgb
}
outputColor = { ...targetColor }
} else if (value.default) {
// same as above except in object form
outputColor = convert(value.default).rgb
} else {
// calculate color
const defaultColorFunc = (mod, dep) => ({ ...dep })
const colorFunc = value.color || defaultColorFunc
if (value.textColor) {
if (value.textColor === 'bw') {
outputColor = contrastRatio(backgroundColor).rgb
} else {
let color = { ...colors[deps[0]] }
if (value.color) {
color = colorFunc(mod, ...deps.map((dep) => ({ ...colors[dep] })))
}
outputColor = getTextColor(
backgroundColor,
{ ...color },
value.textColor === 'preserve',
)
}
} else {
// background color case
outputColor = colorFunc(
mod,
...deps.map((dep) => ({ ...colors[dep] })),
)
}
}
if (!outputColor) {
throw new Error("Couldn't generate color for " + key)
}
const opacitySlot = value.opacity || getOpacitySlot(key)
const ownOpacitySlot = value.opacity
if (ownOpacitySlot === null) {
outputColor.a = 1
} else if (sourceColor === 'transparent') {
outputColor.a = 0
} else {
const opacityOverriden =
ownOpacitySlot && sourceOpacity[opacitySlot] !== undefined
const dependencySlot = deps[0]
const dependencyColor = dependencySlot && colors[dependencySlot]
if (
!ownOpacitySlot &&
dependencyColor &&
!value.textColor &&
ownOpacitySlot !== null
) {
// Inheriting color from dependency (weird, i know)
// except if it's a text color or opacity slot is set to 'null'
outputColor.a = dependencyColor.a
} else if (!dependencyColor && !opacitySlot) {
// Remove any alpha channel if no dependency and no opacitySlot found
delete outputColor.a
} else {
// Otherwise try to assign opacity
- if (dependencyColor && dependencyColor.a === 0) {
+ if (dependencyColor?.a === 0) {
// transparent dependency shall make dependents transparent too
outputColor.a = 0
} else {
// Otherwise check if opacity is overriden and use that or default value instead
outputColor.a = Number(
opacityOverriden
? sourceOpacity[opacitySlot]
: (OPACITIES[opacitySlot] || {}).defaultValue,
)
}
}
}
if (Number.isNaN(outputColor.a) || outputColor.a === undefined) {
outputColor.a = 1
}
if (opacitySlot) {
return {
colors: { ...colors, [key]: outputColor },
opacity: { ...opacity, [opacitySlot]: outputColor.a },
}
} else {
return {
colors: { ...colors, [key]: outputColor },
opacity,
}
}
},
{ colors: {}, opacity: {} },
)
export const composePreset = (colors, radii, shadows, fonts) => {
return {
rules: {
...shadows.rules,
...colors.rules,
...radii.rules,
...fonts.rules,
},
theme: {
...shadows.theme,
...colors.theme,
...radii.theme,
...fonts.theme,
},
}
}
export const generatePreset = (input) => {
const colors = generateColors(input)
return composePreset(
colors,
generateRadii(input),
generateShadows(input, colors.theme.colors, colors.mod),
generateFonts(input),
)
}
export const getCssShadow = (input, usesDropShadow) => {
if (input.length === 0) {
return 'none'
}
return input
.filter((_) => (usesDropShadow ? _.inset : _))
.map((shad) =>
[shad.x, shad.y, shad.blur, shad.spread]
.map((_) => _ + 'px')
.concat([
getCssColor(shad.color, shad.alpha),
shad.inset ? 'inset' : '',
])
.join(' '),
)
.join(', ')
}
export const getCssShadowFilter = (input) => {
if (input.length === 0) {
return 'none'
}
return (
input
// drop-shadow doesn't support inset or spread
.filter((shad) => !shad.inset && Number(shad.spread) === 0)
.map((shad) =>
[
shad.x,
shad.y,
// drop-shadow's blur is twice as strong compared to box-shadow
shad.blur / 2,
]
.map((_) => _ + 'px')
.concat([getCssColor(shad.color, shad.alpha)])
.join(' '),
)
.map((_) => `drop-shadow(${_})`)
.join(' ')
)
}
export const generateColors = (themeData) => {
const sourceColors = !themeData.themeEngineVersion
? colors2to3(themeData.colors || themeData)
: themeData.colors || themeData
const { colors, opacity } = getColors(sourceColors, themeData.opacity || {})
const htmlColors = Object.entries(colors).reduce(
(acc, [k, v]) => {
if (!v) return acc
acc.solid[k] = rgb2hex(v)
- acc.complete[k] = typeof v.a === 'undefined' ? rgb2hex(v) : rgba2css(v)
+ acc.complete[k] = v.a === undefined ? rgb2hex(v) : rgba2css(v)
return acc
},
{ complete: {}, solid: {} },
)
return {
rules: {
colors: Object.entries(htmlColors.complete)
.filter(([, v]) => v)
.map(([k, v]) => `--${k}: ${v}`)
.join(';'),
},
theme: {
colors: htmlColors.solid,
opacity,
},
}
}
export const generateRadii = (input) => {
let inputRadii = input.radii || {}
// v1 -> v2
- if (typeof input.btnRadius !== 'undefined') {
+ if (input.btnRadius !== undefined) {
inputRadii = Object.entries(input)
.filter(([k]) => k.endsWith('Radius'))
.reduce((acc, e) => {
acc[e[0].split('Radius')[0]] = e[1]
return acc
}, {})
}
const radii = Object.entries(inputRadii)
.filter(([, v]) => v)
.reduce(
(acc, [k, v]) => {
acc[k] = v
return acc
},
{
btn: 4,
input: 4,
checkbox: 2,
panel: 10,
avatar: 5,
avatarAlt: 50,
tooltip: 2,
attachment: 5,
chatMessage: inputRadii.panel,
},
)
return {
rules: {
radii: Object.entries(radii)
.filter(([, v]) => v)
.map(([k, v]) => `--${k}Radius: ${v}px`)
.join(';'),
},
theme: {
radii,
},
}
}
export const generateFonts = (input) => {
const fonts = Object.entries(input.fonts || {})
.filter(([, v]) => v)
.reduce(
(acc, [k, v]) => {
acc[k] = Object.entries(v)
.filter(([, v]) => v)
.reduce((acc, [k, v]) => {
acc[k] = v
return acc
}, acc[k])
return acc
},
{
interface: {
family: 'sans-serif',
},
input: {
family: 'inherit',
},
post: {
family: 'inherit',
},
postCode: {
family: 'monospace',
},
},
)
return {
rules: {
fonts: Object.entries(fonts)
.filter(([, v]) => v)
.map(([k, v]) => `--${k}Font: ${v}`)
.join(';'),
},
theme: {
fonts,
},
}
}
const border = (top, shadow) => ({
x: 0,
y: top ? 1 : -1,
blur: 0,
spread: 0,
color: shadow ? '#000000' : '#FFFFFF',
alpha: 0.2,
inset: true,
})
const buttonInsetFakeBorders = [border(true, false), border(false, true)]
const inputInsetFakeBorders = [border(true, true), border(false, false)]
const hoverGlow = {
x: 0,
y: 0,
blur: 4,
spread: 0,
color: '--faint',
alpha: 1,
}
export const DEFAULT_SHADOWS = {
panel: [
{
x: 1,
y: 1,
blur: 4,
spread: 0,
color: '#000000',
alpha: 0.6,
},
],
topBar: [
{
x: 0,
y: 0,
blur: 4,
spread: 0,
color: '#000000',
alpha: 0.6,
},
],
popup: [
{
x: 2,
y: 2,
blur: 3,
spread: 0,
color: '#000000',
alpha: 0.5,
},
],
avatar: [
{
x: 0,
y: 1,
blur: 8,
spread: 0,
color: '#000000',
alpha: 0.7,
},
],
avatarStatus: [],
panelHeader: [],
button: [
{
x: 0,
y: 0,
blur: 2,
spread: 0,
color: '#000000',
alpha: 1,
},
...buttonInsetFakeBorders,
],
buttonHover: [hoverGlow, ...buttonInsetFakeBorders],
buttonPressed: [hoverGlow, ...inputInsetFakeBorders],
input: [
...inputInsetFakeBorders,
{
x: 0,
y: 0,
blur: 2,
inset: true,
spread: 0,
color: '#000000',
alpha: 1,
},
],
}
export const generateShadows = (input, colors) => {
// TODO this is a small hack for `mod` to work with shadows
// this is used to get the "context" of shadow, i.e. for `mod` properly depend on background color of element
const hackContextDict = {
button: 'btn',
panel: 'bg',
top: 'topBar',
popup: 'popover',
avatar: 'bg',
panelHeader: 'panel',
input: 'input',
}
const cleanInputShadows = Object.fromEntries(
Object.entries(input.shadows || {}).map(([name, shadowSlot]) => [
name,
// defaulting color to black to avoid potential problems
shadowSlot.map((shadowDef) => ({ color: '#000000', ...shadowDef })),
]),
)
const inputShadows =
cleanInputShadows && !input.themeEngineVersion
? shadows2to3(cleanInputShadows, input.opacity)
: cleanInputShadows || {}
const shadows = Object.entries({
...DEFAULT_SHADOWS,
...inputShadows,
}).reduce((shadowsAcc, [slotName, shadowDefs]) => {
const slotFirstWord = slotName.replace(/[A-Z].*$/, '')
const colorSlotName = hackContextDict[slotFirstWord]
const isLightOnDark =
relativeLuminance(convert(colors[colorSlotName]).rgb) < 0.5
const mod = isLightOnDark ? 1 : -1
const newShadow = shadowDefs.reduce(
(shadowAcc, def) => [
...shadowAcc,
{
...def,
color: rgb2hex(
computeDynamicColor(
def.color,
(variableSlot) => convert(colors[variableSlot]).rgb,
mod,
),
),
},
],
[],
)
return { ...shadowsAcc, [slotName]: newShadow }
}, {})
return {
rules: {
shadows: Object.entries(shadows)
// TODO for v2.2: if shadow doesn't have non-inset shadows with spread > 0 - optionally
// convert all non-inset shadows into filter: drop-shadow() to boost performance
.map(([k, v]) =>
[
`--${k}Shadow: ${getCssShadow(v)}`,
`--${k}ShadowFilter: ${getCssShadowFilter(v)}`,
`--${k}ShadowInset: ${getCssShadow(v, true)}`,
].join(';'),
)
.join(';'),
},
theme: {
shadows,
},
}
}
/**
* This handles compatibility issues when importing v2 theme's shadows to current format
*
* Back in v2 shadows allowed you to use dynamic colors however those used pure CSS3 variables
*/
export const shadows2to3 = (shadows, opacity) => {
return Object.entries(shadows).reduce(
(shadowsAcc, [slotName, shadowDefs]) => {
const isDynamic = ({ color = '#000000' }) => color.startsWith('--')
const getOpacity = ({ color }) =>
opacity[getOpacitySlot(color.substring(2).split(',')[0])]
const newShadow = shadowDefs.reduce(
(shadowAcc, def) => [
...shadowAcc,
{
...def,
alpha: isDynamic(def) ? getOpacity(def) || 1 : def.alpha,
},
],
[],
)
return { ...shadowsAcc, [slotName]: newShadow }
},
{},
)
}
export const colors2to3 = (colors) => {
return Object.entries(colors).reduce((acc, [slotName, color]) => {
const btnPositions = ['', 'Panel', 'TopBar']
switch (slotName) {
case 'lightBg':
return { ...acc, highlight: color }
case 'btnText':
return {
...acc,
...btnPositions.reduce(
(statePositionAcc, position) => ({
...statePositionAcc,
['btn' + position + 'Text']: color,
}),
{},
),
}
default:
return { ...acc, [slotName]: color }
}
}, {})
}
diff --git a/src/services/theme_data/theme_data_3.service.js b/src/services/theme_data/theme_data_3.service.js
index 0c5c82991f..626d7daeb6 100644
--- a/src/services/theme_data/theme_data_3.service.js
+++ b/src/services/theme_data/theme_data_3.service.js
@@ -1,780 +1,780 @@
import { brightness, convert } from 'chromatism'
import sum from 'hash-sum'
import { flattenDeep, sortBy } from 'lodash'
import {
alphaBlend,
getTextColor,
mixrgb,
relativeLuminance,
rgba2css,
} from '../color_convert/color_convert.js'
import { deserializeShadow } from './iss_deserializer.js'
import {
findRules,
genericRuleToSelector,
getAllPossibleCombinations,
normalizeCombination,
unroll,
} from './iss_utils.js'
import {
colorFunctions,
process,
shadowFunctions,
} from './theme3_slot_functions.js'
// Ensuring the order of components
const components = {
Root: null,
Text: null,
FunText: null,
Link: null,
Icon: null,
Border: null,
PanelHeader: null,
Panel: null,
Chat: null,
ChatMessage: null,
Button: null,
}
export const findShadow = (shadows, { dynamicVars, staticVars }) => {
return (shadows || []).map((shadow) => {
let targetShadow
if (typeof shadow === 'string') {
if (shadow.startsWith('$')) {
targetShadow = process(
shadow,
shadowFunctions,
{ findColor, findShadow },
{ dynamicVars, staticVars },
)
} else if (shadow.startsWith('--')) {
// modifiers are completely unsupported here
const variableSlot = shadow.substring(2)
return findShadow(staticVars[variableSlot], { dynamicVars, staticVars })
} else {
targetShadow = deserializeShadow(shadow)
}
} else {
targetShadow = shadow
}
const shadowArray = Array.isArray(targetShadow)
? targetShadow
: [targetShadow]
return shadowArray.map((s) => ({
...s,
color: findColor(s.color, { dynamicVars, staticVars }),
}))
})
}
export const findColor = (color, { dynamicVars, staticVars }) => {
try {
if (
typeof color !== 'string' ||
(!color.startsWith('--') && !color.startsWith('$'))
)
return color
let targetColor = null
if (color.startsWith('--')) {
// Modifier support is pretty much for v2 themes only
const [variable, modifier] = color.split(/,/g).map((str) => str.trim())
const variableSlot = variable.substring(2)
if (variableSlot === 'stack') {
const { r, g, b } = dynamicVars.stacked
targetColor = { r, g, b }
} else if (variableSlot.startsWith('parent')) {
if (variableSlot === 'parent') {
const { r, g, b } = dynamicVars.lowerLevelBackground ?? {}
targetColor = { r, g, b }
} else {
const virtualSlot = variableSlot.replace(/^parent/, '')
targetColor = convert(
dynamicVars.lowerLevelVirtualDirectivesRaw[virtualSlot],
).rgb
}
} else {
const staticVar = staticVars[variableSlot]
const dynamicVar = dynamicVars[variableSlot]
if (!staticVar && !dynamicVar) {
console.warn(
`Couldn't find variable "${variableSlot}", falling back to magenta. Variables are:
Static:
${JSON.stringify(staticVars, null, 2)}
Dynamic:
${JSON.stringify(dynamicVars, null, 2)}`,
dynamicVars,
variableSlot,
dynamicVars[variableSlot],
)
}
targetColor = convert(staticVar ?? dynamicVar ?? '#FF00FF').rgb
}
if (modifier) {
const effectiveBackground =
dynamicVars.lowerLevelBackground ?? targetColor
const isLightOnDark =
relativeLuminance(convert(effectiveBackground).rgb) < 0.5
const mod = isLightOnDark ? 1 : -1
targetColor = brightness(
Number.parseFloat(modifier) * mod,
targetColor,
).rgb
}
}
if (color.startsWith('$')) {
try {
targetColor = process(
color,
colorFunctions,
{ findColor },
{ dynamicVars, staticVars },
)
} catch (e) {
console.error(
'Failure executing color function',
e,
'\n Function: ' + color,
)
targetColor = '#FF00FF'
}
}
// Color references other color
return targetColor
} catch (e) {
throw new Error(`Couldn't find color "${color}", variables are:
Static:
${JSON.stringify(staticVars, null, 2)}
Dynamic:
${JSON.stringify(dynamicVars, null, 2)}\nError: ${e}`)
}
}
const getTextColorAlpha = (
directives,
intendedTextColor,
dynamicVars,
staticVars,
) => {
const opacity = directives.textOpacity
const backgroundColor = convert(dynamicVars.lowerLevelBackground).rgb
const textColor = convert(
findColor(intendedTextColor, { dynamicVars, staticVars }),
).rgb
if (opacity === null || opacity === undefined || opacity >= 1) {
return convert(textColor).hex
}
if (opacity === 0) {
return convert(backgroundColor).hex
}
const opacityMode = directives.textOpacityMode
switch (opacityMode) {
case 'fake':
return convert(alphaBlend(textColor, opacity, backgroundColor)).hex
case 'mixrgb':
return convert(mixrgb(backgroundColor, textColor)).hex
default:
return rgba2css({ a: opacity, ...textColor })
}
}
// Loading all style.js[on] files dynamically
const componentsContext = import.meta.glob(
['/src/**/*.style.js', '/src/**/*.style.json'],
{ eager: true },
)
Object.keys(componentsContext).forEach((key) => {
const component = componentsContext[key].default
if (components[component.name] != null) {
console.warn(
`Component in file ${key} is trying to override existing component ${component.name}! You have collisions/duplicates!`,
)
}
components[component.name] = component
})
Object.keys(components).forEach((key) => {
if (key === 'Root') return
components.Root.validInnerComponents =
components.Root.validInnerComponents || []
components.Root.validInnerComponents.push(key)
})
Object.keys(components).forEach((key) => {
const component = components[key]
const { validInnerComponents = [] } = component
validInnerComponents.forEach((inner) => {
const child = components[inner]
component.possibleChildren = component.possibleChildren || []
component.possibleChildren.push(child)
child.possibleParents = child.possibleParents || []
child.possibleParents.push(component)
})
})
const engineChecksum = sum(components)
const ruleToSelector = genericRuleToSelector(components)
export const getEngineChecksum = () => engineChecksum
/**
* Initializes and compiles the theme according to the ruleset
*
* @param {Object[]} inputRuleset - set of rules to compile theme against. Acts as an override to
* component default rulesets
* @param {string} ultimateBackgroundColor - Color that will be the "final" background for
* calculating contrast ratios and making text automatically accessible. Really used for cases when
* stuff is transparent.
* @param {boolean} debug - print out debug information in console, mostly just performance stuff
* @param {boolean} liteMode - use validInnerComponentsLite instead of validInnerComponents, meant to
* generatate theme previews and such that need to be compiled faster and don't require a lot of other
* components present in "normal" mode
* @param {boolean} onlyNormalState - only use components 'normal' states, meant for generating theme
* previews since states are the biggest factor for compilation time and are completely unnecessary
* when previewing multiple themes at same time
*/
export const init = ({
inputRuleset,
ultimateBackgroundColor,
debug = false,
liteMode = false,
editMode = false,
onlyNormalState = false,
initialStaticVars = {},
}) => {
const rootComponentName = 'Root'
if (!inputRuleset) throw new Error('Ruleset is null or undefined!')
const staticVars = { ...initialStaticVars }
const stacked = {}
const computed = {}
const rulesetUnsorted = [
...Object.values(components)
.map((c) =>
(c.defaultRules || []).map((r) => ({
source: 'Built-in',
component: c.name,
...r,
})),
)
.reduce((acc, arr) => [...acc, ...arr], []),
...inputRuleset,
].map((rule) => {
normalizeCombination(rule)
let currentParent = rule.parent
while (currentParent) {
normalizeCombination(currentParent)
currentParent = currentParent.parent
}
return rule
})
const ruleset = rulesetUnsorted
.map((data, index) => ({ data, index }))
.toSorted(({ data: a, index: ai }, { data: b, index: bi }) => {
const parentsA = unroll(a).length
const parentsB = unroll(b).length
let aScore = 0
let bScore = 0
aScore += parentsA * 1000
bScore += parentsB * 1000
aScore += a.variant !== 'normal' ? 100 : 0
bScore += b.variant !== 'normal' ? 100 : 0
aScore += a.state.filter((x) => x !== 'normal').length * 1000
bScore += b.state.filter((x) => x !== 'normal').length * 1000
aScore += a.component === 'Text' ? 1 : 0
bScore += b.component === 'Text' ? 1 : 0
// Debug
a._specificityScore = aScore
b._specificityScore = bScore
if (aScore === bScore) {
return ai - bi
}
return aScore - bScore
})
.map(({ data }) => data)
if (!ultimateBackgroundColor) {
console.warn(
'No ultimate background color provided, falling back to panel color',
)
const rootRule = ruleset.findLast(
(x) => x.component === 'Root' && x.directives?.['--bg'],
)
ultimateBackgroundColor = rootRule.directives['--bg'].split('|')[1].trim()
}
const virtualComponents = new Set(
Object.values(components)
.filter((c) => c.virtual)
.map((c) => c.name),
)
const transparentComponents = new Set(
Object.values(components)
.filter((c) => c.transparent)
.map((c) => c.name),
)
const nonEditableComponents = new Set(
Object.values(components)
.filter((c) => c.notEditable)
.map((c) => c.name),
)
const extraCompileComponents = new Set([])
Object.values(components).forEach((component) => {
const relevantRules = ruleset.filter((r) => r.component === component.name)
const backgrounds = relevantRules
.map((r) => r.directives.background)
- .filter((x) => x)
+ .filter(Boolean)
const opacities = relevantRules
.map((r) => r.directives.opacity)
- .filter((x) => x)
+ .filter(Boolean)
if (
backgrounds.some((x) => x.match(/--parent/)) ||
opacities.some((x) => x != null && x < 1)
) {
extraCompileComponents.add(component.name)
}
})
const processCombination = (combination) => {
try {
const selector = ruleToSelector(combination, true)
const cssSelector = ruleToSelector(combination)
const parentSelector = selector.split(/ /g).slice(0, -1).join(' ')
const soloSelector = selector.split(/ /g).slice(-1)[0]
const lowerLevelSelector = parentSelector
let lowerLevelBackground = computed[lowerLevelSelector]?.background
if (editMode && !lowerLevelBackground) {
// FIXME hack for editor until it supports handling component backgrounds
lowerLevelBackground = '#00FFFF'
}
const lowerLevelVirtualDirectives =
computed[lowerLevelSelector]?.virtualDirectives
const lowerLevelVirtualDirectivesRaw =
computed[lowerLevelSelector]?.virtualDirectivesRaw
const dynamicVars = computed[selector] || {
lowerLevelSelector,
lowerLevelBackground,
lowerLevelVirtualDirectives,
lowerLevelVirtualDirectivesRaw,
}
// Inheriting all of the applicable rules
const existingRules = ruleset.filter(findRules(combination))
const computedDirectives = existingRules
.map((r) => r.directives)
.reduce((acc, directives) => ({ ...acc, ...directives }), {})
const computedRule = {
...combination,
directives: computedDirectives,
}
computed[selector] = computed[selector] || {}
computed[selector].computedRule = computedRule
computed[selector].dynamicVars = dynamicVars
// avoid putting more stuff into actual CSS
computed[selector].virtualDirectives = {}
// but still be able to access it i.e. from --parent
computed[selector].virtualDirectivesRaw =
computed[lowerLevelSelector]?.virtualDirectivesRaw || {}
if (virtualComponents.has(combination.component)) {
const virtualName = [
'--',
combination.component.toLowerCase(),
combination.variant === 'normal'
? ''
: combination.variant[0].toUpperCase() +
combination.variant.slice(1).toLowerCase(),
...sortBy(combination.state.filter((x) => x !== 'normal')).map(
(state) => state[0].toUpperCase() + state.slice(1).toLowerCase(),
),
].join('')
let inheritedTextColor = computedDirectives.textColor
let inheritedTextAuto = computedDirectives.textAuto
let inheritedTextOpacity = computedDirectives.textOpacity
let inheritedTextOpacityMode = computedDirectives.textOpacityMode
const lowerLevelTextSelector = [
...selector.split(/ /g).slice(0, -1),
soloSelector,
].join(' ')
const lowerLevelTextRule = computed[lowerLevelTextSelector]
if (
inheritedTextColor == null ||
inheritedTextOpacity == null ||
inheritedTextOpacityMode == null
) {
inheritedTextColor =
computedDirectives.textColor ?? lowerLevelTextRule.textColor
inheritedTextAuto =
computedDirectives.textAuto ?? lowerLevelTextRule.textAuto
inheritedTextOpacity =
computedDirectives.textOpacity ?? lowerLevelTextRule.textOpacity
inheritedTextOpacityMode =
computedDirectives.textOpacityMode ??
lowerLevelTextRule.textOpacityMode
}
const newTextRule = {
...computedRule,
directives: {
...computedRule.directives,
textColor: inheritedTextColor,
textAuto: inheritedTextAuto ?? 'preserve',
textOpacity: inheritedTextOpacity,
textOpacityMode: inheritedTextOpacityMode,
},
}
dynamicVars.inheritedBackground = lowerLevelBackground
dynamicVars.stacked = convert(stacked[lowerLevelSelector]).rgb
const intendedTextColor = convert(
findColor(inheritedTextColor, { dynamicVars, staticVars }),
).rgb
const textColor =
newTextRule.directives.textAuto === 'no-auto'
? intendedTextColor
: getTextColor(
convert(stacked[lowerLevelSelector]).rgb,
intendedTextColor,
newTextRule.directives.textAuto === 'preserve',
)
const virtualDirectives = {
...(computed[lowerLevelSelector].virtualDirectives || {}),
}
const virtualDirectivesRaw = {
...(computed[lowerLevelSelector].virtualDirectivesRaw || {}),
}
// Storing color data in lower layer to use as custom css properties
virtualDirectives[virtualName] = getTextColorAlpha(
newTextRule.directives,
textColor,
dynamicVars,
)
virtualDirectivesRaw[virtualName] = textColor
computed[lowerLevelSelector].virtualDirectives = virtualDirectives
computed[lowerLevelSelector].virtualDirectivesRaw = virtualDirectivesRaw
return {
dynamicVars,
selector: cssSelector.split(/ /g).slice(0, -1).join(' '),
...combination,
directives: {},
virtualDirectives,
virtualDirectivesRaw,
}
} else {
computed[selector] = computed[selector] || {}
// TODO: DEFAULT TEXT COLOR
const lowerLevelStackedBackground =
stacked[lowerLevelSelector] || convert(ultimateBackgroundColor).rgb
if (computedDirectives.background) {
let inheritRule = null
const variantRules = ruleset.filter(
findRules({
component: combination.component,
variant: combination.variant,
parent: combination.parent,
}),
)
const lastVariantRule = variantRules[variantRules.length - 1]
const lastVariantSelector = ruleToSelector(lastVariantRule, true)
if (lastVariantRule && lastVariantSelector !== selector) {
inheritRule = lastVariantRule
} else {
const normalRules = ruleset.filter(
findRules({
component: combination.component,
parent: combination.parent,
}),
)
const lastNormalRule = normalRules[normalRules.length - 1]
inheritRule = lastNormalRule
}
const inheritSelector = ruleToSelector(
{ ...inheritRule, parent: combination.parent },
true,
)
const inheritedBackground = computed[inheritSelector].background
dynamicVars.inheritedBackground = inheritedBackground
const rgb = convert(
findColor(computedDirectives.background, {
dynamicVars,
staticVars,
}),
).rgb
if (!stacked[selector]) {
let blend
const alpha = computedDirectives.opacity ?? 1
if (alpha >= 1) {
blend = rgb
} else if (alpha <= 0) {
blend = lowerLevelStackedBackground
} else {
blend = alphaBlend(
rgb,
computedDirectives.opacity,
lowerLevelStackedBackground,
)
}
stacked[selector] = blend
computed[selector].background = {
...rgb,
a: computedDirectives.opacity ?? 1,
}
}
}
if (computedDirectives.shadow) {
dynamicVars.shadow = flattenDeep(
findShadow(flattenDeep(computedDirectives.shadow), {
dynamicVars,
staticVars,
}),
)
}
if (!stacked[selector]) {
computedDirectives.background = 'transparent'
computedDirectives.opacity = 0
stacked[selector] = lowerLevelStackedBackground
computed[selector].background = {
...lowerLevelStackedBackground,
a: 0,
}
}
dynamicVars.stacked = stacked[selector]
dynamicVars.background = computed[selector].background
const dynamicSlots = Object.entries(computedDirectives).filter(([k]) =>
k.startsWith('--'),
)
dynamicSlots.forEach(([k, v]) => {
const [type, value] = v.split('|').map((x) => x.trim()) // woah, Extreme!
switch (type) {
case 'color': {
const color = findColor(value, { dynamicVars, staticVars })
dynamicVars[k] = color
if (combination.component === rootComponentName) {
staticVars[k.substring(2)] = color
}
break
}
case 'shadow': {
const shadow = value
.split(/,/g)
.map((s) => s.trim())
- .filter((x) => x)
+ .filter(Boolean)
dynamicVars[k] = shadow
if (combination.component === rootComponentName) {
staticVars[k.substring(2)] = shadow
}
break
}
case 'generic': {
dynamicVars[k] = value
if (combination.component === rootComponentName) {
staticVars[k.substring(2)] = value
}
break
}
}
})
const rule = {
dynamicVars,
selector: cssSelector,
...combination,
directives: computedDirectives,
}
return rule
}
} catch (e) {
const { component, variant, state } = combination
throw new Error(
`Error processing combination ${component}.${variant}:${state.join(':')}: ${e}`,
)
}
}
const processInnerComponent = (component, parent) => {
const combinations = []
const { states: originalStates = {}, variants: originalVariants = {} } =
component
let validInnerComponents
if (editMode) {
const temp =
component.validInnerComponentsLite ||
component.validInnerComponents ||
[]
validInnerComponents = temp.filter(
(c) => virtualComponents.has(c) && !nonEditableComponents.has(c),
)
} else if (liteMode) {
validInnerComponents =
component.validInnerComponentsLite ||
component.validInnerComponents ||
[]
} else if (
component.name === 'Root' ||
component.states != null ||
component.background?.includes('--parent')
) {
validInnerComponents = component.validInnerComponents || []
} else {
validInnerComponents =
component.validInnerComponents?.filter(
(c) =>
virtualComponents.has(c) ||
transparentComponents.has(c) ||
extraCompileComponents.has(c),
) || []
}
// Normalizing states and variants to always include "normal"
const states = { normal: '', ...originalStates }
const variants = { normal: '', ...originalVariants }
const innerComponents = validInnerComponents.map((name) => {
const result = components[name]
if (result === undefined)
console.error(
`Component ${component.name} references a component ${name} which does not exist!`,
)
return result
})
// Optimization: we only really need combinations without "normal" because all states implicitly have it
const permutationStateKeys = Object.keys(states).filter(
(s) => s !== 'normal',
)
const stateCombinations =
onlyNormalState && !virtualComponents.has(component.name)
? [['normal']]
: [
['normal'],
...getAllPossibleCombinations(permutationStateKeys)
.map((combination) => ['normal', ...combination])
.filter((combo) => {
// Optimization: filter out some hard-coded combinations that don't make sense
if (combo.indexOf('disabled') >= 0) {
return !(
combo.indexOf('hover') >= 0 ||
combo.indexOf('focused') >= 0 ||
combo.indexOf('pressed') >= 0
)
}
return true
}),
]
const stateVariantCombination = Object.keys(variants)
.map((variant) => {
return stateCombinations.map((state) => ({ variant, state }))
})
.reduce((acc, x) => [...acc, ...x], [])
stateVariantCombination.forEach((combination) => {
combination.component = component.name
combination.lazy = component.lazy || parent?.lazy
combination.parent = parent
if (!liteMode && combination.state.indexOf('hover') >= 0) {
combination.lazy = true
}
if (
!liteMode &&
parent?.component !== 'Root' &&
!virtualComponents.has(component.name) &&
!transparentComponents.has(component.name) &&
extraCompileComponents.has(component.name)
) {
combination.lazy = true
}
combinations.push(combination)
innerComponents.forEach((innerComponent) => {
combinations.push(...processInnerComponent(innerComponent, combination))
})
})
return combinations
}
const t0 = performance.now()
const combinations = processInnerComponent(
components[rootComponentName] ?? components.Root,
)
const t1 = performance.now()
if (debug) {
console.debug('Tree traveral took ' + (t1 - t0) + ' ms')
}
const result = combinations
.map((combination) => {
if (combination.lazy) {
return async () => processCombination(combination)
} else {
return processCombination(combination)
}
})
- .filter((x) => x)
+ .filter(Boolean)
const t2 = performance.now()
if (debug) {
console.debug('Eager processing took ' + (t2 - t1) + ' ms')
}
// optimization to traverse big-ass array only once instead of twice
const eager = []
const lazy = []
result.forEach((x) => {
if (typeof x === 'function') {
lazy.push(x)
} else {
eager.push(x)
}
})
return {
lazy,
eager,
staticVars,
engineChecksum,
themeChecksum: sum([lazy, eager]),
}
}
diff --git a/src/services/user_profile_link_generator/user_profile_link_generator.js b/src/services/user_profile_link_generator/user_profile_link_generator.js
index 8f4ab7e58b..45cd0ea99f 100644
--- a/src/services/user_profile_link_generator/user_profile_link_generator.js
+++ b/src/services/user_profile_link_generator/user_profile_link_generator.js
@@ -1,16 +1,16 @@
import { includes } from 'lodash'
const generateProfileLink = (id, screenName, restrictedNicknames) => {
const complicated =
!screenName ||
isExternal(screenName) ||
includes(restrictedNicknames, screenName)
return {
name: complicated ? 'external-user-profile' : 'user-profile',
params: complicated ? { id } : { name: screenName },
}
}
-const isExternal = (screenName) => screenName && screenName.includes('@')
+const isExternal = (screenName) => screenName?.includes('@')
export default generateProfileLink
diff --git a/src/stores/admin_settings.js b/src/stores/admin_settings.js
index 5aa91c8191..766da26fbb 100644
--- a/src/stores/admin_settings.js
+++ b/src/stores/admin_settings.js
@@ -1,615 +1,615 @@
-import { cloneDeep, differenceWith, flatten, get, isEqual, set } from 'lodash'
+import { cloneDeep, differenceWith, get, isEqual, set } from 'lodash'
import { defineStore } from 'pinia'
import { useOAuthStore } from 'src/stores/oauth.js'
import {
addNewEmojiFile,
changeStatusScope,
createEmojiPack,
deleteAccounts,
deleteEmojiPack,
disableMFA,
downloadRemoteEmojiPack,
downloadRemoteEmojiPackZIP,
getAvailableFrontends,
getInstanceConfigDescriptions,
getInstanceDBConfig,
getUserData,
importEmojiFromFS,
installFrontend,
listRemoteEmojiPacks,
listStatuses,
listUsers,
pushInstanceDBConfig,
reloadEmoji,
requirePasswordChange,
resendConfirmationEmail,
setUsersActivationStatus,
setUsersApprovalStatus,
setUsersConfirmationStatus,
setUsersRight,
setUsersSuggestionStatus,
setUsersTags,
} from 'src/api/admin.js'
import { listEmojiPacks } from 'src/api/public.js'
import { parseStatus } from 'src/services/entity_normalizer/entity_normalizer.service.js'
export const defaultState = {
frontends: [],
loaded: false,
needsReboot: null,
config: null,
modifiedPaths: null,
descriptions: null,
draft: null,
dbConfigEnabled: null,
}
export const newUserFlags = {
...defaultState.flagStorage,
}
export const useAdminSettingsStore = defineStore('adminSettings', {
state: () => ({
...cloneDeep(defaultState),
}),
actions: {
// Configuration Stuff
setInstanceAdminNoDbConfig() {
this.loaded = false
this.dbConfigEnabled = false
},
updateAdminSettings({ config, modifiedPaths }) {
this.loaded = true
this.dbConfigEnabled = true
this.config = config
this.modifiedPaths = modifiedPaths
},
updateAdminDescriptions({ descriptions }) {
this.descriptions = descriptions
},
updateAdminDraft({ path, value }) {
const [group, key, subkey] = path
const parent = [group, key, subkey]
set(this.draft, path, value)
// force-updating grouped draft to trigger refresh of group settings
if (path.length > parent.length) {
set(this.draft, parent, cloneDeep(get(this.draft, parent)))
}
},
resetAdminDraft() {
this.draft = cloneDeep(this.config)
},
loadAdminStuff() {
getInstanceDBConfig({
credentials: useOAuthStore().token,
})
.then(({ data: backendDbConfig }) =>
this.setInstanceAdminSettings({
credentials: useOAuthStore().token,
backendDbConfig,
}),
)
.catch(({ statusCode, statusText }) => {
if (statusCode === 400) {
if (/configurable_from_database/.test(statusText)) {
this.setInstanceAdminNoDbConfig()
}
}
})
if (this.descriptions === null) {
getInstanceConfigDescriptions({
credentials: useOAuthStore().token,
}).then(({ data: backendDescriptions }) =>
this.setInstanceAdminDescriptions({
credentials: useOAuthStore().token,
backendDescriptions,
}),
)
}
},
setInstanceAdminSettings({ backendDbConfig }) {
const config = this.config || {}
const modifiedPaths = new Set()
backendDbConfig.configs.forEach((c) => {
const path = [c.group, c.key]
if (c.db) {
// Path elements can contain dot, therefore we use ' -> ' as a separator instead
// Using strings for modified paths for easier searching
c.db.forEach((x) => modifiedPaths.add([...path, x].join(' -> ')))
}
// we need to preserve tuples on second level only, possibly third
// but it's not a case right now.
const convert = (value, preserveTuples, preserveTuplesLv2) => {
if (Array.isArray(value) && value.length > 0 && value[0].tuple) {
if (!preserveTuples) {
return value.reduce((acc, c) => {
if (c.tuple == null) {
return {
...acc,
[c]: c,
}
}
return {
...acc,
[c.tuple[0]]: convert(c.tuple[1], preserveTuplesLv2),
}
}, {})
} else {
return value.map((x) => x.tuple)
}
} else {
if (!preserveTuples) {
return value
} else {
return value.tuple
}
}
}
// for most stuff we want maps since those are more convenient
// however this doesn't allow for multiple values per same key
// so for those cases we want to preserve tuples as-is
// right now it's made exclusively for :pleroma.:rate_limit
// so it might not work properly elsewhere
const preserveTuples = path.find((x) => x === ':rate_limit')
set(config, path, convert(c.value, false, preserveTuples))
})
// patching http adapter config to be easier to handle
const adapter = config[':pleroma'][':http'][':adapter']
if (Array.isArray(adapter)) {
config[':pleroma'][':http'][':adapter'] = {
[':ssl_options']: {
[':versions']: [],
},
}
}
this.updateAdminSettings({ config, modifiedPaths })
this.resetAdminDraft()
},
setInstanceAdminDescriptions({ backendDescriptions }) {
const convert = (
{ children, description, label, key = '<ROOT>', group, suggestions },
path,
acc,
) => {
const newPath = group ? [group, key] : [key]
const obj = { description, label, suggestions }
if (Array.isArray(children)) {
children.forEach((c) => {
convert(c, newPath, obj)
})
}
set(acc, newPath, obj)
}
const descriptions = {}
backendDescriptions.forEach((d) => convert(d, '', descriptions))
this.updateAdminDescriptions({ descriptions })
},
// This action takes draft state, diffs it with live config state and then pushes
// only differences between the two. Difference detection only work up to subkey (third) level.
pushAdminDraft() {
// TODO cleanup paths in modifiedPaths
const convert = (value) => {
if (typeof value !== 'object') {
return value
} else if (Array.isArray(value)) {
return value.map(convert)
} else {
return Object.entries(value).map(([k, v]) => ({ tuple: [k, v] }))
}
}
// Getting all group-keys used in config
- const allGroupKeys = flatten(
- Object.entries(this.config).map(([group, lv1data]) =>
+ const allGroupKeys = Object.entries(this.config)
+ .map(([group, lv1data]) =>
Object.keys(lv1data).map((key) => ({ group, key })),
- ),
- )
+ )
+ .flat()
// Only using group-keys where there are changes detected
const changedGroupKeys = allGroupKeys.filter(({ group, key }) => {
return !isEqual(this.config[group][key], this.draft[group][key])
})
// Here we take all changed group-keys and get all changed subkeys
const changed = changedGroupKeys.map(({ group, key }) => {
const config = this.config[group][key]
const draft = this.draft[group][key]
// We convert group-key value into entries arrays
const eConfig = Object.entries(config)
const eDraft = Object.entries(draft)
// Then those entries array we diff so only changed subkey entries remain
// We use the diffed array to reconstruct the object and then shove it into convert()
return {
group,
key,
value: convert(
Object.fromEntries(differenceWith(eDraft, eConfig, isEqual)),
),
}
})
pushInstanceDBConfig({
credentials: useOAuthStore().token,
payload: {
configs: changed,
},
})
.then(() =>
getInstanceDBConfig({
credentials: useOAuthStore().token,
}).then(({ data }) => data),
)
.then((backendDbConfig) =>
this.setInstanceAdminSettings({
credentials: useOAuthStore().token,
backendDbConfig,
}),
)
},
pushAdminSetting({ path, value }) {
const [group, key, ...rest] = Array.isArray(path)
? path
: path.split(/\./g)
const clone = {} // not actually cloning the entire thing to avoid excessive writes
set(clone, rest, value)
// TODO cleanup paths in modifiedPaths
const convert = (value) => {
if (typeof value !== 'object') {
return value
} else if (Array.isArray(value)) {
return value.map(convert)
} else {
return Object.entries(value).map(([k, v]) => ({ tuple: [k, v] }))
}
}
pushInstanceDBConfig({
credentials: useOAuthStore().token,
payload: {
configs: [
{
group,
key,
value: convert(clone),
},
],
},
})
.then(() =>
getInstanceDBConfig({
credentials: useOAuthStore().token,
}).then(({ data }) => data),
)
.then((backendDbConfig) =>
this.setInstanceAdminSettings({
credentials: useOAuthStore().token,
backendDbConfig,
}),
)
},
resetAdminSetting({ path }) {
const [group, key, subkey] = Array.isArray(path)
? path
: path.split(/\./g)
this.modifiedPaths.delete(path)
return pushInstanceDBConfig({
credentials: useOAuthStore().token,
payload: {
configs: [
{
group,
key,
delete: true,
subkeys: [subkey],
},
],
},
})
.then(() =>
getInstanceDBConfig({
credentials: useOAuthStore().token,
}).then(({ data }) => data),
)
.then((backendDbConfig) =>
this.setInstanceAdminSettings({ backendDbConfig }),
)
},
// Frontends Stuff
loadFrontendsStuff() {
getAvailableFrontends({
credentials: useOAuthStore().token,
}).then(({ data: frontends }) =>
this.setAvailableFrontends({ frontends }),
)
},
setAvailableFrontends({ frontends }) {
this.frontends = frontends.map((f) => {
f.installedRefs = f.installed_refs
if (f.name === 'pleroma-fe') {
f.refs = ['master', 'develop']
} else {
f.refs = [f.ref]
}
return f
})
},
installFrontend() {
return installFrontend({
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
// Statuses stuff
async fetchStatuses(opts) {
const {
data: { total, activities },
} = await listStatuses({
credentials: useOAuthStore().token,
opts,
})
const statuses = activities.map(parseStatus)
await window.vuex.dispatch('addNewStatuses', { statuses })
return {
items: statuses,
count: total,
}
},
async changeStatusScope(opts) {
const { data } = await changeStatusScope({
credentials: useOAuthStore().token,
opts,
})
const status = parseStatus(data)
await window.vuex.dispatch('addNewStatuses', { statuses: [status] })
},
// Users stuff
async fetchUsers(opts) {
const {
data: { users, count },
} = await listUsers({
credentials: useOAuthStore().token,
opts,
})
return {
items: await Promise.all(
users.map(
async (userAdminData) =>
await window.vuex.dispatch('updateUserAdminData', {
userAdminData,
}),
),
),
count,
}
},
async getUserData({ user }) {
const api = getUserData
const { screen_name } = user
const result = await api({
credentials: useOAuthStore().token,
screen_name,
})
window.vuex.commit('updateUserAdminData', { user: result.data })
},
async deleteUsers({ users }) {
const screen_names = users.map((u) => u.screen_name)
const api = deleteAccounts
const resultUserIds = await api({
credentials: useOAuthStore().token,
screen_names,
})
resultUserIds.data.forEach((userId) => {
window.vuex.dispatch(
'markStatusesAsDeleted',
(status) => userId === status.user.id,
)
// TODO when migrated to pinia, also remove user
})
return resultUserIds
},
resendConfirmationEmail({ users }) {
const screen_names = users.map((u) => u.screen_name)
return resendConfirmationEmail({
credentials: useOAuthStore().token,
screen_names,
}).then(({ data }) => data)
},
requirePasswordChange({ users }) {
const screen_names = users.map((u) => u.screen_name)
return requirePasswordChange({
credentials: useOAuthStore().token,
screen_names,
}).then(({ data }) => data)
},
// Singular only!
disableMFA({ user }) {
const { screen_name } = user
return disableMFA({
credentials: useOAuthStore().token,
screen_name,
}).then(({ data }) => data)
},
async setUsersTags({ users, tags, value }) {
const screen_names = users.map((u) => u.screen_name)
const api = setUsersTags
await api({
credentials: useOAuthStore().token,
screen_names,
tags,
value,
})
users.forEach((user) => {
this.getUserData({ user })
})
},
async setUsersRight({ users, right, value }) {
const screen_names = users.map((u) => u.screen_name)
const api = setUsersRight
await api({
credentials: useOAuthStore().token,
screen_names,
right,
value,
})
users.forEach((user) => {
window.vuex.commit('updateRight', { user, right, value })
})
},
async setUsersActivationStatus({ users, value }) {
const screen_names = users.map((u) => u.screen_name)
const api = setUsersActivationStatus
const resultUsers = await api({
credentials: useOAuthStore().token,
screen_names,
value,
})
resultUsers.data.forEach((user) => {
window.vuex.commit('updateUserAdminData', { user })
})
},
async setUsersSuggestionStatus({ users, value }) {
const screen_names = users.map((u) => u.screen_name)
const api = setUsersSuggestionStatus
const resultUsers = await api({
credentials: useOAuthStore().token,
screen_names,
value,
})
resultUsers.data.forEach((user) => {
window.vuex.commit('updateUserAdminData', { user })
})
},
async setUsersConfirmationStatus({ users }) {
const screen_names = users.map((u) => u.screen_name)
const api = setUsersConfirmationStatus
await api({
credentials: useOAuthStore().token,
screen_names,
})
users.forEach((user) => {
this.getUserData({ user })
})
},
async setUsersApprovalStatus({ users }) {
const screen_names = users.map((u) => u.screen_name)
const api = setUsersApprovalStatus
const resultUsers = await api({
credentials: useOAuthStore().token,
screen_names,
})
resultUsers.data.forEach((user) => {
window.vuex.commit('updateUserAdminData', { user })
})
},
reloadEmoji() {
return reloadEmoji({ credentials: useOAuthStore().token }).then(
({ data }) => data,
)
},
importEmojiFromFS() {
return importEmojiFromFS({ credentials: useOAuthStore().token }).then(
({ data }) => data,
)
},
listEmojiPacks(params) {
return listEmojiPacks({
...params,
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
listRemoteEmojiPacks(params) {
return listRemoteEmojiPacks({
...params,
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
addNewEmojiFile({ packName, file, shortcode, filename }) {
return addNewEmojiFile({
packName,
file,
shortcode,
filename,
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
downloadRemoteEmojiPack({ instance, packName, as }) {
return downloadRemoteEmojiPack({
instance,
packName,
as,
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
downloadRemoteEmojiPackZIP({ url, packName }) {
return downloadRemoteEmojiPackZIP({
url,
packName,
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
createEmojiPack({ name }) {
return createEmojiPack({
name,
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
deleteEmojiPack({ name }) {
return deleteEmojiPack({
name,
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
saveEmojiPackMetadata({ name, newData }) {
return createEmojiPack({
name,
newData,
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
},
})
diff --git a/src/stores/announcements.js b/src/stores/announcements.js
index a5f3e4d8ef..b096557556 100644
--- a/src/stores/announcements.js
+++ b/src/stores/announcements.js
@@ -1,154 +1,154 @@
import { defineStore } from 'pinia'
import { useOAuthStore } from 'src/stores/oauth.js'
import { dismissAnnouncement, getAnnouncements } from 'src/api/user.js'
const FETCH_ANNOUNCEMENT_INTERVAL_MS = 1000 * 60 * 5
export const useAnnouncementsStore = defineStore('announcements', {
state: () => ({
announcements: [],
supportsAnnouncements: true,
fetchAnnouncementsTimer: undefined,
adminActions: {},
userActions: {},
}),
getters: {
unreadAnnouncementCount() {
if (!window.vuex.state.users.currentUser) {
return 0
}
const unread = this.announcements.filter(
(announcement) => !(announcement.inactive || announcement.read),
)
return unread.length
},
},
actions: {
async fetchAnnouncements() {
if (!this.supportsAnnouncements) return
const currentUser = window.vuex.state.users.currentUser
const isAdmin =
currentUser &&
currentUser.privileges.has('announcements_manage_announcements')
try {
if (isAdmin) {
this.adminActions = await import('src/api/admin.js')
} else {
const all = await getAnnouncements({
credentials: useOAuthStore().token,
})
return all.data
}
const { data: all } = await this.adminActions.getAnnouncements({
credentials: useOAuthStore().token,
})
const { data: visible } = await getAnnouncements({
credentials: useOAuthStore().token,
})
const visibleObject = visible.reduce((a, c) => {
a[c.id] = c
return a
}, {})
const getWithinVisible = (announcement) =>
visibleObject[announcement.id]
all.forEach((announcement) => {
const visibleAnnouncement = getWithinVisible(announcement)
if (!visibleAnnouncement) {
announcement.inactive = true
} else {
announcement.read = visibleAnnouncement.read
}
})
this.announcements = all
} catch (error) {
// If and only if backend does not support announcements, it would return 404.
// In this case, silently ignores it.
- if (error && error.statusCode === 404) {
+ if (error?.statusCode === 404) {
this.supportsAnnouncements = false
} else {
throw error
}
}
},
markAnnouncementAsRead(id) {
return dismissAnnouncement({
id,
credentials: useOAuthStore().token,
}).then(() => {
const index = this.announcements.findIndex((a) => a.id === id)
if (index < 0) {
return
}
this.announcements[index].read = true
})
},
startFetchingAnnouncements() {
if (this.fetchAnnouncementsTimer) {
return
}
const interval = setInterval(
() => this.fetchAnnouncements(),
FETCH_ANNOUNCEMENT_INTERVAL_MS,
)
this.fetchAnnouncementsTimer = interval
return this.fetchAnnouncements()
},
stopFetchingAnnouncements() {
const interval = this.fetchAnnouncementsTimer
this.fetchAnnouncementsTimer = undefined
clearInterval(interval)
},
postAnnouncement({ content, startsAt, endsAt, allDay }) {
return this.adminActions
.postAnnouncement({
credentials: useOAuthStore().token,
content,
startsAt,
endsAt,
allDay,
})
.then(() => {
return this.fetchAnnouncements()
})
},
editAnnouncement({ id, content, startsAt, endsAt, allDay }) {
return this.adminActions
.editAnnouncement({
id,
content,
startsAt,
endsAt,
allDay,
credentials: useOAuthStore().token,
})
.then(() => {
return this.fetchAnnouncements()
})
},
deleteAnnouncement(id) {
return this.adminActions
.deleteAnnouncement({
id,
credentials: useOAuthStore().token,
})
.then(() => {
return this.fetchAnnouncements()
})
},
},
})
diff --git a/src/stores/interface.js b/src/stores/interface.js
index 275d6893dd..87ef658650 100644
--- a/src/stores/interface.js
+++ b/src/stores/interface.js
@@ -1,803 +1,803 @@
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'
const GENERIC_FONT_NAMES = new Set([
'serif',
'sans-serif',
'system-ui',
'cursive',
'fantasy',
'math',
'monospace',
])
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: [],
globalError: null,
layoutHeight: 0,
lastTimeline: null,
foreignProfileBackground: null,
}),
actions: {
setTemporaryChanges({ confirm, revert }) {
this.temporaryChangesCountdown = 10
this.temporaryChangesConfirm = confirm
this.temporaryChangesRevert = revert
const countdownFunc = () => {
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().instanceIdentity.name}`
} catch (error) {
console.error(`${error}`)
}
},
setForeignProfileBackground(url) {
this.foreignProfileBackground = url
},
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
}
}
},
setSettingsModalState(newState) {
const oldState = this.settingsModalState
const legal = (() => {
switch (oldState) {
case 'minimized':
return true
case 'visible':
return true
case 'hidden':
return newState === 'visible'
}
})()
if (legal) {
this.settingsModalState = newState
}
},
toggleMinimizeSettingsModal() {
switch (this.settingsModalState) {
case 'minimized':
this.settingsModalState = 'visible'
return
case 'visible':
this.settingsModalState = 'minimized'
return
case 'hidden':
return
default:
throw new Error(
`Illegal minimization state of settings modal: ${this.settingsModalState}`,
)
}
},
clearSettingsModalTargetTab() {
this.settingsModalTargetTab = null
},
openSettingsModalTab(value, mode = 'user') {
this.settingsModalTargetTab = value
this.openSettingsModal(mode)
},
removeGlobalNotice(notice) {
this.globalNotices = this.globalNotices.filter((n) => n !== notice)
},
setGlobalError({ error, instance, info }) {
switch (info) {
case 'https://vuejs.org/error-reference/#runtime-13': {
this.globalError = {
title: 'general.refresh_required',
content: 'general.refresh_required_content',
// `true` disables cache on Firefox (non-standard)
recover: () => window.location.reload(true),
recoverText: 'general.refresh_required_refresh',
error,
}
break
}
default: {
this.globalError = { error }
break
}
}
},
clearGlobalError() {
this.globalError = null
},
pushGlobalNotice({
messageKey,
messageArgs = {},
level = 'error',
timeout = 5000,
}) {
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 > 0) {
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 } = 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(({ family }) => 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({
path: 'palettesIndex',
value,
})
return value
} catch (e) {
console.error('Could not fetch palettes index', e)
useInstanceStore().set({
path: 'palettesIndex',
value: { _error: e },
})
return Promise.resolve({})
}
},
setPalette(value) {
this.resetThemeV3Palette()
this.resetThemeV2()
useSyncConfigStore().setPreference({ path: 'simple.palette', value })
useSyncConfigStore().pushSyncConfig()
this.applyTheme({ recompile: true })
},
setPaletteCustom(value) {
this.resetThemeV3Palette()
this.resetThemeV2()
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({ path: 'stylesIndex', value })
return value
} catch (e) {
console.error('Could not fetch styles index', e)
useInstanceStore().set({
path: 'simple.stylesIndex',
value: { _error: e },
})
return Promise.resolve({})
}
},
setStyle(value) {
this.resetThemeV3()
this.resetThemeV2()
this.resetThemeV3Palette()
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()
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({ path: 'themesIndex', value })
return value
} catch (e) {
console.error('Could not fetch themes index', e)
useInstanceStore().set({
path: 'themesIndex',
value: { _error: e },
})
return Promise.resolve({})
}
},
setTheme(value) {
this.resetThemeV3()
this.resetThemeV3Palette()
this.resetThemeV2()
useSyncConfigStore().setPreference({ path: 'simple.theme', value })
useSyncConfigStore().pushSyncConfig()
this.applyTheme({ recompile: true })
},
setThemeCustom(value) {
this.resetThemeV3()
this.resetThemeV3Palette()
this.resetThemeV2()
useSyncConfigStore().setPreference({ path: 'simple.customTheme', value })
useSyncConfigStore().setPreference({
path: 'simple.customThemeSource',
value,
})
useSyncConfigStore().pushSyncConfig()
this.applyTheme({ recompile: true })
},
resetThemeV3() {
useSyncConfigStore().setPreference({ path: 'simple.style', value: null })
useSyncConfigStore().setPreference({
path: 'simple.styleCustomData',
value: null,
})
},
resetThemeV3Palette() {
useSyncConfigStore().setPreference({
path: 'simple.palette',
value: null,
})
useSyncConfigStore().setPreference({
path: 'simple.paletteCustomData',
value: null,
})
},
resetThemeV2() {
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
}
let {
theme: instanceThemeName,
style: instanceStyleName,
palette: instancePaletteName,
} = useInstanceStore().instanceIdentity
let { themesIndex, stylesIndex, palettesIndex } = useInstanceStore()
const {
style: userStyleName,
styleCustomData: userStyleCustomData,
palette: userPaletteName,
paletteCustomData: userPaletteCustomData,
} = useMergedConfigStore().mergedConfig
let {
theme: userThemeV2Name,
customTheme: userThemeV2Snapshot,
customThemeSource: userThemeV2Source,
} = 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: ' + instanceThemeName)
if (
userPaletteName ||
userPaletteCustomData ||
userStyleName ||
userStyleCustomData ||
// User V2 overrides instance V3
((instancePaletteName || instanceStyleName) &&
instanceThemeName == null &&
userThemeV2Name == null)
) {
// Palette and/or style overrides V2 themes
instanceThemeName = null
userThemeV2Name = null
userThemeV2Source = null
userThemeV2Snapshot = null
majorVersionUsed = 'v3'
} else if (
userThemeV2Name ||
userThemeV2Snapshot ||
userThemeV2Source ||
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) {
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 || instanceThemeName,
)
this.themeNameUsed = theme.nameUsed
this.themeDataUsed = theme.dataUsed
}
},
async setThemeApplied() {
this.themeApplied = true
},
async applyTheme({ recompile = false } = {}) {
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 = []
const fontMap = {
Interface: 'Root',
Input: 'Input',
Posts: 'Post',
Monospace: 'Root',
}
Object.entries(fontMap).forEach(([font, component]) => {
const family = mergedConfig[`font${font}`]
const variable = font === 'Monospace' ? '--monoFont' : '--font'
if (typeof family === 'string') {
const familyString = GENERIC_FONT_NAMES.has(family)
? family
: `"${family}"`
hacks.push({
component,
directives: {
[variable]: `generic | ${familyString}`,
},
})
}
})
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)
+ ].filter(Boolean)
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) {
+ if (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/sync_config.js b/src/stores/sync_config.js
index 47c6978d5a..87083d8500 100644
--- a/src/stores/sync_config.js
+++ b/src/stores/sync_config.js
@@ -1,840 +1,841 @@
import sum from 'hash-sum'
import {
merge as _merge,
clamp,
cloneDeep,
findLastIndex,
- flatten,
get,
groupBy,
isEqual,
+ last,
set,
take,
- takeRight,
uniqWith,
unset,
} from 'lodash'
import { defineStore } from 'pinia'
import { v4 as uuidv4 } from 'uuid'
import { toRaw } from 'vue'
import { CURRENT_UPDATE_COUNTER } from 'src/components/update_notification/update_notification.js'
import { useLocalConfigStore } from 'src/stores/local_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { updateProfileJSON } from 'src/api/user.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
reset: 0, // special flag that can be used to force-reset all data, debug purposes only
// special reset codes:
// 1000: trim keys to those known by currently running FE
// 1001: same as above + reset everything to 0
},
prefsStorage: {
_journal: [],
simple: {
muteFilters: {},
...makeUndefined({ ...ROOT_CONFIG }),
},
collections: {
pinnedStatusActions: ['reply', 'retweet', 'favorite', 'emoji'],
pinnedNavItems: ['home', 'dms', 'chats'],
},
},
// raw data
raw: null,
// local cache
cache: null,
}
export const newUserFlags = {
...defaultState.flagStorage,
updateCounter: CURRENT_UPDATE_COUNTER, // new users don't need to see update notification
}
export const _moveItemInArray = (array, value, movement) => {
const oldIndex = array.indexOf(value)
const newIndex = oldIndex + movement
const newArray = [...array]
// remove old
newArray.splice(oldIndex, 1)
// add new
newArray.splice(clamp(newIndex, 0, newArray.length + 1), 0, value)
return newArray
}
const _wrapData = (data, userName) => {
return {
...data,
_user: userName,
_timestamp: Date.now(),
_version: VERSION,
}
}
const _checkValidity = (data) => data._timestamp > 0 && data._version > 0
const _verifyPrefs = (state) => {
state.prefsStorage = state.prefsStorage || {
simple: {},
collections: {},
}
// Simple
Object.entries(defaultState.prefsStorage.simple).forEach(([k, v]) => {
if (typeof v === 'undefined') return
if (typeof v === 'number' || typeof v === 'boolean') return
if (typeof v === 'object') return
console.warn(
`Preference simple.${k} as invalid type ${typeof v}, reinitializing`,
)
set(state.prefsStorage.simple, k, defaultState.prefsStorage.simple[k])
})
// Collections
Object.entries(defaultState.prefsStorage.collections).forEach(([k, v]) => {
if (Array.isArray(v)) return
console.warn(
`Preference collections.${k} as invalid type ${typeof v}, reinitializing`,
)
set(
state.prefsStorage.collections,
k,
defaultState.prefsStorage.collections[k],
)
})
}
export const _getRecentData = (cache, live, isTest) => {
const result = { recent: null, stale: null, needUpload: false }
const cacheValid = _checkValidity(cache || {})
const liveValid = _checkValidity(live || {})
if (!liveValid && cacheValid) {
result.needUpload = true
console.debug(
'Nothing valid stored on server, assuming cache to be source of truth',
)
result.recent = cache
result.stale = live
} else if (!cacheValid && liveValid) {
console.debug(
'Valid storage on server found, no local cache found, using live as source of truth',
)
result.recent = live
result.stale = cache
} else if (cacheValid && liveValid) {
console.debug('Both sources have valid data, figuring things out...')
if (
live._timestamp === cache._timestamp &&
live._version === cache._version
) {
console.debug(
'Same version/timestamp on both sources, source of truth irrelevant',
)
result.recent = cache
result.stale = live
} else {
console.debug(
'Different timestamp or version, figuring out which one is more recent',
)
if (live._timestamp < cache._timestamp) {
result.recent = cache
result.stale = live
} else {
result.recent = live
result.stale = cache
}
}
} else {
console.debug('Both sources are invalid, start from scratch')
result.needUpload = true
}
const merge = (a, b) => {
return {
_user: a._user ?? b._user,
_version: a._version ?? b._version,
_timestamp: a._timestamp ?? b._timestamp,
needUpload: b.needUpload ?? a.needUpload,
prefsStorage: _merge(cloneDeep(a.prefsStorage), b.prefsStorage),
flagStorage: _merge(a.flagStorage, b.flagStorage),
}
}
result.recent = isTest
? result.recent
: result.recent && merge(defaultState, result.recent)
result.stale = isTest
? result.stale
: result.stale && merge(defaultState, result.stale)
return result
}
export const _getAllFlags = (recent, stale) => {
return Array.from(
new Set([
...Object.keys(toRaw((recent || {}).flagStorage || {})),
...Object.keys(toRaw((stale || {}).flagStorage || {})),
]),
)
}
export const _mergeFlags = (recent, stale, allFlagKeys) => {
if (!stale.flagStorage) return recent.flagStorage
if (!recent.flagStorage) return stale.flagStorage
return Object.fromEntries(
allFlagKeys.map((flag) => {
const recentFlag = recent.flagStorage[flag]
const staleFlag = stale.flagStorage[flag]
// use flag that is of higher value
return [
flag,
Number((recentFlag > staleFlag ? recentFlag : staleFlag) || 0),
]
}),
)
}
export const _mergeJournal = (...journals) => {
// Ignore invalid journal entries
- const allJournals = flatten(
- journals.map((j) => (Array.isArray(j) ? j : [])),
- ).filter(
- (entry) =>
- Object.hasOwn(entry, 'path') &&
- Object.hasOwn(entry, 'operation') &&
- Object.hasOwn(entry, 'args') &&
- Object.hasOwn(entry, 'timestamp'),
- )
+ const allJournals = journals
+ .map((j) => (Array.isArray(j) ? j : []))
+ .flat()
+ .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, rawJournal]) => {
const journal = rawJournal
.map((data, index) => ({ data, index }))
.toSorted(({ data: a, index: ai }, { data: b, index: bi }) => {
if (a.timestamp === b.timestamp) {
return ai - bi
} else {
return a.timestamp > b.timestamp ? 1 : -1
}
})
.map((x) => x.data)
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)
+ return [last(journal)]
} else {
return journal
}
})
- const flat = flatten(trimmedGrouped)
+ const flat = trimmedGrouped
+ .flat()
.map((data, index) => ({ data, index }))
.toSorted(({ data: a, index: ai }, { data: b, index: bi }) => {
if (a.timestamp === b.timestamp) {
return ai - bi
} else {
return a.timestamp > b.timestamp ? 1 : -1
}
})
.map((x) => x.data)
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
.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
}
case 'unset':
if (path.startsWith('collections')) {
return console.error('Illegal operation "unset" on a collection')
}
if (path.split(/\./g).length <= 2) {
return console.error(
`Calling unset on depth <= 2 (path: ${path}) is not allowed`,
)
}
unset(resultOutput, path)
break
case 'addToCollection':
if (!path.startsWith('collections')) {
return console.error(
'Illegal operation "addToCollection" on a non-collection',
)
}
set(
resultOutput,
path,
Array.from(new Set(get(resultOutput, path)).add(args[0])),
)
break
case 'removeFromCollection': {
if (!path.startsWith('collections')) {
return console.error(
'Illegal operation "removeFromCollection" on a non-collection',
)
}
const newSet = new Set(get(resultOutput, path))
newSet.delete(args[0])
set(resultOutput, path, Array.from(newSet))
break
}
case 'reorderCollection': {
const [value, movement] = args
set(
resultOutput,
path,
_moveItemInArray(get(resultOutput, path), value, movement),
)
break
}
default:
return console.error(
`Unknown journal operation: '${operation}', did we forget to run reverse migrations beforehand?`,
)
}
})
return { ...resultOutput, _journal: totalJournal }
}
export const _resetFlags = (
totalFlags,
knownKeys = defaultState.flagStorage,
) => {
let result = { ...totalFlags }
const allFlagKeys = Object.keys(totalFlags)
// flag reset functionality
if (
totalFlags.reset >= COMMAND_TRIM_FLAGS &&
totalFlags.reset <= COMMAND_TRIM_FLAGS_AND_RESET
) {
console.debug('Received command to trim the flags')
const knownKeysSet = new Set(Object.keys(knownKeys))
// Trim
result = {}
allFlagKeys.forEach((flag) => {
if (knownKeysSet.has(flag)) {
result[flag] = totalFlags[flag]
}
})
// Reset
if (totalFlags.reset === COMMAND_TRIM_FLAGS_AND_RESET) {
// 1001 - and reset everything to 0
console.debug('Received command to reset the flags')
Object.keys(knownKeys).forEach((flag) => {
result[flag] = 0
})
}
}
result.reset = 0
return result
}
const _resetPrefs = (
totalPrefs,
totalFlags,
knownKeys = defaultState.flagStorage,
) => {
// prefs reset functionality
if (
totalFlags.reset >= COMMAND_WIPE_JOURNAL &&
totalFlags.reset <= COMMAND_WIPE_JOURNAL_AND_STORAGE
) {
console.debug('Received command to reset journals')
this.flagStorage.reset = COMMAND_WIPE_JOURNAL
this.prefsStorage._journal = []
this.cache.prefsStorage._journal = []
this.raw.prefsStorage._journal = []
this.pushSyncConfig()
if (totalFlags.reset === COMMAND_WIPE_JOURNAL_AND_STORAGE) {
console.debug('Received command to reset storage')
return cloneDeep(defaultState)
}
}
return totalPrefs
}
const _doMigrations = async (data, setPreference) => {
if (data._version < VERSION) {
console.debug(
'Data has older version, seeing if there any migrations that can be applied',
)
}
if (data._version > VERSION) {
console.debug(
'Data has newer version, seeing if there any reverse migrations that can be applied',
)
// no reverse migrations right now but we leave a possibility of loading a hotpatch if need be
if (window._PLEROMA_HOTPATCH) {
if (window._PLEROMA_HOTPATCH.reverseMigrations) {
console.debug('Found hotpatch migration, applying')
return window._PLEROMA_HOTPATCH.reverseMigrations.call(
{},
'syncConfigStore',
{ from: data._version, to: VERSION },
data,
)
}
}
}
return data
}
export const useSyncConfigStore = defineStore('sync_config', {
state() {
return cloneDeep(defaultState)
},
actions: {
setFlag({ flag, value }) {
this.flagStorage[flag] = value
this.dirty = true
},
setSimplePrefAndSave({ path, value }) {
this.setPreference({ path: `simple.${path}`, value })
this.pushSyncConfig()
},
unsetSimplePrefAndSave({ path }) {
this.unsetPreference({ path: `simple.${path}` })
this.pushSyncConfig()
},
setPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`Tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
if (path.startsWith('collections')) {
throw new Error(
`Invalid operation 'set' for collection field '${path}', ignoring.`,
)
}
if (path.split(/\./g).length <= 1) {
throw new Error(
`Calling set on depth <= 1 (path: ${path}) is not allowed`,
)
}
if (path.split(/\./g).length > 3) {
throw new Error(
`Calling set on depth > 3 (path: ${path}) is not allowed`,
)
}
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')) {
throw new Error(
`Invalid operation 'unset' for collection field '${path}', ignoring.`,
)
}
if (path.split(/\./g).length <= 2) {
throw new Error(
`Calling unset on depth <= 2 (path: ${path}) is not allowed`,
)
}
if (path.split(/\./g).length > 3) {
throw new Error(
`Calling unset on depth > 3 (path: ${path}) is not allowed`,
)
}
unset(this.prefsStorage, path)
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{ operation: 'unset', path, args: [], timestamp: Date.now() },
]
this.dirty = true
},
addCollectionPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`tried to edit internal (starts with _) field '${path}'`,
)
}
if (path.startsWith('collections')) {
const collection = new Set(get(this.prefsStorage, path))
collection.add(value)
set(this.prefsStorage, path, [...collection])
}
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{
operation: 'addToCollection',
path,
args: [value],
timestamp: Date.now(),
},
]
this.dirty = true
},
removeCollectionPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
const { _key } = value
if (path.startsWith('collection')) {
const collection = new Set(get(this.prefsStorage, path))
collection.delete(value)
set(this.prefsStorage, path, [...collection])
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{
operation: 'removeFromCollection',
path,
args: [value],
timestamp: Date.now(),
},
]
this.dirty = true
}
},
reorderCollectionPreference({ path, value, movement }) {
if (path.startsWith('_')) {
throw new Error(
`tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
const collection = get(this.prefsStorage, path)
const newCollection = _moveItemInArray(collection, value, movement)
set(this.prefsStorage, path, newCollection)
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{
operation: 'arrangeCollection',
path,
args: [value],
timestamp: Date.now(),
},
]
this.dirty = true
},
updateCache({ username }) {
this.prefsStorage._journal = _mergeJournal(this.prefsStorage._journal)
this.cache = _wrapData(
{
flagStorage: toRaw(this.flagStorage),
prefsStorage: toRaw(this.prefsStorage),
},
username,
)
},
clearSyncConfig() {
const blankState = { ...cloneDeep(defaultState) }
Object.keys(this).forEach((k) => {
this[k] = blankState[k]
})
this.flagStorage.reset = COMMAND_WIPE_JOURNAL_AND_STORAGE
},
async initSyncConfig(userData) {
const live = userData.storage
this.raw = live
let cache = this.cache
if (cache?._user !== userData.fqn) {
console.warn(
'Cache belongs to another user! reinitializing local cache!',
)
cache = null
}
let { recent, stale, needUpload } = _getRecentData(cache, live)
const userNew = userData.created_at > NEW_USER_DATE
const flagsTemplate = userNew ? newUserFlags : defaultState.flagStorage
let dirty = false
if (recent === null) {
console.debug(
`Data is empty, initializing for ${userNew ? 'new' : 'existing'} user`,
)
recent = _wrapData({
flagStorage: { ...flagsTemplate },
prefsStorage: { ...defaultState.prefsStorage },
})
}
recent = recent && (await _doMigrations(recent, this.setPreference))
stale = stale && (await _doMigrations(stale, this.setPreference))
// Various migrations
console.debug('Migrating from old config')
const vuexState = (await storage.getItem('vuex-lz')) ?? {}
const config = vuexState.config ?? {}
const migratedEntries = new Set(config._syncMigration ?? [])
console.debug(
`Already migrated Values: ${[...migratedEntries].join() || '[none]'}`,
)
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 = 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()
},
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 } }
updateProfileJSON({
params,
credentials: useOAuthStore().token,
})
},
},
persist: {
afterLoad(state) {
console.debug('Validating persisted state of SyncConfig')
const newState = { ...state }
newState.prefsStorage = newState.prefsStorage || {}
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
index 759ebd5097..0db37703d0 100644
--- a/src/stores/user_highlight.js
+++ b/src/stores/user_highlight.js
@@ -1,349 +1,349 @@
import {
merge as _merge,
clone,
cloneDeep,
- flatten,
groupBy,
isEqual,
- takeRight,
+ last,
} from 'lodash'
import { defineStore } from 'pinia'
import { toRaw } from 'vue'
import { useOAuthStore } from 'src/stores/oauth.js'
import { updateProfileJSON } from 'src/api/user.js'
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,
}
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 allJournals = journals
+ .map((j) => (Array.isArray(j) ? j : []))
+ .flat()
+ .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 [last(journal)]
})
- return flatten(trimmedGrouped).sort((a, b) =>
- a.timestamp > b.timestamp ? 1 : -1,
- )
+ return trimmedGrouped
+ .flat()
+ .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 },
}
updateProfileJSON({
params,
credentials: useOAuthStore().token,
}).then(({ data: user }) => {
this.initUserHighlight(user)
this.dirty = false
})
},
},
persist: {
afterLoad(state) {
return state
},
},
})
diff --git a/test/fixtures/setup_test.js b/test/fixtures/setup_test.js
index 1a04b9549c..b3804b102e 100644
--- a/test/fixtures/setup_test.js
+++ b/test/fixtures/setup_test.js
@@ -1,145 +1,145 @@
import { createTestingPinia } from '@pinia/testing'
import { config } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import VueVirtualScroller from 'vue-virtual-scroller'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import Status from 'src/components/status/status.vue'
import StillImage from 'src/components/still-image/still-image.vue'
import makeMockStore from './mock_store'
import routes from 'src/boot/routes'
export const $t = (msg) => msg
const $i18n = { t: (msg) => msg }
const applyAfterStore = (store, afterStore) => {
afterStore(store)
return store
}
const getDefaultOpts = ({
afterStore = () => {
/* no-op */
},
} = {}) => ({
global: {
plugins: [
applyAfterStore(makeMockStore(), afterStore),
createTestingPinia(),
VueVirtualScroller,
createRouter({
history: createMemoryHistory(),
routes: routes({
state: {
users: {
currentUser: {},
},
instance: {},
},
}),
}),
(Vue) => {
Vue.directive('body-scroll-lock', {})
},
],
components: {
RichContent,
Status,
StillImage,
},
stubs: {
I18nT: true,
teleport: true,
FAIcon: true,
FALayers: true,
},
mocks: {
$t,
$i18n,
},
},
})
// https://github.com/vuejs/vue-test-utils/issues/960
const customBehaviors = () => {
const filterByText = (keyword) => {
const match =
keyword instanceof RegExp
? (target) => target && keyword.test(target)
: (target) => keyword === target
return (wrapper) =>
match(wrapper.text()) ||
match(wrapper.attributes('aria-label')) ||
match(wrapper.attributes('title'))
}
return {
findComponentByText(searchedComponent, text) {
return this.findAllComponents(searchedComponent)
.filter(filterByText(text))
.at(0)
},
findByText(searchedElement, text) {
return this.findAll(searchedElement).filter(filterByText(text)).at(0)
},
}
}
config.plugins.VueWrapper.install(customBehaviors)
export const mountOpts = (allOpts = {}) => {
const { afterStore, ...opts } = allOpts
const defaultOpts = getDefaultOpts({ afterStore })
const mergedOpts = {
...opts,
global: {
...defaultOpts.global,
},
}
if (opts.global) {
mergedOpts.global.plugins = mergedOpts.global.plugins.concat(
opts.global.plugins || [],
)
Object.entries(opts.global).forEach(([k, v]) => {
if (k === 'plugins') {
return
}
if (defaultOpts.global[k]) {
mergedOpts.global[k] = {
...defaultOpts.global[k],
...v,
}
} else {
mergedOpts.global[k] = v
}
})
}
return mergedOpts
}
// https://stackoverflow.com/questions/78033718/how-can-i-wait-for-an-emitted-event-of-a-mounted-component-in-vue-test-utils
export const waitForEvent = (
wrapper,
event,
{ timeout = 1000, timesEmitted = 1 } = {},
) => {
const tick = 10
return vi.waitFor(
() => {
const e = wrapper.emitted(event)
- if (e && e.length >= timesEmitted) {
+ if (e?.length >= timesEmitted) {
return
}
throw new Error('event is not emitted')
},
{
timeout,
interval: tick,
},
)
}
diff --git a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
index afd17e56bc..da3f88636f 100644
--- a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
+++ b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
@@ -1,213 +1,205 @@
import mastoapidata from '../../../../fixtures/mastoapi.json'
import {
parseLinkHeaderPagination,
parseStatus,
parseUser,
} from 'src/services/entity_normalizer/entity_normalizer.service.js'
const makeMockUserMasto = (overrides = {}) => {
- return Object.assign(
- {
- acct: 'hj',
- avatar:
- 'https://shigusegubu.club/media/1657b945-8d5b-4ce6-aafb-4c3fc5772120/8ce851029af84d55de9164e30cc7f46d60cbf12eee7e96c5c0d35d9038ddade1.png',
- avatar_static:
- 'https://shigusegubu.club/media/1657b945-8d5b-4ce6-aafb-4c3fc5772120/8ce851029af84d55de9164e30cc7f46d60cbf12eee7e96c5c0d35d9038ddade1.png',
- bot: false,
- created_at: '2017-12-17T21:54:14.000Z',
- display_name: 'whatever whatever whatever witch',
- emojis: [],
- fields: [],
- followers_count: 705,
- following_count: 326,
- header:
- 'https://shigusegubu.club/media/7ab024d9-2a8a-4fbc-9ce8-da06756ae2db/6aadefe4e264133bc377ab450e6b045b6f5458542a5c59e6c741f86107f0388b.png',
- header_static:
- 'https://shigusegubu.club/media/7ab024d9-2a8a-4fbc-9ce8-da06756ae2db/6aadefe4e264133bc377ab450e6b045b6f5458542a5c59e6c741f86107f0388b.png',
- id: '1',
- locked: false,
- note: 'Volatile Internet Weirdo. Name pronounced as Hee Jay. JS and Java dark arts mage, Elixir trainee. I love sampo and lain. Matrix is <span><a data-user="1" href="https://shigusegubu.club/users/hj">@<span>hj</span></a></span>:matrix.heldscal.la Pronouns are whatever. Do not DM me unless it\'s truly private matter and you\'re instance\'s admin or you risk your DM to be reposted publicly.Wish i was Finnish girl.',
- pleroma: { confirmation_pending: false, tags: null },
- source: { note: '', privacy: 'public', sensitive: false },
- statuses_count: 41775,
- url: 'https://shigusegubu.club/users/hj',
- username: 'hj',
- },
- overrides,
- )
+ return {
+ acct: 'hj',
+ avatar:
+ 'https://shigusegubu.club/media/1657b945-8d5b-4ce6-aafb-4c3fc5772120/8ce851029af84d55de9164e30cc7f46d60cbf12eee7e96c5c0d35d9038ddade1.png',
+ avatar_static:
+ 'https://shigusegubu.club/media/1657b945-8d5b-4ce6-aafb-4c3fc5772120/8ce851029af84d55de9164e30cc7f46d60cbf12eee7e96c5c0d35d9038ddade1.png',
+ bot: false,
+ created_at: '2017-12-17T21:54:14.000Z',
+ display_name: 'whatever whatever whatever witch',
+ emojis: [],
+ fields: [],
+ followers_count: 705,
+ following_count: 326,
+ header:
+ 'https://shigusegubu.club/media/7ab024d9-2a8a-4fbc-9ce8-da06756ae2db/6aadefe4e264133bc377ab450e6b045b6f5458542a5c59e6c741f86107f0388b.png',
+ header_static:
+ 'https://shigusegubu.club/media/7ab024d9-2a8a-4fbc-9ce8-da06756ae2db/6aadefe4e264133bc377ab450e6b045b6f5458542a5c59e6c741f86107f0388b.png',
+ id: '1',
+ locked: false,
+ note: 'Volatile Internet Weirdo. Name pronounced as Hee Jay. JS and Java dark arts mage, Elixir trainee. I love sampo and lain. Matrix is <span><a data-user="1" href="https://shigusegubu.club/users/hj">@<span>hj</span></a></span>:matrix.heldscal.la Pronouns are whatever. Do not DM me unless it\'s truly private matter and you\'re instance\'s admin or you risk your DM to be reposted publicly.Wish i was Finnish girl.',
+ pleroma: { confirmation_pending: false, tags: null },
+ source: { note: '', privacy: 'public', sensitive: false },
+ statuses_count: 41775,
+ url: 'https://shigusegubu.club/users/hj',
+ username: 'hj',
+ ...overrides,
+ }
}
const makeMockStatusMasto = (overrides = {}) => {
- return Object.assign(
- {
- account: makeMockUserMasto(),
- application: { name: 'Web', website: null },
- content:
- '<span><a data-user="14660" href="https://pleroma.soykaf.com/users/sampo">@<span>sampo</span></a></span> god i wish i was there',
- created_at: '2019-01-17T16:29:23.000Z',
- emojis: [],
- favourited: false,
- favourites_count: 1,
- id: '10423476',
- in_reply_to_account_id: '14660',
- in_reply_to_id: '10423197',
- language: null,
- media_attachments: [],
- mentions: [
- {
- acct: 'sampo@pleroma.soykaf.com',
- id: '14660',
- url: 'https://pleroma.soykaf.com/users/sampo',
- username: 'sampo',
- },
- ],
- muted: false,
- reblog: null,
- reblogged: false,
- reblogs_count: 0,
- replies_count: 0,
- sensitive: false,
- spoiler_text: '',
- tags: [],
- uri: 'https://shigusegubu.club/objects/16033fbb-97c0-4f0e-b834-7abb92fb8639',
- url: 'https://shigusegubu.club/objects/16033fbb-97c0-4f0e-b834-7abb92fb8639',
- visibility: 'public',
- pleroma: {
- local: true,
+ return {
+ account: makeMockUserMasto(),
+ application: { name: 'Web', website: null },
+ content:
+ '<span><a data-user="14660" href="https://pleroma.soykaf.com/users/sampo">@<span>sampo</span></a></span> god i wish i was there',
+ created_at: '2019-01-17T16:29:23.000Z',
+ emojis: [],
+ favourited: false,
+ favourites_count: 1,
+ id: '10423476',
+ in_reply_to_account_id: '14660',
+ in_reply_to_id: '10423197',
+ language: null,
+ media_attachments: [],
+ mentions: [
+ {
+ acct: 'sampo@pleroma.soykaf.com',
+ id: '14660',
+ url: 'https://pleroma.soykaf.com/users/sampo',
+ username: 'sampo',
},
+ ],
+ muted: false,
+ reblog: null,
+ reblogged: false,
+ reblogs_count: 0,
+ replies_count: 0,
+ sensitive: false,
+ spoiler_text: '',
+ tags: [],
+ uri: 'https://shigusegubu.club/objects/16033fbb-97c0-4f0e-b834-7abb92fb8639',
+ url: 'https://shigusegubu.club/objects/16033fbb-97c0-4f0e-b834-7abb92fb8639',
+ visibility: 'public',
+ pleroma: {
+ local: true,
},
- overrides,
- )
+ ...overrides,
+ }
}
const makeMockEmojiMasto = (overrides = [{}]) => {
return [
- Object.assign(
- {
- shortcode: 'image',
- static_url: 'https://example.com/image.png',
- url: 'https://example.com/image.png',
- visible_in_picker: false,
- },
- overrides[0],
- ),
- Object.assign(
- {
- shortcode: 'thinking',
- static_url: 'https://example.com/think.png',
- url: 'https://example.com/think.png',
- visible_in_picker: false,
- },
- overrides[1],
- ),
+ {
+ shortcode: 'image',
+ static_url: 'https://example.com/image.png',
+ url: 'https://example.com/image.png',
+ visible_in_picker: false,
+ ...overrides[0],
+ },
+ {
+ shortcode: 'thinking',
+ static_url: 'https://example.com/think.png',
+ url: 'https://example.com/think.png',
+ visible_in_picker: false,
+ ...overrides[1],
+ },
]
}
describe('API Entities normalizer', () => {
describe('parseStatus', () => {
describe('Mastoapi preprocessing and converting', () => {
it("doesn't blow up", () => {
const parsed = mastoapidata.map(parseStatus)
expect(parsed.length).to.eq(mastoapidata.length)
})
it('processes repeats correctly', () => {
const post = makeMockStatusMasto({ reblog: null, id: 'deadbeef' })
const repeat = makeMockStatusMasto({ reblog: post, id: 'foobar' })
const parsedPost = parseStatus(post)
const parsedRepeat = parseStatus(repeat)
expect(parsedPost).to.have.property('type', 'status')
expect(parsedRepeat).to.have.property('type', 'retweet')
expect(parsedRepeat).to.have.property('retweeted_status')
expect(parsedRepeat).to.have.nested.property(
'retweeted_status.id',
'deadbeef',
)
})
})
})
// Statuses generally already contain some info regarding users and there's nearly 1:1 mapping, so very little to test
describe('parseUsers (MastoAPI)', () => {
it('sets correct is_local for users depending on their screen_name', () => {
const local = makeMockUserMasto({ acct: 'foo' })
const remote = makeMockUserMasto({ acct: 'foo@bar.baz' })
expect(parseUser(local)).to.have.property('is_local', true)
expect(parseUser(remote)).to.have.property('is_local', false)
})
it('removes html tags from user profile fields', () => {
const user = makeMockUserMasto({
emojis: makeMockEmojiMasto(),
fields: [
{
name: 'user',
value: '<a rel="me" href="https://example.com/@user">@user</a>',
},
],
})
const parsedUser = parseUser(user)
expect(parsedUser).to.have.property('fields_text').to.be.an('array')
const field = parsedUser.fields_text[0]
expect(field).to.have.property('name').that.equal('user')
expect(field).to.have.property('value').that.equal('@user')
})
it('adds hide_follows and hide_followers user settings', () => {
const user = makeMockUserMasto({
pleroma: {
hide_followers: true,
hide_follows: false,
hide_followers_count: false,
hide_follows_count: true,
},
})
expect(parseUser(user)).to.have.property('hide_followers', true)
expect(parseUser(user)).to.have.property('hide_follows', false)
expect(parseUser(user)).to.have.property('hide_followers_count', false)
expect(parseUser(user)).to.have.property('hide_follows_count', true)
})
it('converts IDN to unicode and marks it as internatonal', () => {
const user = makeMockUserMasto({ acct: 'lain@xn--lin-6cd.com' })
expect(parseUser(user))
.to.have.property('screen_name_ui')
.that.equal('lain@lаin.com')
expect(parseUser(user))
.to.have.property('screen_name_ui_contains_non_ascii')
.that.equal(true)
})
})
describe('Link header pagination', () => {
it('Parses min and max ids as integers', () => {
const linkHeader =
'<https://example.com/api/v1/notifications?max_id=861676>; rel="next", <https://example.com/api/v1/notifications?min_id=861741>; rel="prev"'
const result = parseLinkHeaderPagination(linkHeader)
expect(result).to.eql({
maxId: 861676,
minId: 861741,
})
})
it('Parses min and max ids as flakes', () => {
const linkHeader =
'<http://example.com/api/v1/timelines/home?max_id=9waQx5IIS48qVue2Ai>; rel="next", <http://example.com/api/v1/timelines/home?min_id=9wi61nIPnfn674xgie>; rel="prev"'
const result = parseLinkHeaderPagination(linkHeader, { flakeId: true })
expect(result).to.eql({
maxId: '9waQx5IIS48qVue2Ai',
minId: '9wi61nIPnfn674xgie',
})
})
})
})

File Metadata

Mime Type
text/x-diff
Expires
Sun, Aug 9, 6:55 AM (1 d, 14 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1724300
Default Alt Text
(648 KB)

Event Timeline