Merge branch 'develop' into 'master'

Update master with 2.0.0

See merge request pleroma/pleroma-fe!1074
This commit is contained in:
Shpuld Shpludson 2020-02-28 20:27:02 +00:00
commit 1b9805b550
168 changed files with 4975 additions and 3513 deletions

View file

@ -4,19 +4,36 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
## [2.0.0] - 2020-02-28
### Added ### Added
- Tons of color slots including ones for hover/pressed/toggled buttons
- Experimental `--variable[,mod]` syntax support for color slots in themes. the `mod` makes color brighter/darker depending on background color (makes darker color brighter/darker depending on background color)
- Paper theme by Shpuld
- Icons in nav panel - Icons in nav panel
- Private mode support - Private mode support
- Support for 'Move' type notifications - Support for 'Move' type notifications
- Pleroma AMOLED dark theme - Pleroma AMOLED dark theme
- User level domain mutes, under User Settings -> Mutes
- Emoji reactions for statuses
- MRF keyword policy disclosure
### Changed ### Changed
- Updated Pleroma default themes
- theme engine update to 3 (themes v2.1 introduction)
- massive internal changes in theme engine - slowly away from "generate things separately with spaghetti code" towards "feed all data into single 'generateTheme' function and declare slot inheritance and all in a separate file"
- Breezy theme updates to make it closer to actual Breeze in some aspects
- when using `--variable` in shadows it no longer uses the actual CSS3 variable, instead it generates color from other slots
- theme doesn't get saved to local storage when opening FE anonymously
- Captcha now resets on failed registrations - Captcha now resets on failed registrations
- Notifications column now cleans itself up to optimize performance when tab is left open for a long time - Notifications column now cleans itself up to optimize performance when tab is left open for a long time
- 403 messaging - 403 messaging
### Fixed ### Fixed
- anon viewers won't get theme data saved to local storage, so admin changing default theme will have an effect for users coming back to instance.
- Single notifications left unread when hitting read on another device/tab - Single notifications left unread when hitting read on another device/tab
- Registration fixed - Registration fixed
- Deactivation of remote accounts from frontend - Deactivation of remote accounts from frontend
- Fixed NSFW unhiding not working with videos when using one-click unhiding/displaying
- Improved performance of anything that uses popovers (most notably statuses)
## [1.1.7 and earlier] - 2019-12-14 ## [1.1.7 and earlier] - 2019-12-14
### Added ### Added

View file

@ -35,6 +35,7 @@ module.exports = {
], ],
alias: { alias: {
'vue$': 'vue/dist/vue.runtime.common', 'vue$': 'vue/dist/vue.runtime.common',
'static': path.resolve(__dirname, '../static'),
'src': path.resolve(__dirname, '../src'), 'src': path.resolve(__dirname, '../src'),
'assets': path.resolve(__dirname, '../src/assets'), 'assets': path.resolve(__dirname, '../src/assets'),
'components': path.resolve(__dirname, '../src/components') 'components': path.resolve(__dirname, '../src/components')

View file

@ -21,6 +21,7 @@
"chromatism": "^3.0.0", "chromatism": "^3.0.0",
"cropperjs": "^1.4.3", "cropperjs": "^1.4.3",
"diff": "^3.0.1", "diff": "^3.0.1",
"escape-html": "^1.0.3",
"karma-mocha-reporter": "^2.2.1", "karma-mocha-reporter": "^2.2.1",
"localforage": "^1.5.0", "localforage": "^1.5.0",
"object-path": "^0.11.3", "object-path": "^0.11.3",
@ -28,7 +29,6 @@
"portal-vue": "^2.1.4", "portal-vue": "^2.1.4",
"sanitize-html": "^1.13.0", "sanitize-html": "^1.13.0",
"v-click-outside": "^2.1.1", "v-click-outside": "^2.1.1",
"v-tooltip": "^2.0.2",
"vue": "^2.5.13", "vue": "^2.5.13",
"vue-chat-scroll": "^1.2.1", "vue-chat-scroll": "^1.2.1",
"vue-i18n": "^7.3.2", "vue-i18n": "^7.3.2",
@ -43,6 +43,7 @@
"@babel/plugin-transform-runtime": "^7.7.6", "@babel/plugin-transform-runtime": "^7.7.6",
"@babel/preset-env": "^7.7.6", "@babel/preset-env": "^7.7.6",
"@babel/register": "^7.7.4", "@babel/register": "^7.7.4",
"@ungap/event-target": "^0.1.0",
"@vue/babel-helper-vue-jsx-merge-props": "^1.0.0", "@vue/babel-helper-vue-jsx-merge-props": "^1.0.0",
"@vue/babel-plugin-transform-vue-jsx": "^1.1.2", "@vue/babel-plugin-transform-vue-jsx": "^1.1.2",
"@vue/test-utils": "^1.0.0-beta.26", "@vue/test-utils": "^1.0.0-beta.26",
@ -56,6 +57,7 @@
"connect-history-api-fallback": "^1.1.0", "connect-history-api-fallback": "^1.1.0",
"cross-spawn": "^4.0.2", "cross-spawn": "^4.0.2",
"css-loader": "^0.28.0", "css-loader": "^0.28.0",
"custom-event-polyfill": "^1.0.7",
"eslint": "^5.16.0", "eslint": "^5.16.0",
"eslint-config-standard": "^12.0.0", "eslint-config-standard": "^12.0.0",
"eslint-friendly-formatter": "^2.0.5", "eslint-friendly-formatter": "^2.0.5",

View file

@ -31,9 +31,12 @@ h4 {
margin: auto; margin: auto;
min-height: 100vh; min-height: 100vh;
max-width: 980px; max-width: 980px;
background-color: rgba(0,0,0,0.15);
align-content: flex-start; align-content: flex-start;
} }
.underlay {
background-color: rgba(0,0,0,0.15);
background-color: var(--underlay, rgba(0,0,0,0.15));
}
.text-center { .text-center {
text-align: center; text-align: center;
@ -75,7 +78,7 @@ button {
border-radius: $fallback--btnRadius; border-radius: $fallback--btnRadius;
border-radius: var(--btnRadius, $fallback--btnRadius); border-radius: var(--btnRadius, $fallback--btnRadius);
cursor: pointer; cursor: pointer;
box-shadow: 0px 0px 2px 0px rgba(0, 0, 0, 1), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset; box-shadow: $fallback--buttonShadow;
box-shadow: var(--buttonShadow); box-shadow: var(--buttonShadow);
font-size: 14px; font-size: 14px;
font-family: sans-serif; font-family: sans-serif;
@ -98,18 +101,39 @@ button {
&:active { &:active {
box-shadow: 0px 0px 4px 0px rgba(255, 255, 255, 0.3), 0px 1px 0px 0px rgba(0, 0, 0, 0.2) inset, 0px -1px 0px 0px rgba(255, 255, 255, 0.2) inset; box-shadow: 0px 0px 4px 0px rgba(255, 255, 255, 0.3), 0px 1px 0px 0px rgba(0, 0, 0, 0.2) inset, 0px -1px 0px 0px rgba(255, 255, 255, 0.2) inset;
box-shadow: var(--buttonPressedShadow); box-shadow: var(--buttonPressedShadow);
color: $fallback--text;
color: var(--btnPressedText, $fallback--text);
background-color: $fallback--fg;
background-color: var(--btnPressed, $fallback--fg);
i {
color: $fallback--text;
color: var(--btnPressedText, $fallback--text);
}
} }
&:disabled { &:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.5; color: $fallback--text;
color: var(--btnDisabledText, $fallback--text);
background-color: $fallback--fg;
background-color: var(--btnDisabled, $fallback--fg);
i {
color: $fallback--text;
color: var(--btnDisabledText, $fallback--text);
}
} }
&.pressed { &.toggled {
color: $fallback--faint; color: $fallback--text;
color: var(--faint, $fallback--faint); color: var(--btnToggledText, $fallback--text);
background-color: $fallback--bg; background-color: $fallback--fg;
background-color: var(--bg, $fallback--bg) background-color: var(--btnToggled, $fallback--fg);
box-shadow: 0px 0px 4px 0px rgba(255, 255, 255, 0.3), 0px 1px 0px 0px rgba(0, 0, 0, 0.2) inset, 0px -1px 0px 0px rgba(255, 255, 255, 0.2) inset;
box-shadow: var(--buttonPressedShadow);
i {
color: $fallback--text;
color: var(--btnToggledText, $fallback--text);
}
} }
&.danger { &.danger {
@ -121,12 +145,15 @@ button {
} }
} }
label.select { input, textarea, .select, .input {
padding: 0;
} &.unstyled {
border-radius: 0;
background: none;
box-shadow: none;
height: unset;
}
input, textarea, .select {
border: none; border: none;
border-radius: $fallback--inputRadius; border-radius: $fallback--inputRadius;
border-radius: var(--inputRadius, $fallback--inputRadius); border-radius: var(--inputRadius, $fallback--inputRadius);
@ -140,13 +167,17 @@ input, textarea, .select {
font-family: var(--inputFont, sans-serif); font-family: var(--inputFont, sans-serif);
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
padding: 8px .5em;
box-sizing: border-box; box-sizing: border-box;
display: inline-block; display: inline-block;
position: relative; position: relative;
height: 28px; height: 28px;
line-height: 16px; line-height: 16px;
hyphens: none; hyphens: none;
padding: 8px .5em;
&.select {
padding: 0;
}
&:disabled, &[disabled=disabled] { &:disabled, &[disabled=disabled] {
cursor: not-allowed; cursor: not-allowed;
@ -160,7 +191,7 @@ input, textarea, .select {
right: 5px; right: 5px;
height: 100%; height: 100%;
color: $fallback--text; color: $fallback--text;
color: var(--text, $fallback--text); color: var(--inputText, $fallback--text);
line-height: 28px; line-height: 28px;
z-index: 0; z-index: 0;
pointer-events: none; pointer-events: none;
@ -198,7 +229,7 @@ input, textarea, .select {
&:checked + label::before { &:checked + label::before {
box-shadow: 0px 0px 2px black inset, 0px 0px 0px 4px $fallback--fg inset; box-shadow: 0px 0px 2px black inset, 0px 0px 0px 4px $fallback--fg inset;
box-shadow: var(--inputShadow), 0px 0px 0px 4px var(--fg, $fallback--fg) inset; box-shadow: var(--inputShadow), 0px 0px 0px 4px var(--fg, $fallback--fg) inset;
background-color: var(--link, $fallback--link); background-color: var(--accent, $fallback--link);
} }
&:disabled { &:disabled {
&, &,
@ -235,7 +266,7 @@ input, textarea, .select {
display: none; display: none;
&:checked + label::before { &:checked + label::before {
color: $fallback--text; color: $fallback--text;
color: var(--text, $fallback--text); color: var(--inputText, $fallback--text);
} }
&:disabled { &:disabled {
&, &,
@ -353,6 +384,33 @@ i[class*=icon-] {
height: 50px; height: 50px;
box-sizing: border-box; box-sizing: border-box;
button {
&, i[class*=icon-] {
color: $fallback--text;
color: var(--btnTopBarText, $fallback--text);
}
&:active {
background-color: $fallback--fg;
background-color: var(--btnPressedTopBar, $fallback--fg);
color: $fallback--text;
color: var(--btnPressedTopBarText, $fallback--text);
}
&:disabled {
color: $fallback--text;
color: var(--btnDisabledTopBarText, $fallback--text);
}
&.toggled {
color: $fallback--text;
color: var(--btnToggledTopBarText, $fallback--text);
background-color: $fallback--fg;
background-color: var(--btnToggledTopBar, $fallback--fg)
}
}
.logo { .logo {
display: flex; display: flex;
position: absolute; position: absolute;
@ -487,6 +545,10 @@ main-router {
color: $fallback--faint; color: $fallback--faint;
color: var(--panelFaint, $fallback--faint); color: var(--panelFaint, $fallback--faint);
} }
.faint-link {
color: $fallback--faint;
color: var(--faintLink, $fallback--faint);
}
.alert { .alert {
white-space: nowrap; white-space: nowrap;
@ -509,6 +571,30 @@ main-router {
align-self: stretch; align-self: stretch;
} }
button {
&, i[class*=icon-] {
color: $fallback--text;
color: var(--btnPanelText, $fallback--text);
}
&:active {
background-color: $fallback--fg;
background-color: var(--btnPressedPanel, $fallback--fg);
color: $fallback--text;
color: var(--btnPressedPanelText, $fallback--text);
}
&:disabled {
color: $fallback--text;
color: var(--btnDisabledPanelText, $fallback--text);
}
&.toggled {
color: $fallback--text;
color: var(--btnToggledPanelText, $fallback--text);
}
}
a { a {
color: $fallback--link; color: $fallback--link;
color: var(--panelLink, $fallback--link) color: var(--panelLink, $fallback--link)

View file

@ -78,7 +78,7 @@
</nav> </nav>
<div <div
id="content" id="content"
class="container" class="container underlay"
> >
<div class="sidebar-flexer mobile-hidden"> <div class="sidebar-flexer mobile-hidden">
<div class="sidebar-bounds"> <div class="sidebar-bounds">

View file

@ -27,3 +27,5 @@ $fallback--tooltipRadius: 5px;
$fallback--avatarRadius: 4px; $fallback--avatarRadius: 4px;
$fallback--avatarAltRadius: 10px; $fallback--avatarAltRadius: 10px;
$fallback--attachmentRadius: 10px; $fallback--attachmentRadius: 10px;
$fallback--buttonShadow: 0px 0px 2px 0px rgba(0, 0, 0, 1), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;

View file

@ -5,6 +5,8 @@ import App from '../App.vue'
import { windowWidth } from '../services/window_utils/window_utils' import { windowWidth } from '../services/window_utils/window_utils'
import { getOrCreateApp, getClientToken } from '../services/new_api/oauth.js' import { getOrCreateApp, getClientToken } from '../services/new_api/oauth.js'
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js'
import { applyTheme } from '../services/style_setter/style_setter.js'
const getStatusnetConfig = async ({ store }) => { const getStatusnetConfig = async ({ store }) => {
try { try {
@ -185,12 +187,9 @@ const getAppSecret = async ({ store }) => {
}) })
} }
const resolveStaffAccounts = async ({ store, accounts }) => { const resolveStaffAccounts = ({ store, accounts }) => {
const backendInteractor = store.state.api.backendInteractor const nicknames = accounts.map(uri => uri.split('/').pop())
let nicknames = accounts.map(uri => uri.split('/').pop()) nicknames.map(nickname => store.dispatch('fetchUser', nickname))
.map(id => backendInteractor.fetchUser({ id }))
nicknames = await Promise.all(nicknames)
store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames }) store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames })
} }
@ -224,9 +223,16 @@ const getNodeInfo = async ({ store }) => {
const frontendVersion = window.___pleromafe_commit_hash const frontendVersion = window.___pleromafe_commit_hash
store.dispatch('setInstanceOption', { name: 'frontendVersion', value: frontendVersion }) store.dispatch('setInstanceOption', { name: 'frontendVersion', value: frontendVersion })
store.dispatch('setInstanceOption', { name: 'tagPolicyAvailable', value: metadata.federation.mrf_policies.includes('TagPolicy') })
const federation = metadata.federation const federation = metadata.federation
store.dispatch('setInstanceOption', {
name: 'tagPolicyAvailable',
value: typeof federation.mrf_policies === 'undefined'
? false
: metadata.federation.mrf_policies.includes('TagPolicy')
})
store.dispatch('setInstanceOption', { name: 'federationPolicy', value: federation }) store.dispatch('setInstanceOption', { name: 'federationPolicy', value: federation })
store.dispatch('setInstanceOption', { store.dispatch('setInstanceOption', {
name: 'federating', name: 'federating',
@ -236,7 +242,7 @@ const getNodeInfo = async ({ store }) => {
}) })
const accounts = metadata.staffAccounts const accounts = metadata.staffAccounts
await resolveStaffAccounts({ store, accounts }) resolveStaffAccounts({ store, accounts })
} else { } else {
throw (res) throw (res)
} }
@ -261,7 +267,7 @@ const checkOAuthToken = async ({ store }) => {
try { try {
await store.dispatch('loginUser', store.getters.getUserToken()) await store.dispatch('loginUser', store.getters.getUserToken())
} catch (e) { } catch (e) {
console.log(e) console.error(e)
} }
} }
resolve() resolve()
@ -269,23 +275,29 @@ const checkOAuthToken = async ({ store }) => {
} }
const afterStoreSetup = async ({ store, i18n }) => { const afterStoreSetup = async ({ store, i18n }) => {
if (store.state.config.customTheme) {
// This is a hack to deal with async loading of config.json and themes
// See: style_setter.js, setPreset()
window.themeLoaded = true
store.dispatch('setOption', {
name: 'customTheme',
value: store.state.config.customTheme
})
}
const width = windowWidth() const width = windowWidth()
store.dispatch('setMobileLayout', width <= 800) store.dispatch('setMobileLayout', width <= 800)
await setConfig({ store })
const { customTheme, customThemeSource } = store.state.config
const { theme } = store.state.instance
const customThemePresent = customThemeSource || customTheme
if (customThemePresent) {
if (customThemeSource && customThemeSource.themeEngineVersion === CURRENT_VERSION) {
applyTheme(customThemeSource)
} else {
applyTheme(customTheme)
}
} else if (theme) {
// do nothing, it will load asynchronously
} else {
console.error('Failed to load any theme!')
}
// Now we can try getting the server settings and logging in // Now we can try getting the server settings and logging in
await Promise.all([ await Promise.all([
checkOAuthToken({ store }), checkOAuthToken({ store }),
setConfig({ store }),
getTOS({ store }), getTOS({ store }),
getInstancePanel({ store }), getInstancePanel({ store }),
getStickers({ store }), getStickers({ store }),

View file

@ -1,4 +1,5 @@
import ProgressButton from '../progress_button/progress_button.vue' import ProgressButton from '../progress_button/progress_button.vue'
import Popover from '../popover/popover.vue'
const AccountActions = { const AccountActions = {
props: [ props: [
@ -8,7 +9,8 @@ const AccountActions = {
return { } return { }
}, },
components: { components: {
ProgressButton ProgressButton,
Popover
}, },
methods: { methods: {
showRepeats () { showRepeats () {

View file

@ -1,13 +1,13 @@
<template> <template>
<div class="account-actions"> <div class="account-actions">
<v-popover <Popover
trigger="click" trigger="click"
class="account-tools-popover" placement="bottom"
:container="false" >
placement="bottom-end" <div
:offset="5" slot="content"
class="account-tools-popover"
> >
<div slot="popover">
<div class="dropdown-menu"> <div class="dropdown-menu">
<template v-if="user.following"> <template v-if="user.following">
<button <button
@ -51,10 +51,13 @@
</button> </button>
</div> </div>
</div> </div>
<div class="btn btn-default ellipsis-button"> <div
slot="trigger"
class="btn btn-default ellipsis-button"
>
<i class="icon-ellipsis trigger-button" /> <i class="icon-ellipsis trigger-button" />
</div> </div>
</v-popover> </Popover>
</div> </div>
</template> </template>
@ -62,7 +65,6 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
@import '../popper/popper.scss';
.account-actions { .account-actions {
margin: 0 .8em; margin: 0 .8em;
} }
@ -70,6 +72,7 @@
.account-actions button.dropdown-item { .account-actions button.dropdown-item {
margin-left: 0; margin-left: 0;
} }
.account-actions .trigger-button { .account-actions .trigger-button {
color: $fallback--lightText; color: $fallback--lightText;
color: var(--lightText, $fallback--lightText); color: var(--lightText, $fallback--lightText);

View file

@ -2,6 +2,7 @@ import StillImage from '../still-image/still-image.vue'
import VideoAttachment from '../video_attachment/video_attachment.vue' import VideoAttachment from '../video_attachment/video_attachment.vue'
import nsfwImage from '../../assets/nsfw.png' import nsfwImage from '../../assets/nsfw.png'
import fileTypeService from '../../services/file_type/file_type.service.js' import fileTypeService from '../../services/file_type/file_type.service.js'
import { mapGetters } from 'vuex'
const Attachment = { const Attachment = {
props: [ props: [
@ -49,7 +50,8 @@ const Attachment = {
}, },
fullwidth () { fullwidth () {
return this.type === 'html' || this.type === 'audio' return this.type === 'html' || this.type === 'audio'
} },
...mapGetters(['mergedConfig'])
}, },
methods: { methods: {
linkClicked ({ target }) { linkClicked ({ target }) {
@ -58,7 +60,7 @@ const Attachment = {
} }
}, },
openModal (event) { openModal (event) {
const modalTypes = this.$store.getters.mergedConfig.playVideosInModal const modalTypes = this.mergedConfig.playVideosInModal
? ['image', 'video'] ? ['image', 'video']
: ['image'] : ['image']
if (fileTypeService.fileMatchesSomeType(modalTypes, this.attachment) || if (fileTypeService.fileMatchesSomeType(modalTypes, this.attachment) ||
@ -71,7 +73,10 @@ const Attachment = {
} }
}, },
toggleHidden (event) { toggleHidden (event) {
if (this.$store.getters.mergedConfig.useOneClickNsfw && !this.showHidden) { if (
(this.mergedConfig.useOneClickNsfw && !this.showHidden) &&
(this.type !== 'video' || this.mergedConfig.playVideosInModal)
) {
this.openModal(event) this.openModal(event)
return return
} }

View file

@ -130,6 +130,8 @@
.placeholder { .placeholder {
margin-right: 8px; margin-right: 8px;
margin-bottom: 4px; margin-bottom: 4px;
color: $fallback--link;
color: var(--postLink, $fallback--link);
} }
.nsfw-placeholder { .nsfw-placeholder {

View file

@ -40,8 +40,8 @@
top: 100%; top: 100%;
right: 0; right: 0;
max-height: 400px; max-height: 400px;
background-color: $fallback--lightBg; background-color: $fallback--bg;
background-color: var(--lightBg, $fallback--lightBg); background-color: var(--bg, $fallback--bg);
border-style: solid; border-style: solid;
border-width: 1px; border-width: 1px;
border-color: $fallback--border; border-color: $fallback--border;

View file

@ -87,13 +87,13 @@ export default {
&:checked + .checkbox-indicator::before { &:checked + .checkbox-indicator::before {
color: $fallback--text; color: $fallback--text;
color: var(--text, $fallback--text); color: var(--inputText, $fallback--text);
} }
&:indeterminate + .checkbox-indicator::before { &:indeterminate + .checkbox-indicator::before {
content: ''; content: '';
color: $fallback--text; color: $fallback--text;
color: var(--text, $fallback--text); color: var(--inputText, $fallback--text);
} }
} }

View file

@ -0,0 +1,68 @@
@import '../../_variables.scss';
.color-input {
display: inline-flex;
&-field.input {
display: inline-flex;
flex: 0 0 0;
max-width: 9em;
align-items: stretch;
padding: .2em 8px;
input {
background: none;
color: $fallback--lightText;
color: var(--inputText, $fallback--lightText);
border: none;
padding: 0;
margin: 0;
&.textColor {
flex: 1 0 3em;
min-width: 3em;
padding: 0;
}
&.nativeColor {
flex: 0 0 2em;
min-width: 2em;
align-self: center;
height: 100%;
}
}
.computedIndicator,
.transparentIndicator {
flex: 0 0 2em;
min-width: 2em;
align-self: center;
height: 100%;
}
.transparentIndicator {
// forgot to install counter-strike source, ooops
background-color: #FF00FF;
position: relative;
&::before, &::after {
display: block;
content: '';
background-color: #000000;
position: absolute;
height: 50%;
width: 50%;
}
&::after {
top: 0;
left: 0;
}
&::before {
bottom: 0;
right: 0;
}
}
}
.label {
flex: 1 1 auto;
}
}

View file

@ -1,6 +1,6 @@
<template> <template>
<div <div
class="color-control style-control" class="color-input style-control"
:class="{ disabled: !present || disabled }" :class="{ disabled: !present || disabled }"
> >
<label <label
@ -9,46 +9,100 @@
> >
{{ label }} {{ label }}
</label> </label>
<input <Checkbox
v-if="typeof fallback !== 'undefined'" v-if="typeof fallback !== 'undefined' && showOptionalTickbox"
:id="name + '-o'"
class="opt exlcude-disabled"
type="checkbox"
:checked="present" :checked="present"
@input="$emit('input', typeof value === 'undefined' ? fallback : undefined)" :disabled="disabled"
> class="opt"
<label @change="$emit('input', typeof value === 'undefined' ? fallback : undefined)"
v-if="typeof fallback !== 'undefined'"
class="opt-l"
:for="name + '-o'"
/> />
<input <div class="input color-input-field">
:id="name"
class="color-input"
type="color"
:value="value || fallback"
:disabled="!present || disabled"
@input="$emit('input', $event.target.value)"
>
<input <input
:id="name + '-t'" :id="name + '-t'"
class="text-input" class="textColor unstyled"
type="text" type="text"
:value="value || fallback" :value="value || fallback"
:disabled="!present || disabled" :disabled="!present || disabled"
@input="$emit('input', $event.target.value)" @input="$emit('input', $event.target.value)"
> >
<input
v-if="validColor"
:id="name"
class="nativeColor unstyled"
type="color"
:value="value || fallback"
:disabled="!present || disabled"
@input="$emit('input', $event.target.value)"
>
<div
v-if="transparentColor"
class="transparentIndicator"
/>
<div
v-if="computedColor"
class="computedIndicator"
:style="{backgroundColor: fallback}"
/>
</div>
</div> </div>
</template> </template>
<style lang="scss" src="./color_input.scss"></style>
<script> <script>
import Checkbox from '../checkbox/checkbox.vue'
import { hex2rgb } from '../../services/color_convert/color_convert.js'
export default { export default {
props: [ components: {
'name', 'label', 'value', 'fallback', 'disabled' Checkbox
], },
props: {
// Name of color, used for identifying
name: {
required: true,
type: String
},
// Readable label
label: {
required: true,
type: String
},
// Color value, should be required but vue cannot tell the difference
// between "property missing" and "property set to undefined"
value: {
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
showOptionalTickbox: {
required: false,
type: Boolean,
default: true
}
},
computed: { computed: {
present () { present () {
return typeof this.value !== 'undefined' return typeof this.value !== 'undefined'
},
validColor () {
return hex2rgb(this.value || this.fallback)
},
transparentColor () {
return this.value === 'transparent'
},
computedColor () {
return this.value && this.value.startsWith('--')
} }
} }
} }

View file

@ -37,9 +37,17 @@
<script> <script>
export default { export default {
props: [ props: {
'large', 'contrast' large: {
], required: false
},
// TODO: Make theme switcher compute theme initially so that contrast
// component won't be called without contrast data
contrast: {
required: false,
type: Object
}
},
computed: { computed: {
hint () { hint () {
const levelVal = this.contrast.aaa ? 'aaa' : (this.contrast.aa ? 'aa' : 'bad') const levelVal = this.contrast.aaa ? 'aaa' : (this.contrast.aa ? 'aa' : 'bad')

View file

@ -150,6 +150,7 @@ const conversation = {
if (!id) return if (!id) return
this.highlight = id this.highlight = id
this.$store.dispatch('fetchFavsAndRepeats', id) this.$store.dispatch('fetchFavsAndRepeats', id)
this.$store.dispatch('fetchEmojiReactionsBy', id)
}, },
getHighlight () { getHighlight () {
return this.isExpanded ? this.highlight : null return this.isExpanded ? this.highlight : null

View file

@ -75,18 +75,18 @@
.dialog-modal-content { .dialog-modal-content {
margin: 0; margin: 0;
padding: 1rem 1rem; padding: 1rem 1rem;
background-color: $fallback--lightBg; background-color: $fallback--bg;
background-color: var(--lightBg, $fallback--lightBg); background-color: var(--bg, $fallback--bg);
white-space: normal; white-space: normal;
} }
.dialog-modal-footer { .dialog-modal-footer {
margin: 0; margin: 0;
padding: .5em .5em; padding: .5em .5em;
background-color: $fallback--lightBg; background-color: $fallback--bg;
background-color: var(--lightBg, $fallback--lightBg); background-color: var(--bg, $fallback--bg);
border-top: 1px solid $fallback--bg; border-top: 1px solid $fallback--border;
border-top: 1px solid var(--bg, $fallback--bg); border-top: 1px solid var(--border, $fallback--border);
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;

View file

@ -0,0 +1,15 @@
import ProgressButton from '../progress_button/progress_button.vue'
const DomainMuteCard = {
props: ['domain'],
components: {
ProgressButton
},
methods: {
unmuteDomain () {
return this.$store.dispatch('unmuteDomain', this.domain)
}
}
}
export default DomainMuteCard

View file

@ -0,0 +1,38 @@
<template>
<div class="domain-mute-card">
<div class="domain-mute-card-domain">
{{ domain }}
</div>
<ProgressButton
:click="unmuteDomain"
class="btn btn-default"
>
{{ $t('domain_mute_card.unmute') }}
<template slot="progress">
{{ $t('domain_mute_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
</template>
<script src="./domain_mute_card.js"></script>
<style lang="scss">
.domain-mute-card {
flex: 1 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6em 1em 0.6em 0;
&-domain {
margin-right: 1em;
overflow: hidden;
text-overflow: ellipsis;
}
button {
width: 10em;
}
}
</style>

View file

@ -147,7 +147,7 @@ const EmojiInput = {
input.elm.addEventListener('keydown', this.onKeyDown) input.elm.addEventListener('keydown', this.onKeyDown)
input.elm.addEventListener('click', this.onClickInput) input.elm.addEventListener('click', this.onClickInput)
input.elm.addEventListener('transitionend', this.onTransition) input.elm.addEventListener('transitionend', this.onTransition)
input.elm.addEventListener('compositionupdate', this.onCompositionUpdate) input.elm.addEventListener('input', this.onInput)
}, },
unmounted () { unmounted () {
const { input } = this const { input } = this
@ -159,7 +159,7 @@ const EmojiInput = {
input.elm.removeEventListener('keydown', this.onKeyDown) input.elm.removeEventListener('keydown', this.onKeyDown)
input.elm.removeEventListener('click', this.onClickInput) input.elm.removeEventListener('click', this.onClickInput)
input.elm.removeEventListener('transitionend', this.onTransition) input.elm.removeEventListener('transitionend', this.onTransition)
input.elm.removeEventListener('compositionupdate', this.onCompositionUpdate) input.elm.removeEventListener('input', this.onInput)
} }
}, },
methods: { methods: {
@ -406,12 +406,6 @@ const EmojiInput = {
this.resize() this.resize()
this.$emit('input', e.target.value) this.$emit('input', e.target.value)
}, },
onCompositionUpdate (e) {
this.showPicker = false
this.setCaret(e)
this.resize()
this.$emit('input', e.target.value)
},
onClickInput (e) { onClickInput (e) {
this.showPicker = false this.showPicker = false
}, },

View file

@ -109,10 +109,16 @@
box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5); box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
box-shadow: var(--popupShadow); box-shadow: var(--popupShadow);
min-width: 75%; min-width: 75%;
background: $fallback--bg; background-color: $fallback--bg;
background: var(--bg, $fallback--bg); background-color: var(--popover, $fallback--bg);
color: $fallback--lightText; color: $fallback--link;
color: var(--lightText, $fallback--lightText); color: var(--popoverText, $fallback--link);
--faint: var(--popoverFaintText, $fallback--faint);
--faintLink: var(--popoverFaintLink, $fallback--faint);
--lightText: var(--popoverLightText, $fallback--lightText);
--postLink: var(--popoverPostLink, $fallback--link);
--postFaintLink: var(--popoverPostFaintLink, $fallback--link);
--icon: var(--popoverIcon, $fallback--icon);
} }
} }
@ -157,7 +163,12 @@
&.highlighted { &.highlighted {
background-color: $fallback--fg; background-color: $fallback--fg;
background-color: var(--lightBg, $fallback--fg); background-color: var(--selectedMenuPopover, $fallback--fg);
color: var(--selectedMenuPopoverText, $fallback--text);
--faint: var(--selectedMenuPopoverFaintText, $fallback--faint);
--faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint);
--lightText: var(--selectedMenuPopoverLightText, $fallback--lightText);
--icon: var(--selectedMenuPopoverIcon, $fallback--icon);
} }
} }
} }

View file

@ -8,6 +8,15 @@
left: 0; left: 0;
margin: 0 !important; margin: 0 !important;
z-index: 1; z-index: 1;
background-color: $fallback--bg;
background-color: var(--popover, $fallback--bg);
color: $fallback--link;
color: var(--popoverText, $fallback--link);
--lightText: var(--popoverLightText, $fallback--faint);
--faint: var(--popoverFaintText, $fallback--faint);
--faintLink: var(--popoverFaintLink, $fallback--faint);
--lightText: var(--popoverLightText, $fallback--lightText);
--icon: var(--popoverIcon, $fallback--icon);
.keep-open, .keep-open,
.too-many-emoji { .too-many-emoji {

View file

@ -0,0 +1,69 @@
import UserAvatar from '../user_avatar/user_avatar.vue'
import Popover from '../popover/popover.vue'
const EMOJI_REACTION_COUNT_CUTOFF = 12
const EmojiReactions = {
name: 'EmojiReactions',
components: {
UserAvatar,
Popover
},
props: ['status'],
data: () => ({
showAll: false
}),
computed: {
tooManyReactions () {
return this.status.emoji_reactions.length > EMOJI_REACTION_COUNT_CUTOFF
},
emojiReactions () {
return this.showAll
? this.status.emoji_reactions
: this.status.emoji_reactions.slice(0, EMOJI_REACTION_COUNT_CUTOFF)
},
showMoreString () {
return `+${this.status.emoji_reactions.length - EMOJI_REACTION_COUNT_CUTOFF}`
},
accountsForEmoji () {
return this.status.emoji_reactions.reduce((acc, reaction) => {
acc[reaction.name] = reaction.accounts || []
return acc
}, {})
},
loggedIn () {
return !!this.$store.state.users.currentUser
}
},
methods: {
toggleShowAll () {
this.showAll = !this.showAll
},
reactedWith (emoji) {
return this.status.emoji_reactions.find(r => r.name === emoji).me
},
fetchEmojiReactionsByIfMissing () {
const hasNoAccounts = this.status.emoji_reactions.find(r => !r.accounts)
if (hasNoAccounts) {
this.$store.dispatch('fetchEmojiReactionsBy', this.status.id)
}
},
reactWith (emoji) {
this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji })
},
unreact (emoji) {
this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji })
},
emojiOnClick (emoji, event) {
if (!this.loggedIn) return
if (this.reactedWith(emoji)) {
this.unreact(emoji)
} else {
this.reactWith(emoji)
}
}
}
}
export default EmojiReactions

View file

@ -0,0 +1,141 @@
<template>
<div class="emoji-reactions">
<Popover
v-for="(reaction) in emojiReactions"
:key="reaction.name"
trigger="hover"
placement="top"
:offset="{ y: 5 }"
>
<div
slot="content"
class="reacted-users"
>
<div v-if="accountsForEmoji[reaction.name].length">
<div
v-for="(account) in accountsForEmoji[reaction.name]"
:key="account.id"
class="reacted-user"
>
<UserAvatar
:user="account"
class="avatar-small"
:compact="true"
/>
<div class="reacted-user-names">
<!-- eslint-disable vue/no-v-html -->
<span
class="reacted-user-name"
v-html="account.name_html"
/>
<!-- eslint-enable vue/no-v-html -->
<span class="reacted-user-screen-name">{{ account.screen_name }}</span>
</div>
</div>
</div>
<div v-else>
<i class="icon-spin4 animate-spin" />
</div>
</div>
<button
slot="trigger"
class="emoji-reaction btn btn-default"
:class="{ 'picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }"
@click="emojiOnClick(reaction.name, $event)"
@mouseenter="fetchEmojiReactionsByIfMissing()"
>
<span class="reaction-emoji">{{ reaction.name }}</span>
<span>{{ reaction.count }}</span>
</button>
</Popover>
<a
v-if="tooManyReactions"
class="emoji-reaction-expand faint"
href="javascript:void(0)"
@click="toggleShowAll"
>
{{ showAll ? $t('general.show_less') : showMoreString }}
</a>
</div>
</template>
<script src="./emoji_reactions.js" ></script>
<style lang="scss">
@import '../../_variables.scss';
.emoji-reactions {
display: flex;
margin-top: 0.25em;
flex-wrap: wrap;
}
.reacted-users {
padding: 0.5em;
}
.reacted-user {
padding: 0.25em;
display: flex;
flex-direction: row;
.reacted-user-names {
display: flex;
flex-direction: column;
margin-left: 0.5em;
min-width: 5em;
img {
width: 1em;
height: 1em;
}
}
.reacted-user-screen-name {
font-size: 9px;
}
}
.emoji-reaction {
padding: 0 0.5em;
margin-right: 0.5em;
margin-top: 0.5em;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
.reaction-emoji {
width: 1.25em;
margin-right: 0.25em;
}
&:focus {
outline: none;
}
&.not-clickable {
cursor: default;
&:hover {
box-shadow: $fallback--buttonShadow;
box-shadow: var(--buttonShadow);
}
}
}
.emoji-reaction-expand {
padding: 0 0.5em;
margin-right: 0.5em;
margin-top: 0.5em;
display: flex;
align-items: center;
justify-content: center;
&:hover {
text-decoration: underline;
}
}
.picked-reaction {
border: 1px solid var(--accent, $fallback--link);
margin-left: -1px; // offset the border, can't use inset shadows either
margin-right: calc(0.5em - 1px);
}
</style>

View file

@ -42,7 +42,7 @@ export default {
}, },
methods: { methods: {
exportData () { exportData () {
const stringified = JSON.stringify(this.exportObject) // Pretty-print and indent with 2 spaces const stringified = JSON.stringify(this.exportObject, null, 2) // Pretty-print and indent with 2 spaces
// Create an invisible link with a data url and simulate a click // Create an invisible link with a data url and simulate a click
const e = document.createElement('a') const e = document.createElement('a')

View file

@ -1,5 +1,8 @@
import Popover from '../popover/popover.vue'
const ExtraButtons = { const ExtraButtons = {
props: [ 'status' ], props: [ 'status' ],
components: { Popover },
methods: { methods: {
deleteStatus () { deleteStatus () {
const confirmed = window.confirm(this.$t('status.delete_confirm')) const confirmed = window.confirm(this.$t('status.delete_confirm'))

View file

@ -1,11 +1,11 @@
<template> <template>
<v-popover <Popover
v-if="canDelete || canMute || canPin" v-if="canDelete || canMute || canPin"
trigger="click" trigger="click"
placement="top" placement="top"
class="extra-button-popover" class="extra-button-popover"
> >
<div slot="popover"> <div slot="content">
<div class="dropdown-menu"> <div class="dropdown-menu">
<button <button
v-if="canMute && !status.thread_muted" v-if="canMute && !status.thread_muted"
@ -47,17 +47,17 @@
</button> </button>
</div> </div>
</div> </div>
<div class="button-icon"> <i
<i class="icon-ellipsis" /> slot="trigger"
</div> class="icon-ellipsis button-icon"
</v-popover> />
</Popover>
</template> </template>
<script src="./extra_buttons.js" ></script> <script src="./extra_buttons.js" ></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
@import '../popper/popper.scss';
.icon-ellipsis { .icon-ellipsis {
cursor: pointer; cursor: pointer;

View file

@ -1,7 +1,7 @@
<template> <template>
<button <button
class="btn btn-default follow-button" class="btn btn-default follow-button"
:class="{ pressed: isPressed }" :class="{ toggled: isPressed }"
:disabled="inProgress" :disabled="inProgress"
:title="title" :title="title"
@click="onClick" @click="onClick"

View file

@ -10,6 +10,7 @@ const tabModeDict = {
const Interactions = { const Interactions = {
data () { data () {
return { return {
allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
filterMode: tabModeDict['mentions'] filterMode: tabModeDict['mentions']
} }
}, },

View file

@ -22,6 +22,7 @@
:label="$t('interactions.follows')" :label="$t('interactions.follows')"
/> />
<span <span
v-if="!allowFollowingMove"
key="moves" key="moves"
:label="$t('interactions.moves')" :label="$t('interactions.moves')"
/> />

View file

@ -1,4 +1,5 @@
import DialogModal from '../dialog_modal/dialog_modal.vue' import DialogModal from '../dialog_modal/dialog_modal.vue'
import Popover from '../popover/popover.vue'
const FORCE_NSFW = 'mrf_tag:media-force-nsfw' const FORCE_NSFW = 'mrf_tag:media-force-nsfw'
const STRIP_MEDIA = 'mrf_tag:media-strip' const STRIP_MEDIA = 'mrf_tag:media-strip'
@ -14,7 +15,6 @@ const ModerationTools = {
], ],
data () { data () {
return { return {
showDropDown: false,
tags: { tags: {
FORCE_NSFW, FORCE_NSFW,
STRIP_MEDIA, STRIP_MEDIA,
@ -24,11 +24,13 @@ const ModerationTools = {
SANDBOX, SANDBOX,
QUARANTINE QUARANTINE
}, },
showDeleteUserDialog: false showDeleteUserDialog: false,
toggled: false
} }
}, },
components: { components: {
DialogModal DialogModal,
Popover
}, },
computed: { computed: {
tagsSet () { tagsSet () {
@ -89,6 +91,9 @@ const ModerationTools = {
window.history.back() window.history.back()
} }
}) })
},
setToggled (value) {
this.toggled = value
} }
} }
} }

View file

@ -1,13 +1,14 @@
<template> <template>
<div> <div>
<v-popover <Popover
trigger="click" trigger="click"
class="moderation-tools-popover" class="moderation-tools-popover"
placement="bottom-end" placement="bottom"
@show="showDropDown = true" :offset="{ y: 5 }"
@hide="showDropDown = false" @show="setToggled(true)"
@close="setToggled(false)"
> >
<div slot="popover"> <div slot="content">
<div class="dropdown-menu"> <div class="dropdown-menu">
<span v-if="user.is_local"> <span v-if="user.is_local">
<button <button
@ -122,12 +123,13 @@
</div> </div>
</div> </div>
<button <button
slot="trigger"
class="btn btn-default btn-block" class="btn btn-default btn-block"
:class="{ pressed: showDropDown }" :class="{ toggled }"
> >
{{ $t('user_card.admin_menu.moderation') }} {{ $t('user_card.admin_menu.moderation') }}
</button> </button>
</v-popover> </Popover>
<portal to="modal"> <portal to="modal">
<DialogModal <DialogModal
v-if="showDeleteUserDialog" v-if="showDeleteUserDialog"
@ -160,7 +162,6 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
@import '../popper/popper.scss';
.menu-checkbox { .menu-checkbox {
float: right; float: right;

View file

@ -11,7 +11,10 @@ const MRFTransparencyPanel = {
rejectInstances: state => get(state, 'instance.federationPolicy.mrf_simple.reject', []), rejectInstances: state => get(state, 'instance.federationPolicy.mrf_simple.reject', []),
ftlRemovalInstances: state => get(state, 'instance.federationPolicy.mrf_simple.federated_timeline_removal', []), ftlRemovalInstances: state => get(state, 'instance.federationPolicy.mrf_simple.federated_timeline_removal', []),
mediaNsfwInstances: state => get(state, 'instance.federationPolicy.mrf_simple.media_nsfw', []), mediaNsfwInstances: state => get(state, 'instance.federationPolicy.mrf_simple.media_nsfw', []),
mediaRemovalInstances: state => get(state, 'instance.federationPolicy.mrf_simple.media_removal', []) mediaRemovalInstances: state => get(state, 'instance.federationPolicy.mrf_simple.media_removal', []),
keywordsFtlRemoval: state => get(state, 'instance.federationPolicy.mrf_keyword.federated_timeline_removal', []),
keywordsReject: state => get(state, 'instance.federationPolicy.mrf_keyword.reject', []),
keywordsReplace: state => get(state, 'instance.federationPolicy.mrf_keyword.replace', [])
}), }),
hasInstanceSpecificPolicies () { hasInstanceSpecificPolicies () {
return this.quarantineInstances.length || return this.quarantineInstances.length ||
@ -20,6 +23,11 @@ const MRFTransparencyPanel = {
this.ftlRemovalInstances.length || this.ftlRemovalInstances.length ||
this.mediaNsfwInstances.length || this.mediaNsfwInstances.length ||
this.mediaRemovalInstances.length this.mediaRemovalInstances.length
},
hasKeywordPolicies () {
return this.keywordsFtlRemoval.length ||
this.keywordsReject.length ||
this.keywordsReplace.length
} }
} }
} }

View file

@ -6,13 +6,13 @@
<div class="panel panel-default base01-background"> <div class="panel panel-default base01-background">
<div class="panel-heading timeline-heading base02-background"> <div class="panel-heading timeline-heading base02-background">
<div class="title"> <div class="title">
{{ $t("about.federation") }} {{ $t("about.mrf.federation") }}
</div> </div>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<div class="mrf-section"> <div class="mrf-section">
<h2>{{ $t("about.mrf_policies") }}</h2> <h2>{{ $t("about.mrf.mrf_policies") }}</h2>
<p>{{ $t("about.mrf_policies_desc") }}</p> <p>{{ $t("about.mrf.mrf_policies_desc") }}</p>
<ul> <ul>
<li <li
@ -23,13 +23,13 @@
</ul> </ul>
<h2 v-if="hasInstanceSpecificPolicies"> <h2 v-if="hasInstanceSpecificPolicies">
{{ $t("about.mrf_policy_simple") }} {{ $t("about.mrf.simple.simple_policies") }}
</h2> </h2>
<div v-if="acceptInstances.length"> <div v-if="acceptInstances.length">
<h4>{{ $t("about.mrf_policy_simple_accept") }}</h4> <h4>{{ $t("about.mrf.simple.accept") }}</h4>
<p>{{ $t("about.mrf_policy_simple_accept_desc") }}</p> <p>{{ $t("about.mrf.simple.accept_desc") }}</p>
<ul> <ul>
<li <li
@ -41,9 +41,9 @@
</div> </div>
<div v-if="rejectInstances.length"> <div v-if="rejectInstances.length">
<h4>{{ $t("about.mrf_policy_simple_reject") }}</h4> <h4>{{ $t("about.mrf.simple.reject") }}</h4>
<p>{{ $t("about.mrf_policy_simple_reject_desc") }}</p> <p>{{ $t("about.mrf.simple.reject_desc") }}</p>
<ul> <ul>
<li <li
@ -55,9 +55,9 @@
</div> </div>
<div v-if="quarantineInstances.length"> <div v-if="quarantineInstances.length">
<h4>{{ $t("about.mrf_policy_simple_quarantine") }}</h4> <h4>{{ $t("about.mrf.simple.quarantine") }}</h4>
<p>{{ $t("about.mrf_policy_simple_quarantine_desc") }}</p> <p>{{ $t("about.mrf.simple.quarantine_desc") }}</p>
<ul> <ul>
<li <li
@ -69,9 +69,9 @@
</div> </div>
<div v-if="ftlRemovalInstances.length"> <div v-if="ftlRemovalInstances.length">
<h4>{{ $t("about.mrf_policy_simple_ftl_removal") }}</h4> <h4>{{ $t("about.mrf.simple.ftl_removal") }}</h4>
<p>{{ $t("about.mrf_policy_simple_ftl_removal_desc") }}</p> <p>{{ $t("about.mrf.simple.ftl_removal_desc") }}</p>
<ul> <ul>
<li <li
@ -83,9 +83,9 @@
</div> </div>
<div v-if="mediaNsfwInstances.length"> <div v-if="mediaNsfwInstances.length">
<h4>{{ $t("about.mrf_policy_simple_media_nsfw") }}</h4> <h4>{{ $t("about.mrf.simple.media_nsfw") }}</h4>
<p>{{ $t("about.mrf_policy_simple_media_nsfw_desc") }}</p> <p>{{ $t("about.mrf.simple.media_nsfw_desc") }}</p>
<ul> <ul>
<li <li
@ -97,9 +97,9 @@
</div> </div>
<div v-if="mediaRemovalInstances.length"> <div v-if="mediaRemovalInstances.length">
<h4>{{ $t("about.mrf_policy_simple_media_removal") }}</h4> <h4>{{ $t("about.mrf.simple.media_removal") }}</h4>
<p>{{ $t("about.mrf_policy_simple_media_removal_desc") }}</p> <p>{{ $t("about.mrf.simple.media_removal_desc") }}</p>
<ul> <ul>
<li <li
@ -109,6 +109,49 @@
/> />
</ul> </ul>
</div> </div>
<h2 v-if="hasKeywordPolicies">
{{ $t("about.mrf.keyword.keyword_policies") }}
</h2>
<div v-if="keywordsFtlRemoval.length">
<h4>{{ $t("about.mrf.keyword.ftl_removal") }}</h4>
<ul>
<li
v-for="keyword in keywordsFtlRemoval"
:key="keyword"
v-text="keyword"
/>
</ul>
</div>
<div v-if="keywordsReject.length">
<h4>{{ $t("about.mrf.keyword.reject") }}</h4>
<ul>
<li
v-for="keyword in keywordsReject"
:key="keyword"
v-text="keyword"
/>
</ul>
</div>
<div v-if="keywordsReplace.length">
<h4>{{ $t("about.mrf.keyword.replace") }}</h4>
<ul>
<li
v-for="keyword in keywordsReplace"
:key="keyword"
>
{{ keyword.pattern }}
{{ $t("about.mrf.keyword.is_replaced_by") }}
{{ keyword.replacement }}
</li>
</ul>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -3,7 +3,7 @@ import { mapState } from 'vuex'
const NavPanel = { const NavPanel = {
created () { created () {
if (this.currentUser && this.currentUser.locked) { if (this.currentUser && this.currentUser.locked) {
this.$store.dispatch('startFetchingFollowRequest') this.$store.dispatch('startFetchingFollowRequests')
} }
}, },
computed: mapState({ computed: mapState({

View file

@ -33,7 +33,7 @@
<i class="button-icon icon-users" /> {{ $t("nav.public_tl") }} <i class="button-icon icon-users" /> {{ $t("nav.public_tl") }}
</router-link> </router-link>
</li> </li>
<li v-if="federating && !privateMode"> <li v-if="federating && (currentUser || !privateMode)">
<router-link :to="{ name: 'public-external-timeline' }"> <router-link :to="{ name: 'public-external-timeline' }">
<i class="button-icon icon-globe" /> {{ $t("nav.twkn") }} <i class="button-icon icon-globe" /> {{ $t("nav.twkn") }}
</router-link> </router-link>
@ -100,13 +100,25 @@
&:hover { &:hover {
background-color: $fallback--lightBg; background-color: $fallback--lightBg;
background-color: var(--lightBg, $fallback--lightBg); background-color: var(--selectedMenu, $fallback--lightBg);
color: $fallback--link;
color: var(--selectedMenuText, $fallback--link);
--faint: var(--selectedMenuFaintText, $fallback--faint);
--faintLink: var(--selectedMenuFaintLink, $fallback--faint);
--lightText: var(--selectedMenuLightText, $fallback--lightText);
--icon: var(--selectedMenuIcon, $fallback--icon);
} }
&.router-link-active { &.router-link-active {
font-weight: bolder; font-weight: bolder;
background-color: $fallback--lightBg; background-color: $fallback--lightBg;
background-color: var(--lightBg, $fallback--lightBg); background-color: var(--selectedMenu, $fallback--lightBg);
color: $fallback--text;
color: var(--selectedMenuText, $fallback--text);
--faint: var(--selectedMenuFaintText, $fallback--faint);
--faintLink: var(--selectedMenuFaintLink, $fallback--faint);
--lightText: var(--selectedMenuLightText, $fallback--lightText);
--icon: var(--selectedMenuIcon, $fallback--icon);
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;

View file

@ -78,6 +78,13 @@
<i class="fa icon-arrow-curved lit" /> <i class="fa icon-arrow-curved lit" />
<small>{{ $t('notifications.migrated_to') }}</small> <small>{{ $t('notifications.migrated_to') }}</small>
</span> </span>
<span v-if="notification.type === 'pleroma:emoji_reaction'">
<small>
<i18n path="notifications.reacted_with">
<span class="emoji-reaction-emoji">{{ notification.emoji }}</span>
</i18n>
</small>
</span>
</div> </div>
<div <div
v-if="notification.type === 'follow' || notification.type === 'move'" v-if="notification.type === 'follow' || notification.type === 'move'"

View file

@ -68,6 +68,9 @@
a { a {
color: var(--faintLink); color: var(--faintLink);
} }
.status-content a {
color: var(--postFaintLink);
}
} }
padding: 0; padding: 0;
.media-body { .media-body {
@ -94,6 +97,10 @@
min-width: 0; min-width: 0;
} }
.emoji-reaction-emoji {
font-size: 16px;
}
.notification-details { .notification-details {
min-width: 0px; min-width: 0px;
word-wrap: break-word; word-wrap: break-word;

View file

@ -9,18 +9,12 @@
> >
{{ $t('settings.style.common.opacity') }} {{ $t('settings.style.common.opacity') }}
</label> </label>
<input <Checkbox
v-if="typeof fallback !== 'undefined'" v-if="typeof fallback !== 'undefined'"
:id="name + '-o'"
class="opt exclude-disabled"
type="checkbox"
:checked="present" :checked="present"
@input="$emit('input', !present ? fallback : undefined)" :disabled="disabled"
> class="opt"
<label @change="$emit('input', !present ? fallback : undefined)"
v-if="typeof fallback !== 'undefined'"
class="opt-l"
:for="name + '-o'"
/> />
<input <input
:id="name" :id="name"
@ -37,7 +31,11 @@
</template> </template>
<script> <script>
import Checkbox from '../checkbox/checkbox.vue'
export default { export default {
components: {
Checkbox
},
props: [ props: [
'name', 'value', 'fallback', 'disabled' 'name', 'value', 'fallback', 'disabled'
], ],

View file

@ -104,8 +104,10 @@
.result-fill { .result-fill {
height: 100%; height: 100%;
position: absolute; position: absolute;
color: $fallback--text;
color: var(--pollText, $fallback--text);
background-color: $fallback--lightBg; background-color: $fallback--lightBg;
background-color: var(--linkBg, $fallback--lightBg); background-color: var(--poll, $fallback--lightBg);
border-radius: $fallback--panelRadius; border-radius: $fallback--panelRadius;
border-radius: var(--panelRadius, $fallback--panelRadius); border-radius: var(--panelRadius, $fallback--panelRadius);
top: 0; top: 0;

View file

@ -0,0 +1,156 @@
const Popover = {
name: 'Popover',
props: {
// Action to trigger popover: either 'hover' or 'click'
trigger: String,
// Either 'top' or 'bottom'
placement: String,
// Takes object with properties 'x' and 'y', values of these can be
// 'container' for using offsetParent as boundaries for either axis
// or 'viewport'
boundTo: Object,
// Takes a top/bottom/left/right object, how much space to leave
// between boundary and popover element
margin: Object,
// Takes a x/y object and tells how many pixels to offset from
// anchor point on either axis
offset: Object,
// Additional styles you may want for the popover container
popoverClass: String
},
data () {
return {
hidden: true,
styles: { opacity: 0 },
oldSize: { width: 0, height: 0 }
}
},
methods: {
updateStyles () {
if (this.hidden) {
this.styles = {
opacity: 0
}
return
}
// Popover will be anchored around this element, trigger ref is the container, so
// its children are what are inside the slot. Expect only one slot="trigger".
const anchorEl = (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el
const screenBox = anchorEl.getBoundingClientRect()
// Screen position of the origin point for popover
const origin = { x: screenBox.left + screenBox.width * 0.5, y: screenBox.top }
const content = this.$refs.content
// Minor optimization, don't call a slow reflow call if we don't have to
const parentBounds = this.boundTo &&
(this.boundTo.x === 'container' || this.boundTo.y === 'container') &&
this.$el.offsetParent.getBoundingClientRect()
const margin = this.margin || {}
// What are the screen bounds for the popover? Viewport vs container
// when using viewport, using default margin values to dodge the navbar
const xBounds = this.boundTo && this.boundTo.x === 'container' ? {
min: parentBounds.left + (margin.left || 0),
max: parentBounds.right - (margin.right || 0)
} : {
min: 0 + (margin.left || 10),
max: window.innerWidth - (margin.right || 10)
}
const yBounds = this.boundTo && this.boundTo.y === 'container' ? {
min: parentBounds.top + (margin.top || 0),
max: parentBounds.bottom - (margin.bottom || 0)
} : {
min: 0 + (margin.top || 50),
max: window.innerHeight - (margin.bottom || 5)
}
let horizOffset = 0
// If overflowing from left, move it so that it doesn't
if ((origin.x - content.offsetWidth * 0.5) < xBounds.min) {
horizOffset += -(origin.x - content.offsetWidth * 0.5) + xBounds.min
}
// If overflowing from right, move it so that it doesn't
if ((origin.x + horizOffset + content.offsetWidth * 0.5) > xBounds.max) {
horizOffset -= (origin.x + horizOffset + content.offsetWidth * 0.5) - xBounds.max
}
// Default to whatever user wished with placement prop
let usingTop = this.placement !== 'bottom'
// Handle special cases, first force to displaying on top if there's not space on bottom,
// regardless of what placement value was. Then check if there's not space on top, and
// force to bottom, again regardless of what placement value was.
if (origin.y + content.offsetHeight > yBounds.max) usingTop = true
if (origin.y - content.offsetHeight < yBounds.min) usingTop = false
const yOffset = (this.offset && this.offset.y) || 0
const translateY = usingTop
? -anchorEl.offsetHeight - yOffset - content.offsetHeight
: yOffset
const xOffset = (this.offset && this.offset.x) || 0
const translateX = (anchorEl.offsetWidth * 0.5) - content.offsetWidth * 0.5 + horizOffset + xOffset
// Note, separate translateX and translateY avoids blurry text on chromium,
// single translate or translate3d resulted in blurry text.
this.styles = {
opacity: 1,
transform: `translateX(${Math.floor(translateX)}px) translateY(${Math.floor(translateY)}px)`
}
},
showPopover () {
if (this.hidden) this.$emit('show')
this.hidden = false
this.$nextTick(this.updateStyles)
},
hidePopover () {
if (!this.hidden) this.$emit('close')
this.hidden = true
this.styles = { opacity: 0 }
},
onMouseenter (e) {
if (this.trigger === 'hover') this.showPopover()
},
onMouseleave (e) {
if (this.trigger === 'hover') this.hidePopover()
},
onClick (e) {
if (this.trigger === 'click') {
if (this.hidden) {
this.showPopover()
} else {
this.hidePopover()
}
}
},
onClickOutside (e) {
if (this.hidden) return
if (this.$el.contains(e.target)) return
this.hidePopover()
}
},
updated () {
// Monitor changes to content size, update styles only when content sizes have changed,
// that should be the only time we need to move the popover box if we don't care about scroll
// or resize
const content = this.$refs.content
if (!content) return
if (this.oldSize.width !== content.offsetWidth || this.oldSize.height !== content.offsetHeight) {
this.updateStyles()
this.oldSize = { width: content.offsetWidth, height: content.offsetHeight }
}
},
created () {
document.addEventListener('click', this.onClickOutside)
},
destroyed () {
document.removeEventListener('click', this.onClickOutside)
this.hidePopover()
}
}
export default Popover

View file

@ -0,0 +1,118 @@
<template>
<div
@mouseenter="onMouseenter"
@mouseleave="onMouseleave"
>
<div
ref="trigger"
@click="onClick"
>
<slot name="trigger" />
</div>
<div
v-if="!hidden"
ref="content"
:style="styles"
class="popover"
:class="popoverClass"
>
<slot
name="content"
class="popover-inner"
:close="hidePopover"
/>
</div>
</div>
</template>
<script src="./popover.js" />
<style lang=scss>
@import '../../_variables.scss';
.popover {
z-index: 8;
position: absolute;
min-width: 0;
transition: opacity 0.3s;
box-shadow: 1px 1px 4px rgba(0,0,0,.6);
box-shadow: var(--panelShadow);
border-radius: $fallback--btnRadius;
border-radius: var(--btnRadius, $fallback--btnRadius);
background-color: $fallback--bg;
background-color: var(--popover, $fallback--bg);
color: $fallback--text;
color: var(--popoverText, $fallback--text);
--faint: var(--popoverFaintText, $fallback--faint);
--faintLink: var(--popoverFaintLink, $fallback--faint);
--lightText: var(--popoverLightText, $fallback--lightText);
--postLink: var(--popoverPostLink, $fallback--link);
--postFaintLink: var(--popoverPostFaintLink, $fallback--link);
--icon: var(--popoverIcon, $fallback--icon);
}
.dropdown-menu {
display: block;
padding: .5rem 0;
font-size: 1rem;
text-align: left;
list-style: none;
max-width: 100vw;
z-index: 10;
white-space: nowrap;
.dropdown-divider {
height: 0;
margin: .5rem 0;
overflow: hidden;
border-top: 1px solid $fallback--border;
border-top: 1px solid var(--border, $fallback--border);
}
.dropdown-item {
line-height: 21px;
margin-right: 5px;
overflow: auto;
display: block;
padding: .25rem 1.0rem .25rem 1.5rem;
clear: both;
font-weight: 400;
text-align: inherit;
white-space: nowrap;
border: none;
border-radius: 0px;
background-color: transparent;
box-shadow: none;
width: 100%;
height: 100%;
--btnText: var(--popoverText, $fallback--text);
&-icon {
padding-left: 0.5rem;
i {
margin-right: 0.25rem;
color: var(--menuPopoverIcon, $fallback--icon)
}
}
&:active, &:hover {
background-color: $fallback--lightBg;
background-color: var(--selectedMenuPopover, $fallback--lightBg);
color: $fallback--link;
color: var(--selectedMenuPopoverText, $fallback--link);
--faint: var(--selectedMenuPopoverFaintText, $fallback--faint);
--faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint);
--lightText: var(--selectedMenuPopoverLightText, $fallback--lightText);
--icon: var(--selectedMenuPopoverIcon, $fallback--icon);
i {
color: var(--selectedMenuPopoverIcon, $fallback--icon);
}
}
}
}
</style>

View file

@ -1,147 +0,0 @@
@import '../../_variables.scss';
.tooltip.popover {
z-index: 8;
.popover-inner {
box-shadow: 1px 1px 4px rgba(0,0,0,.6);
box-shadow: var(--panelShadow);
border-radius: $fallback--btnRadius;
border-radius: var(--btnRadius, $fallback--btnRadius);
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
}
.popover-arrow {
width: 0;
height: 0;
border-style: solid;
position: absolute;
margin: 5px;
border-color: $fallback--bg;
border-color: var(--bg, $fallback--bg);
}
&[x-placement^="top"] {
margin-bottom: 5px;
.popover-arrow {
border-width: 5px 5px 0 5px;
border-left-color: transparent !important;
border-right-color: transparent !important;
border-bottom-color: transparent !important;
bottom: -4px;
left: calc(50% - 5px);
margin-top: 0;
margin-bottom: 0;
}
}
&[x-placement^="bottom"] {
margin-top: 5px;
.popover-arrow {
border-width: 0 5px 5px 5px;
border-left-color: transparent !important;
border-right-color: transparent !important;
border-top-color: transparent !important;
top: -4px;
left: calc(50% - 5px);
margin-top: 0;
margin-bottom: 0;
}
}
&[x-placement^="right"] {
margin-left: 5px;
.popover-arrow {
border-width: 5px 5px 5px 0;
border-left-color: transparent !important;
border-top-color: transparent !important;
border-bottom-color: transparent !important;
left: -4px;
top: calc(50% - 5px);
margin-left: 0;
margin-right: 0;
}
}
&[x-placement^="left"] {
margin-right: 5px;
.popover-arrow {
border-width: 5px 0 5px 5px;
border-top-color: transparent !important;
border-right-color: transparent !important;
border-bottom-color: transparent !important;
right: -4px;
top: calc(50% - 5px);
margin-left: 0;
margin-right: 0;
}
}
&[aria-hidden='true'] {
visibility: hidden;
opacity: 0;
transition: opacity .15s, visibility .15s;
}
&[aria-hidden='false'] {
visibility: visible;
opacity: 1;
transition: opacity .15s;
}
}
.dropdown-menu {
display: block;
padding: .5rem 0;
font-size: 1rem;
text-align: left;
list-style: none;
max-width: 100vw;
z-index: 10;
.dropdown-divider {
height: 0;
margin: .5rem 0;
overflow: hidden;
border-top: 1px solid $fallback--border;
border-top: 1px solid var(--border, $fallback--border);
}
.dropdown-item {
line-height: 21px;
margin-right: 5px;
overflow: auto;
display: block;
padding: .25rem 1.0rem .25rem 1.5rem;
clear: both;
font-weight: 400;
text-align: inherit;
white-space: normal;
border: none;
border-radius: 0px;
background-color: transparent;
box-shadow: none;
width: 100%;
height: 100%;
&-icon {
padding-left: 0.5rem;
i {
margin-right: 0.25rem;
}
}
&:hover {
// TODO: improve the look on breeze themes
background-color: $fallback--fg;
background-color: var(--btn, $fallback--fg);
box-shadow: none;
}
}
}

View file

@ -12,7 +12,7 @@
<input <input
v-if="typeof fallback !== 'undefined'" v-if="typeof fallback !== 'undefined'"
:id="name + '-o'" :id="name + '-o'"
class="opt exclude-disabled" class="opt"
type="checkbox" type="checkbox"
:checked="present" :checked="present"
@input="$emit('input', !present ? fallback : undefined)" @input="$emit('input', !present ? fallback : undefined)"

View file

@ -0,0 +1,39 @@
import Popover from '../popover/popover.vue'
import { mapGetters } from 'vuex'
const ReactButton = {
props: ['status', 'loggedIn'],
data () {
return {
filterWord: ''
}
},
components: {
Popover
},
methods: {
addReaction (event, emoji, close) {
const existingReaction = this.status.emoji_reactions.find(r => r.name === emoji)
if (existingReaction && existingReaction.me) {
this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji })
} else {
this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji })
}
close()
}
},
computed: {
commonEmojis () {
return ['❤️', '😠', '👀', '😂', '🔥']
},
emojis () {
if (this.filterWord !== '') {
return this.$store.state.instance.emoji.filter(emoji => emoji.displayText.includes(this.filterWord))
}
return this.$store.state.instance.emoji || []
},
...mapGetters(['mergedConfig'])
}
}
export default ReactButton

View file

@ -0,0 +1,111 @@
<template>
<Popover
trigger="click"
placement="top"
:offset="{ y: 5 }"
class="react-button-popover"
>
<div
slot="content"
slot-scope="{close}"
>
<div class="reaction-picker-filter">
<input
v-model="filterWord"
:placeholder="$t('emoji.search_emoji')"
>
</div>
<div class="reaction-picker">
<span
v-for="emoji in commonEmojis"
:key="emoji"
class="emoji-button"
@click="addReaction($event, emoji, close)"
>
{{ emoji }}
</span>
<div class="reaction-picker-divider" />
<span
v-for="(emoji, key) in emojis"
:key="key"
class="emoji-button"
@click="addReaction($event, emoji.replacement, close)"
>
{{ emoji.replacement }}
</span>
<div class="reaction-bottom-fader" />
</div>
</div>
<i
v-if="loggedIn"
slot="trigger"
class="icon-smile button-icon add-reaction-button"
:title="$t('tool_tip.add_reaction')"
/>
</Popover>
</template>
<script src="./react_button.js" ></script>
<style lang="scss">
@import '../../_variables.scss';
.reaction-picker-filter {
padding: 0.5em;
display: flex;
input {
flex: 1;
}
}
.reaction-picker-divider {
height: 1px;
width: 100%;
margin: 0.5em;
background-color: var(--border, $fallback--border);
}
.reaction-picker {
width: 10em;
height: 9em;
font-size: 1.5em;
overflow-y: scroll;
display: flex;
flex-wrap: wrap;
padding: 0.5em;
text-align: center;
align-content: flex-start;
user-select: none;
mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat,
linear-gradient(to bottom, white 0, transparent 100%) top no-repeat,
linear-gradient(to top, white, white);
transition: mask-size 150ms;
mask-size: 100% 20px, 100% 20px, auto;
// Autoprefixed seem to ignore this one, and also syntax is different
-webkit-mask-composite: xor;
mask-composite: exclude;
.emoji-button {
cursor: pointer;
flex-basis: 20%;
line-height: 1.5em;
align-content: center;
&:hover {
transform: scale(1.25);
}
}
}
.add-reaction-button {
cursor: pointer;
&:hover {
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
</style>

View file

@ -68,7 +68,12 @@
&-item-selected-inner { &-item-selected-inner {
background-color: $fallback--lightBg; background-color: $fallback--lightBg;
background-color: var(--lightBg, $fallback--lightBg); background-color: var(--selectedMenu, $fallback--lightBg);
color: var(--selectedMenuText, $fallback--text);
--faint: var(--selectedMenuFaintText, $fallback--faint);
--faintLink: var(--selectedMenuFaintLink, $fallback--faint);
--lightText: var(--selectedMenuLightText, $fallback--lightText);
--icon: var(--selectedMenuIcon, $fallback--icon);
} }
&-header { &-header {

View file

@ -76,7 +76,7 @@
<li> <li>
<Checkbox v-model="useStreamingApi"> <Checkbox v-model="useStreamingApi">
{{ $t('settings.useStreamingApi') }} {{ $t('settings.useStreamingApi') }}
<br/> <br>
<small> <small>
{{ $t('settings.useStreamingApiWarning') }} {{ $t('settings.useStreamingApiWarning') }}
</small> </small>
@ -92,6 +92,11 @@
{{ $t('settings.reply_link_preview') }} {{ $t('settings.reply_link_preview') }}
</Checkbox> </Checkbox>
</li> </li>
<li>
<Checkbox v-model="emojiReactionsOnTimeline">
{{ $t('settings.emoji_reactions_on_timeline') }}
</Checkbox>
</li>
</ul> </ul>
</div> </div>
@ -328,6 +333,11 @@
{{ $t('settings.notification_visibility_moves') }} {{ $t('settings.notification_visibility_moves') }}
</Checkbox> </Checkbox>
</li> </li>
<li>
<Checkbox v-model="notificationVisibility.emojiReactions">
{{ $t('settings.notification_visibility_emoji_reactions') }}
</Checkbox>
</li>
</ul> </ul>
</div> </div>
<div> <div>

View file

@ -3,6 +3,17 @@ import OpacityInput from '../opacity_input/opacity_input.vue'
import { getCssShadow } from '../../services/style_setter/style_setter.js' import { getCssShadow } from '../../services/style_setter/style_setter.js'
import { hex2rgb } from '../../services/color_convert/color_convert.js' import { hex2rgb } from '../../services/color_convert/color_convert.js'
const toModel = (object = {}) => ({
x: 0,
y: 0,
blur: 0,
spread: 0,
inset: false,
color: '#000000',
alpha: 1,
...object
})
export default { export default {
// 'Value' and 'Fallback' can be undefined, but if they are // 'Value' and 'Fallback' can be undefined, but if they are
// initially vue won't detect it when they become something else // initially vue won't detect it when they become something else
@ -15,7 +26,7 @@ export default {
return { return {
selectedId: 0, selectedId: 0,
// TODO there are some bugs regarding display of array (it's not getting updated when deleting for some reason) // TODO there are some bugs regarding display of array (it's not getting updated when deleting for some reason)
cValue: this.value || this.fallback || [] cValue: (this.value || this.fallback || []).map(toModel)
} }
}, },
components: { components: {
@ -24,12 +35,12 @@ export default {
}, },
methods: { methods: {
add () { add () {
this.cValue.push(Object.assign({}, this.selected)) this.cValue.push(toModel(this.selected))
this.selectedId = this.cValue.length - 1 this.selectedId = this.cValue.length - 1
}, },
del () { del () {
this.cValue.splice(this.selectedId, 1) this.cValue.splice(this.selectedId, 1)
this.selectedId = this.cValue.length === 0 ? undefined : this.selectedId - 1 this.selectedId = this.cValue.length === 0 ? undefined : Math.max(this.selectedId - 1, 0)
}, },
moveUp () { moveUp () {
const movable = this.cValue.splice(this.selectedId, 1)[0] const movable = this.cValue.splice(this.selectedId, 1)[0]
@ -46,19 +57,24 @@ export default {
this.cValue = this.value || this.fallback this.cValue = this.value || this.fallback
}, },
computed: { computed: {
anyShadows () {
return this.cValue.length > 0
},
anyShadowsFallback () {
return this.fallback.length > 0
},
selected () { selected () {
if (this.ready && this.cValue.length > 0) { if (this.ready && this.anyShadows) {
return this.cValue[this.selectedId] return this.cValue[this.selectedId]
} else { } else {
return { return toModel({})
x: 0,
y: 0,
blur: 0,
spread: 0,
inset: false,
color: '#000000',
alpha: 1
} }
},
currentFallback () {
if (this.ready && this.anyShadowsFallback) {
return this.fallback[this.selectedId]
} else {
return toModel({})
} }
}, },
moveUpValid () { moveUpValid () {
@ -80,7 +96,7 @@ export default {
}, },
style () { style () {
return this.ready ? { return this.ready ? {
boxShadow: getCssShadow(this.cValue) boxShadow: getCssShadow(this.fallback)
} : {} } : {}
} }
} }

View file

@ -191,15 +191,20 @@
v-model="selected.color" v-model="selected.color"
:disabled="!present" :disabled="!present"
:label="$t('settings.style.common.color')" :label="$t('settings.style.common.color')"
:fallback="currentFallback.color"
:show-optional-tickbox="false"
name="shadow" name="shadow"
/> />
<OpacityInput <OpacityInput
v-model="selected.alpha" v-model="selected.alpha"
:disabled="!present" :disabled="!present"
/> />
<p> <i18n
{{ $t('settings.style.shadows.hint') }} path="settings.style.shadows.hintV3"
</p> tag="p"
>
<code>--variable,mod</code>
</i18n>
</div> </div>
</div> </div>
</template> </template>

View file

@ -12,7 +12,7 @@ const SideDrawer = {
this.closeGesture = GestureService.swipeGesture(GestureService.DIRECTION_LEFT, this.toggleDrawer) this.closeGesture = GestureService.swipeGesture(GestureService.DIRECTION_LEFT, this.toggleDrawer)
if (this.currentUser && this.currentUser.locked) { if (this.currentUser && this.currentUser.locked) {
this.$store.dispatch('startFetchingFollowRequest') this.$store.dispatch('startFetchingFollowRequests')
} }
}, },
components: { UserCard }, components: { UserCard },

View file

@ -88,7 +88,7 @@
</router-link> </router-link>
</li> </li>
<li <li
v-if="federating && !privateMode" v-if="federating && (currentUser || !privateMode)"
@click="toggleDrawer" @click="toggleDrawer"
> >
<router-link to="/main/all"> <router-link to="/main/all">
@ -223,7 +223,13 @@
box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6); box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6);
box-shadow: var(--panelShadow); box-shadow: var(--panelShadow);
background-color: $fallback--bg; background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg); background-color: var(--popover, $fallback--bg);
color: $fallback--link;
color: var(--popoverText, $fallback--link);
--faint: var(--popoverFaintText, $fallback--faint);
--faintLink: var(--popoverFaintLink, $fallback--faint);
--lightText: var(--popoverLightText, $fallback--lightText);
--icon: var(--popoverIcon, $fallback--icon);
.button-icon:before { .button-icon:before {
width: 1.1em; width: 1.1em;
@ -289,7 +295,13 @@
&:hover { &:hover {
background-color: $fallback--lightBg; background-color: $fallback--lightBg;
background-color: var(--lightBg, $fallback--lightBg); background-color: var(--selectedMenuPopover, $fallback--lightBg);
color: $fallback--text;
color: var(--selectedMenuPopoverText, $fallback--text);
--faint: var(--selectedMenuPopoverFaintText, $fallback--faint);
--faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint);
--lightText: var(--selectedMenuPopoverLightText, $fallback--lightText);
--icon: var(--selectedMenuPopoverIcon, $fallback--icon);
} }
} }
} }

View file

@ -1,3 +1,4 @@
import map from 'lodash/map'
import BasicUserCard from '../basic_user_card/basic_user_card.vue' import BasicUserCard from '../basic_user_card/basic_user_card.vue'
const StaffPanel = { const StaffPanel = {
@ -6,7 +7,7 @@ const StaffPanel = {
}, },
computed: { computed: {
staffAccounts () { staffAccounts () {
return this.$store.state.instance.staffAccounts return map(this.$store.state.instance.staffAccounts, nickname => this.$store.getters.findUser(nickname)).filter(_ => _)
} }
} }
} }

View file

@ -1,5 +1,6 @@
import Attachment from '../attachment/attachment.vue' import Attachment from '../attachment/attachment.vue'
import FavoriteButton from '../favorite_button/favorite_button.vue' import FavoriteButton from '../favorite_button/favorite_button.vue'
import ReactButton from '../react_button/react_button.vue'
import RetweetButton from '../retweet_button/retweet_button.vue' import RetweetButton from '../retweet_button/retweet_button.vue'
import Poll from '../poll/poll.vue' import Poll from '../poll/poll.vue'
import ExtraButtons from '../extra_buttons/extra_buttons.vue' import ExtraButtons from '../extra_buttons/extra_buttons.vue'
@ -11,6 +12,7 @@ import LinkPreview from '../link-preview/link-preview.vue'
import AvatarList from '../avatar_list/avatar_list.vue' import AvatarList from '../avatar_list/avatar_list.vue'
import Timeago from '../timeago/timeago.vue' import Timeago from '../timeago/timeago.vue'
import StatusPopover from '../status_popover/status_popover.vue' import StatusPopover from '../status_popover/status_popover.vue'
import EmojiReactions from '../emoji_reactions/emoji_reactions.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import fileType from 'src/services/file_type/file_type.service' import fileType from 'src/services/file_type/file_type.service'
import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js' import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js'
@ -254,6 +256,16 @@ const Status = {
file => !fileType.fileMatchesSomeType(this.galleryTypes, file) file => !fileType.fileMatchesSomeType(this.galleryTypes, file)
) )
}, },
hasImageAttachments () {
return this.status.attachments.some(
file => fileType.fileType(file.mimetype) === 'image'
)
},
hasVideoAttachments () {
return this.status.attachments.some(
file => fileType.fileType(file.mimetype) === 'video'
)
},
maxThumbnails () { maxThumbnails () {
return this.mergedConfig.maxThumbnails return this.mergedConfig.maxThumbnails
}, },
@ -319,6 +331,7 @@ const Status = {
components: { components: {
Attachment, Attachment,
FavoriteButton, FavoriteButton,
ReactButton,
RetweetButton, RetweetButton,
ExtraButtons, ExtraButtons,
PostStatusForm, PostStatusForm,
@ -329,7 +342,8 @@ const Status = {
LinkPreview, LinkPreview,
AvatarList, AvatarList,
Timeago, Timeago,
StatusPopover StatusPopover,
EmojiReactions
}, },
methods: { methods: {
visibilityIcon (visibility) { visibilityIcon (visibility) {

View file

@ -177,6 +177,8 @@
<StatusPopover <StatusPopover
v-if="!isPreview" v-if="!isPreview"
:status-id="status.in_reply_to_status_id" :status-id="status.in_reply_to_status_id"
class="reply-to-popover"
style="min-width: 0"
> >
<a <a
class="reply-to" class="reply-to"
@ -277,7 +279,21 @@
href="#" href="#"
class="cw-status-hider" class="cw-status-hider"
@click.prevent="toggleShowMore" @click.prevent="toggleShowMore"
>{{ $t("general.show_more") }}</a> >
{{ $t("general.show_more") }}
<span
v-if="hasImageAttachments"
class="icon-picture"
/>
<span
v-if="hasVideoAttachments"
class="icon-video"
/>
<span
v-if="status.card"
class="icon-link"
/>
</a>
<a <a
v-if="showingMore" v-if="showingMore"
href="#" href="#"
@ -354,6 +370,11 @@
</div> </div>
</transition> </transition>
<EmojiReactions
v-if="(mergedConfig.emojiReactionsOnTimeline || isFocused) && (!noHeading && !isPreview)"
:status="status"
/>
<div <div
v-if="!noHeading && !isPreview" v-if="!noHeading && !isPreview"
class="status-actions media-body" class="status-actions media-body"
@ -382,6 +403,10 @@
:logged-in="loggedIn" :logged-in="loggedIn"
:status="status" :status="status"
/> />
<ReactButton
:logged-in="loggedIn"
:status="status"
/>
<extra-buttons <extra-buttons
:status="status" :status="status"
@onError="showError" @onError="showError"
@ -445,7 +470,15 @@ $status-margin: 0.75em;
&_focused { &_focused {
background-color: $fallback--lightBg; background-color: $fallback--lightBg;
background-color: var(--lightBg, $fallback--lightBg); background-color: var(--selectedPost, $fallback--lightBg);
color: $fallback--text;
color: var(--selectedPostText, $fallback--text);
--lightText: var(--selectedPostLightText, $fallback--light);
--faint: var(--selectedPostFaintText, $fallback--faint);
--faintLink: var(--selectedPostFaintLink, $fallback--faint);
--postLink: var(--selectedPostPostLink, $fallback--faint);
--postFaintLink: var(--selectedPostFaintPostLink, $fallback--faint);
--icon: var(--selectedPostIcon, $fallback--icon);
} }
.timeline & { .timeline & {
@ -541,11 +574,10 @@ $status-margin: 0.75em;
align-items: stretch; align-items: stretch;
> .reply-to-and-accountname > a { > .reply-to-and-accountname > a {
overflow: hidden;
max-width: 100%; max-width: 100%;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap; white-space: nowrap;
display: inline-block;
word-break: break-all; word-break: break-all;
} }
} }
@ -554,7 +586,6 @@ $status-margin: 0.75em;
display: flex; display: flex;
height: 18px; height: 18px;
margin-right: 0.5em; margin-right: 0.5em;
overflow: hidden;
max-width: 100%; max-width: 100%;
.icon-reply { .icon-reply {
transform: scaleX(-1); transform: scaleX(-1);
@ -565,6 +596,10 @@ $status-margin: 0.75em;
display: flex; display: flex;
} }
.reply-to-popover {
min-width: 0;
}
.reply-to { .reply-to {
display: flex; display: flex;
} }
@ -572,9 +607,8 @@ $status-margin: 0.75em;
.reply-to-text { .reply-to-text {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
margin: 0 0.4em 0 0.2em; margin: 0 0.4em 0 0.2em;
color: $fallback--faint;
color: var(--faint, $fallback--faint);
} }
.replies-separator { .replies-separator {
@ -636,6 +670,11 @@ $status-margin: 0.75em;
line-height: 1.4em; line-height: 1.4em;
white-space: pre-wrap; white-space: pre-wrap;
a {
color: $fallback--link;
color: var(--postLink, $fallback--link);
}
img, video { img, video {
max-width: 100%; max-width: 100%;
max-height: 400px; max-height: 400px;

View file

@ -5,22 +5,14 @@ const StatusPopover = {
props: [ props: [
'statusId' 'statusId'
], ],
data () {
return {
popperOptions: {
modifiers: {
preventOverflow: { padding: { top: 50 }, boundariesElement: 'viewport' }
}
}
}
},
computed: { computed: {
status () { status () {
return find(this.$store.state.statuses.allStatuses, { id: this.statusId }) return find(this.$store.state.statuses.allStatuses, { id: this.statusId })
} }
}, },
components: { components: {
Status: () => import('../status/status.vue') Status: () => import('../status/status.vue'),
Popover: () => import('../popover/popover.vue')
}, },
methods: { methods: {
enter () { enter () {

View file

@ -1,11 +1,16 @@
<template> <template>
<v-popover <Popover
trigger="hover"
popover-class="status-popover" popover-class="status-popover"
placement="top-start" :bound-to="{ x: 'container' }"
:popper-options="popperOptions" @show="enter"
@show="enter()" >
<template slot="trigger">
<slot />
</template>
<div
slot="content"
> >
<template slot="popover">
<Status <Status
v-if="status" v-if="status"
:is-preview="true" :is-preview="true"
@ -18,10 +23,8 @@
> >
<i class="icon-spin4 animate-spin" /> <i class="icon-spin4 animate-spin" />
</div> </div>
</template> </div>
</Popover>
<slot />
</v-popover>
</template> </template>
<script src="./status_popover.js" ></script> <script src="./status_popover.js" ></script>
@ -29,13 +32,11 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
.tooltip.popover.status-popover { .status-popover {
font-size: 1rem; font-size: 1rem;
min-width: 15em; min-width: 15em;
max-width: 95%; max-width: 95%;
margin-left: 0.5em;
.popover-inner {
border-color: $fallback--border; border-color: $fallback--border;
border-color: var(--border, $fallback--border); border-color: var(--border, $fallback--border);
border-style: solid; border-style: solid;
@ -44,29 +45,6 @@
border-radius: var(--tooltipRadius, $fallback--tooltipRadius); border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5); box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5);
box-shadow: var(--popupShadow); box-shadow: var(--popupShadow);
}
.popover-arrow::before {
position: absolute;
content: '';
left: -7px;
border: solid 7px transparent;
z-index: -1;
}
&[x-placement^="bottom-start"] .popover-arrow::before {
top: -2px;
border-top-width: 0;
border-bottom-color: $fallback--border;
border-bottom-color: var(--border, $fallback--border);
}
&[x-placement^="top-start"] .popover-arrow::before {
bottom: -2px;
border-bottom-width: 0;
border-top-color: $fallback--border;
border-top-color: var(--border, $fallback--border);
}
.status-el.status-el { .status-el.status-el {
border: none; border: none;

View file

@ -51,7 +51,7 @@
img { img {
height: 100%; height: 100%;
&:hover { &:hover {
filter: drop-shadow(0 0 5px var(--link, $fallback--link)); filter: drop-shadow(0 0 5px var(--accent, $fallback--link));
} }
} }
} }

View file

@ -1,4 +1,6 @@
<template> <template>
<div class="preview-container">
<div class="underlay underlay-preview" />
<div class="panel dummy"> <div class="panel dummy">
<div class="panel-heading"> <div class="panel-heading">
<div class="title"> <div class="title">
@ -19,7 +21,7 @@
</div> </div>
<div class="panel-body theme-preview-content"> <div class="panel-body theme-preview-content">
<div class="post"> <div class="post">
<div class="avatar"> <div class="avatar still-image">
( ͡° ͜ʖ ͡°) ( ͡° ͜ʖ ͡°)
</div> </div>
<div class="content"> <div class="content">
@ -98,4 +100,18 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<style lang="scss">
.preview-container {
position: relative;
}
.underlay-preview {
position: absolute;
top: 0;
bottom: 0;
left: 10px;
right: 10px;
}
</style>

View file

@ -1,6 +1,29 @@
import { rgb2hex, hex2rgb, getContrastRatio, alphaBlend } from '../../services/color_convert/color_convert.js'
import { set, delete as del } from 'vue' import { set, delete as del } from 'vue'
import { generateColors, generateShadows, generateRadii, generateFonts, composePreset, getThemes } from '../../services/style_setter/style_setter.js' import {
rgb2hex,
hex2rgb,
getContrastRatioLayers
} from '../../services/color_convert/color_convert.js'
import {
DEFAULT_SHADOWS,
generateColors,
generateShadows,
generateRadii,
generateFonts,
composePreset,
getThemes,
shadows2to3,
colors2to3
} from '../../services/style_setter/style_setter.js'
import {
SLOT_INHERITANCE
} from '../../services/theme_data/pleromafe.js'
import {
CURRENT_VERSION,
OPACITIES,
getLayers,
getOpacitySlot
} from '../../services/theme_data/theme_data.service.js'
import ColorInput from '../color_input/color_input.vue' import ColorInput from '../color_input/color_input.vue'
import RangeInput from '../range_input/range_input.vue' import RangeInput from '../range_input/range_input.vue'
import OpacityInput from '../opacity_input/opacity_input.vue' import OpacityInput from '../opacity_input/opacity_input.vue'
@ -24,11 +47,22 @@ const v1OnlyNames = [
'cOrange' 'cOrange'
].map(_ => _ + 'ColorLocal') ].map(_ => _ + 'ColorLocal')
const colorConvert = (color) => {
if (color.startsWith('--') || color === 'transparent') {
return color
} else {
return hex2rgb(color)
}
}
export default { export default {
data () { data () {
return { return {
availableStyles: [], availableStyles: [],
selected: this.$store.getters.mergedConfig.theme, selected: this.$store.getters.mergedConfig.theme,
themeWarning: undefined,
tempImportFile: undefined,
engineVersion: 0,
previewShadows: {}, previewShadows: {},
previewColors: {}, previewColors: {},
@ -45,51 +79,13 @@ export default {
keepRoundness: false, keepRoundness: false,
keepFonts: false, keepFonts: false,
textColorLocal: '', ...Object.keys(SLOT_INHERITANCE)
linkColorLocal: '', .map(key => [key, ''])
.reduce((acc, [key, val]) => ({ ...acc, [ key + 'ColorLocal' ]: val }), {}),
bgColorLocal: '', ...Object.keys(OPACITIES)
bgOpacityLocal: undefined, .map(key => [key, ''])
.reduce((acc, [key, val]) => ({ ...acc, [ key + 'OpacityLocal' ]: val }), {}),
fgColorLocal: '',
fgTextColorLocal: undefined,
fgLinkColorLocal: undefined,
btnColorLocal: undefined,
btnTextColorLocal: undefined,
btnOpacityLocal: undefined,
inputColorLocal: undefined,
inputTextColorLocal: undefined,
inputOpacityLocal: undefined,
panelColorLocal: undefined,
panelTextColorLocal: undefined,
panelLinkColorLocal: undefined,
panelFaintColorLocal: undefined,
panelOpacityLocal: undefined,
topBarColorLocal: undefined,
topBarTextColorLocal: undefined,
topBarLinkColorLocal: undefined,
alertErrorColorLocal: undefined,
alertWarningColorLocal: undefined,
badgeOpacityLocal: undefined,
badgeNotificationColorLocal: undefined,
borderColorLocal: undefined,
borderOpacityLocal: undefined,
faintColorLocal: undefined,
faintOpacityLocal: undefined,
faintLinkColorLocal: undefined,
cRedColorLocal: '',
cBlueColorLocal: '',
cGreenColorLocal: '',
cOrangeColorLocal: '',
shadowSelected: undefined, shadowSelected: undefined,
shadowsLocal: {}, shadowsLocal: {},
@ -108,69 +104,105 @@ export default {
created () { created () {
const self = this const self = this
getThemes().then((themesComplete) => { getThemes()
.then((promises) => {
return Promise.all(
Object.entries(promises)
.map(([k, v]) => v.then(res => [k, res]))
)
})
.then(themes => themes.reduce((acc, [k, v]) => {
if (v) {
return {
...acc,
[k]: v
}
} else {
return acc
}
}, {}))
.then((themesComplete) => {
self.availableStyles = themesComplete self.availableStyles = themesComplete
}) })
}, },
mounted () { mounted () {
this.normalizeLocalState(this.$store.getters.mergedConfig.customTheme) this.loadThemeFromLocalStorage()
if (typeof this.shadowSelected === 'undefined') { if (typeof this.shadowSelected === 'undefined') {
this.shadowSelected = this.shadowsAvailable[0] this.shadowSelected = this.shadowsAvailable[0]
} }
}, },
computed: { 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 () { selectedVersion () {
return Array.isArray(this.selected) ? 1 : 2 return Array.isArray(this.selected) ? 1 : 2
}, },
currentColors () { currentColors () {
return { return Object.keys(SLOT_INHERITANCE)
bg: this.bgColorLocal, .map(key => [key, this[key + 'ColorLocal']])
text: this.textColorLocal, .reduce((acc, [key, val]) => ({ ...acc, [ key ]: val }), {})
link: this.linkColorLocal,
fg: this.fgColorLocal,
fgText: this.fgTextColorLocal,
fgLink: this.fgLinkColorLocal,
panel: this.panelColorLocal,
panelText: this.panelTextColorLocal,
panelLink: this.panelLinkColorLocal,
panelFaint: this.panelFaintColorLocal,
input: this.inputColorLocal,
inputText: this.inputTextColorLocal,
topBar: this.topBarColorLocal,
topBarText: this.topBarTextColorLocal,
topBarLink: this.topBarLinkColorLocal,
btn: this.btnColorLocal,
btnText: this.btnTextColorLocal,
alertError: this.alertErrorColorLocal,
alertWarning: this.alertWarningColorLocal,
badgeNotification: this.badgeNotificationColorLocal,
faint: this.faintColorLocal,
faintLink: this.faintLinkColorLocal,
border: this.borderColorLocal,
cRed: this.cRedColorLocal,
cBlue: this.cBlueColorLocal,
cGreen: this.cGreenColorLocal,
cOrange: this.cOrangeColorLocal
}
}, },
currentOpacity () { currentOpacity () {
return { return Object.keys(OPACITIES)
bg: this.bgOpacityLocal, .map(key => [key, this[key + 'OpacityLocal']])
btn: this.btnOpacityLocal, .reduce((acc, [key, val]) => ({ ...acc, [ key ]: val }), {})
input: this.inputOpacityLocal,
panel: this.panelOpacityLocal,
topBar: this.topBarOpacityLocal,
border: this.borderOpacityLocal,
faint: this.faintOpacityLocal
}
}, },
currentRadii () { currentRadii () {
return { return {
@ -193,6 +225,7 @@ export default {
}, },
// This needs optimization maybe // This needs optimization maybe
previewContrast () { previewContrast () {
try {
if (!this.previewTheme.colors.bg) return {} if (!this.previewTheme.colors.bg) return {}
const colors = this.previewTheme.colors const colors = this.previewTheme.colors
const opacity = this.previewTheme.opacity const opacity = this.previewTheme.opacity
@ -206,62 +239,52 @@ export default {
laa: ratio >= 3, laa: ratio >= 3,
laaa: ratio >= 4.5 laaa: ratio >= 4.5
}) })
const colorsConverted = Object.entries(colors).reduce((acc, [key, value]) => ({ ...acc, [key]: colorConvert(value) }), {})
// fgsfds :DDDD const ratios = Object.entries(SLOT_INHERITANCE).reduce((acc, [key, value]) => {
const fgs = { const slotIsBaseText = key === 'text' || key === 'link'
text: hex2rgb(colors.text), const slotIsText = slotIsBaseText || (
panelText: hex2rgb(colors.panelText), typeof value === 'object' && value !== null && value.textColor
panelLink: hex2rgb(colors.panelLink), )
btnText: hex2rgb(colors.btnText), if (!slotIsText) return acc
topBarText: hex2rgb(colors.topBarText), const { layer, variant } = slotIsBaseText ? { layer: 'bg' } : value
inputText: hex2rgb(colors.inputText), const background = variant || layer
const opacitySlot = getOpacitySlot(background)
const textColors = [
key,
...(background === 'bg' ? ['cRed', 'cGreen', 'cBlue', 'cOrange'] : [])
]
link: hex2rgb(colors.link), const layers = getLayers(
topBarLink: hex2rgb(colors.topBarLink), layer,
variant || layer,
opacitySlot,
colorsConverted,
opacity
)
red: hex2rgb(colors.cRed), return {
green: hex2rgb(colors.cGreen), ...acc,
blue: hex2rgb(colors.cBlue), ...textColors.reduce((acc, textColorKey) => {
orange: hex2rgb(colors.cOrange) const newKey = slotIsBaseText
? 'bg' + textColorKey[0].toUpperCase() + textColorKey.slice(1)
: textColorKey
return {
...acc,
[newKey]: getContrastRatioLayers(
colorsConverted[textColorKey],
layers,
colorsConverted[textColorKey]
)
} }
}, {})
const bgs = {
bg: hex2rgb(colors.bg),
btn: hex2rgb(colors.btn),
panel: hex2rgb(colors.panel),
topBar: hex2rgb(colors.topBar),
input: hex2rgb(colors.input),
alertError: hex2rgb(colors.alertError),
alertWarning: hex2rgb(colors.alertWarning),
badgeNotification: hex2rgb(colors.badgeNotification)
}
/* This is a bit confusing because "bottom layer" used is text color
* This is done to get worst case scenario when background below transparent
* layer matches text color, making it harder to read the lower alpha is.
*/
const ratios = {
bgText: getContrastRatio(alphaBlend(bgs.bg, opacity.bg, fgs.text), fgs.text),
bgLink: getContrastRatio(alphaBlend(bgs.bg, opacity.bg, fgs.link), fgs.link),
bgRed: getContrastRatio(alphaBlend(bgs.bg, opacity.bg, fgs.red), fgs.red),
bgGreen: getContrastRatio(alphaBlend(bgs.bg, opacity.bg, fgs.green), fgs.green),
bgBlue: getContrastRatio(alphaBlend(bgs.bg, opacity.bg, fgs.blue), fgs.blue),
bgOrange: getContrastRatio(alphaBlend(bgs.bg, opacity.bg, fgs.orange), fgs.orange),
tintText: getContrastRatio(alphaBlend(bgs.bg, 0.5, fgs.panelText), fgs.text),
panelText: getContrastRatio(alphaBlend(bgs.panel, opacity.panel, fgs.panelText), fgs.panelText),
panelLink: getContrastRatio(alphaBlend(bgs.panel, opacity.panel, fgs.panelLink), fgs.panelLink),
btnText: getContrastRatio(alphaBlend(bgs.btn, opacity.btn, fgs.btnText), fgs.btnText),
inputText: getContrastRatio(alphaBlend(bgs.input, opacity.input, fgs.inputText), fgs.inputText),
topBarText: getContrastRatio(alphaBlend(bgs.topBar, opacity.topBar, fgs.topBarText), fgs.topBarText),
topBarLink: getContrastRatio(alphaBlend(bgs.topBar, opacity.topBar, fgs.topBarLink), fgs.topBarLink)
} }
}, {})
return Object.entries(ratios).reduce((acc, [k, v]) => { acc[k] = hints(v); return acc }, {}) return Object.entries(ratios).reduce((acc, [k, v]) => { acc[k] = hints(v); return acc }, {})
} catch (e) {
console.warn('Failure computing contrasts', e)
}
}, },
previewRules () { previewRules () {
if (!this.preview.rules) return '' if (!this.preview.rules) return ''
@ -272,7 +295,7 @@ export default {
].join(';') ].join(';')
}, },
shadowsAvailable () { shadowsAvailable () {
return Object.keys(this.previewTheme.shadows).sort() return Object.keys(DEFAULT_SHADOWS).sort()
}, },
currentShadowOverriden: { currentShadowOverriden: {
get () { get () {
@ -287,7 +310,7 @@ export default {
} }
}, },
currentShadowFallback () { currentShadowFallback () {
return this.previewTheme.shadows[this.shadowSelected] return (this.previewTheme.shadows || {})[this.shadowSelected]
}, },
currentShadow: { currentShadow: {
get () { get () {
@ -309,27 +332,34 @@ export default {
!this.keepColor !this.keepColor
) )
const theme = {} const source = {
themeEngineVersion: CURRENT_VERSION
}
if (this.keepFonts || saveEverything) { if (this.keepFonts || saveEverything) {
theme.fonts = this.fontsLocal source.fonts = this.fontsLocal
} }
if (this.keepShadows || saveEverything) { if (this.keepShadows || saveEverything) {
theme.shadows = this.shadowsLocal source.shadows = this.shadowsLocal
} }
if (this.keepOpacity || saveEverything) { if (this.keepOpacity || saveEverything) {
theme.opacity = this.currentOpacity source.opacity = this.currentOpacity
} }
if (this.keepColor || saveEverything) { if (this.keepColor || saveEverything) {
theme.colors = this.currentColors source.colors = this.currentColors
} }
if (this.keepRoundness || saveEverything) { if (this.keepRoundness || saveEverything) {
theme.radii = this.currentRadii source.radii = this.currentRadii
}
const theme = {
themeEngineVersion: CURRENT_VERSION,
...this.previewTheme
} }
return { return {
// To separate from other random JSON files and possible future theme formats // To separate from other random JSON files and possible future source formats
_pleroma_theme_version: 2, theme _pleroma_theme_version: 2, theme, source
} }
} }
}, },
@ -346,10 +376,128 @@ export default {
Checkbox Checkbox
}, },
methods: { methods: {
loadTheme (
{
theme,
source,
_pleroma_theme_version: fileVersion
},
origin,
forceUseSource = false
) {
this.dismissWarning()
if (!source && !theme) {
throw new Error('Can\'t load theme: empty')
}
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.err('Forcing snapshout from file is not supported yet')
break
}
this.dismissWarning()
},
loadThemeFromLocalStorage (confirmLoadSource = false, forceSnapshot = false) {
const {
customTheme: theme,
customThemeSource: source
} = this.$store.getters.mergedConfig
if (!theme && !source) {
// Anon user or never touched themes
this.loadTheme(
this.$store.state.instance.themeData,
'defaults',
confirmLoadSource
)
} else {
this.loadTheme(
{
theme,
source: forceSnapshot ? theme : source
},
'localStorage',
confirmLoadSource
)
}
},
setCustomTheme () { setCustomTheme () {
this.$store.dispatch('setOption', { this.$store.dispatch('setOption', {
name: 'customTheme', name: 'customTheme',
value: { value: {
themeEngineVersion: CURRENT_VERSION,
...this.previewTheme
}
})
this.$store.dispatch('setOption', {
name: 'customThemeSource',
value: {
themeEngineVersion: CURRENT_VERSION,
shadows: this.shadowsLocal, shadows: this.shadowsLocal,
fonts: this.fontsLocal, fonts: this.fontsLocal,
opacity: this.currentOpacity, opacity: this.currentOpacity,
@ -358,21 +506,27 @@ export default {
} }
}) })
}, },
onImport (parsed) { updatePreviewColorsAndShadows () {
if (parsed._pleroma_theme_version === 1) { this.previewColors = generateColors({
this.normalizeLocalState(parsed, 1) opacity: this.currentOpacity,
} else if (parsed._pleroma_theme_version === 2) { colors: this.currentColors
this.normalizeLocalState(parsed.theme, 2) })
} this.previewShadows = generateShadows(
{ shadows: this.shadowsLocal, opacity: this.previewTheme.opacity, themeEngineVersion: this.engineVersion },
this.previewColors.theme.colors,
this.previewColors.mod
)
},
onImport (parsed, forceSource = false) {
this.tempImportFile = parsed
this.loadTheme(parsed, 'file', forceSource)
}, },
importValidator (parsed) { importValidator (parsed) {
const version = parsed._pleroma_theme_version const version = parsed._pleroma_theme_version
return version >= 1 || version <= 2 return version >= 1 || version <= 2
}, },
clearAll () { clearAll () {
const state = this.$store.getters.mergedConfig.customTheme this.loadThemeFromLocalStorage()
const version = state.colors ? 2 : 'l1'
this.normalizeLocalState(this.$store.getters.mergedConfig.customTheme, version)
}, },
// Clears all the extra stuff when loading V1 theme // Clears all the extra stuff when loading V1 theme
@ -411,19 +565,37 @@ export default {
/** /**
* This applies stored theme data onto form. Supports three versions of data: * 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. * v2 (version = 2) - newer version of themes.
* v1 (version = 1) - older version of themes (import from file) * v1 (version = 1) - older version of themes (import from file)
* v1l (version = l1) - older version of theme (load from local storage) * v1l (version = l1) - older version of theme (load from local storage)
* v1 and v1l differ because of way themes were stored/exported. * v1 and v1l differ because of way themes were stored/exported.
* @param {Object} input - input data * @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 {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 (input, version = 0) { normalizeLocalState (theme, version = 0, source, forceSource = false) {
const colors = input.colors || input 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 radii = input.radii || input
const opacity = input.opacity const opacity = input.opacity
const shadows = input.shadows || {} const shadows = input.shadows || {}
const fonts = input.fonts || {} const fonts = input.fonts || {}
const colors = !input.themeEngineVersion
? colors2to3(input.colors || input)
: input.colors || input
if (version === 0) { if (version === 0) {
if (input.version) version = input.version if (input.version) version = input.version
@ -437,6 +609,8 @@ export default {
} }
} }
this.engineVersion = version
// Stuff that differs between V1 and V2 // Stuff that differs between V1 and V2
if (version === 1) { if (version === 1) {
this.fgColorLocal = rgb2hex(colors.btn) this.fgColorLocal = rgb2hex(colors.btn)
@ -445,7 +619,7 @@ export default {
if (!this.keepColor) { if (!this.keepColor) {
this.clearV1() this.clearV1()
const keys = new Set(version !== 1 ? Object.keys(colors) : []) const keys = new Set(version !== 1 ? Object.keys(SLOT_INHERITANCE) : [])
if (version === 1 || version === 'l1') { if (version === 1 || version === 'l1') {
keys keys
.add('bg') .add('bg')
@ -457,7 +631,17 @@ export default {
} }
keys.forEach(key => { keys.forEach(key => {
this[key + 'ColorLocal'] = rgb2hex(colors[key]) const color = colors[key]
const hex = rgb2hex(colors[key])
this[key + 'ColorLocal'] = hex === '#aN' ? color : hex
})
}
if (opacity && !this.keepOpacity) {
this.clearOpacity()
Object.entries(opacity).forEach(([k, v]) => {
if (typeof v === 'undefined' || v === null || Number.isNaN(v)) return
this[k + 'OpacityLocal'] = v
}) })
} }
@ -472,7 +656,11 @@ export default {
if (!this.keepShadows) { if (!this.keepShadows) {
this.clearShadows() this.clearShadows()
if (version === 2) {
this.shadowsLocal = shadows2to3(shadows, this.previewTheme.opacity)
} else {
this.shadowsLocal = shadows this.shadowsLocal = shadows
}
this.shadowSelected = this.shadowsAvailable[0] this.shadowSelected = this.shadowsAvailable[0]
} }
@ -480,14 +668,6 @@ export default {
this.clearFonts() this.clearFonts()
this.fontsLocal = fonts this.fontsLocal = fonts
} }
if (opacity && !this.keepOpacity) {
this.clearOpacity()
Object.entries(opacity).forEach(([k, v]) => {
if (typeof v === 'undefined' || v === null || Number.isNaN(v)) return
this[k + 'OpacityLocal'] = v
})
}
} }
}, },
watch: { watch: {
@ -502,8 +682,9 @@ export default {
}, },
shadowsLocal: { shadowsLocal: {
handler () { handler () {
if (Object.getOwnPropertyNames(this.previewColors).length === 1) return
try { try {
this.previewShadows = generateShadows({ shadows: this.shadowsLocal }) this.updatePreviewColorsAndShadows()
this.shadowsInvalid = false this.shadowsInvalid = false
} catch (e) { } catch (e) {
this.shadowsInvalid = true this.shadowsInvalid = true
@ -526,27 +707,24 @@ export default {
}, },
currentColors () { currentColors () {
try { try {
this.previewColors = generateColors({ this.updatePreviewColorsAndShadows()
opacity: this.currentOpacity,
colors: this.currentColors
})
this.colorsInvalid = false this.colorsInvalid = false
this.shadowsInvalid = false
} catch (e) { } catch (e) {
this.colorsInvalid = true this.colorsInvalid = true
this.shadowsInvalid = true
console.warn(e) console.warn(e)
} }
}, },
currentOpacity () { currentOpacity () {
try { try {
this.previewColors = generateColors({ this.updatePreviewColorsAndShadows()
opacity: this.currentOpacity,
colors: this.currentColors
})
} catch (e) { } catch (e) {
console.warn(e) console.warn(e)
} }
}, },
selected () { selected () {
this.dismissWarning()
if (this.selectedVersion === 1) { if (this.selectedVersion === 1) {
if (!this.keepRoundness) { if (!this.keepRoundness) {
this.clearRoundness() this.clearRoundness()
@ -573,7 +751,7 @@ export default {
this.cOrangeColorLocal = this.selected[8] this.cOrangeColorLocal = this.selected[8]
} }
} else if (this.selectedVersion >= 2) { } else if (this.selectedVersion >= 2) {
this.normalizeLocalState(this.selected.theme, 2) this.normalizeLocalState(this.selected.theme, 2, this.selected.source)
} }
} }
} }

View file

@ -1,5 +1,15 @@
@import '../../_variables.scss'; @import '../../_variables.scss';
.style-switcher { .style-switcher {
.theme-warning {
display: flex;
align-items: baseline;
margin-bottom: .5em;
.buttons {
.btn {
margin-bottom: .5em;
}
}
}
.preset-switcher { .preset-switcher {
margin-right: 1em; margin-right: 1em;
} }
@ -15,10 +25,16 @@
&.disabled { &.disabled {
input, select { input, select {
&:not(.exclude-disabled) {
opacity: .5 opacity: .5
} }
} }
.opt {
margin: .5em;
}
.color-input {
flex: 0 0 0;
} }
input, select { input, select {
@ -26,15 +42,6 @@
margin: 0; margin: 0;
flex: 0; flex: 0;
&[type=color] {
padding: 1px;
cursor: pointer;
height: 29px;
min-width: 2em;
border: none;
align-self: stretch;
}
&[type=number] { &[type=number] {
min-width: 5em; min-width: 5em;
} }
@ -42,13 +49,6 @@
&[type=range] { &[type=range] {
flex: 1; flex: 1;
min-width: 3em; min-width: 3em;
}
&[type=checkbox] + label {
margin: 6px 0;
}
&:not([type=number]):not([type=text]) {
align-self: flex-start; align-self: flex-start;
} }
} }

View file

@ -2,7 +2,53 @@
<div class="style-switcher"> <div class="style-switcher">
<div class="presets-container"> <div class="presets-container">
<div class="save-load"> <div class="save-load">
<export-import <div
v-if="themeWarning"
class="theme-warning"
>
<div class="alert warning">
{{ themeWarningHelp }}
</div>
<div class="buttons">
<template v-if="themeWarning.type === 'snapshot_source_mismatch'">
<button
class="btn"
@click="forceLoad"
>
{{ $t('settings.style.switcher.use_source') }}
</button>
<button
class="btn"
@click="forceSnapshot"
>
{{ $t('settings.style.switcher.use_snapshot') }}
</button>
</template>
<template v-else-if="themeWarning.noActionsPossible">
<button
class="btn"
@click="dismissWarning"
>
{{ $t('general.dismiss') }}
</button>
</template>
<template v-else>
<button
class="btn"
@click="forceLoad"
>
{{ $t('settings.style.switcher.load_theme') }}
</button>
<button
class="btn"
@click="dismissWarning"
>
{{ $t('settings.style.switcher.keep_as_is') }}
</button>
</template>
</div>
</div>
<ExportImport
:export-object="exportedTheme" :export-object="exportedTheme"
:export-label="$t(&quot;settings.export_theme&quot;)" :export-label="$t(&quot;settings.export_theme&quot;)"
:import-label="$t(&quot;settings.import_theme&quot;)" :import-label="$t(&quot;settings.import_theme&quot;)"
@ -27,8 +73,8 @@
:key="style.name" :key="style.name"
:value="style" :value="style"
:style="{ :style="{
backgroundColor: style[1] || style.theme.colors.bg, backgroundColor: style[1] || (style.theme || style.source).colors.bg,
color: style[3] || style.theme.colors.text color: style[3] || (style.theme || style.source).colors.text
}" }"
> >
{{ style[0] || style.name }} {{ style[0] || style.name }}
@ -38,7 +84,7 @@
</label> </label>
</div> </div>
</template> </template>
</export-import> </ExportImport>
</div> </div>
<div class="save-load-options"> <div class="save-load-options">
<span class="keep-option"> <span class="keep-option">
@ -70,9 +116,7 @@
</div> </div>
</div> </div>
<div class="preview-container">
<preview :style="previewRules" /> <preview :style="previewRules" />
</div>
<keep-alive> <keep-alive>
<tab-switcher key="style-tweak"> <tab-switcher key="style-tweak">
@ -106,7 +150,7 @@
<OpacityInput <OpacityInput
v-model="bgOpacityLocal" v-model="bgOpacityLocal"
name="bgOpacity" name="bgOpacity"
:fallback="previewTheme.opacity.bg || 1" :fallback="previewTheme.opacity.bg"
/> />
<ColorInput <ColorInput
v-model="textColorLocal" v-model="textColorLocal"
@ -114,10 +158,19 @@
:label="$t('settings.text')" :label="$t('settings.text')"
/> />
<ContrastRatio :contrast="previewContrast.bgText" /> <ContrastRatio :contrast="previewContrast.bgText" />
<ColorInput
v-model="accentColorLocal"
name="accentColor"
:fallback="previewTheme.colors.link"
:label="$t('settings.accent')"
:show-optional-tickbox="typeof linkColorLocal !== 'undefined'"
/>
<ColorInput <ColorInput
v-model="linkColorLocal" v-model="linkColorLocal"
name="linkColor" name="linkColor"
:fallback="previewTheme.colors.accent"
:label="$t('settings.links')" :label="$t('settings.links')"
:show-optional-tickbox="typeof accentColorLocal !== 'undefined'"
/> />
<ContrastRatio :contrast="previewContrast.bgLink" /> <ContrastRatio :contrast="previewContrast.bgLink" />
</div> </div>
@ -148,13 +201,13 @@
name="cRedColor" name="cRedColor"
:label="$t('settings.cRed')" :label="$t('settings.cRed')"
/> />
<ContrastRatio :contrast="previewContrast.bgRed" /> <ContrastRatio :contrast="previewContrast.bgCRed" />
<ColorInput <ColorInput
v-model="cBlueColorLocal" v-model="cBlueColorLocal"
name="cBlueColor" name="cBlueColor"
:label="$t('settings.cBlue')" :label="$t('settings.cBlue')"
/> />
<ContrastRatio :contrast="previewContrast.bgBlue" /> <ContrastRatio :contrast="previewContrast.bgCBlue" />
</div> </div>
<div class="color-item"> <div class="color-item">
<ColorInput <ColorInput
@ -162,13 +215,13 @@
name="cGreenColor" name="cGreenColor"
:label="$t('settings.cGreen')" :label="$t('settings.cGreen')"
/> />
<ContrastRatio :contrast="previewContrast.bgGreen" /> <ContrastRatio :contrast="previewContrast.bgCGreen" />
<ColorInput <ColorInput
v-model="cOrangeColorLocal" v-model="cOrangeColorLocal"
name="cOrangeColor" name="cOrangeColor"
:label="$t('settings.cOrange')" :label="$t('settings.cOrange')"
/> />
<ContrastRatio :contrast="previewContrast.bgOrange" /> <ContrastRatio :contrast="previewContrast.bgCOrange" />
</div> </div>
<p>{{ $t('settings.theme_help_v2_2') }}</p> <p>{{ $t('settings.theme_help_v2_2') }}</p>
</div> </div>
@ -193,6 +246,14 @@
</button> </button>
</div> </div>
<div class="color-item"> <div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.post') }}</h4>
<ColorInput
v-model="postLinkColorLocal"
name="postLinkColor"
:fallback="previewTheme.colors.accent"
:label="$t('settings.links')"
/>
<ContrastRatio :contrast="previewContrast.postLink" />
<h4>{{ $t('settings.style.advanced_colors.alert') }}</h4> <h4>{{ $t('settings.style.advanced_colors.alert') }}</h4>
<ColorInput <ColorInput
v-model="alertErrorColorLocal" v-model="alertErrorColorLocal"
@ -200,14 +261,53 @@
:label="$t('settings.style.advanced_colors.alert_error')" :label="$t('settings.style.advanced_colors.alert_error')"
:fallback="previewTheme.colors.alertError" :fallback="previewTheme.colors.alertError"
/> />
<ContrastRatio :contrast="previewContrast.alertError" /> <ColorInput
v-model="alertErrorTextColorLocal"
name="alertErrorText"
:label="$t('settings.text')"
:fallback="previewTheme.colors.alertErrorText"
/>
<ContrastRatio
:contrast="previewContrast.alertErrorText"
large="true"
/>
<ColorInput <ColorInput
v-model="alertWarningColorLocal" v-model="alertWarningColorLocal"
name="alertWarning" name="alertWarning"
:label="$t('settings.style.advanced_colors.alert_warning')" :label="$t('settings.style.advanced_colors.alert_warning')"
:fallback="previewTheme.colors.alertWarning" :fallback="previewTheme.colors.alertWarning"
/> />
<ContrastRatio :contrast="previewContrast.alertWarning" /> <ColorInput
v-model="alertWarningTextColorLocal"
name="alertWarningText"
:label="$t('settings.text')"
:fallback="previewTheme.colors.alertWarningText"
/>
<ContrastRatio
:contrast="previewContrast.alertWarningText"
large="true"
/>
<ColorInput
v-model="alertNeutralColorLocal"
name="alertNeutral"
:label="$t('settings.style.advanced_colors.alert_neutral')"
:fallback="previewTheme.colors.alertNeutral"
/>
<ColorInput
v-model="alertNeutralTextColorLocal"
name="alertNeutralText"
:label="$t('settings.text')"
:fallback="previewTheme.colors.alertNeutralText"
/>
<ContrastRatio
:contrast="previewContrast.alertNeutralText"
large="true"
/>
<OpacityInput
v-model="alertOpacityLocal"
name="alertOpacity"
:fallback="previewTheme.opacity.alert"
/>
</div> </div>
<div class="color-item"> <div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.badge') }}</h4> <h4>{{ $t('settings.style.advanced_colors.badge') }}</h4>
@ -217,19 +317,30 @@
:label="$t('settings.style.advanced_colors.badge_notification')" :label="$t('settings.style.advanced_colors.badge_notification')"
:fallback="previewTheme.colors.badgeNotification" :fallback="previewTheme.colors.badgeNotification"
/> />
<ColorInput
v-model="badgeNotificationTextColorLocal"
name="badgeNotificationText"
:label="$t('settings.text')"
:fallback="previewTheme.colors.badgeNotificationText"
/>
<ContrastRatio
:contrast="previewContrast.badgeNotificationText"
large="true"
/>
</div> </div>
<div class="color-item"> <div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.panel_header') }}</h4> <h4>{{ $t('settings.style.advanced_colors.panel_header') }}</h4>
<ColorInput <ColorInput
v-model="panelColorLocal" v-model="panelColorLocal"
name="panelColor" name="panelColor"
:fallback="fgColorLocal" :fallback="previewTheme.colors.panel"
:label="$t('settings.background')" :label="$t('settings.background')"
/> />
<OpacityInput <OpacityInput
v-model="panelOpacityLocal" v-model="panelOpacityLocal"
name="panelOpacity" name="panelOpacity"
:fallback="previewTheme.opacity.panel || 1" :fallback="previewTheme.opacity.panel"
:disabled="panelColorLocal === 'transparent'"
/> />
<ColorInput <ColorInput
v-model="panelTextColorLocal" v-model="panelTextColorLocal"
@ -239,7 +350,7 @@
/> />
<ContrastRatio <ContrastRatio
:contrast="previewContrast.panelText" :contrast="previewContrast.panelText"
large="1" large="true"
/> />
<ColorInput <ColorInput
v-model="panelLinkColorLocal" v-model="panelLinkColorLocal"
@ -249,7 +360,7 @@
/> />
<ContrastRatio <ContrastRatio
:contrast="previewContrast.panelLink" :contrast="previewContrast.panelLink"
large="1" large="true"
/> />
</div> </div>
<div class="color-item"> <div class="color-item">
@ -257,7 +368,7 @@
<ColorInput <ColorInput
v-model="topBarColorLocal" v-model="topBarColorLocal"
name="topBarColor" name="topBarColor"
:fallback="fgColorLocal" :fallback="previewTheme.colors.topBar"
:label="$t('settings.background')" :label="$t('settings.background')"
/> />
<ColorInput <ColorInput
@ -280,13 +391,14 @@
<ColorInput <ColorInput
v-model="inputColorLocal" v-model="inputColorLocal"
name="inputColor" name="inputColor"
:fallback="fgColorLocal" :fallback="previewTheme.colors.input"
:label="$t('settings.background')" :label="$t('settings.background')"
/> />
<OpacityInput <OpacityInput
v-model="inputOpacityLocal" v-model="inputOpacityLocal"
name="inputOpacity" name="inputOpacity"
:fallback="previewTheme.opacity.input || 1" :fallback="previewTheme.opacity.input"
:disabled="inputColorLocal === 'transparent'"
/> />
<ColorInput <ColorInput
v-model="inputTextColorLocal" v-model="inputTextColorLocal"
@ -301,13 +413,14 @@
<ColorInput <ColorInput
v-model="btnColorLocal" v-model="btnColorLocal"
name="btnColor" name="btnColor"
:fallback="fgColorLocal" :fallback="previewTheme.colors.btn"
:label="$t('settings.background')" :label="$t('settings.background')"
/> />
<OpacityInput <OpacityInput
v-model="btnOpacityLocal" v-model="btnOpacityLocal"
name="btnOpacity" name="btnOpacity"
:fallback="previewTheme.opacity.btn || 1" :fallback="previewTheme.opacity.btn"
:disabled="btnColorLocal === 'transparent'"
/> />
<ColorInput <ColorInput
v-model="btnTextColorLocal" v-model="btnTextColorLocal"
@ -316,6 +429,124 @@
:label="$t('settings.text')" :label="$t('settings.text')"
/> />
<ContrastRatio :contrast="previewContrast.btnText" /> <ContrastRatio :contrast="previewContrast.btnText" />
<ColorInput
v-model="btnPanelTextColorLocal"
name="btnPanelTextColor"
:fallback="previewTheme.colors.btnPanelText"
:label="$t('settings.style.advanced_colors.panel_header')"
/>
<ContrastRatio :contrast="previewContrast.btnPanelText" />
<ColorInput
v-model="btnTopBarTextColorLocal"
name="btnTopBarTextColor"
:fallback="previewTheme.colors.btnTopBarText"
:label="$t('settings.style.advanced_colors.top_bar')"
/>
<ContrastRatio :contrast="previewContrast.btnTopBarText" />
<h5>{{ $t('settings.style.advanced_colors.pressed') }}</h5>
<ColorInput
v-model="btnPressedColorLocal"
name="btnPressedColor"
:fallback="previewTheme.colors.btnPressed"
:label="$t('settings.background')"
/>
<ColorInput
v-model="btnPressedTextColorLocal"
name="btnPressedTextColor"
:fallback="previewTheme.colors.btnPressedText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.btnPressedText" />
<ColorInput
v-model="btnPressedPanelTextColorLocal"
name="btnPressedPanelTextColor"
:fallback="previewTheme.colors.btnPressedPanelText"
:label="$t('settings.style.advanced_colors.panel_header')"
/>
<ContrastRatio :contrast="previewContrast.btnPressedPanelText" />
<ColorInput
v-model="btnPressedTopBarTextColorLocal"
name="btnPressedTopBarTextColor"
:fallback="previewTheme.colors.btnPressedTopBarText"
:label="$t('settings.style.advanced_colors.top_bar')"
/>
<ContrastRatio :contrast="previewContrast.btnPressedTopBarText" />
<h5>{{ $t('settings.style.advanced_colors.disabled') }}</h5>
<ColorInput
v-model="btnDisabledColorLocal"
name="btnDisabledColor"
:fallback="previewTheme.colors.btnDisabled"
:label="$t('settings.background')"
/>
<ColorInput
v-model="btnDisabledTextColorLocal"
name="btnDisabledTextColor"
:fallback="previewTheme.colors.btnDisabledText"
:label="$t('settings.text')"
/>
<ColorInput
v-model="btnDisabledPanelTextColorLocal"
name="btnDisabledPanelTextColor"
:fallback="previewTheme.colors.btnDisabledPanelText"
:label="$t('settings.style.advanced_colors.panel_header')"
/>
<ColorInput
v-model="btnDisabledTopBarTextColorLocal"
name="btnDisabledTopBarTextColor"
:fallback="previewTheme.colors.btnDisabledTopBarText"
:label="$t('settings.style.advanced_colors.top_bar')"
/>
<h5>{{ $t('settings.style.advanced_colors.toggled') }}</h5>
<ColorInput
v-model="btnToggledColorLocal"
name="btnToggledColor"
:fallback="previewTheme.colors.btnToggled"
:label="$t('settings.background')"
/>
<ColorInput
v-model="btnToggledTextColorLocal"
name="btnToggledTextColor"
:fallback="previewTheme.colors.btnToggledText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.btnToggledText" />
<ColorInput
v-model="btnToggledPanelTextColorLocal"
name="btnToggledPanelTextColor"
:fallback="previewTheme.colors.btnToggledPanelText"
:label="$t('settings.style.advanced_colors.panel_header')"
/>
<ContrastRatio :contrast="previewContrast.btnToggledPanelText" />
<ColorInput
v-model="btnToggledTopBarTextColorLocal"
name="btnToggledTopBarTextColor"
:fallback="previewTheme.colors.btnToggledTopBarText"
:label="$t('settings.style.advanced_colors.top_bar')"
/>
<ContrastRatio :contrast="previewContrast.btnToggledTopBarText" />
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.tabs') }}</h4>
<ColorInput
v-model="tabColorLocal"
name="tabColor"
:fallback="previewTheme.colors.tab"
:label="$t('settings.background')"
/>
<ColorInput
v-model="tabTextColorLocal"
name="tabTextColor"
:fallback="previewTheme.colors.tabText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.tabText" />
<ColorInput
v-model="tabActiveTextColorLocal"
name="tabActiveTextColor"
:fallback="previewTheme.colors.tabActiveText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.tabActiveText" />
</div> </div>
<div class="color-item"> <div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.borders') }}</h4> <h4>{{ $t('settings.style.advanced_colors.borders') }}</h4>
@ -328,7 +559,8 @@
<OpacityInput <OpacityInput
v-model="borderOpacityLocal" v-model="borderOpacityLocal"
name="borderOpacity" name="borderOpacity"
:fallback="previewTheme.opacity.border || 1" :fallback="previewTheme.opacity.border"
:disabled="borderColorLocal === 'transparent'"
/> />
</div> </div>
<div class="color-item"> <div class="color-item">
@ -336,7 +568,7 @@
<ColorInput <ColorInput
v-model="faintColorLocal" v-model="faintColorLocal"
name="faintColor" name="faintColor"
:fallback="previewTheme.colors.faint || 1" :fallback="previewTheme.colors.faint"
:label="$t('settings.text')" :label="$t('settings.text')"
/> />
<ColorInput <ColorInput
@ -354,9 +586,146 @@
<OpacityInput <OpacityInput
v-model="faintOpacityLocal" v-model="faintOpacityLocal"
name="faintOpacity" name="faintOpacity"
:fallback="previewTheme.opacity.faint || 0.5" :fallback="previewTheme.opacity.faint"
/> />
</div> </div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.underlay') }}</h4>
<ColorInput
v-model="underlayColorLocal"
name="underlay"
:label="$t('settings.style.advanced_colors.underlay')"
:fallback="previewTheme.colors.underlay"
/>
<OpacityInput
v-model="underlayOpacityLocal"
name="underlayOpacity"
:fallback="previewTheme.opacity.underlay"
:disabled="underlayOpacityLocal === 'transparent'"
/>
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.poll') }}</h4>
<ColorInput
v-model="pollColorLocal"
name="poll"
:label="$t('settings.background')"
:fallback="previewTheme.colors.poll"
/>
<ColorInput
v-model="pollTextColorLocal"
name="pollText"
:label="$t('settings.text')"
:fallback="previewTheme.colors.pollText"
/>
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.icons') }}</h4>
<ColorInput
v-model="iconColorLocal"
name="icon"
:label="$t('settings.style.advanced_colors.icons')"
:fallback="previewTheme.colors.icon"
/>
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.highlight') }}</h4>
<ColorInput
v-model="highlightColorLocal"
name="highlight"
:label="$t('settings.background')"
:fallback="previewTheme.colors.highlight"
/>
<ColorInput
v-model="highlightTextColorLocal"
name="highlightText"
:label="$t('settings.text')"
:fallback="previewTheme.colors.highlightText"
/>
<ContrastRatio :contrast="previewContrast.highlightText" />
<ColorInput
v-model="highlightLinkColorLocal"
name="highlightLink"
:label="$t('settings.links')"
:fallback="previewTheme.colors.highlightLink"
/>
<ContrastRatio :contrast="previewContrast.highlightLink" />
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.popover') }}</h4>
<ColorInput
v-model="popoverColorLocal"
name="popover"
:label="$t('settings.background')"
:fallback="previewTheme.colors.popover"
/>
<OpacityInput
v-model="popoverOpacityLocal"
name="popoverOpacity"
:fallback="previewTheme.opacity.popover"
:disabled="popoverOpacityLocal === 'transparent'"
/>
<ColorInput
v-model="popoverTextColorLocal"
name="popoverText"
:label="$t('settings.text')"
:fallback="previewTheme.colors.popoverText"
/>
<ContrastRatio :contrast="previewContrast.popoverText" />
<ColorInput
v-model="popoverLinkColorLocal"
name="popoverLink"
:label="$t('settings.links')"
:fallback="previewTheme.colors.popoverLink"
/>
<ContrastRatio :contrast="previewContrast.popoverLink" />
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.selectedPost') }}</h4>
<ColorInput
v-model="selectedPostColorLocal"
name="selectedPost"
:label="$t('settings.background')"
:fallback="previewTheme.colors.selectedPost"
/>
<ColorInput
v-model="selectedPostTextColorLocal"
name="selectedPostText"
:label="$t('settings.text')"
:fallback="previewTheme.colors.selectedPostText"
/>
<ContrastRatio :contrast="previewContrast.selectedPostText" />
<ColorInput
v-model="selectedPostLinkColorLocal"
name="selectedPostLink"
:label="$t('settings.links')"
:fallback="previewTheme.colors.selectedPostLink"
/>
<ContrastRatio :contrast="previewContrast.selectedPostLink" />
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.selectedMenu') }}</h4>
<ColorInput
v-model="selectedMenuColorLocal"
name="selectedMenu"
:label="$t('settings.background')"
:fallback="previewTheme.colors.selectedMenu"
/>
<ColorInput
v-model="selectedMenuTextColorLocal"
name="selectedMenuText"
:label="$t('settings.text')"
:fallback="previewTheme.colors.selectedMenuText"
/>
<ContrastRatio :contrast="previewContrast.selectedMenuText" />
<ColorInput
v-model="selectedMenuLinkColorLocal"
name="selectedMenuLink"
:label="$t('settings.links')"
:fallback="previewTheme.colors.selectedMenuLink"
/>
<ContrastRatio :contrast="previewContrast.selectedMenuLink" />
</div>
</div> </div>
<div <div
@ -491,7 +860,7 @@
{{ $t('settings.style.switcher.clear_all') }} {{ $t('settings.style.switcher.clear_all') }}
</button> </button>
</div> </div>
<shadow-control <ShadowControl
v-model="currentShadow" v-model="currentShadow"
:ready="!!currentShadowFallback" :ready="!!currentShadowFallback"
:fallback="currentShadowFallback" :fallback="currentShadowFallback"

View file

@ -52,6 +52,11 @@
margin-bottom: 6px - 99px; margin-bottom: 6px - 99px;
white-space: nowrap; white-space: nowrap;
color: $fallback--text;
color: var(--tabText, $fallback--text);
background-color: $fallback--fg;
background-color: var(--tab, $fallback--fg);
&:not(.active) { &:not(.active) {
z-index: 4; z-index: 4;
@ -63,6 +68,8 @@
&.active { &.active {
background: transparent; background: transparent;
z-index: 5; z-index: 5;
color: $fallback--text;
color: var(--tabActiveText, $fallback--text);
} }
img { img {

View file

@ -4,7 +4,6 @@ import ProgressButton from '../progress_button/progress_button.vue'
import FollowButton from '../follow_button/follow_button.vue' import FollowButton from '../follow_button/follow_button.vue'
import ModerationTools from '../moderation_tools/moderation_tools.vue' import ModerationTools from '../moderation_tools/moderation_tools.vue'
import AccountActions from '../account_actions/account_actions.vue' import AccountActions from '../account_actions/account_actions.vue'
import { hex2rgb } from '../../services/color_convert/color_convert.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
@ -30,22 +29,12 @@ export default {
}] }]
}, },
style () { style () {
const color = this.$store.getters.mergedConfig.customTheme.colors
? this.$store.getters.mergedConfig.customTheme.colors.bg // v2
: this.$store.getters.mergedConfig.colors.bg // v1
if (color) {
const rgb = (typeof color === 'string') ? hex2rgb(color) : color
const tintColor = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .5)`
return { return {
backgroundColor: `rgb(${Math.floor(rgb.r * 0.53)}, ${Math.floor(rgb.g * 0.56)}, ${Math.floor(rgb.b * 0.59)})`,
backgroundImage: [ backgroundImage: [
`linear-gradient(to bottom, ${tintColor}, ${tintColor})`, `linear-gradient(to bottom, var(--profileTint), var(--profileTint))`,
`url(${this.user.cover_photo})` `url(${this.user.cover_photo})`
].join(', ') ].join(', ')
} }
}
}, },
isOtherUser () { isOtherUser () {
return this.user.id !== this.$store.state.users.currentUser.id return this.user.id !== this.$store.state.users.currentUser.id

View file

@ -151,7 +151,7 @@
</ProgressButton> </ProgressButton>
<ProgressButton <ProgressButton
v-else v-else
class="btn btn-default pressed" class="btn btn-default toggled"
:click="unsubscribeUser" :click="unsubscribeUser"
:title="$t('user_card.unsubscribe')" :title="$t('user_card.unsubscribe')"
> >
@ -162,7 +162,7 @@
<div> <div>
<button <button
v-if="user.muted" v-if="user.muted"
class="btn btn-default btn-block pressed" class="btn btn-default btn-block toggled"
@click="unmuteUser" @click="unmuteUser"
> >
{{ $t('user_card.muted') }} {{ $t('user_card.muted') }}
@ -286,6 +286,7 @@
mask-size: 100% 60%; mask-size: 100% 60%;
border-top-left-radius: calc(var(--panelRadius) - 1px); border-top-left-radius: calc(var(--panelRadius) - 1px);
border-top-right-radius: calc(var(--panelRadius) - 1px); border-top-right-radius: calc(var(--panelRadius) - 1px);
background-color: var(--profileBg);
&.hide-bio { &.hide-bio {
mask-size: 100% 40px; mask-size: 100% 40px;
@ -299,6 +300,11 @@
&-bio { &-bio {
text-align: center; text-align: center;
a {
color: $fallback--link;
color: var(--postLink, $fallback--link);
}
img { img {
object-fit: contain; object-fit: contain;
vertical-align: middle; vertical-align: middle;
@ -460,14 +466,13 @@
color: var(--text, $fallback--text); color: var(--text, $fallback--text);
} }
// TODO use proper colors
.staff { .staff {
flex: none; flex: none;
text-transform: capitalize; text-transform: capitalize;
color: $fallback--text; color: $fallback--text;
color: var(--btnText, $fallback--text); color: var(--alertNeutralText, $fallback--text);
background-color: $fallback--fg; background-color: $fallback--fg;
background-color: var(--btn, $fallback--fg); background-color: var(--alertNeutral, $fallback--fg);
} }
} }
@ -538,12 +543,6 @@
button { button {
margin: 0; margin: 0;
&.pressed {
// TODO: This should be themed.
border-bottom-color: rgba(255, 255, 255, 0.2);
border-top-color: rgba(0, 0, 0, 0.2);
}
} }
} }
} }

View file

@ -9,6 +9,7 @@ import ScopeSelector from '../scope_selector/scope_selector.vue'
import fileSizeFormatService from '../../services/file_size_format/file_size_format.js' import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
import BlockCard from '../block_card/block_card.vue' import BlockCard from '../block_card/block_card.vue'
import MuteCard from '../mute_card/mute_card.vue' import MuteCard from '../mute_card/mute_card.vue'
import DomainMuteCard from '../domain_mute_card/domain_mute_card.vue'
import SelectableList from '../selectable_list/selectable_list.vue' import SelectableList from '../selectable_list/selectable_list.vue'
import ProgressButton from '../progress_button/progress_button.vue' import ProgressButton from '../progress_button/progress_button.vue'
import EmojiInput from '../emoji_input/emoji_input.vue' import EmojiInput from '../emoji_input/emoji_input.vue'
@ -32,6 +33,12 @@ const MuteList = withSubscription({
childPropName: 'items' childPropName: 'items'
})(SelectableList) })(SelectableList)
const DomainMuteList = withSubscription({
fetch: (props, $store) => $store.dispatch('fetchDomainMutes'),
select: (props, $store) => get($store.state.users.currentUser, 'domainMutes', []),
childPropName: 'items'
})(SelectableList)
const UserSettings = { const UserSettings = {
data () { data () {
return { return {
@ -48,6 +55,7 @@ const UserSettings = {
showRole: this.$store.state.users.currentUser.show_role, showRole: this.$store.state.users.currentUser.show_role,
role: this.$store.state.users.currentUser.role, role: this.$store.state.users.currentUser.role,
discoverable: this.$store.state.users.currentUser.discoverable, discoverable: this.$store.state.users.currentUser.discoverable,
allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
pickAvatarBtnVisible: true, pickAvatarBtnVisible: true,
bannerUploading: false, bannerUploading: false,
backgroundUploading: false, backgroundUploading: false,
@ -67,7 +75,8 @@ const UserSettings = {
changedPassword: false, changedPassword: false,
changePasswordError: false, changePasswordError: false,
activeTab: 'profile', activeTab: 'profile',
notificationSettings: this.$store.state.users.currentUser.notification_settings notificationSettings: this.$store.state.users.currentUser.notification_settings,
newDomainToMute: ''
} }
}, },
created () { created () {
@ -80,10 +89,12 @@ const UserSettings = {
ImageCropper, ImageCropper,
BlockList, BlockList,
MuteList, MuteList,
DomainMuteList,
EmojiInput, EmojiInput,
Autosuggest, Autosuggest,
BlockCard, BlockCard,
MuteCard, MuteCard,
DomainMuteCard,
ProgressButton, ProgressButton,
Importer, Importer,
Exporter, Exporter,
@ -152,6 +163,7 @@ const UserSettings = {
hide_follows: this.hideFollows, hide_follows: this.hideFollows,
hide_followers: this.hideFollowers, hide_followers: this.hideFollowers,
discoverable: this.discoverable, discoverable: this.discoverable,
allow_following_move: this.allowFollowingMove,
hide_follows_count: this.hideFollowsCount, hide_follows_count: this.hideFollowsCount,
hide_followers_count: this.hideFollowersCount, hide_followers_count: this.hideFollowersCount,
show_role: this.showRole show_role: this.showRole
@ -297,7 +309,7 @@ const UserSettings = {
newPassword: this.changePasswordInputs[1], newPassword: this.changePasswordInputs[1],
newPasswordConfirmation: this.changePasswordInputs[2] newPasswordConfirmation: this.changePasswordInputs[2]
} }
this.$store.state.api.backendInteractor.changePassword({ params }) this.$store.state.api.backendInteractor.changePassword(params)
.then((res) => { .then((res) => {
if (res.status === 'success') { if (res.status === 'success') {
this.changedPassword = true this.changedPassword = true
@ -314,7 +326,7 @@ const UserSettings = {
email: this.newEmail, email: this.newEmail,
password: this.changeEmailPassword password: this.changeEmailPassword
} }
this.$store.state.api.backendInteractor.changeEmail({ params }) this.$store.state.api.backendInteractor.changeEmail(params)
.then((res) => { .then((res) => {
if (res.status === 'success') { if (res.status === 'success') {
this.changedEmail = true this.changedEmail = true
@ -365,6 +377,13 @@ const UserSettings = {
unmuteUsers (ids) { unmuteUsers (ids) {
return this.$store.dispatch('unmuteUsers', ids) return this.$store.dispatch('unmuteUsers', ids)
}, },
unmuteDomains (domains) {
return this.$store.dispatch('unmuteDomains', domains)
},
muteDomain () {
return this.$store.dispatch('muteDomain', this.newDomainToMute)
.then(() => { this.newDomainToMute = '' })
},
identity (value) { identity (value) {
return value return value
} }

View file

@ -90,9 +90,7 @@
</Checkbox> </Checkbox>
</p> </p>
<p> <p>
<Checkbox <Checkbox v-model="hideFollowers">
v-model="hideFollowers"
>
{{ $t('settings.hide_followers_description') }} {{ $t('settings.hide_followers_description') }}
</Checkbox> </Checkbox>
</p> </p>
@ -104,6 +102,11 @@
{{ $t('settings.hide_followers_count_description') }} {{ $t('settings.hide_followers_count_description') }}
</Checkbox> </Checkbox>
</p> </p>
<p>
<Checkbox v-model="allowFollowingMove">
{{ $t('settings.allow_following_move') }}
</Checkbox>
</p>
<p v-if="role === 'admin' || role === 'moderator'"> <p v-if="role === 'admin' || role === 'moderator'">
<Checkbox v-model="showRole"> <Checkbox v-model="showRole">
<template v-if="role === 'admin'"> <template v-if="role === 'admin'">
@ -509,6 +512,8 @@
</div> </div>
<div :label="$t('settings.mutes_tab')"> <div :label="$t('settings.mutes_tab')">
<tab-switcher>
<div label="Users">
<div class="profile-edit-usersearch-wrapper"> <div class="profile-edit-usersearch-wrapper">
<Autosuggest <Autosuggest
:filter="filterUnMutedUsers" :filter="filterUnMutedUsers"
@ -563,6 +568,59 @@
</template> </template>
</MuteList> </MuteList>
</div> </div>
<div :label="$t('settings.domain_mutes')">
<div class="profile-edit-domain-mute-form">
<input
v-model="newDomainToMute"
:placeholder="$t('settings.type_domains_to_mute')"
type="text"
@keyup.enter="muteDomain"
>
<ProgressButton
class="btn btn-default"
:click="muteDomain"
>
{{ $t('domain_mute_card.mute') }}
<template slot="progress">
{{ $t('domain_mute_card.mute_progress') }}
</template>
</ProgressButton>
</div>
<DomainMuteList
:refresh="true"
:get-key="identity"
>
<template
slot="header"
slot-scope="{selected}"
>
<div class="profile-edit-bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn btn-default"
:click="() => unmuteDomains(selected)"
>
{{ $t('domain_mute_card.unmute') }}
<template slot="progress">
{{ $t('domain_mute_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
</template>
<template
slot="item"
slot-scope="{item}"
>
<DomainMuteCard :domain="item" />
</template>
<template slot="empty">
{{ $t('settings.no_mutes') }}
</template>
</DomainMuteList>
</div>
</tab-switcher>
</div>
</tab-switcher> </tab-switcher>
</div> </div>
</div> </div>
@ -639,6 +697,18 @@
} }
} }
&-domain-mute-form {
padding: 1em;
display: flex;
flex-direction: column;
button {
align-self: flex-end;
margin-top: 1em;
width: 10em;
}
}
.setting-subitem { .setting-subitem {
margin-left: 1.75em; margin-left: 1.75em;
} }

View file

@ -1,26 +1,43 @@
{ {
"about": { "about": {
"staff": "Staff", "mrf": {
"federation": "Federation", "federation": "Federation",
"keyword": {
"keyword_policies": "Keyword Policies",
"ftl_removal": "Removal from \"The Whole Known Network\" Timeline",
"reject": "Reject",
"replace": "Replace",
"is_replaced_by": "→"
},
"mrf_policies": "Enabled MRF Policies", "mrf_policies": "Enabled MRF Policies",
"mrf_policies_desc": "MRF policies manipulate the federation behaviour of the instance. The following policies are enabled:", "mrf_policies_desc": "MRF policies manipulate the federation behaviour of the instance. The following policies are enabled:",
"mrf_policy_simple": "Instance-specific Policies", "simple": {
"mrf_policy_simple_accept": "Accept", "simple_policies": "Instance-specific Policies",
"mrf_policy_simple_accept_desc": "This instance only accepts messages from the following instances:", "accept": "Accept",
"mrf_policy_simple_reject": "Reject", "accept_desc": "This instance only accepts messages from the following instances:",
"mrf_policy_simple_reject_desc": "This instance will not accept messages from the following instances:", "reject": "Reject",
"mrf_policy_simple_quarantine": "Quarantine", "reject_desc": "This instance will not accept messages from the following instances:",
"mrf_policy_simple_quarantine_desc": "This instance will send only public posts to the following instances:", "quarantine": "Quarantine",
"mrf_policy_simple_ftl_removal": "Removal from \"The Whole Known Network\" Timeline", "quarantine_desc": "This instance will send only public posts to the following instances:",
"mrf_policy_simple_ftl_removal_desc": "This instance removes these instances from \"The Whole Known Network\" timeline:", "ftl_removal": "Removal from \"The Whole Known Network\" Timeline",
"mrf_policy_simple_media_removal": "Media Removal", "ftl_removal_desc": "This instance removes these instances from \"The Whole Known Network\" timeline:",
"mrf_policy_simple_media_removal_desc": "This instance removes media from posts on the following instances:", "media_removal": "Media Removal",
"mrf_policy_simple_media_nsfw": "Media Force-set As Sensitive", "media_removal_desc": "This instance removes media from posts on the following instances:",
"mrf_policy_simple_media_nsfw_desc": "This instance forces media to be set sensitive in posts on the following instances:" "media_nsfw": "Media Force-set As Sensitive",
"media_nsfw_desc": "This instance forces media to be set sensitive in posts on the following instances:"
}
},
"staff": "Staff"
}, },
"chat": { "chat": {
"title": "Chat" "title": "Chat"
}, },
"domain_mute_card": {
"mute": "Mute",
"mute_progress": "Muting...",
"unmute": "Unmute",
"unmute_progress": "Unmuting..."
},
"exporter": { "exporter": {
"export": "Export", "export": "Export",
"processing": "Processing, you'll soon be asked to download your file" "processing": "Processing, you'll soon be asked to download your file"
@ -46,6 +63,7 @@
"optional": "optional", "optional": "optional",
"show_more": "Show more", "show_more": "Show more",
"show_less": "Show less", "show_less": "Show less",
"dismiss": "Dismiss",
"cancel": "Cancel", "cancel": "Cancel",
"disable": "Disable", "disable": "Disable",
"enable": "Enable", "enable": "Enable",
@ -111,7 +129,8 @@
"read": "Read!", "read": "Read!",
"repeated_you": "repeated your status", "repeated_you": "repeated your status",
"no_more_notifications": "No more notifications", "no_more_notifications": "No more notifications",
"migrated_to": "migrated to" "migrated_to": "migrated to",
"reacted_with": "reacted with {0}"
}, },
"polls": { "polls": {
"add_poll": "Add Poll", "add_poll": "Add Poll",
@ -226,6 +245,7 @@
"desc": "To enable two-factor authentication, enter the code from your two-factor app:" "desc": "To enable two-factor authentication, enter the code from your two-factor app:"
} }
}, },
"allow_following_move": "Allow auto-follow when following account moves",
"attachmentRadius": "Attachments", "attachmentRadius": "Attachments",
"attachments": "Attachments", "attachments": "Attachments",
"autoload": "Enable automatic loading when scrolled to the bottom", "autoload": "Enable automatic loading when scrolled to the bottom",
@ -264,8 +284,10 @@
"delete_account_error": "There was an issue deleting your account. If this persists please contact your instance administrator.", "delete_account_error": "There was an issue deleting your account. If this persists please contact your instance administrator.",
"delete_account_instructions": "Type your password in the input below to confirm account deletion.", "delete_account_instructions": "Type your password in the input below to confirm account deletion.",
"discoverable": "Allow discovery of this account in search results and other services", "discoverable": "Allow discovery of this account in search results and other services",
"domain_mutes": "Domains",
"avatar_size_instruction": "The recommended minimum size for avatar images is 150x150 pixels.", "avatar_size_instruction": "The recommended minimum size for avatar images is 150x150 pixels.",
"pad_emoji": "Pad emoji with spaces when adding from picker", "pad_emoji": "Pad emoji with spaces when adding from picker",
"emoji_reactions_on_timeline": "Show emoji reactions on timeline",
"export_theme": "Save preset", "export_theme": "Save preset",
"filtering": "Filtering", "filtering": "Filtering",
"filtering_explanation": "All statuses containing these words will be muted, one per line", "filtering_explanation": "All statuses containing these words will be muted, one per line",
@ -274,6 +296,7 @@
"follow_import": "Follow import", "follow_import": "Follow import",
"follow_import_error": "Error importing followers", "follow_import_error": "Error importing followers",
"follows_imported": "Follows imported! Processing them will take a while.", "follows_imported": "Follows imported! Processing them will take a while.",
"accent": "Accent",
"foreground": "Foreground", "foreground": "Foreground",
"general": "General", "general": "General",
"hide_attachments_in_convo": "Hide attachments in conversations", "hide_attachments_in_convo": "Hide attachments in conversations",
@ -314,6 +337,7 @@
"notification_visibility_mentions": "Mentions", "notification_visibility_mentions": "Mentions",
"notification_visibility_repeats": "Repeats", "notification_visibility_repeats": "Repeats",
"notification_visibility_moves": "User Migrates", "notification_visibility_moves": "User Migrates",
"notification_visibility_emoji_reactions": "Reactions",
"no_rich_text_description": "Strip rich text formatting from all posts", "no_rich_text_description": "Strip rich text formatting from all posts",
"no_blocks": "No blocks", "no_blocks": "No blocks",
"no_mutes": "No mutes", "no_mutes": "No mutes",
@ -361,6 +385,7 @@
"post_status_content_type": "Post status content type", "post_status_content_type": "Post status content type",
"stop_gifs": "Play-on-hover GIFs", "stop_gifs": "Play-on-hover GIFs",
"streaming": "Enable automatic streaming of new posts when scrolled to the top", "streaming": "Enable automatic streaming of new posts when scrolled to the top",
"user_mutes": "Users",
"useStreamingApi": "Receive posts and notifications real-time", "useStreamingApi": "Receive posts and notifications real-time",
"useStreamingApiWarning": "(Not recommended, experimental, known to skip posts)", "useStreamingApiWarning": "(Not recommended, experimental, known to skip posts)",
"text": "Text", "text": "Text",
@ -369,6 +394,7 @@
"theme_help_v2_1": "You can also override certain component's colors and opacity by toggling the checkbox, use \"Clear all\" button to clear all overrides.", "theme_help_v2_1": "You can also override certain component's colors and opacity by toggling the checkbox, use \"Clear all\" button to clear all overrides.",
"theme_help_v2_2": "Icons underneath some entries are background/text contrast indicators, hover over for detailed info. Please keep in mind that when using transparency contrast indicators show the worst possible case.", "theme_help_v2_2": "Icons underneath some entries are background/text contrast indicators, hover over for detailed info. Please keep in mind that when using transparency contrast indicators show the worst possible case.",
"tooltipRadius": "Tooltips/alerts", "tooltipRadius": "Tooltips/alerts",
"type_domains_to_mute": "Type in domains to mute",
"upload_a_photo": "Upload a photo", "upload_a_photo": "Upload a photo",
"user_settings": "User Settings", "user_settings": "User Settings",
"values": { "values": {
@ -396,7 +422,24 @@
"save_load_hint": "\"Keep\" options preserve currently set options when selecting or loading themes, it also stores said options when exporting a theme. When all checkboxes unset, exporting theme will save everything.", "save_load_hint": "\"Keep\" options preserve currently set options when selecting or loading themes, it also stores said options when exporting a theme. When all checkboxes unset, exporting theme will save everything.",
"reset": "Reset", "reset": "Reset",
"clear_all": "Clear all", "clear_all": "Clear all",
"clear_opacity": "Clear opacity" "clear_opacity": "Clear opacity",
"load_theme": "Load theme",
"keep_as_is": "Keep as is",
"use_snapshot": "Old version",
"use_source": "New version",
"help": {
"upgraded_from_v2": "PleromaFE has been upgraded, theme could look a little bit different than you remember.",
"v2_imported": "File you imported was made for older FE. We try to maximize compatibility but there still could be inconsitencies.",
"future_version_imported": "File you imported was made in newer version of FE.",
"older_version_imported": "File you imported was made in older version of FE.",
"snapshot_present": "Theme snapshot is loaded, so all values are overriden. You can load theme's actual data instead.",
"snapshot_missing": "No theme snapshot was in the file so it could look different than originally envisioned.",
"fe_upgraded": "PleromaFE's theme engine upgraded after version update.",
"fe_downgraded": "PleromaFE's version rolled back.",
"migration_snapshot_ok": "Just to be safe, theme snapshot loaded. You can try loading theme data.",
"migration_napshot_gone": "For whatever reason snapshot was missing, some stuff could look different than you remember.",
"snapshot_source_mismatch": "Versions conflict: most likely FE was rolled back and updated again, if you changed theme using older version of FE you most likely want to use old version, otherwise use new version."
}
}, },
"common": { "common": {
"color": "Color", "color": "Color",
@ -425,14 +468,27 @@
"alert": "Alert background", "alert": "Alert background",
"alert_error": "Error", "alert_error": "Error",
"alert_warning": "Warning", "alert_warning": "Warning",
"alert_neutral": "Neutral",
"post": "Posts/User bios",
"badge": "Badge background", "badge": "Badge background",
"popover": "Tooltips, menus, popovers",
"badge_notification": "Notification", "badge_notification": "Notification",
"panel_header": "Panel header", "panel_header": "Panel header",
"top_bar": "Top bar", "top_bar": "Top bar",
"borders": "Borders", "borders": "Borders",
"buttons": "Buttons", "buttons": "Buttons",
"inputs": "Input fields", "inputs": "Input fields",
"faint_text": "Faded text" "faint_text": "Faded text",
"underlay": "Underlay",
"poll": "Poll graph",
"icons": "Icons",
"highlight": "Highlighted elements",
"pressed": "Pressed",
"selectedPost": "Selected post",
"selectedMenu": "Selected menu item",
"disabled": "Disabled",
"toggled": "Toggled",
"tabs": "Tabs"
}, },
"radii": { "radii": {
"_tab_label": "Roundness" "_tab_label": "Roundness"
@ -445,7 +501,7 @@
"blur": "Blur", "blur": "Blur",
"spread": "Spread", "spread": "Spread",
"inset": "Inset", "inset": "Inset",
"hint": "For shadows you can also use --variable as a color value to use CSS3 variables. Please note that setting opacity won't work in this case.", "hintV3": "For shadows you can also use the {0} notation to use other color slot.",
"filter_hint": { "filter_hint": {
"always_drop_shadow": "Warning, this shadow always uses {0} when browser supports it.", "always_drop_shadow": "Warning, this shadow always uses {0} when browser supports it.",
"drop_shadow_syntax": "{0} does not support {1} parameter and {2} keyword.", "drop_shadow_syntax": "{0} does not support {1} parameter and {2} keyword.",
@ -639,6 +695,7 @@
"repeat": "Repeat", "repeat": "Repeat",
"reply": "Reply", "reply": "Reply",
"favorite": "Favorite", "favorite": "Favorite",
"add_reaction": "Add Reaction",
"user_settings": "User Settings" "user_settings": "User Settings"
}, },
"upload":{ "upload":{

View file

@ -53,7 +53,8 @@
"notifications": "Ilmoitukset", "notifications": "Ilmoitukset",
"read": "Lue!", "read": "Lue!",
"repeated_you": "toisti viestisi", "repeated_you": "toisti viestisi",
"no_more_notifications": "Ei enempää ilmoituksia" "no_more_notifications": "Ei enempää ilmoituksia",
"reacted_with": "lisäsi reaktion {0}"
}, },
"polls": { "polls": {
"add_poll": "Lisää äänestys", "add_poll": "Lisää äänestys",
@ -140,6 +141,7 @@
"delete_account_description": "Poista tilisi ja viestisi pysyvästi.", "delete_account_description": "Poista tilisi ja viestisi pysyvästi.",
"delete_account_error": "Virhe poistaessa tiliäsi. Jos virhe jatkuu, ota yhteyttä palvelimesi ylläpitoon.", "delete_account_error": "Virhe poistaessa tiliäsi. Jos virhe jatkuu, ota yhteyttä palvelimesi ylläpitoon.",
"delete_account_instructions": "Syötä salasanasi vahvistaaksesi tilin poiston.", "delete_account_instructions": "Syötä salasanasi vahvistaaksesi tilin poiston.",
"emoji_reactions_on_timeline": "Näytä emojireaktiot aikajanalla",
"export_theme": "Tallenna teema", "export_theme": "Tallenna teema",
"filtering": "Suodatus", "filtering": "Suodatus",
"filtering_explanation": "Kaikki viestit, jotka sisältävät näitä sanoja, suodatetaan. Yksi sana per rivi.", "filtering_explanation": "Kaikki viestit, jotka sisältävät näitä sanoja, suodatetaan. Yksi sana per rivi.",
@ -183,6 +185,7 @@
"notification_visibility_likes": "Tykkäykset", "notification_visibility_likes": "Tykkäykset",
"notification_visibility_mentions": "Maininnat", "notification_visibility_mentions": "Maininnat",
"notification_visibility_repeats": "Toistot", "notification_visibility_repeats": "Toistot",
"notification_visibility_emoji_reactions": "Reaktiot",
"no_rich_text_description": "Älä näytä tekstin muotoilua.", "no_rich_text_description": "Älä näytä tekstin muotoilua.",
"hide_network_description": "Älä näytä seurauksiani tai seuraajiani", "hide_network_description": "Älä näytä seurauksiani tai seuraajiani",
"nsfw_clickthrough": "Piilota NSFW liitteet klikkauksen taakse", "nsfw_clickthrough": "Piilota NSFW liitteet klikkauksen taakse",

View file

@ -1,22 +1,26 @@
{ {
"about": { "about": {
"staff": "スタッフ", "mrf": {
"federation": "フェデレーション", "federation": "フェデレーション",
"mrf_policies": "ゆうこうなMRFポリシー", "mrf_policies": "ゆうこうなMRFポリシー",
"mrf_policies_desc": "MRFポリシーは、このインスタンスのフェデレーションのふるまいを、いじります。これらのMRFポリシーがゆうこうになっています:", "mrf_policies_desc": "MRFポリシーは、このインスタンスのフェデレーションのふるまいを、いじります。これらのMRFポリシーがゆうこうになっています:",
"mrf_policy_simple": "インスタンスのポリシー", "simple": {
"mrf_policy_simple_accept": "うけいれ", "simple_policies": "インスタンスのポリシー",
"mrf_policy_simple_accept_desc": "このインスンスは、これらのインスタンスからのメッセージのみをうけいれます:", "accept": "うけいれ",
"mrf_policy_simple_reject": "おことわり", "accept_desc": "このインスンスは、これらのインスタンスからのメッセージのみをうけいれます:",
"mrf_policy_simple_reject_desc": "このインスタンスは、これらのインスタンスからのメッセージをうけいれません:", "reject": "おことわり",
"mrf_policy_simple_quarantine": "けんえき", "reject_desc": "このインスタンスは、これらのインスタンスからのメッセージをうけいれません:",
"mrf_policy_simple_quarantine_desc": "このインスタンスは、これらのインスタンスに、パブリックなとうこうのみを、おくります:", "quarantine": "けんえき",
"mrf_policy_simple_ftl_removal": "「つながっているすべてのネットワーク」タイムラインからのぞく", "quarantine_desc": "このインスタンスは、これらのインスタンスに、パブリックなとうこうのみを、おくります:",
"mrf_policy_simple_ftl_removal_desc": "このインスタンスは、つながっているすべてのネットワーク」タイムラインから、これらのインスタンスを、とりのぞきます:", "ftl_removal": "「つながっているすべてのネットワーク」タイムラインからのぞく",
"mrf_policy_simple_media_removal": "メディアをのぞく", "ftl_removal_desc": "このインスタンスは、つながっているすべてのネットワーク」タイムラインから、これらのインスタンスを、とりのぞきます:",
"mrf_policy_simple_media_removal_desc": "このインスタンスは、これらのインスタンスからおくられてきたメディアを、とりのぞきます:", "media_removal": "メディアをのぞく",
"mrf_policy_simple_media_nsfw": "メディアをすべてセンシティブにする", "media_removal_desc": "このインスタンスは、これらのインスタンスからおくられてきたメディアを、とりのぞきます:",
"mrf_policy_simple_media_nsfw_desc": "このインスタンスは、これらのインスタンスからおくられてきたメディアを、すべて、センシティブにマークします:" "media_nsfw": "メディアをすべてセンシティブにする",
"media_nsfw_desc": "このインスタンスは、これらのインスタンスからおくられてきたメディアを、すべて、センシティブにマークします:"
}
},
"staff": "スタッフ"
}, },
"chat": { "chat": {
"title": "チャット" "title": "チャット"

View file

@ -0,0 +1,9 @@
import EventTargetPolyfill from '@ungap/event-target'
try {
/* eslint-disable no-new */
new EventTarget()
/* eslint-enable no-new */
} catch (e) {
window.EventTarget = EventTargetPolyfill
}

View file

@ -2,6 +2,9 @@ import Vue from 'vue'
import VueRouter from 'vue-router' import VueRouter from 'vue-router'
import Vuex from 'vuex' import Vuex from 'vuex'
import 'custom-event-polyfill'
import './lib/event_target_polyfill.js'
import interfaceModule from './modules/interface.js' import interfaceModule from './modules/interface.js'
import instanceModule from './modules/instance.js' import instanceModule from './modules/instance.js'
import statusesModule from './modules/statuses.js' import statusesModule from './modules/statuses.js'
@ -28,7 +31,6 @@ import VueChatScroll from 'vue-chat-scroll'
import VueClickOutside from 'v-click-outside' import VueClickOutside from 'v-click-outside'
import PortalVue from 'portal-vue' import PortalVue from 'portal-vue'
import VBodyScrollLock from './directives/body_scroll_lock' import VBodyScrollLock from './directives/body_scroll_lock'
import VTooltip from 'v-tooltip'
import afterStoreSetup from './boot/after_store.js' import afterStoreSetup from './boot/after_store.js'
@ -41,13 +43,6 @@ Vue.use(VueChatScroll)
Vue.use(VueClickOutside) Vue.use(VueClickOutside)
Vue.use(PortalVue) Vue.use(PortalVue)
Vue.use(VBodyScrollLock) Vue.use(VBodyScrollLock)
Vue.use(VTooltip, {
popover: {
defaultTrigger: 'hover click',
defaultContainer: false,
defaultOffset: 5
}
})
const i18n = new VueI18n({ const i18n = new VueI18n({
// By default, use the browser locale, we will update it if neccessary // By default, use the browser locale, we will update it if neccessary

View file

@ -146,6 +146,7 @@ const api = {
startFetchingFollowRequests (store) { startFetchingFollowRequests (store) {
if (store.state.fetchers['followRequests']) return if (store.state.fetchers['followRequests']) return
const fetcher = store.state.backendInteractor.startFetchingFollowRequests({ store }) const fetcher = store.state.backendInteractor.startFetchingFollowRequests({ store })
store.commit('addFetcher', { fetcherName: 'followRequests', fetcher }) store.commit('addFetcher', { fetcherName: 'followRequests', fetcher })
}, },
stopFetchingFollowRequests (store) { stopFetchingFollowRequests (store) {

View file

@ -5,6 +5,9 @@ const browserLocale = (window.navigator.language || 'en').split('-')[0]
export const defaultState = { export const defaultState = {
colors: {}, colors: {},
theme: undefined,
customTheme: undefined,
customThemeSource: undefined,
hideISP: false, hideISP: false,
// bad name: actually hides posts of muted USERS // bad name: actually hides posts of muted USERS
hideMutedPosts: undefined, // instance default hideMutedPosts: undefined, // instance default
@ -20,6 +23,7 @@ export const defaultState = {
autoLoad: true, autoLoad: true,
streaming: false, streaming: false,
hoverPreview: true, hoverPreview: true,
emojiReactionsOnTimeline: true,
autohideFloatingPostButton: false, autohideFloatingPostButton: false,
pauseOnUnfocused: true, pauseOnUnfocused: true,
stopGifs: false, stopGifs: false,
@ -29,7 +33,8 @@ export const defaultState = {
mentions: true, mentions: true,
likes: true, likes: true,
repeats: true, repeats: true,
moves: true moves: true,
emojiReactions: false
}, },
webPushNotifications: false, webPushNotifications: false,
muteWords: [], muteWords: [],
@ -94,10 +99,10 @@ const config = {
commit('setOption', { name, value }) commit('setOption', { name, value })
switch (name) { switch (name) {
case 'theme': case 'theme':
setPreset(value, commit) setPreset(value)
break break
case 'customTheme': case 'customTheme':
applyTheme(value, commit) applyTheme(value)
} }
} }
} }

View file

@ -1,5 +1,6 @@
import { set } from 'vue' import { set } from 'vue'
import { setPreset } from '../services/style_setter/style_setter.js' import { getPreset, applyTheme } from '../services/style_setter/style_setter.js'
import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js'
import { instanceDefaultProperties } from './config.js' import { instanceDefaultProperties } from './config.js'
const defaultState = { const defaultState = {
@ -10,6 +11,7 @@ const defaultState = {
textlimit: 5000, textlimit: 5000,
server: 'http://localhost:4040/', server: 'http://localhost:4040/',
theme: 'pleroma-dark', theme: 'pleroma-dark',
themeData: undefined,
background: '/static/aurora_borealis.jpg', background: '/static/aurora_borealis.jpg',
logo: '/static/logo.png', logo: '/static/logo.png',
logoMask: true, logoMask: true,
@ -96,6 +98,9 @@ const instance = {
dispatch('initializeSocket') dispatch('initializeSocket')
} }
break break
case 'theme':
dispatch('setTheme', value)
break
} }
}, },
async getStaticEmoji ({ commit }) { async getStaticEmoji ({ commit }) {
@ -147,9 +152,23 @@ const instance = {
} }
}, },
setTheme ({ commit }, themeName) { setTheme ({ commit, rootState }, themeName) {
commit('setInstanceOption', { name: 'theme', value: themeName }) commit('setInstanceOption', { name: 'theme', value: themeName })
return setPreset(themeName, commit) getPreset(themeName)
.then(themeData => {
commit('setInstanceOption', { name: 'themeData', value: themeData })
// No need to apply theme if there's user theme already
const { customTheme } = rootState.config
if (customTheme) return
// New theme presets don't have 'theme' property, they use 'source'
const themeSource = themeData.source
if (!themeData.theme || (themeSource && themeSource.themeEngineVersion === CURRENT_VERSION)) {
applyTheme(themeSource)
} else {
applyTheme(themeData.theme)
}
})
}, },
fetchEmoji ({ dispatch, state }) { fetchEmoji ({ dispatch, state }) {
if (!state.customEmojiFetched) { if (!state.customEmojiFetched) {

View file

@ -1,4 +1,17 @@
import { remove, slice, each, findIndex, find, maxBy, minBy, merge, first, last, isArray, omitBy } from 'lodash' import {
remove,
slice,
each,
findIndex,
find,
maxBy,
minBy,
merge,
first,
last,
isArray,
omitBy
} from 'lodash'
import { set } from 'vue' import { set } from 'vue'
import apiService from '../services/api/api.service.js' import apiService from '../services/api/api.service.js'
// import parse from '../services/status_parser/status_parser.js' // import parse from '../services/status_parser/status_parser.js'
@ -68,7 +81,8 @@ const visibleNotificationTypes = (rootState) => {
rootState.config.notificationVisibility.mentions && 'mention', rootState.config.notificationVisibility.mentions && 'mention',
rootState.config.notificationVisibility.repeats && 'repeat', rootState.config.notificationVisibility.repeats && 'repeat',
rootState.config.notificationVisibility.follows && 'follow', rootState.config.notificationVisibility.follows && 'follow',
rootState.config.notificationVisibility.moves && 'move' rootState.config.notificationVisibility.moves && 'move',
rootState.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reactions'
].filter(_ => _) ].filter(_ => _)
} }
@ -312,6 +326,10 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
notification.status = notification.status && addStatusToGlobalStorage(state, notification.status).item notification.status = notification.status && addStatusToGlobalStorage(state, notification.status).item
} }
if (notification.type === 'pleroma:emoji_reaction') {
dispatch('fetchEmojiReactionsBy', notification.status.id)
}
// Only add a new notification if we don't have one for the same action // Only add a new notification if we don't have one for the same action
if (!state.notifications.idStore.hasOwnProperty(notification.id)) { if (!state.notifications.idStore.hasOwnProperty(notification.id)) {
state.notifications.maxId = notification.id > state.notifications.maxId state.notifications.maxId = notification.id > state.notifications.maxId
@ -345,7 +363,9 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
break break
} }
if (i18nString) { if (notification.type === 'pleroma:emoji_reaction') {
notifObj.body = rootGetters.i18n.t('notifications.reacted_with', [notification.emoji])
} else if (i18nString) {
notifObj.body = rootGetters.i18n.t('notifications.' + i18nString) notifObj.body = rootGetters.i18n.t('notifications.' + i18nString)
} else { } else {
notifObj.body = notification.status.text notifObj.body = notification.status.text
@ -358,10 +378,10 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
} }
if (!notification.seen && !state.notifications.desktopNotificationSilence && visibleNotificationTypes.includes(notification.type)) { if (!notification.seen && !state.notifications.desktopNotificationSilence && visibleNotificationTypes.includes(notification.type)) {
let notification = new window.Notification(title, notifObj) let desktopNotification = new window.Notification(title, notifObj)
// Chrome is known for not closing notifications automatically // Chrome is known for not closing notifications automatically
// according to MDN, anyway. // according to MDN, anyway.
setTimeout(notification.close.bind(notification), 5000) setTimeout(desktopNotification.close.bind(desktopNotification), 5000)
} }
} }
} else if (notification.seen) { } else if (notification.seen) {
@ -518,6 +538,53 @@ export const mutations = {
newStatus.fave_num = newStatus.favoritedBy.length newStatus.fave_num = newStatus.favoritedBy.length
newStatus.favorited = !!newStatus.favoritedBy.find(({ id }) => currentUser.id === id) newStatus.favorited = !!newStatus.favoritedBy.find(({ id }) => currentUser.id === id)
}, },
addEmojiReactionsBy (state, { id, emojiReactions, currentUser }) {
const status = state.allStatusesObject[id]
set(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) {
set(status.emoji_reactions, reactionIndex, newReaction)
} else {
set(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) {
set(status.emoji_reactions, reactionIndex, newReaction)
} else {
set(status, 'emoji_reactions', status.emoji_reactions.filter(r => r.name !== emoji))
}
},
updateStatusWithPoll (state, { id, poll }) { updateStatusWithPoll (state, { id, poll }) {
const status = state.allStatusesObject[id] const status = state.allStatusesObject[id]
status.poll = poll status.poll = poll
@ -622,6 +689,35 @@ const statuses = {
commit('addRepeats', { id, rebloggedByUsers, 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 })
rootState.api.backendInteractor.reactWithEmoji({ id, emoji }).then(
ok => {
dispatch('fetchEmojiReactionsBy', id)
}
)
},
unreactWithEmoji ({ rootState, dispatch, commit }, { id, emoji }) {
const currentUser = rootState.users.currentUser
if (!currentUser) return
commit('removeOwnReaction', { id, emoji, currentUser })
rootState.api.backendInteractor.unreactWithEmoji({ id, emoji }).then(
ok => {
dispatch('fetchEmojiReactionsBy', id)
}
)
},
fetchEmojiReactionsBy ({ rootState, commit }, id) {
rootState.api.backendInteractor.fetchEmojiReactions({ id }).then(
emojiReactions => {
commit('addEmojiReactionsBy', { id, emojiReactions, currentUser: rootState.users.currentUser })
}
)
},
fetchFavs ({ rootState, commit }, id) { fetchFavs ({ rootState, commit }, id) {
rootState.api.backendInteractor.fetchFavoritedByUsers({ id }) rootState.api.backendInteractor.fetchFavoritedByUsers({ id })
.then(favoritedByUsers => commit('addFavs', { id, favoritedByUsers, currentUser: rootState.users.currentUser })) .then(favoritedByUsers => commit('addFavs', { id, favoritedByUsers, currentUser: rootState.users.currentUser }))

View file

@ -72,6 +72,16 @@ const showReblogs = (store, userId) => {
.then((relationship) => store.commit('updateUserRelationship', [relationship])) .then((relationship) => store.commit('updateUserRelationship', [relationship]))
} }
const muteDomain = (store, domain) => {
return store.rootState.api.backendInteractor.muteDomain({ domain })
.then(() => store.commit('addDomainMute', domain))
}
const unmuteDomain = (store, domain) => {
return store.rootState.api.backendInteractor.unmuteDomain({ domain })
.then(() => store.commit('removeDomainMute', domain))
}
export const mutations = { export const mutations = {
setMuted (state, { user: { id }, muted }) { setMuted (state, { user: { id }, muted }) {
const user = state.usersObject[id] const user = state.usersObject[id]
@ -177,6 +187,20 @@ export const mutations = {
state.currentUser.muteIds.push(muteId) 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) { setPinnedToUser (state, status) {
const user = state.usersObject[status.user.id] const user = state.usersObject[status.user.id]
const index = user.pinnedStatusIds.indexOf(status.id) const index = user.pinnedStatusIds.indexOf(status.id)
@ -297,6 +321,25 @@ const users = {
unmuteUsers (store, ids = []) { unmuteUsers (store, ids = []) {
return Promise.all(ids.map(id => unmuteUser(store, id))) return Promise.all(ids.map(id => unmuteUser(store, id)))
}, },
fetchDomainMutes (store) {
return store.rootState.api.backendInteractor.fetchDomainMutes()
.then((domainMutes) => {
store.commit('saveDomainMutes', domainMutes)
return domainMutes
})
},
muteDomain (store, domain) {
return muteDomain(store, domain)
},
unmuteDomain (store, domain) {
return unmuteDomain(store, domain)
},
muteDomains (store, domains = []) {
return Promise.all(domains.map(domain => muteDomain(store, domain)))
},
unmuteDomains (store, domain = []) {
return Promise.all(domain.map(domain => unmuteDomain(store, domain)))
},
fetchFriends ({ rootState, commit }, id) { fetchFriends ({ rootState, commit }, id) {
const user = rootState.users.usersObject[id] const user = rootState.users.usersObject[id]
const maxId = last(user.friendIds) const maxId = last(user.friendIds)
@ -331,9 +374,9 @@ const users = {
return rootState.api.backendInteractor.unsubscribeUser({ id }) return rootState.api.backendInteractor.unsubscribeUser({ id })
.then((relationship) => commit('updateUserRelationship', [relationship])) .then((relationship) => commit('updateUserRelationship', [relationship]))
}, },
toggleActivationStatus ({ rootState, commit }, user) { toggleActivationStatus ({ rootState, commit }, { user }) {
const api = user.deactivated ? rootState.api.backendInteractor.activateUser : rootState.api.backendInteractor.deactivateUser const api = user.deactivated ? rootState.api.backendInteractor.activateUser : rootState.api.backendInteractor.deactivateUser
api(user) api({ user })
.then(({ deactivated }) => commit('updateActivationStatus', { user, deactivated })) .then(({ deactivated }) => commit('updateActivationStatus', { user, deactivated }))
}, },
registerPushNotifications (store) { registerPushNotifications (store) {
@ -460,6 +503,7 @@ const users = {
user.credentials = accessToken user.credentials = accessToken
user.blockIds = [] user.blockIds = []
user.muteIds = [] user.muteIds = []
user.domainMutes = []
commit('setCurrentUser', user) commit('setCurrentUser', user)
commit('addNewUsers', [user]) commit('addNewUsers', [user])

View file

@ -72,7 +72,11 @@ const MASTODON_MUTE_CONVERSATION = id => `/api/v1/statuses/${id}/mute`
const MASTODON_UNMUTE_CONVERSATION = id => `/api/v1/statuses/${id}/unmute` const MASTODON_UNMUTE_CONVERSATION = id => `/api/v1/statuses/${id}/unmute`
const MASTODON_SEARCH_2 = `/api/v2/search` const MASTODON_SEARCH_2 = `/api/v2/search`
const MASTODON_USER_SEARCH_URL = '/api/v1/accounts/search' const MASTODON_USER_SEARCH_URL = '/api/v1/accounts/search'
const MASTODON_DOMAIN_BLOCKS_URL = '/api/v1/domain_blocks'
const MASTODON_STREAMING = '/api/v1/streaming' const MASTODON_STREAMING = '/api/v1/streaming'
const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/reactions`
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 oldfetch = window.fetch const oldfetch = window.fetch
@ -398,8 +402,8 @@ const fetchStatus = ({ id, credentials }) => {
.then((data) => parseStatus(data)) .then((data) => parseStatus(data))
} }
const tagUser = ({ tag, credentials, ...options }) => { const tagUser = ({ tag, credentials, user }) => {
const screenName = options.screen_name const screenName = user.screen_name
const form = { const form = {
nicknames: [screenName], nicknames: [screenName],
tags: [tag] tags: [tag]
@ -415,8 +419,8 @@ const tagUser = ({ tag, credentials, ...options }) => {
}) })
} }
const untagUser = ({ tag, credentials, ...options }) => { const untagUser = ({ tag, credentials, user }) => {
const screenName = options.screen_name const screenName = user.screen_name
const body = { const body = {
nicknames: [screenName], nicknames: [screenName],
tags: [tag] tags: [tag]
@ -432,7 +436,7 @@ const untagUser = ({ tag, credentials, ...options }) => {
}) })
} }
const addRight = ({ right, credentials, ...user }) => { const addRight = ({ right, credentials, user }) => {
const screenName = user.screen_name const screenName = user.screen_name
return fetch(PERMISSION_GROUP_URL(screenName, right), { return fetch(PERMISSION_GROUP_URL(screenName, right), {
@ -442,7 +446,7 @@ const addRight = ({ right, credentials, ...user }) => {
}) })
} }
const deleteRight = ({ right, credentials, ...user }) => { const deleteRight = ({ right, credentials, user }) => {
const screenName = user.screen_name const screenName = user.screen_name
return fetch(PERMISSION_GROUP_URL(screenName, right), { return fetch(PERMISSION_GROUP_URL(screenName, right), {
@ -474,7 +478,7 @@ const deactivateUser = ({ credentials, user: { screen_name: nickname } }) => {
}).then(response => get(response, 'users.0')) }).then(response => get(response, 'users.0'))
} }
const deleteUser = ({ credentials, ...user }) => { const deleteUser = ({ credentials, user }) => {
const screenName = user.screen_name const screenName = user.screen_name
const headers = authHeaders(credentials) const headers = authHeaders(credentials)
@ -491,7 +495,8 @@ const fetchTimeline = ({
until = false, until = false,
userId = false, userId = false,
tag = false, tag = false,
withMuted = false withMuted = false,
withMove = false
}) => { }) => {
const timelineUrls = { const timelineUrls = {
public: MASTODON_PUBLIC_TIMELINE, public: MASTODON_PUBLIC_TIMELINE,
@ -531,6 +536,9 @@ const fetchTimeline = ({
if (timeline === 'public' || timeline === 'publicAndExternal') { if (timeline === 'public' || timeline === 'publicAndExternal') {
params.push(['only_media', false]) params.push(['only_media', false])
} }
if (timeline === 'notifications') {
params.push(['with_move', withMove])
}
params.push(['count', 20]) params.push(['count', 20])
params.push(['with_muted', withMuted]) params.push(['with_muted', withMuted])
@ -880,6 +888,30 @@ const fetchRebloggedByUsers = ({ id }) => {
return promisedRequest({ url: MASTODON_STATUS_REBLOGGEDBY_URL(id) }).then((users) => users.map(parseUser)) return promisedRequest({ url: MASTODON_STATUS_REBLOGGEDBY_URL(id) }).then((users) => users.map(parseUser))
} }
const fetchEmojiReactions = ({ id, credentials }) => {
return promisedRequest({ url: PLEROMA_EMOJI_REACTIONS_URL(id), credentials })
.then((reactions) => reactions.map(r => {
r.accounts = r.accounts.map(parseUser)
return r
}))
}
const reactWithEmoji = ({ id, emoji, credentials }) => {
return promisedRequest({
url: PLEROMA_EMOJI_REACT_URL(id, emoji),
method: 'PUT',
credentials
}).then(parseStatus)
}
const unreactWithEmoji = ({ id, emoji, credentials }) => {
return promisedRequest({
url: PLEROMA_EMOJI_UNREACT_URL(id, emoji),
method: 'DELETE',
credentials
}).then(parseStatus)
}
const reportUser = ({ credentials, userId, statusIds, comment, forward }) => { const reportUser = ({ credentials, userId, statusIds, comment, forward }) => {
return promisedRequest({ return promisedRequest({
url: MASTODON_REPORT_USER_URL, url: MASTODON_REPORT_USER_URL,
@ -948,6 +980,28 @@ const search2 = ({ credentials, q, resolve, limit, offset, following }) => {
}) })
} }
const fetchDomainMutes = ({ credentials }) => {
return promisedRequest({ url: MASTODON_DOMAIN_BLOCKS_URL, credentials })
}
const muteDomain = ({ domain, credentials }) => {
return promisedRequest({
url: MASTODON_DOMAIN_BLOCKS_URL,
method: 'POST',
payload: { domain },
credentials
})
}
const unmuteDomain = ({ domain, credentials }) => {
return promisedRequest({
url: MASTODON_DOMAIN_BLOCKS_URL,
method: 'DELETE',
payload: { domain },
credentials
})
}
export const getMastodonSocketURI = ({ credentials, stream, args = {} }) => { export const getMastodonSocketURI = ({ credentials, stream, args = {} }) => {
return Object.entries({ return Object.entries({
...(credentials ...(credentials
@ -1107,10 +1161,16 @@ const apiService = {
fetchPoll, fetchPoll,
fetchFavoritedByUsers, fetchFavoritedByUsers,
fetchRebloggedByUsers, fetchRebloggedByUsers,
fetchEmojiReactions,
reactWithEmoji,
unreactWithEmoji,
reportUser, reportUser,
updateNotificationSettings, updateNotificationSettings,
search2, search2,
searchUsers searchUsers,
fetchDomainMutes,
muteDomain,
unmuteDomain
} }
export default apiService export default apiService

View file

@ -16,7 +16,7 @@ const backendInteractorService = credentials => ({
return notificationsFetcher.fetchAndUpdate({ store, credentials }) return notificationsFetcher.fetchAndUpdate({ store, credentials })
}, },
startFetchingFollowRequest ({ store }) { startFetchingFollowRequests ({ store }) {
return followRequestFetcher.startFetching({ store, credentials }) return followRequestFetcher.startFetching({ store, credentials })
}, },

View file

@ -1,16 +1,27 @@
import { map } from 'lodash' import { invertLightness, contrastRatio } from 'chromatism'
const rgb2hex = (r, g, b) => { // useful for visualizing color when debugging
export const consoleColor = (color) => console.log('%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') { if (r === null || typeof r === 'undefined') {
return undefined return undefined
} }
if (r[0] === '#') { // TODO: clean up this mess
if (r[0] === '#' || r === 'transparent') {
return r return r
} }
if (typeof r === 'object') { if (typeof r === 'object') {
({ r, g, b } = r) ({ r, g, b } = r)
} }
[r, g, b] = map([r, g, b], (val) => { [r, g, b] = [r, g, b].map(val => {
val = Math.ceil(val) val = Math.ceil(val)
val = val < 0 ? 0 : val val = val < 0 ? 0 : val
val = val > 255 ? 255 : val val = val > 255 ? 255 : val
@ -58,7 +69,7 @@ const srgbToLinear = (srgb) => {
* @param {Object} srgb - sRGB color * @param {Object} srgb - sRGB color
* @returns {Number} relative luminance * @returns {Number} relative luminance
*/ */
const relativeLuminance = (srgb) => { export const relativeLuminance = (srgb) => {
const { r, g, b } = srgbToLinear(srgb) const { r, g, b } = srgbToLinear(srgb)
return 0.2126 * r + 0.7152 * g + 0.0722 * b return 0.2126 * r + 0.7152 * g + 0.0722 * b
} }
@ -71,7 +82,7 @@ const relativeLuminance = (srgb) => {
* @param {Object} b - sRGB color * @param {Object} b - sRGB color
* @returns {Number} color ratio * @returns {Number} color ratio
*/ */
const getContrastRatio = (a, b) => { export const getContrastRatio = (a, b) => {
const la = relativeLuminance(a) const la = relativeLuminance(a)
const lb = relativeLuminance(b) const lb = relativeLuminance(b)
const [l1, l2] = la > lb ? [la, lb] : [lb, la] const [l1, l2] = la > lb ? [la, lb] : [lb, la]
@ -79,6 +90,17 @@ const getContrastRatio = (a, b) => {
return (l1 + 0.05) / (l2 + 0.05) 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)
}
/** /**
* This performs alpha blending between solid background and semi-transparent foreground * This performs alpha blending between solid background and semi-transparent foreground
* *
@ -87,7 +109,7 @@ const getContrastRatio = (a, b) => {
* @param {Object} bg - bottom layer color * @param {Object} bg - bottom layer color
* @returns {Object} sRGB of resulting color * @returns {Object} sRGB of resulting color
*/ */
const alphaBlend = (fg, fga, bg) => { export const alphaBlend = (fg, fga, bg) => {
if (fga === 1 || typeof fga === 'undefined') return fg if (fga === 1 || typeof fga === 'undefined') return fg
return 'rgb'.split('').reduce((acc, c) => { return 'rgb'.split('').reduce((acc, c) => {
// Simplified https://en.wikipedia.org/wiki/Alpha_compositing#Alpha_blending // Simplified https://en.wikipedia.org/wiki/Alpha_compositing#Alpha_blending
@ -97,14 +119,30 @@ const alphaBlend = (fg, fga, bg) => {
}, {}) }, {})
} }
const invert = (rgb) => { /**
* 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 'rgb'.split('').reduce((acc, c) => { return 'rgb'.split('').reduce((acc, c) => {
acc[c] = 255 - rgb[c] acc[c] = 255 - rgb[c]
return acc return acc
}, {}) }, {})
} }
const hex2rgb = (hex) => { /**
* 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) const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result ? { return result ? {
r: parseInt(result[1], 16), r: parseInt(result[1], 16),
@ -113,18 +151,72 @@ const hex2rgb = (hex) => {
} : null } : null
} }
const mixrgb = (a, b) => { /**
return Object.keys(a).reduce((acc, k) => { * 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 'rgb'.split('').reduce((acc, k) => {
acc[k] = (a[k] + b[k]) / 2 acc[k] = (a[k] + b[k]) / 2
return acc return acc
}, {}) }, {})
} }
/**
export { * Converts rgb object into a CSS rgba() color
rgb2hex, *
hex2rgb, * @param {Object} color - rgb
mixrgb, * @returns {String} CSS rgba() color
invert, */
getContrastRatio, export const rgba2css = function (rgba) {
alphaBlend return `rgba(${Math.floor(rgba.r)}, ${Math.floor(rgba.g)}, ${Math.floor(rgba.b)}, ${rgba.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 contrast = getContrastRatio(bg, text)
if (contrast < 4.5) {
const base = typeof text.a !== 'undefined' ? { a: text.a } : {}
const result = Object.assign(base, invertLightness(text).rgb)
if (!preserve && getContrastRatio(bg, result) < 4.5) {
// B&W
return contrastRatio(bg, text).rgb
}
// Inverted color
return result
}
return text
}
/**
* 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 })
} }

View file

@ -1,3 +1,5 @@
import escape from 'escape-html'
const qvitterStatusType = (status) => { const qvitterStatusType = (status) => {
if (status.is_post_verb) { if (status.is_post_verb) {
return 'status' return 'status'
@ -41,7 +43,7 @@ export const parseUser = (data) => {
} }
output.name = data.display_name output.name = data.display_name
output.name_html = addEmojis(data.display_name, data.emojis) output.name_html = addEmojis(escape(data.display_name), data.emojis)
output.description = data.note output.description = data.note
output.description_html = addEmojis(data.note, data.emojis) output.description_html = addEmojis(data.note, data.emojis)
@ -81,6 +83,8 @@ export const parseUser = (data) => {
output.subscribed = relationship.subscribing output.subscribed = relationship.subscribing
} }
output.allow_following_move = data.pleroma.allow_following_move
output.hide_follows = data.pleroma.hide_follows output.hide_follows = data.pleroma.hide_follows
output.hide_followers = data.pleroma.hide_followers output.hide_followers = data.pleroma.hide_followers
output.hide_follows_count = data.pleroma.hide_follows_count output.hide_follows_count = data.pleroma.hide_follows_count
@ -242,6 +246,7 @@ export const parseStatus = (data) => {
output.is_local = pleroma.local output.is_local = pleroma.local
output.in_reply_to_screen_name = data.pleroma.in_reply_to_account_acct output.in_reply_to_screen_name = data.pleroma.in_reply_to_account_acct
output.thread_muted = pleroma.thread_muted output.thread_muted = pleroma.thread_muted
output.emoji_reactions = pleroma.emoji_reactions
} else { } else {
output.text = data.content output.text = data.content
output.summary = data.spoiler_text output.summary = data.spoiler_text
@ -255,7 +260,7 @@ export const parseStatus = (data) => {
output.retweeted_status = parseStatus(data.reblog) output.retweeted_status = parseStatus(data.reblog)
} }
output.summary_html = addEmojis(data.spoiler_text, data.emojis) output.summary_html = addEmojis(escape(data.spoiler_text), data.emojis)
output.external_url = data.url output.external_url = data.url
output.poll = data.poll output.poll = data.poll
output.pinned = data.pinned output.pinned = data.pinned
@ -349,6 +354,7 @@ export const parseNotification = (data) => {
? null ? null
: parseUser(data.target) : parseUser(data.target)
output.from_profile = parseUser(data.account) output.from_profile = parseUser(data.account)
output.emoji = data.emoji
} else { } else {
const parsedNotice = parseStatus(data.notice) const parsedNotice = parseStatus(data.notice)
output.type = data.ntype output.type = data.ntype

View file

@ -7,7 +7,8 @@ export const visibleTypes = store => ([
store.state.config.notificationVisibility.mentions && 'mention', store.state.config.notificationVisibility.mentions && 'mention',
store.state.config.notificationVisibility.repeats && 'repeat', store.state.config.notificationVisibility.repeats && 'repeat',
store.state.config.notificationVisibility.follows && 'follow', store.state.config.notificationVisibility.follows && 'follow',
store.state.config.notificationVisibility.moves && 'move' store.state.config.notificationVisibility.moves && 'move',
store.state.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reaction'
].filter(_ => _)) ].filter(_ => _))
const sortById = (a, b) => { const sortById = (a, b) => {

View file

@ -11,9 +11,12 @@ const fetchAndUpdate = ({ store, credentials, older = false }) => {
const rootState = store.rootState || store.state const rootState = store.rootState || store.state
const timelineData = rootState.statuses.notifications const timelineData = rootState.statuses.notifications
const hideMutedPosts = getters.mergedConfig.hideMutedPosts const hideMutedPosts = getters.mergedConfig.hideMutedPosts
const allowFollowingMove = rootState.users.currentUser.allow_following_move
args['withMuted'] = !hideMutedPosts args['withMuted'] = !hideMutedPosts
args['withMove'] = !allowFollowingMove
args['timeline'] = 'notifications' args['timeline'] = 'notifications'
if (older) { if (older) {
if (timelineData.minId !== Number.POSITIVE_INFINITY) { if (timelineData.minId !== Number.POSITIVE_INFINITY) {

View file

@ -1,78 +1,9 @@
import { times } from 'lodash' import { convert } from 'chromatism'
import { brightness, invertLightness, convert, contrastRatio } from 'chromatism' import { rgb2hex, hex2rgb, rgba2css, getCssColor, relativeLuminance } from '../color_convert/color_convert.js'
import { rgb2hex, hex2rgb, mixrgb, getContrastRatio, alphaBlend } from '../color_convert/color_convert.js' import { getColors, computeDynamicColor, getOpacitySlot } from '../theme_data/theme_data.service.js'
// While this is not used anymore right now, I left it in if we want to do custom export const applyTheme = (input) => {
// styles that aren't just colors, so user can pick from a few different distinct const { rules } = generatePreset(input)
// styles as well as set their own colors in the future.
const setStyle = (href, commit) => {
/***
What's going on here?
I want to make it easy for admins to style this application. To have
a good set of default themes, I chose the system from base16
(https://chriskempson.github.io/base16/) to style all elements. They
all have the base00..0F classes. So the only thing an admin needs to
do to style Pleroma is to change these colors in that one css file.
Some default things (body text color, link color) need to be set dy-
namically, so this is done here by waiting for the stylesheet to be
loaded and then creating an element with the respective classes.
It is a bit weird, but should make life for admins somewhat easier.
***/
const head = document.head
const body = document.body
body.classList.add('hidden')
const cssEl = document.createElement('link')
cssEl.setAttribute('rel', 'stylesheet')
cssEl.setAttribute('href', href)
head.appendChild(cssEl)
const setDynamic = () => {
const baseEl = document.createElement('div')
body.appendChild(baseEl)
let colors = {}
times(16, (n) => {
const name = `base0${n.toString(16).toUpperCase()}`
baseEl.setAttribute('class', name)
const color = window.getComputedStyle(baseEl).getPropertyValue('color')
colors[name] = color
})
body.removeChild(baseEl)
const styleEl = document.createElement('style')
head.appendChild(styleEl)
// const styleSheet = styleEl.sheet
body.classList.remove('hidden')
}
cssEl.addEventListener('load', setDynamic)
}
const rgb2rgba = function (rgba) {
return `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${rgba.a})`
}
const getTextColor = function (bg, text, preserve) {
const bgIsLight = convert(bg).hsl.l > 50
const textIsLight = convert(text).hsl.l > 50
if ((bgIsLight && textIsLight) || (!bgIsLight && !textIsLight)) {
const base = typeof text.a !== 'undefined' ? { a: text.a } : {}
const result = Object.assign(base, invertLightness(text).rgb)
if (!preserve && getContrastRatio(bg, result) < 4.5) {
return contrastRatio(bg, text).rgb
}
return result
}
return text
}
const applyTheme = (input, commit) => {
const { rules, theme } = generatePreset(input)
const head = document.head const head = document.head
const body = document.body const body = document.body
body.classList.add('hidden') body.classList.add('hidden')
@ -87,14 +18,9 @@ const applyTheme = (input, commit) => {
styleSheet.insertRule(`body { ${rules.shadows} }`, 'index-max') styleSheet.insertRule(`body { ${rules.shadows} }`, 'index-max')
styleSheet.insertRule(`body { ${rules.fonts} }`, 'index-max') styleSheet.insertRule(`body { ${rules.fonts} }`, 'index-max')
body.classList.remove('hidden') body.classList.remove('hidden')
// commit('setOption', { name: 'colors', value: htmlColors })
// commit('setOption', { name: 'radii', value: radii })
commit('setOption', { name: 'customTheme', value: input })
commit('setOption', { name: 'colors', value: theme.colors })
} }
const getCssShadow = (input, usesDropShadow) => { export const getCssShadow = (input, usesDropShadow) => {
if (input.length === 0) { if (input.length === 0) {
return 'none' return 'none'
} }
@ -132,122 +58,18 @@ const getCssShadowFilter = (input) => {
.join(' ') .join(' ')
} }
const getCssColor = (input, a) => { export const generateColors = (themeData) => {
let rgb = {} const sourceColors = !themeData.themeEngineVersion
if (typeof input === 'object') { ? colors2to3(themeData.colors || themeData)
rgb = input : themeData.colors || themeData
} else if (typeof input === 'string') {
if (input.startsWith('#')) {
rgb = hex2rgb(input)
} else if (input.startsWith('--')) {
return `var(${input})`
} else {
return input
}
}
return rgb2rgba({ ...rgb, a })
}
const generateColors = (input) => { const { colors, opacity } = getColors(sourceColors, themeData.opacity || {})
const colors = {}
const opacity = Object.assign({
alert: 0.5,
input: 0.5,
faint: 0.5
}, Object.entries(input.opacity || {}).reduce((acc, [k, v]) => {
if (typeof v !== 'undefined') {
acc[k] = v
}
return acc
}, {}))
const col = Object.entries(input.colors || input).reduce((acc, [k, v]) => {
if (typeof v === 'object') {
acc[k] = v
} else {
acc[k] = hex2rgb(v)
}
return acc
}, {})
const isLightOnDark = convert(col.bg).hsl.l < convert(col.text).hsl.l
const mod = isLightOnDark ? 1 : -1
colors.text = col.text
colors.lightText = brightness(20 * mod, colors.text).rgb
colors.link = col.link
colors.faint = col.faint || Object.assign({}, col.text)
colors.bg = col.bg
colors.lightBg = col.lightBg || brightness(5, colors.bg).rgb
colors.fg = col.fg
colors.fgText = col.fgText || getTextColor(colors.fg, colors.text)
colors.fgLink = col.fgLink || getTextColor(colors.fg, colors.link, true)
colors.border = col.border || brightness(2 * mod, colors.fg).rgb
colors.btn = col.btn || Object.assign({}, col.fg)
colors.btnText = col.btnText || getTextColor(colors.btn, colors.fgText)
colors.input = col.input || Object.assign({}, col.fg)
colors.inputText = col.inputText || getTextColor(colors.input, colors.lightText)
colors.panel = col.panel || Object.assign({}, col.fg)
colors.panelText = col.panelText || getTextColor(colors.panel, colors.fgText)
colors.panelLink = col.panelLink || getTextColor(colors.panel, colors.fgLink)
colors.panelFaint = col.panelFaint || getTextColor(colors.panel, colors.faint)
colors.topBar = col.topBar || Object.assign({}, col.fg)
colors.topBarText = col.topBarText || getTextColor(colors.topBar, colors.fgText)
colors.topBarLink = col.topBarLink || getTextColor(colors.topBar, colors.fgLink)
colors.faintLink = col.faintLink || Object.assign({}, col.link)
colors.linkBg = alphaBlend(colors.link, 0.4, colors.bg)
colors.icon = mixrgb(colors.bg, colors.text)
colors.cBlue = col.cBlue || hex2rgb('#0000FF')
colors.cRed = col.cRed || hex2rgb('#FF0000')
colors.cGreen = col.cGreen || hex2rgb('#00FF00')
colors.cOrange = col.cOrange || hex2rgb('#E3FF00')
colors.alertError = col.alertError || Object.assign({}, colors.cRed)
colors.alertErrorText = getTextColor(alphaBlend(colors.alertError, opacity.alert, colors.bg), colors.text)
colors.alertErrorPanelText = getTextColor(alphaBlend(colors.alertError, opacity.alert, colors.panel), colors.panelText)
colors.alertWarning = col.alertWarning || Object.assign({}, colors.cOrange)
colors.alertWarningText = getTextColor(alphaBlend(colors.alertWarning, opacity.alert, colors.bg), colors.text)
colors.alertWarningPanelText = getTextColor(alphaBlend(colors.alertWarning, opacity.alert, colors.panel), colors.panelText)
colors.badgeNotification = col.badgeNotification || Object.assign({}, colors.cRed)
colors.badgeNotificationText = contrastRatio(colors.badgeNotification).rgb
Object.entries(opacity).forEach(([ k, v ]) => {
if (typeof v === 'undefined') return
if (k === 'alert') {
colors.alertError.a = v
colors.alertWarning.a = v
return
}
if (k === 'faint') {
colors[k + 'Link'].a = v
colors['panelFaint'].a = v
}
if (k === 'bg') {
colors['lightBg'].a = v
}
if (colors[k]) {
colors[k].a = v
} else {
console.error('Wrong key ' + k)
}
})
const htmlColors = Object.entries(colors) const htmlColors = Object.entries(colors)
.reduce((acc, [k, v]) => { .reduce((acc, [k, v]) => {
if (!v) return acc if (!v) return acc
acc.solid[k] = rgb2hex(v) acc.solid[k] = rgb2hex(v)
acc.complete[k] = typeof v.a === 'undefined' ? rgb2hex(v) : rgb2rgba(v) acc.complete[k] = typeof v.a === 'undefined' ? rgb2hex(v) : rgba2css(v)
return acc return acc
}, { complete: {}, solid: {} }) }, { complete: {}, solid: {} })
return { return {
@ -264,7 +86,7 @@ const generateColors = (input) => {
} }
} }
const generateRadii = (input) => { export const generateRadii = (input) => {
let inputRadii = input.radii || {} let inputRadii = input.radii || {}
// v1 -> v2 // v1 -> v2
if (typeof input.btnRadius !== 'undefined') { if (typeof input.btnRadius !== 'undefined') {
@ -297,7 +119,7 @@ const generateRadii = (input) => {
} }
} }
const generateFonts = (input) => { export const generateFonts = (input) => {
const fonts = Object.entries(input.fonts || {}).filter(([k, v]) => v).reduce((acc, [k, v]) => { const fonts = Object.entries(input.fonts || {}).filter(([k, v]) => v).reduce((acc, [k, v]) => {
acc[k] = Object.entries(v).filter(([k, v]) => v).reduce((acc, [k, v]) => { acc[k] = Object.entries(v).filter(([k, v]) => v).reduce((acc, [k, v]) => {
acc[k] = v acc[k] = v
@ -332,8 +154,7 @@ const generateFonts = (input) => {
} }
} }
const generateShadows = (input) => { const border = (top, shadow) => ({
const border = (top, shadow) => ({
x: 0, x: 0,
y: top ? 1 : -1, y: top ? 1 : -1,
blur: 0, blur: 0,
@ -341,19 +162,19 @@ const generateShadows = (input) => {
color: shadow ? '#000000' : '#FFFFFF', color: shadow ? '#000000' : '#FFFFFF',
alpha: 0.2, alpha: 0.2,
inset: true inset: true
}) })
const buttonInsetFakeBorders = [border(true, false), border(false, true)] const buttonInsetFakeBorders = [border(true, false), border(false, true)]
const inputInsetFakeBorders = [border(true, true), border(false, false)] const inputInsetFakeBorders = [border(true, true), border(false, false)]
const hoverGlow = { const hoverGlow = {
x: 0, x: 0,
y: 0, y: 0,
blur: 4, blur: 4,
spread: 0, spread: 0,
color: '--faint', color: '--faint',
alpha: 1 alpha: 1
} }
const shadows = { export const DEFAULT_SHADOWS = {
panel: [{ panel: [{
x: 1, x: 1,
y: 1, y: 1,
@ -406,15 +227,50 @@ const generateShadows = (input) => {
spread: 0, spread: 0,
color: '#000000', color: '#000000',
alpha: 1 alpha: 1
}], }]
...(input.shadows || {}) }
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 inputShadows = input.shadows && !input.themeEngineVersion
? shadows2to3(input.shadows, input.opacity)
: input.shadows || {}
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 { return {
rules: { rules: {
shadows: Object shadows: Object
.entries(shadows) .entries(shadows)
// TODO for v2.1: if shadow doesn't have non-inset shadows with spread > 0 - optionally // 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 // convert all non-inset shadows into filter: drop-shadow() to boost performance
.map(([k, v]) => [ .map(([k, v]) => [
`--${k}Shadow: ${getCssShadow(v)}`, `--${k}Shadow: ${getCssShadow(v)}`,
@ -429,7 +285,7 @@ const generateShadows = (input) => {
} }
} }
const composePreset = (colors, radii, shadows, fonts) => { export const composePreset = (colors, radii, shadows, fonts) => {
return { return {
rules: { rules: {
...shadows.rules, ...shadows.rules,
@ -446,98 +302,110 @@ const composePreset = (colors, radii, shadows, fonts) => {
} }
} }
const generatePreset = (input) => { export const generatePreset = (input) => {
const shadows = generateShadows(input)
const colors = generateColors(input) const colors = generateColors(input)
const radii = generateRadii(input) return composePreset(
const fonts = generateFonts(input) colors,
generateRadii(input),
return composePreset(colors, radii, shadows, fonts) generateShadows(input, colors.theme.colors, colors.mod),
generateFonts(input)
)
} }
const getThemes = () => { export const getThemes = () => {
return window.fetch('/static/styles.json') const cache = 'no-store'
return window.fetch('/static/styles.json', { cache })
.then((data) => data.json()) .then((data) => data.json())
.then((themes) => { .then((themes) => {
return Promise.all(Object.entries(themes).map(([k, v]) => { return Object.entries(themes).map(([k, v]) => {
let promise = null
if (typeof v === 'object') { if (typeof v === 'object') {
return Promise.resolve([k, v]) promise = Promise.resolve(v)
} else if (typeof v === 'string') { } else if (typeof v === 'string') {
return window.fetch(v) promise = window.fetch(v, { cache })
.then((data) => data.json()) .then((data) => data.json())
.then((theme) => {
return [k, theme]
})
.catch((e) => { .catch((e) => {
console.error(e) console.error(e)
return [] return null
}) })
} }
})) return [k, promise]
})
}) })
.then((promises) => { .then((promises) => {
return promises return promises
.filter(([k, v]) => v)
.reduce((acc, [k, v]) => { .reduce((acc, [k, v]) => {
acc[k] = v acc[k] = v
return acc return acc
}, {}) }, {})
}) })
} }
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 }
}
}, {})
}
const setPreset = (val, commit) => { /**
return getThemes().then((themes) => { * This handles compatibility issues when importing v2 theme's shadows to current format
const theme = themes[val] ? themes[val] : themes['pleroma-dark'] *
* 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 }) => 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 getPreset = (val) => {
return getThemes()
.then((themes) => themes[val] ? themes[val] : themes['pleroma-dark'])
.then((theme) => {
const isV1 = Array.isArray(theme) const isV1 = Array.isArray(theme)
const data = isV1 ? {} : theme.theme const data = isV1 ? {} : theme.theme
if (isV1) { if (isV1) {
const bgRgb = hex2rgb(theme[1]) const bg = hex2rgb(theme[1])
const fgRgb = hex2rgb(theme[2]) const fg = hex2rgb(theme[2])
const textRgb = hex2rgb(theme[3]) const text = hex2rgb(theme[3])
const linkRgb = hex2rgb(theme[4]) const link = hex2rgb(theme[4])
const cRedRgb = hex2rgb(theme[5] || '#FF0000') const cRed = hex2rgb(theme[5] || '#FF0000')
const cGreenRgb = hex2rgb(theme[6] || '#00FF00') const cGreen = hex2rgb(theme[6] || '#00FF00')
const cBlueRgb = hex2rgb(theme[7] || '#0000FF') const cBlue = hex2rgb(theme[7] || '#0000FF')
const cOrangeRgb = hex2rgb(theme[8] || '#E3FF00') const cOrange = hex2rgb(theme[8] || '#E3FF00')
data.colors = { data.colors = { bg, fg, text, link, cRed, cBlue, cGreen, cOrange }
bg: bgRgb,
fg: fgRgb,
text: textRgb,
link: linkRgb,
cRed: cRedRgb,
cBlue: cBlueRgb,
cGreen: cGreenRgb,
cOrange: cOrangeRgb
}
} }
// This is a hack, this function is only called during initial load. return { theme: data, source: theme.source }
// We want to cancel loading the theme from config.json if we're already
// loading a theme from the persisted state.
// Needed some way of dealing with the async way of things.
// load config -> set preset -> wait for styles.json to load ->
// load persisted state -> set colors -> styles.json loaded -> set colors
if (!window.themeLoaded) {
applyTheme(data, commit)
}
}) })
} }
export { export const setPreset = (val) => getPreset(val).then(data => applyTheme(data.theme))
setStyle,
setPreset,
applyTheme,
getTextColor,
generateColors,
generateRadii,
generateShadows,
generateFonts,
generatePreset,
getThemes,
composePreset,
getCssShadow,
getCssShadowFilter
}

View file

@ -0,0 +1,631 @@
import { invertLightness, brightness } from 'chromatism'
import { alphaBlend, mixrgb } from '../color_convert/color_convert.js'
/* This is a definition of all layer combinations
* each key is a topmost layer, each value represents layer underneath
* this is essentially a simplified tree
*/
export const LAYERS = {
undelay: null, // root
topBar: null, // no transparency support
badge: null, // no transparency support
profileTint: null, // doesn't matter
fg: null,
bg: 'underlay',
highlight: 'bg',
panel: 'bg',
popover: 'bg',
selectedMenu: 'popover',
btn: 'bg',
btnPanel: 'panel',
btnTopBar: 'topBar',
input: 'bg',
inputPanel: 'panel',
inputTopBar: 'topBar',
alert: 'bg',
alertPanel: 'panel',
poll: 'bg'
}
/* By default opacity slots have 1 as default opacity
* this allows redefining it to something else
*/
export const DEFAULT_OPACITY = {
profileTint: 0.5,
alert: 0.5,
input: 0.5,
faint: 0.5,
underlay: 0.15
}
/** SUBJECT TO CHANGE IN THE FUTURE, this is all beta
* Color and opacity slots definitions. Each key represents a slot.
*
* Short-hands:
* String beginning with `--` - value after dashes treated as sole
* dependency - i.e. `--value` equivalent to { depends: ['value']}
* String beginning with `#` - value would be treated as solid color
* defined in hexadecimal representation (i.e. #FFFFFF) and will be
* used as default. `#FFFFFF` is equivalent to { default: '#FFFFFF'}
*
* Full definition:
* @property {String[]} depends - color slot names this color depends ones.
* cyclic dependencies are supported to some extent but not recommended.
* @property {String} [opacity] - opacity slot used by this color slot.
* opacity is inherited from parents. To break inheritance graph use null
* @property {Number} [priority] - EXPERIMENTAL. used to pre-sort slots so
* that slots with higher priority come earlier
* @property {Function(mod, ...colors)} [color] - function that will be
* used to determine the color. By default it just copies first color in
* dependency list.
* @argument {Number} mod - `1` (light-on-dark) or `-1` (dark-on-light)
* depending on background color (for textColor)/given color.
* @argument {...Object} deps - each argument after mod represents each
* color from `depends` array. All colors take user customizations into
* account and represented by { r, g, b } objects.
* @returns {Object} resulting color, should be in { r, g, b } form
*
* @property {Boolean|String} [textColor] - true to mark color slot as text
* color. This enables automatic text color generation for the slot. Use
* 'preserve' string if you don't want text color to fall back to
* black/white. Use 'bw' to only ever use black or white. This also makes
* following properties required:
* @property {String} [layer] - which layer the text sit on top on - used
* to account for transparency in text color calculation
* layer is inherited from parents. To break inheritance graph use null
* @property {String} [variant] - which color slot is background (same as
* above, used to account for transparency)
*/
export const SLOT_INHERITANCE = {
bg: {
depends: [],
opacity: 'bg',
priority: 1
},
fg: {
depends: [],
priority: 1
},
text: {
depends: [],
layer: 'bg',
opacity: null,
priority: 1
},
underlay: {
default: '#000000',
opacity: 'underlay'
},
link: {
depends: ['accent'],
priority: 1
},
accent: {
depends: ['link'],
priority: 1
},
faint: {
depends: ['text'],
opacity: 'faint'
},
faintLink: {
depends: ['link'],
opacity: 'faint'
},
postFaintLink: {
depends: ['postLink'],
opacity: 'faint'
},
cBlue: '#0000ff',
cRed: '#FF0000',
cGreen: '#00FF00',
cOrange: '#E3FF00',
profileBg: {
depends: ['bg'],
color: (mod, bg) => ({
r: Math.floor(bg.r * 0.53),
g: Math.floor(bg.g * 0.56),
b: Math.floor(bg.b * 0.59)
})
},
profileTint: {
depends: ['bg'],
layer: 'profileTint',
opacity: 'profileTint'
},
highlight: {
depends: ['bg'],
color: (mod, bg) => brightness(5 * mod, bg).rgb
},
highlightLightText: {
depends: ['lightText'],
layer: 'highlight',
textColor: true
},
highlightPostLink: {
depends: ['postLink'],
layer: 'highlight',
textColor: 'preserve'
},
highlightFaintText: {
depends: ['faint'],
layer: 'highlight',
textColor: true
},
highlightFaintLink: {
depends: ['faintLink'],
layer: 'highlight',
textColor: 'preserve'
},
highlightPostFaintLink: {
depends: ['postFaintLink'],
layer: 'highlight',
textColor: 'preserve'
},
highlightText: {
depends: ['text'],
layer: 'highlight',
textColor: true
},
highlightLink: {
depends: ['link'],
layer: 'highlight',
textColor: 'preserve'
},
highlightIcon: {
depends: ['highlight', 'highlightText'],
color: (mod, bg, text) => mixrgb(bg, text)
},
popover: {
depends: ['bg'],
opacity: 'popover'
},
popoverLightText: {
depends: ['lightText'],
layer: 'popover',
textColor: true
},
popoverPostLink: {
depends: ['postLink'],
layer: 'popover',
textColor: 'preserve'
},
popoverFaintText: {
depends: ['faint'],
layer: 'popover',
textColor: true
},
popoverFaintLink: {
depends: ['faintLink'],
layer: 'popover',
textColor: 'preserve'
},
popoverPostFaintLink: {
depends: ['postFaintLink'],
layer: 'popover',
textColor: 'preserve'
},
popoverText: {
depends: ['text'],
layer: 'popover',
textColor: true
},
popoverLink: {
depends: ['link'],
layer: 'popover',
textColor: 'preserve'
},
popoverIcon: {
depends: ['popover', 'popoverText'],
color: (mod, bg, text) => mixrgb(bg, text)
},
selectedPost: '--highlight',
selectedPostFaintText: {
depends: ['highlightFaintText'],
layer: 'highlight',
variant: 'selectedPost',
textColor: true
},
selectedPostLightText: {
depends: ['highlightLightText'],
layer: 'highlight',
variant: 'selectedPost',
textColor: true
},
selectedPostPostLink: {
depends: ['highlightPostLink'],
layer: 'highlight',
variant: 'selectedPost',
textColor: 'preserve'
},
selectedPostFaintLink: {
depends: ['highlightFaintLink'],
layer: 'highlight',
variant: 'selectedPost',
textColor: 'preserve'
},
selectedPostText: {
depends: ['highlightText'],
layer: 'highlight',
variant: 'selectedPost',
textColor: true
},
selectedPostLink: {
depends: ['highlightLink'],
layer: 'highlight',
variant: 'selectedPost',
textColor: 'preserve'
},
selectedPostIcon: {
depends: ['selectedPost', 'selectedPostText'],
color: (mod, bg, text) => mixrgb(bg, text)
},
selectedMenu: {
depends: ['bg'],
color: (mod, bg) => brightness(5 * mod, bg).rgb
},
selectedMenuLightText: {
depends: ['highlightLightText'],
layer: 'selectedMenu',
variant: 'selectedMenu',
textColor: true
},
selectedMenuFaintText: {
depends: ['highlightFaintText'],
layer: 'selectedMenu',
variant: 'selectedMenu',
textColor: true
},
selectedMenuFaintLink: {
depends: ['highlightFaintLink'],
layer: 'selectedMenu',
variant: 'selectedMenu',
textColor: 'preserve'
},
selectedMenuText: {
depends: ['highlightText'],
layer: 'selectedMenu',
variant: 'selectedMenu',
textColor: true
},
selectedMenuLink: {
depends: ['highlightLink'],
layer: 'selectedMenu',
variant: 'selectedMenu',
textColor: 'preserve'
},
selectedMenuIcon: {
depends: ['selectedMenu', 'selectedMenuText'],
color: (mod, bg, text) => mixrgb(bg, text)
},
selectedMenuPopover: {
depends: ['popover'],
color: (mod, bg) => brightness(5 * mod, bg).rgb
},
selectedMenuPopoverLightText: {
depends: ['selectedMenuLightText'],
layer: 'selectedMenuPopover',
variant: 'selectedMenuPopover',
textColor: true
},
selectedMenuPopoverFaintText: {
depends: ['selectedMenuFaintText'],
layer: 'selectedMenuPopover',
variant: 'selectedMenuPopover',
textColor: true
},
selectedMenuPopoverFaintLink: {
depends: ['selectedMenuFaintLink'],
layer: 'selectedMenuPopover',
variant: 'selectedMenuPopover',
textColor: 'preserve'
},
selectedMenuPopoverText: {
depends: ['selectedMenuText'],
layer: 'selectedMenuPopover',
variant: 'selectedMenuPopover',
textColor: true
},
selectedMenuPopoverLink: {
depends: ['selectedMenuLink'],
layer: 'selectedMenuPopover',
variant: 'selectedMenuPopover',
textColor: 'preserve'
},
selectedMenuPopoverIcon: {
depends: ['selectedMenuPopover', 'selectedMenuText'],
color: (mod, bg, text) => mixrgb(bg, text)
},
lightText: {
depends: ['text'],
layer: 'bg',
textColor: 'preserve',
color: (mod, text) => brightness(20 * mod, text).rgb
},
postLink: {
depends: ['link'],
layer: 'bg',
textColor: 'preserve'
},
border: {
depends: ['fg'],
opacity: 'border',
color: (mod, fg) => brightness(2 * mod, fg).rgb
},
poll: {
depends: ['accent', 'bg'],
copacity: 'poll',
color: (mod, accent, bg) => alphaBlend(accent, 0.4, bg)
},
pollText: {
depends: ['text'],
layer: 'poll',
textColor: true
},
icon: {
depends: ['bg', 'text'],
inheritsOpacity: false,
color: (mod, bg, text) => mixrgb(bg, text)
},
// Foreground
fgText: {
depends: ['text'],
layer: 'fg',
textColor: true
},
fgLink: {
depends: ['link'],
layer: 'fg',
textColor: 'preserve'
},
// Panel header
panel: {
depends: ['fg'],
opacity: 'panel'
},
panelText: {
depends: ['text'],
layer: 'panel',
textColor: true
},
panelFaint: {
depends: ['fgText'],
layer: 'panel',
opacity: 'faint',
textColor: true
},
panelLink: {
depends: ['fgLink'],
layer: 'panel',
textColor: 'preserve'
},
// Top bar
topBar: '--fg',
topBarText: {
depends: ['fgText'],
layer: 'topBar',
textColor: true
},
topBarLink: {
depends: ['fgLink'],
layer: 'topBar',
textColor: 'preserve'
},
// Tabs
tab: {
depends: ['btn']
},
tabText: {
depends: ['btnText'],
layer: 'btn',
textColor: true
},
tabActiveText: {
depends: ['text'],
layer: 'bg',
textColor: true
},
// Buttons
btn: {
depends: ['fg'],
variant: 'btn',
opacity: 'btn'
},
btnText: {
depends: ['fgText'],
layer: 'btn',
textColor: true
},
btnPanelText: {
depends: ['btnText'],
layer: 'btnPanel',
variant: 'btn',
textColor: true
},
btnTopBarText: {
depends: ['btnText'],
layer: 'btnTopBar',
variant: 'btn',
textColor: true
},
// Buttons: pressed
btnPressed: {
depends: ['btn'],
layer: 'btn'
},
btnPressedText: {
depends: ['btnText'],
layer: 'btn',
variant: 'btnPressed',
textColor: true
},
btnPressedPanel: {
depends: ['btnPressed'],
layer: 'btn'
},
btnPressedPanelText: {
depends: ['btnPanelText'],
layer: 'btnPanel',
variant: 'btnPressed',
textColor: true
},
btnPressedTopBar: {
depends: ['btnPressed'],
layer: 'btn'
},
btnPressedTopBarText: {
depends: ['btnTopBarText'],
layer: 'btnTopBar',
variant: 'btnPressed',
textColor: true
},
// Buttons: toggled
btnToggled: {
depends: ['btn'],
layer: 'btn',
color: (mod, btn) => brightness(mod * 20, btn).rgb
},
btnToggledText: {
depends: ['btnText'],
layer: 'btn',
variant: 'btnToggled',
textColor: true
},
btnToggledPanelText: {
depends: ['btnPanelText'],
layer: 'btnPanel',
variant: 'btnToggled',
textColor: true
},
btnToggledTopBarText: {
depends: ['btnTopBarText'],
layer: 'btnTopBar',
variant: 'btnToggled',
textColor: true
},
// Buttons: disabled
btnDisabled: {
depends: ['btn', 'bg'],
color: (mod, btn, bg) => alphaBlend(btn, 0.25, bg)
},
btnDisabledText: {
depends: ['btnText', 'btnDisabled'],
layer: 'btn',
variant: 'btnDisabled',
color: (mod, text, btn) => alphaBlend(text, 0.25, btn)
},
btnDisabledPanelText: {
depends: ['btnPanelText', 'btnDisabled'],
layer: 'btnPanel',
variant: 'btnDisabled',
color: (mod, text, btn) => alphaBlend(text, 0.25, btn)
},
btnDisabledTopBarText: {
depends: ['btnTopBarText', 'btnDisabled'],
layer: 'btnTopBar',
variant: 'btnDisabled',
color: (mod, text, btn) => alphaBlend(text, 0.25, btn)
},
// Input fields
input: {
depends: ['fg'],
opacity: 'input'
},
inputText: {
depends: ['text'],
layer: 'input',
textColor: true
},
inputPanelText: {
depends: ['panelText'],
layer: 'inputPanel',
variant: 'input',
textColor: true
},
inputTopbarText: {
depends: ['topBarText'],
layer: 'inputTopBar',
variant: 'input',
textColor: true
},
alertError: {
depends: ['cRed'],
opacity: 'alert'
},
alertErrorText: {
depends: ['text'],
layer: 'alert',
variant: 'alertError',
textColor: true
},
alertErrorPanelText: {
depends: ['panelText'],
layer: 'alertPanel',
variant: 'alertError',
textColor: true
},
alertWarning: {
depends: ['cOrange'],
opacity: 'alert'
},
alertWarningText: {
depends: ['text'],
layer: 'alert',
variant: 'alertWarning',
textColor: true
},
alertWarningPanelText: {
depends: ['panelText'],
layer: 'alertPanel',
variant: 'alertWarning',
textColor: true
},
alertNeutral: {
depends: ['text'],
opacity: 'alert'
},
alertNeutralText: {
depends: ['text'],
layer: 'alert',
variant: 'alertNeutral',
color: (mod, text) => invertLightness(text).rgb,
textColor: true
},
alertNeutralPanelText: {
depends: ['panelText'],
layer: 'alertPanel',
variant: 'alertNeutral',
textColor: true
},
badgeNotification: '--cRed',
badgeNotificationText: {
depends: ['text', 'badgeNotification'],
layer: 'badge',
variant: 'badgeNotification',
textColor: 'bw'
}
}

View file

@ -0,0 +1,374 @@
import { convert, brightness, contrastRatio } from 'chromatism'
import { alphaBlendLayers, getTextColor, relativeLuminance } from '../color_convert/color_convert.js'
import { LAYERS, DEFAULT_OPACITY, 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) => {
let 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)) {
console.debug('Cyclic depenency in topoSort, ignoring')
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())
}
return output.sort((a, b) => {
const depsA = getDeps(a, inheritance).length
const depsB = getDeps(b, inheritance).length
if (depsA === depsB || (depsB !== 0 && depsA !== 0)) return 0
if (depsA === 0 && depsB !== 0) return -1
if (depsB === 0 && depsA !== 0) return 1
})
}
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(([aK, aV], [bK, bV]) => ((aV && aV.priority) || 0) - ((bV && 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, v]) => {
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 = getOpacitySlot(key)
const ownOpacitySlot = value.opacity
if (opacitySlot && (outputColor.a === undefined || ownOpacitySlot)) {
const dependencySlot = deps[0]
if (dependencySlot && colors[dependencySlot] === 'transparent') {
outputColor.a = 0
} else {
outputColor.a = Number(sourceOpacity[opacitySlot]) || OPACITIES[opacitySlot].defaultValue || 1
}
}
if (opacitySlot) {
return {
colors: { ...colors, [key]: outputColor },
opacity: { ...opacity, [opacitySlot]: outputColor.a }
}
} else {
return {
colors: { ...colors, [key]: outputColor },
opacity
}
}
}, { colors: {}, opacity: {} })

View file

@ -1,33 +0,0 @@
.base00-background { background-color: #090300; }
.base01-background { background-color: #3a3432; }
.base02-background { background-color: #4a4543; }
.base03-background { background-color: #5c5855; }
.base04-background { background-color: #807d7c; }
.base05-background { background-color: #a5a2a2; }
.base06-background { background-color: #d6d5d4; }
.base07-background { background-color: #f7f7f7; }
.base08-background { background-color: #db2d20; }
.base09-background { background-color: #e8bbd0; }
.base0A-background { background-color: #fded02; }
.base0B-background { background-color: #01a252; }
.base0C-background { background-color: #b5e4f4; }
.base0D-background { background-color: #01a0e4; }
.base0E-background { background-color: #a16a94; }
.base0F-background { background-color: #cdab53; }
.base00 { color: #090300; }
.base01 { color: #3a3432; }
.base02 { color: #4a4543; }
.base03 { color: #5c5855; }
.base04 { color: #807d7c; }
.base05 { color: #a5a2a2; }
.base06 { color: #d6d5d4; }
.base07 { color: #f7f7f7; }
.base08 { color: #db2d20; }
.base09 { color: #e8bbd0; }
.base0A { color: #fded02; }
.base0B { color: #01a252; }
.base0C { color: #b5e4f4; }
.base0D { color: #01a0e4; }
.base0E { color: #a16a94; }
.base0F { color: #cdab53; }

View file

@ -1,33 +0,0 @@
.base00-background { background-color: #031A16; }
.base01-background { background-color: #0B342D; }
.base02-background { background-color: #184E45; }
.base03-background { background-color: #2B685E; }
.base04-background { background-color: #5F9C92; }
.base05-background { background-color: #81B5AC; }
.base06-background { background-color: #A7CEC8; }
.base07-background { background-color: #D2E7E4; }
.base08-background { background-color: #3E9688; }
.base09-background { background-color: #3E7996; }
.base0A-background { background-color: #3E4C96; }
.base0B-background { background-color: #883E96; }
.base0C-background { background-color: #963E4C; }
.base0D-background { background-color: #96883E; }
.base0E-background { background-color: #4C963E; }
.base0F-background { background-color: #3E965B; }
.base00 { color: #031A16; }
.base01 { color: #0B342D; }
.base02 { color: #184E45; }
.base03 { color: #2B685E; }
.base04 { color: #5F9C92; }
.base05 { color: #81B5AC; }
.base06 { color: #A7CEC8; }
.base07 { color: #D2E7E4; }
.base08 { color: #3E9688; }
.base09 { color: #3E7996; }
.base0A { color: #3E4C96; }
.base0B { color: #883E96; }
.base0C { color: #963E4C; }
.base0D { color: #96883E; }
.base0E { color: #4C963E; }
.base0F { color: #3E965B; }

View file

@ -1,33 +0,0 @@
.base00-background { background-color: #1C2023; }
.base01-background { background-color: #393F45; }
.base02-background { background-color: #565E65; }
.base03-background { background-color: #747C84; }
.base04-background { background-color: #ADB3BA; }
.base05-background { background-color: #C7CCD1; }
.base06-background { background-color: #DFE2E5; }
.base07-background { background-color: #F3F4F5; }
.base08-background { background-color: #C7AE95; }
.base09-background { background-color: #C7C795; }
.base0A-background { background-color: #AEC795; }
.base0B-background { background-color: #95C7AE; }
.base0C-background { background-color: #95AEC7; }
.base0D-background { background-color: #AE95C7; }
.base0E-background { background-color: #C795AE; }
.base0F-background { background-color: #C79595; }
.base00 { color: #1C2023; }
.base01 { color: #393F45; }
.base02 { color: #565E65; }
.base03 { color: #747C84; }
.base04 { color: #ADB3BA; }
.base05 { color: #C7CCD1; }
.base06 { color: #DFE2E5; }
.base07 { color: #F3F4F5; }
.base08 { color: #C7AE95; }
.base09 { color: #C7C795; }
.base0A { color: #AEC795; }
.base0B { color: #95C7AE; }
.base0C { color: #95AEC7; }
.base0D { color: #AE95C7; }
.base0E { color: #C795AE; }
.base0F { color: #C79595; }

View file

@ -1,33 +0,0 @@
.base00-background { background-color: #19171c; }
.base01-background { background-color: #26232a; }
.base02-background { background-color: #585260; }
.base03-background { background-color: #655f6d; }
.base04-background { background-color: #7e7887; }
.base05-background { background-color: #8b8792; }
.base06-background { background-color: #e2dfe7; }
.base07-background { background-color: #efecf4; }
.base08-background { background-color: #be4678; }
.base09-background { background-color: #aa573c; }
.base0A-background { background-color: #a06e3b; }
.base0B-background { background-color: #2a9292; }
.base0C-background { background-color: #398bc6; }
.base0D-background { background-color: #576ddb; }
.base0E-background { background-color: #955ae7; }
.base0F-background { background-color: #bf40bf; }
.base00 { color: #19171c; }
.base01 { color: #26232a; }
.base02 { color: #585260; }
.base03 { color: #655f6d; }
.base04 { color: #7e7887; }
.base05 { color: #8b8792; }
.base06 { color: #e2dfe7; }
.base07 { color: #efecf4; }
.base08 { color: #be4678; }
.base09 { color: #aa573c; }
.base0A { color: #a06e3b; }
.base0B { color: #2a9292; }
.base0C { color: #398bc6; }
.base0D { color: #576ddb; }
.base0E { color: #955ae7; }
.base0F { color: #bf40bf; }

View file

@ -1,33 +0,0 @@
.base00-background { background-color: #20201d; }
.base01-background { background-color: #292824; }
.base02-background { background-color: #6e6b5e; }
.base03-background { background-color: #7d7a68; }
.base04-background { background-color: #999580; }
.base05-background { background-color: #a6a28c; }
.base06-background { background-color: #e8e4cf; }
.base07-background { background-color: #fefbec; }
.base08-background { background-color: #d73737; }
.base09-background { background-color: #b65611; }
.base0A-background { background-color: #ae9513; }
.base0B-background { background-color: #60ac39; }
.base0C-background { background-color: #1fad83; }
.base0D-background { background-color: #6684e1; }
.base0E-background { background-color: #b854d4; }
.base0F-background { background-color: #d43552; }
.base00 { color: #20201d; }
.base01 { color: #292824; }
.base02 { color: #6e6b5e; }
.base03 { color: #7d7a68; }
.base04 { color: #999580; }
.base05 { color: #a6a28c; }
.base06 { color: #e8e4cf; }
.base07 { color: #fefbec; }
.base08 { color: #d73737; }
.base09 { color: #b65611; }
.base0A { color: #ae9513; }
.base0B { color: #60ac39; }
.base0C { color: #1fad83; }
.base0D { color: #6684e1; }
.base0E { color: #b854d4; }
.base0F { color: #d43552; }

View file

@ -1,33 +0,0 @@
.base00-background { background-color: #22221b; }
.base01-background { background-color: #302f27; }
.base02-background { background-color: #5f5e4e; }
.base03-background { background-color: #6c6b5a; }
.base04-background { background-color: #878573; }
.base05-background { background-color: #929181; }
.base06-background { background-color: #e7e6df; }
.base07-background { background-color: #f4f3ec; }
.base08-background { background-color: #ba6236; }
.base09-background { background-color: #ae7313; }
.base0A-background { background-color: #a5980d; }
.base0B-background { background-color: #7d9726; }
.base0C-background { background-color: #5b9d48; }
.base0D-background { background-color: #36a166; }
.base0E-background { background-color: #5f9182; }
.base0F-background { background-color: #9d6c7c; }
.base00 { color: #22221b; }
.base01 { color: #302f27; }
.base02 { color: #5f5e4e; }
.base03 { color: #6c6b5a; }
.base04 { color: #878573; }
.base05 { color: #929181; }
.base06 { color: #e7e6df; }
.base07 { color: #f4f3ec; }
.base08 { color: #ba6236; }
.base09 { color: #ae7313; }
.base0A { color: #a5980d; }
.base0B { color: #7d9726; }
.base0C { color: #5b9d48; }
.base0D { color: #36a166; }
.base0E { color: #5f9182; }
.base0F { color: #9d6c7c; }

View file

@ -1,33 +0,0 @@
.base00-background { background-color: #1b1918; }
.base01-background { background-color: #2c2421; }
.base02-background { background-color: #68615e; }
.base03-background { background-color: #766e6b; }
.base04-background { background-color: #9c9491; }
.base05-background { background-color: #a8a19f; }
.base06-background { background-color: #e6e2e0; }
.base07-background { background-color: #f1efee; }
.base08-background { background-color: #f22c40; }
.base09-background { background-color: #df5320; }
.base0A-background { background-color: #c38418; }
.base0B-background { background-color: #7b9726; }
.base0C-background { background-color: #3d97b8; }
.base0D-background { background-color: #407ee7; }
.base0E-background { background-color: #6666ea; }
.base0F-background { background-color: #c33ff3; }
.base00 { color: #1b1918; }
.base01 { color: #2c2421; }
.base02 { color: #68615e; }
.base03 { color: #766e6b; }
.base04 { color: #9c9491; }
.base05 { color: #a8a19f; }
.base06 { color: #e6e2e0; }
.base07 { color: #f1efee; }
.base08 { color: #f22c40; }
.base09 { color: #df5320; }
.base0A { color: #c38418; }
.base0B { color: #7b9726; }
.base0C { color: #3d97b8; }
.base0D { color: #407ee7; }
.base0E { color: #6666ea; }
.base0F { color: #c33ff3; }

View file

@ -1,33 +0,0 @@
.base00-background { background-color: #1b181b; }
.base01-background { background-color: #292329; }
.base02-background { background-color: #695d69; }
.base03-background { background-color: #776977; }
.base04-background { background-color: #9e8f9e; }
.base05-background { background-color: #ab9bab; }
.base06-background { background-color: #d8cad8; }
.base07-background { background-color: #f7f3f7; }
.base08-background { background-color: #ca402b; }
.base09-background { background-color: #a65926; }
.base0A-background { background-color: #bb8a35; }
.base0B-background { background-color: #918b3b; }
.base0C-background { background-color: #159393; }
.base0D-background { background-color: #516aec; }
.base0E-background { background-color: #7b59c0; }
.base0F-background { background-color: #cc33cc; }
.base00 { color: #1b181b; }
.base01 { color: #292329; }
.base02 { color: #695d69; }
.base03 { color: #776977; }
.base04 { color: #9e8f9e; }
.base05 { color: #ab9bab; }
.base06 { color: #d8cad8; }
.base07 { color: #f7f3f7; }
.base08 { color: #ca402b; }
.base09 { color: #a65926; }
.base0A { color: #bb8a35; }
.base0B { color: #918b3b; }
.base0C { color: #159393; }
.base0D { color: #516aec; }
.base0E { color: #7b59c0; }
.base0F { color: #cc33cc; }

View file

@ -1,33 +0,0 @@
.base00-background { background-color: #161b1d; }
.base01-background { background-color: #1f292e; }
.base02-background { background-color: #516d7b; }
.base03-background { background-color: #5a7b8c; }
.base04-background { background-color: #7195a8; }
.base05-background { background-color: #7ea2b4; }
.base06-background { background-color: #c1e4f6; }
.base07-background { background-color: #ebf8ff; }
.base08-background { background-color: #d22d72; }
.base09-background { background-color: #935c25; }
.base0A-background { background-color: #8a8a0f; }
.base0B-background { background-color: #568c3b; }
.base0C-background { background-color: #2d8f6f; }
.base0D-background { background-color: #257fad; }
.base0E-background { background-color: #6b6bb8; }
.base0F-background { background-color: #b72dd2; }
.base00 { color: #161b1d; }
.base01 { color: #1f292e; }
.base02 { color: #516d7b; }
.base03 { color: #5a7b8c; }
.base04 { color: #7195a8; }
.base05 { color: #7ea2b4; }
.base06 { color: #c1e4f6; }
.base07 { color: #ebf8ff; }
.base08 { color: #d22d72; }
.base09 { color: #935c25; }
.base0A { color: #8a8a0f; }
.base0B { color: #568c3b; }
.base0C { color: #2d8f6f; }
.base0D { color: #257fad; }
.base0E { color: #6b6bb8; }
.base0F { color: #b72dd2; }

View file

@ -1,33 +0,0 @@
.base00-background { background-color: #1b1818; }
.base01-background { background-color: #292424; }
.base02-background { background-color: #585050; }
.base03-background { background-color: #655d5d; }
.base04-background { background-color: #7e7777; }
.base05-background { background-color: #8a8585; }
.base06-background { background-color: #e7dfdf; }
.base07-background { background-color: #f4ecec; }
.base08-background { background-color: #ca4949; }
.base09-background { background-color: #b45a3c; }
.base0A-background { background-color: #a06e3b; }
.base0B-background { background-color: #4b8b8b; }
.base0C-background { background-color: #5485b6; }
.base0D-background { background-color: #7272ca; }
.base0E-background { background-color: #8464c4; }
.base0F-background { background-color: #bd5187; }
.base00 { color: #1b1818; }
.base01 { color: #292424; }
.base02 { color: #585050; }
.base03 { color: #655d5d; }
.base04 { color: #7e7777; }
.base05 { color: #8a8585; }
.base06 { color: #e7dfdf; }
.base07 { color: #f4ecec; }
.base08 { color: #ca4949; }
.base09 { color: #b45a3c; }
.base0A { color: #a06e3b; }
.base0B { color: #4b8b8b; }
.base0C { color: #5485b6; }
.base0D { color: #7272ca; }
.base0E { color: #8464c4; }
.base0F { color: #bd5187; }

View file

@ -1,33 +0,0 @@
.base00-background { background-color: #171c19; }
.base01-background { background-color: #232a25; }
.base02-background { background-color: #526057; }
.base03-background { background-color: #5f6d64; }
.base04-background { background-color: #78877d; }
.base05-background { background-color: #87928a; }
.base06-background { background-color: #dfe7e2; }
.base07-background { background-color: #ecf4ee; }
.base08-background { background-color: #b16139; }
.base09-background { background-color: #9f713c; }
.base0A-background { background-color: #a07e3b; }
.base0B-background { background-color: #489963; }
.base0C-background { background-color: #1c9aa0; }
.base0D-background { background-color: #478c90; }
.base0E-background { background-color: #55859b; }
.base0F-background { background-color: #867469; }
.base00 { color: #171c19; }
.base01 { color: #232a25; }
.base02 { color: #526057; }
.base03 { color: #5f6d64; }
.base04 { color: #78877d; }
.base05 { color: #87928a; }
.base06 { color: #dfe7e2; }
.base07 { color: #ecf4ee; }
.base08 { color: #b16139; }
.base09 { color: #9f713c; }
.base0A { color: #a07e3b; }
.base0B { color: #489963; }
.base0C { color: #1c9aa0; }
.base0D { color: #478c90; }
.base0E { color: #55859b; }
.base0F { color: #867469; }

View file

@ -1,33 +0,0 @@
.base00-background { background-color: #131513; }
.base01-background { background-color: #242924; }
.base02-background { background-color: #5e6e5e; }
.base03-background { background-color: #687d68; }
.base04-background { background-color: #809980; }
.base05-background { background-color: #8ca68c; }
.base06-background { background-color: #cfe8cf; }
.base07-background { background-color: #f4fbf4; }
.base08-background { background-color: #e6193c; }
.base09-background { background-color: #87711d; }
.base0A-background { background-color: #98981b; }
.base0B-background { background-color: #29a329; }
.base0C-background { background-color: #1999b3; }
.base0D-background { background-color: #3d62f5; }
.base0E-background { background-color: #ad2bee; }
.base0F-background { background-color: #e619c3; }
.base00 { color: #131513; }
.base01 { color: #242924; }
.base02 { color: #5e6e5e; }
.base03 { color: #687d68; }
.base04 { color: #809980; }
.base05 { color: #8ca68c; }
.base06 { color: #cfe8cf; }
.base07 { color: #f4fbf4; }
.base08 { color: #e6193c; }
.base09 { color: #87711d; }
.base0A { color: #98981b; }
.base0B { color: #29a329; }
.base0C { color: #1999b3; }
.base0D { color: #3d62f5; }
.base0E { color: #ad2bee; }
.base0F { color: #e619c3; }

View file

@ -1,33 +0,0 @@
.base00-background { background-color: #202746; }
.base01-background { background-color: #293256; }
.base02-background { background-color: #5e6687; }
.base03-background { background-color: #6b7394; }
.base04-background { background-color: #898ea4; }
.base05-background { background-color: #979db4; }
.base06-background { background-color: #dfe2f1; }
.base07-background { background-color: #f5f7ff; }
.base08-background { background-color: #c94922; }
.base09-background { background-color: #c76b29; }
.base0A-background { background-color: #c08b30; }
.base0B-background { background-color: #ac9739; }
.base0C-background { background-color: #22a2c9; }
.base0D-background { background-color: #3d8fd1; }
.base0E-background { background-color: #6679cc; }
.base0F-background { background-color: #9c637a; }
.base00 { color: #202746; }
.base01 { color: #293256; }
.base02 { color: #5e6687; }
.base03 { color: #6b7394; }
.base04 { color: #898ea4; }
.base05 { color: #979db4; }
.base06 { color: #dfe2f1; }
.base07 { color: #f5f7ff; }
.base08 { color: #c94922; }
.base09 { color: #c76b29; }
.base0A { color: #c08b30; }
.base0B { color: #ac9739; }
.base0C { color: #22a2c9; }
.base0D { color: #3d8fd1; }
.base0E { color: #6679cc; }
.base0F { color: #9c637a; }

Some files were not shown because too many files have changed in this diff Show more