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/).
## [Unreleased]
## [2.0.0] - 2020-02-28
### 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
- Private mode support
- Support for 'Move' type notifications
- Pleroma AMOLED dark theme
- User level domain mutes, under User Settings -> Mutes
- Emoji reactions for statuses
- MRF keyword policy disclosure
### 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
- Notifications column now cleans itself up to optimize performance when tab is left open for a long time
- 403 messaging
### 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
- Registration fixed
- 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
### Added

View File

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

View File

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

View File

@ -31,9 +31,12 @@ h4 {
margin: auto;
min-height: 100vh;
max-width: 980px;
background-color: rgba(0,0,0,0.15);
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-align: center;
@ -75,7 +78,7 @@ button {
border-radius: $fallback--btnRadius;
border-radius: var(--btnRadius, $fallback--btnRadius);
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);
font-size: 14px;
font-family: sans-serif;
@ -98,18 +101,39 @@ button {
&: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: 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 {
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 {
color: $fallback--faint;
color: var(--faint, $fallback--faint);
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg)
&.toggled {
color: $fallback--text;
color: var(--btnToggledText, $fallback--text);
background-color: $fallback--fg;
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 {
@ -121,12 +145,15 @@ button {
}
}
label.select {
padding: 0;
input, textarea, .select, .input {
}
&.unstyled {
border-radius: 0;
background: none;
box-shadow: none;
height: unset;
}
input, textarea, .select {
border: none;
border-radius: $fallback--inputRadius;
border-radius: var(--inputRadius, $fallback--inputRadius);
@ -140,13 +167,17 @@ input, textarea, .select {
font-family: var(--inputFont, sans-serif);
font-size: 14px;
margin: 0;
padding: 8px .5em;
box-sizing: border-box;
display: inline-block;
position: relative;
height: 28px;
line-height: 16px;
hyphens: none;
padding: 8px .5em;
&.select {
padding: 0;
}
&:disabled, &[disabled=disabled] {
cursor: not-allowed;
@ -160,7 +191,7 @@ input, textarea, .select {
right: 5px;
height: 100%;
color: $fallback--text;
color: var(--text, $fallback--text);
color: var(--inputText, $fallback--text);
line-height: 28px;
z-index: 0;
pointer-events: none;
@ -198,7 +229,7 @@ input, textarea, .select {
&:checked + label::before {
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;
background-color: var(--link, $fallback--link);
background-color: var(--accent, $fallback--link);
}
&:disabled {
&,
@ -235,7 +266,7 @@ input, textarea, .select {
display: none;
&:checked + label::before {
color: $fallback--text;
color: var(--text, $fallback--text);
color: var(--inputText, $fallback--text);
}
&:disabled {
&,
@ -353,6 +384,33 @@ i[class*=icon-] {
height: 50px;
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 {
display: flex;
position: absolute;
@ -487,6 +545,10 @@ main-router {
color: $fallback--faint;
color: var(--panelFaint, $fallback--faint);
}
.faint-link {
color: $fallback--faint;
color: var(--faintLink, $fallback--faint);
}
.alert {
white-space: nowrap;
@ -509,6 +571,30 @@ main-router {
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 {
color: $fallback--link;
color: var(--panelLink, $fallback--link)

View File

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

View File

@ -27,3 +27,5 @@ $fallback--tooltipRadius: 5px;
$fallback--avatarRadius: 4px;
$fallback--avatarAltRadius: 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 { getOrCreateApp, getClientToken } from '../services/new_api/oauth.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 }) => {
try {
@ -185,12 +187,9 @@ const getAppSecret = async ({ store }) => {
})
}
const resolveStaffAccounts = async ({ store, accounts }) => {
const backendInteractor = store.state.api.backendInteractor
let nicknames = accounts.map(uri => uri.split('/').pop())
.map(id => backendInteractor.fetchUser({ id }))
nicknames = await Promise.all(nicknames)
const resolveStaffAccounts = ({ store, accounts }) => {
const nicknames = accounts.map(uri => uri.split('/').pop())
nicknames.map(nickname => store.dispatch('fetchUser', nickname))
store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames })
}
@ -224,9 +223,16 @@ const getNodeInfo = async ({ store }) => {
const frontendVersion = window.___pleromafe_commit_hash
store.dispatch('setInstanceOption', { name: 'frontendVersion', value: frontendVersion })
store.dispatch('setInstanceOption', { name: 'tagPolicyAvailable', value: metadata.federation.mrf_policies.includes('TagPolicy') })
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: 'federating',
@ -236,7 +242,7 @@ const getNodeInfo = async ({ store }) => {
})
const accounts = metadata.staffAccounts
await resolveStaffAccounts({ store, accounts })
resolveStaffAccounts({ store, accounts })
} else {
throw (res)
}
@ -261,7 +267,7 @@ const checkOAuthToken = async ({ store }) => {
try {
await store.dispatch('loginUser', store.getters.getUserToken())
} catch (e) {
console.log(e)
console.error(e)
}
}
resolve()
@ -269,23 +275,29 @@ const checkOAuthToken = async ({ store }) => {
}
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()
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
await Promise.all([
checkOAuthToken({ store }),
setConfig({ store }),
getTOS({ store }),
getInstancePanel({ store }),
getStickers({ store }),

View File

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

View File

@ -1,13 +1,13 @@
<template>
<div class="account-actions">
<v-popover
<Popover
trigger="click"
class="account-tools-popover"
:container="false"
placement="bottom-end"
:offset="5"
placement="bottom"
>
<div slot="popover">
<div
slot="content"
class="account-tools-popover"
>
<div class="dropdown-menu">
<template v-if="user.following">
<button
@ -51,10 +51,13 @@
</button>
</div>
</div>
<div class="btn btn-default ellipsis-button">
<div
slot="trigger"
class="btn btn-default ellipsis-button"
>
<i class="icon-ellipsis trigger-button" />
</div>
</v-popover>
</Popover>
</div>
</template>
@ -62,7 +65,6 @@
<style lang="scss">
@import '../../_variables.scss';
@import '../popper/popper.scss';
.account-actions {
margin: 0 .8em;
}
@ -70,6 +72,7 @@
.account-actions button.dropdown-item {
margin-left: 0;
}
.account-actions .trigger-button {
color: $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 nsfwImage from '../../assets/nsfw.png'
import fileTypeService from '../../services/file_type/file_type.service.js'
import { mapGetters } from 'vuex'
const Attachment = {
props: [
@ -49,7 +50,8 @@ const Attachment = {
},
fullwidth () {
return this.type === 'html' || this.type === 'audio'
}
},
...mapGetters(['mergedConfig'])
},
methods: {
linkClicked ({ target }) {
@ -58,7 +60,7 @@ const Attachment = {
}
},
openModal (event) {
const modalTypes = this.$store.getters.mergedConfig.playVideosInModal
const modalTypes = this.mergedConfig.playVideosInModal
? ['image', 'video']
: ['image']
if (fileTypeService.fileMatchesSomeType(modalTypes, this.attachment) ||
@ -71,7 +73,10 @@ const Attachment = {
}
},
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)
return
}

View File

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

View File

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

View File

@ -87,13 +87,13 @@ export default {
&:checked + .checkbox-indicator::before {
color: $fallback--text;
color: var(--text, $fallback--text);
color: var(--inputText, $fallback--text);
}
&:indeterminate + .checkbox-indicator::before {
content: '';
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>
<div
class="color-control style-control"
class="color-input style-control"
:class="{ disabled: !present || disabled }"
>
<label
@ -9,46 +9,100 @@
>
{{ label }}
</label>
<input
v-if="typeof fallback !== 'undefined'"
:id="name + '-o'"
class="opt exlcude-disabled"
type="checkbox"
<Checkbox
v-if="typeof fallback !== 'undefined' && showOptionalTickbox"
:checked="present"
@input="$emit('input', typeof value === 'undefined' ? fallback : undefined)"
>
<label
v-if="typeof fallback !== 'undefined'"
class="opt-l"
:for="name + '-o'"
:disabled="disabled"
class="opt"
@change="$emit('input', typeof value === 'undefined' ? fallback : undefined)"
/>
<input
:id="name"
class="color-input"
type="color"
:value="value || fallback"
:disabled="!present || disabled"
@input="$emit('input', $event.target.value)"
>
<input
:id="name + '-t'"
class="text-input"
type="text"
:value="value || fallback"
:disabled="!present || disabled"
@input="$emit('input', $event.target.value)"
>
<div class="input color-input-field">
<input
:id="name + '-t'"
class="textColor unstyled"
type="text"
:value="value || fallback"
:disabled="!present || disabled"
@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>
</template>
<style lang="scss" src="./color_input.scss"></style>
<script>
import Checkbox from '../checkbox/checkbox.vue'
import { hex2rgb } from '../../services/color_convert/color_convert.js'
export default {
props: [
'name', 'label', 'value', 'fallback', 'disabled'
],
components: {
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: {
present () {
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>
export default {
props: [
'large', 'contrast'
],
props: {
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: {
hint () {
const levelVal = this.contrast.aaa ? 'aaa' : (this.contrast.aa ? 'aa' : 'bad')

View File

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

View File

@ -75,18 +75,18 @@
.dialog-modal-content {
margin: 0;
padding: 1rem 1rem;
background-color: $fallback--lightBg;
background-color: var(--lightBg, $fallback--lightBg);
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
white-space: normal;
}
.dialog-modal-footer {
margin: 0;
padding: .5em .5em;
background-color: $fallback--lightBg;
background-color: var(--lightBg, $fallback--lightBg);
border-top: 1px solid $fallback--bg;
border-top: 1px solid var(--bg, $fallback--bg);
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
border-top: 1px solid $fallback--border;
border-top: 1px solid var(--border, $fallback--border);
display: flex;
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('click', this.onClickInput)
input.elm.addEventListener('transitionend', this.onTransition)
input.elm.addEventListener('compositionupdate', this.onCompositionUpdate)
input.elm.addEventListener('input', this.onInput)
},
unmounted () {
const { input } = this
@ -159,7 +159,7 @@ const EmojiInput = {
input.elm.removeEventListener('keydown', this.onKeyDown)
input.elm.removeEventListener('click', this.onClickInput)
input.elm.removeEventListener('transitionend', this.onTransition)
input.elm.removeEventListener('compositionupdate', this.onCompositionUpdate)
input.elm.removeEventListener('input', this.onInput)
}
},
methods: {
@ -406,12 +406,6 @@ const EmojiInput = {
this.resize()
this.$emit('input', e.target.value)
},
onCompositionUpdate (e) {
this.showPicker = false
this.setCaret(e)
this.resize()
this.$emit('input', e.target.value)
},
onClickInput (e) {
this.showPicker = false
},

View File

@ -109,10 +109,16 @@
box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
box-shadow: var(--popupShadow);
min-width: 75%;
background: $fallback--bg;
background: var(--bg, $fallback--bg);
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
background-color: $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);
--postLink: var(--popoverPostLink, $fallback--link);
--postFaintLink: var(--popoverPostFaintLink, $fallback--link);
--icon: var(--popoverIcon, $fallback--icon);
}
}
@ -157,7 +163,12 @@
&.highlighted {
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;
margin: 0 !important;
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,
.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: {
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
const e = document.createElement('a')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,10 @@ const MRFTransparencyPanel = {
rejectInstances: state => get(state, 'instance.federationPolicy.mrf_simple.reject', []),
ftlRemovalInstances: state => get(state, 'instance.federationPolicy.mrf_simple.federated_timeline_removal', []),
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 () {
return this.quarantineInstances.length ||
@ -20,6 +23,11 @@ const MRFTransparencyPanel = {
this.ftlRemovalInstances.length ||
this.mediaNsfwInstances.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-heading timeline-heading base02-background">
<div class="title">
{{ $t("about.federation") }}
{{ $t("about.mrf.federation") }}
</div>
</div>
<div class="panel-body">
<div class="mrf-section">
<h2>{{ $t("about.mrf_policies") }}</h2>
<p>{{ $t("about.mrf_policies_desc") }}</p>
<h2>{{ $t("about.mrf.mrf_policies") }}</h2>
<p>{{ $t("about.mrf.mrf_policies_desc") }}</p>
<ul>
<li
@ -23,13 +23,13 @@
</ul>
<h2 v-if="hasInstanceSpecificPolicies">
{{ $t("about.mrf_policy_simple") }}
{{ $t("about.mrf.simple.simple_policies") }}
</h2>
<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>
<li
@ -41,9 +41,9 @@
</div>
<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>
<li
@ -55,9 +55,9 @@
</div>
<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>
<li
@ -69,9 +69,9 @@
</div>
<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>
<li
@ -83,9 +83,9 @@
</div>
<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>
<li
@ -97,9 +97,9 @@
</div>
<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>
<li
@ -109,6 +109,49 @@
/>
</ul>
</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>

View File

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

View File

@ -33,7 +33,7 @@
<i class="button-icon icon-users" /> {{ $t("nav.public_tl") }}
</router-link>
</li>
<li v-if="federating && !privateMode">
<li v-if="federating && (currentUser || !privateMode)">
<router-link :to="{ name: 'public-external-timeline' }">
<i class="button-icon icon-globe" /> {{ $t("nav.twkn") }}
</router-link>
@ -100,13 +100,25 @@
&:hover {
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 {
font-weight: bolder;
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 {
text-decoration: underline;

View File

@ -78,6 +78,13 @@
<i class="fa icon-arrow-curved lit" />
<small>{{ $t('notifications.migrated_to') }}</small>
</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
v-if="notification.type === 'follow' || notification.type === 'move'"

View File

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

View File

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

View File

@ -104,8 +104,10 @@
.result-fill {
height: 100%;
position: absolute;
color: $fallback--text;
color: var(--pollText, $fallback--text);
background-color: $fallback--lightBg;
background-color: var(--linkBg, $fallback--lightBg);
background-color: var(--poll, $fallback--lightBg);
border-radius: $fallback--panelRadius;
border-radius: var(--panelRadius, $fallback--panelRadius);
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
v-if="typeof fallback !== 'undefined'"
:id="name + '-o'"
class="opt exclude-disabled"
class="opt"
type="checkbox"
:checked="present"
@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 {
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 {

View File

@ -76,9 +76,9 @@
<li>
<Checkbox v-model="useStreamingApi">
{{ $t('settings.useStreamingApi') }}
<br/>
<br>
<small>
{{ $t('settings.useStreamingApiWarning') }}
{{ $t('settings.useStreamingApiWarning') }}
</small>
</Checkbox>
</li>
@ -92,6 +92,11 @@
{{ $t('settings.reply_link_preview') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="emojiReactionsOnTimeline">
{{ $t('settings.emoji_reactions_on_timeline') }}
</Checkbox>
</li>
</ul>
</div>
@ -328,6 +333,11 @@
{{ $t('settings.notification_visibility_moves') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationVisibility.emojiReactions">
{{ $t('settings.notification_visibility_emoji_reactions') }}
</Checkbox>
</li>
</ul>
</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 { 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 {
// 'Value' and 'Fallback' can be undefined, but if they are
// initially vue won't detect it when they become something else
@ -15,7 +26,7 @@ export default {
return {
selectedId: 0,
// 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: {
@ -24,12 +35,12 @@ export default {
},
methods: {
add () {
this.cValue.push(Object.assign({}, this.selected))
this.cValue.push(toModel(this.selected))
this.selectedId = this.cValue.length - 1
},
del () {
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 () {
const movable = this.cValue.splice(this.selectedId, 1)[0]
@ -46,19 +57,24 @@ export default {
this.cValue = this.value || this.fallback
},
computed: {
anyShadows () {
return this.cValue.length > 0
},
anyShadowsFallback () {
return this.fallback.length > 0
},
selected () {
if (this.ready && this.cValue.length > 0) {
if (this.ready && this.anyShadows) {
return this.cValue[this.selectedId]
} else {
return {
x: 0,
y: 0,
blur: 0,
spread: 0,
inset: false,
color: '#000000',
alpha: 1
}
return toModel({})
}
},
currentFallback () {
if (this.ready && this.anyShadowsFallback) {
return this.fallback[this.selectedId]
} else {
return toModel({})
}
},
moveUpValid () {
@ -80,7 +96,7 @@ export default {
},
style () {
return this.ready ? {
boxShadow: getCssShadow(this.cValue)
boxShadow: getCssShadow(this.fallback)
} : {}
}
}

View File

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

View File

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

View File

@ -88,7 +88,7 @@
</router-link>
</li>
<li
v-if="federating && !privateMode"
v-if="federating && (currentUser || !privateMode)"
@click="toggleDrawer"
>
<router-link to="/main/all">
@ -223,7 +223,13 @@
box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6);
box-shadow: var(--panelShadow);
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 {
width: 1.1em;
@ -289,7 +295,13 @@
&:hover {
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'
const StaffPanel = {
@ -6,7 +7,7 @@ const StaffPanel = {
},
computed: {
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 FavoriteButton from '../favorite_button/favorite_button.vue'
import ReactButton from '../react_button/react_button.vue'
import RetweetButton from '../retweet_button/retweet_button.vue'
import Poll from '../poll/poll.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 Timeago from '../timeago/timeago.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 fileType from 'src/services/file_type/file_type.service'
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)
)
},
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 () {
return this.mergedConfig.maxThumbnails
},
@ -319,6 +331,7 @@ const Status = {
components: {
Attachment,
FavoriteButton,
ReactButton,
RetweetButton,
ExtraButtons,
PostStatusForm,
@ -329,7 +342,8 @@ const Status = {
LinkPreview,
AvatarList,
Timeago,
StatusPopover
StatusPopover,
EmojiReactions
},
methods: {
visibilityIcon (visibility) {

View File

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

View File

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

View File

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

View File

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

View File

@ -1,101 +1,117 @@
<template>
<div class="panel dummy">
<div class="panel-heading">
<div class="title">
{{ $t('settings.style.preview.header') }}
<span class="badge badge-notification">
99
<div class="preview-container">
<div class="underlay underlay-preview" />
<div class="panel dummy">
<div class="panel-heading">
<div class="title">
{{ $t('settings.style.preview.header') }}
<span class="badge badge-notification">
99
</span>
</div>
<span class="faint">
{{ $t('settings.style.preview.header_faint') }}
</span>
</div>
<span class="faint">
{{ $t('settings.style.preview.header_faint') }}
</span>
<span class="alert error">
{{ $t('settings.style.preview.error') }}
</span>
<button class="btn">
{{ $t('settings.style.preview.button') }}
</button>
</div>
<div class="panel-body theme-preview-content">
<div class="post">
<div class="avatar">
( ͡° ͜ʖ ͡°)
</div>
<div class="content">
<h4>
{{ $t('settings.style.preview.content') }}
</h4>
<i18n path="settings.style.preview.text">
<code style="font-family: var(--postCodeFont)">
{{ $t('settings.style.preview.mono') }}
</code>
<a style="color: var(--link)">
{{ $t('settings.style.preview.link') }}
</a>
</i18n>
<div class="icons">
<i
style="color: var(--cBlue)"
class="button-icon icon-reply"
/>
<i
style="color: var(--cGreen)"
class="button-icon icon-retweet"
/>
<i
style="color: var(--cOrange)"
class="button-icon icon-star"
/>
<i
style="color: var(--cRed)"
class="button-icon icon-cancel"
/>
</div>
</div>
</div>
<div class="after-post">
<div class="avatar-alt">
:^)
</div>
<div class="content">
<i18n
path="settings.style.preview.fine_print"
tag="span"
class="faint"
>
<a style="color: var(--faintLink)">
{{ $t('settings.style.preview.faint_link') }}
</a>
</i18n>
</div>
</div>
<div class="separator" />
<span class="alert error">
{{ $t('settings.style.preview.error') }}
</span>
<input
:value="$t('settings.style.preview.input')"
type="text"
>
<div class="actions">
<span class="checkbox">
<input
id="preview_checkbox"
checked="very yes"
type="checkbox"
>
<label for="preview_checkbox">{{ $t('settings.style.preview.checkbox') }}</label>
<span class="alert error">
{{ $t('settings.style.preview.error') }}
</span>
<button class="btn">
{{ $t('settings.style.preview.button') }}
</button>
</div>
<div class="panel-body theme-preview-content">
<div class="post">
<div class="avatar still-image">
( ͡° ͜ʖ ͡°)
</div>
<div class="content">
<h4>
{{ $t('settings.style.preview.content') }}
</h4>
<i18n path="settings.style.preview.text">
<code style="font-family: var(--postCodeFont)">
{{ $t('settings.style.preview.mono') }}
</code>
<a style="color: var(--link)">
{{ $t('settings.style.preview.link') }}
</a>
</i18n>
<div class="icons">
<i
style="color: var(--cBlue)"
class="button-icon icon-reply"
/>
<i
style="color: var(--cGreen)"
class="button-icon icon-retweet"
/>
<i
style="color: var(--cOrange)"
class="button-icon icon-star"
/>
<i
style="color: var(--cRed)"
class="button-icon icon-cancel"
/>
</div>
</div>
</div>
<div class="after-post">
<div class="avatar-alt">
:^)
</div>
<div class="content">
<i18n
path="settings.style.preview.fine_print"
tag="span"
class="faint"
>
<a style="color: var(--faintLink)">
{{ $t('settings.style.preview.faint_link') }}
</a>
</i18n>
</div>
</div>
<div class="separator" />
<span class="alert error">
{{ $t('settings.style.preview.error') }}
</span>
<input
:value="$t('settings.style.preview.input')"
type="text"
>
<div class="actions">
<span class="checkbox">
<input
id="preview_checkbox"
checked="very yes"
type="checkbox"
>
<label for="preview_checkbox">{{ $t('settings.style.preview.checkbox') }}</label>
</span>
<button class="btn">
{{ $t('settings.style.preview.button') }}
</button>
</div>
</div>
</div>
</div>
</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 { 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 RangeInput from '../range_input/range_input.vue'
import OpacityInput from '../opacity_input/opacity_input.vue'
@ -24,11 +47,22 @@ const v1OnlyNames = [
'cOrange'
].map(_ => _ + 'ColorLocal')
const colorConvert = (color) => {
if (color.startsWith('--') || color === 'transparent') {
return color
} else {
return hex2rgb(color)
}
}
export default {
data () {
return {
availableStyles: [],
selected: this.$store.getters.mergedConfig.theme,
themeWarning: undefined,
tempImportFile: undefined,
engineVersion: 0,
previewShadows: {},
previewColors: {},
@ -45,51 +79,13 @@ export default {
keepRoundness: false,
keepFonts: false,
textColorLocal: '',
linkColorLocal: '',
...Object.keys(SLOT_INHERITANCE)
.map(key => [key, ''])
.reduce((acc, [key, val]) => ({ ...acc, [ key + 'ColorLocal' ]: val }), {}),
bgColorLocal: '',
bgOpacityLocal: undefined,
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: '',
...Object.keys(OPACITIES)
.map(key => [key, ''])
.reduce((acc, [key, val]) => ({ ...acc, [ key + 'OpacityLocal' ]: val }), {}),
shadowSelected: undefined,
shadowsLocal: {},
@ -108,69 +104,105 @@ export default {
created () {
const self = this
getThemes().then((themesComplete) => {
self.availableStyles = 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
})
},
mounted () {
this.normalizeLocalState(this.$store.getters.mergedConfig.customTheme)
this.loadThemeFromLocalStorage()
if (typeof this.shadowSelected === 'undefined') {
this.shadowSelected = this.shadowsAvailable[0]
}
},
computed: {
themeWarningHelp () {
if (!this.themeWarning) return
const t = this.$t
const pre = 'settings.style.switcher.help.'
const {
origin,
themeEngineVersion,
type,
noActionsPossible
} = this.themeWarning
if (origin === 'file') {
// Loaded v2 theme from file
if (themeEngineVersion === 2 && type === 'wrong_version') {
return t(pre + 'v2_imported')
}
if (themeEngineVersion > CURRENT_VERSION) {
return t(pre + 'future_version_imported') + ' ' +
(
noActionsPossible
? t(pre + 'snapshot_missing')
: t(pre + 'snapshot_present')
)
}
if (themeEngineVersion < CURRENT_VERSION) {
return t(pre + 'future_version_imported') + ' ' +
(
noActionsPossible
? t(pre + 'snapshot_missing')
: t(pre + 'snapshot_present')
)
}
} else if (origin === 'localStorage') {
if (type === 'snapshot_source_mismatch') {
return t(pre + 'snapshot_source_mismatch')
}
// FE upgraded from v2
if (themeEngineVersion === 2) {
return t(pre + 'upgraded_from_v2')
}
// Admin downgraded FE
if (themeEngineVersion > CURRENT_VERSION) {
return t(pre + 'fe_downgraded') + ' ' +
(
noActionsPossible
? t(pre + 'migration_snapshot_ok')
: t(pre + 'migration_snapshot_gone')
)
}
// Admin upgraded FE
if (themeEngineVersion < CURRENT_VERSION) {
return t(pre + 'fe_upgraded') + ' ' +
(
noActionsPossible
? t(pre + 'migration_snapshot_ok')
: t(pre + 'migration_snapshot_gone')
)
}
}
},
selectedVersion () {
return Array.isArray(this.selected) ? 1 : 2
},
currentColors () {
return {
bg: this.bgColorLocal,
text: this.textColorLocal,
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
}
return Object.keys(SLOT_INHERITANCE)
.map(key => [key, this[key + 'ColorLocal']])
.reduce((acc, [key, val]) => ({ ...acc, [ key ]: val }), {})
},
currentOpacity () {
return {
bg: this.bgOpacityLocal,
btn: this.btnOpacityLocal,
input: this.inputOpacityLocal,
panel: this.panelOpacityLocal,
topBar: this.topBarOpacityLocal,
border: this.borderOpacityLocal,
faint: this.faintOpacityLocal
}
return Object.keys(OPACITIES)
.map(key => [key, this[key + 'OpacityLocal']])
.reduce((acc, [key, val]) => ({ ...acc, [ key ]: val }), {})
},
currentRadii () {
return {
@ -193,75 +225,66 @@ export default {
},
// This needs optimization maybe
previewContrast () {
if (!this.previewTheme.colors.bg) return {}
const colors = this.previewTheme.colors
const opacity = this.previewTheme.opacity
if (!colors.bg) return {}
const hints = (ratio) => ({
text: ratio.toPrecision(3) + ':1',
// AA level, AAA level
aa: ratio >= 4.5,
aaa: ratio >= 7,
// same but for 18pt+ texts
laa: ratio >= 3,
laaa: ratio >= 4.5
})
try {
if (!this.previewTheme.colors.bg) return {}
const colors = this.previewTheme.colors
const opacity = this.previewTheme.opacity
if (!colors.bg) return {}
const hints = (ratio) => ({
text: ratio.toPrecision(3) + ':1',
// AA level, AAA level
aa: ratio >= 4.5,
aaa: ratio >= 7,
// same but for 18pt+ texts
laa: ratio >= 3,
laaa: ratio >= 4.5
})
const colorsConverted = Object.entries(colors).reduce((acc, [key, value]) => ({ ...acc, [key]: colorConvert(value) }), {})
// fgsfds :DDDD
const fgs = {
text: hex2rgb(colors.text),
panelText: hex2rgb(colors.panelText),
panelLink: hex2rgb(colors.panelLink),
btnText: hex2rgb(colors.btnText),
topBarText: hex2rgb(colors.topBarText),
inputText: hex2rgb(colors.inputText),
const ratios = Object.entries(SLOT_INHERITANCE).reduce((acc, [key, value]) => {
const slotIsBaseText = key === 'text' || key === 'link'
const slotIsText = slotIsBaseText || (
typeof value === 'object' && value !== null && value.textColor
)
if (!slotIsText) return acc
const { layer, variant } = slotIsBaseText ? { layer: 'bg' } : value
const background = variant || layer
const opacitySlot = getOpacitySlot(background)
const textColors = [
key,
...(background === 'bg' ? ['cRed', 'cGreen', 'cBlue', 'cOrange'] : [])
]
link: hex2rgb(colors.link),
topBarLink: hex2rgb(colors.topBarLink),
const layers = getLayers(
layer,
variant || layer,
opacitySlot,
colorsConverted,
opacity
)
red: hex2rgb(colors.cRed),
green: hex2rgb(colors.cGreen),
blue: hex2rgb(colors.cBlue),
orange: hex2rgb(colors.cOrange)
return {
...acc,
...textColors.reduce((acc, textColorKey) => {
const newKey = slotIsBaseText
? 'bg' + textColorKey[0].toUpperCase() + textColorKey.slice(1)
: textColorKey
return {
...acc,
[newKey]: getContrastRatioLayers(
colorsConverted[textColorKey],
layers,
colorsConverted[textColorKey]
)
}
}, {})
}
}, {})
return Object.entries(ratios).reduce((acc, [k, v]) => { acc[k] = hints(v); return acc }, {})
} catch (e) {
console.warn('Failure computing contrasts', e)
}
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 }, {})
},
previewRules () {
if (!this.preview.rules) return ''
@ -272,7 +295,7 @@ export default {
].join(';')
},
shadowsAvailable () {
return Object.keys(this.previewTheme.shadows).sort()
return Object.keys(DEFAULT_SHADOWS).sort()
},
currentShadowOverriden: {
get () {
@ -287,7 +310,7 @@ export default {
}
},
currentShadowFallback () {
return this.previewTheme.shadows[this.shadowSelected]
return (this.previewTheme.shadows || {})[this.shadowSelected]
},
currentShadow: {
get () {
@ -309,27 +332,34 @@ export default {
!this.keepColor
)
const theme = {}
const source = {
themeEngineVersion: CURRENT_VERSION
}
if (this.keepFonts || saveEverything) {
theme.fonts = this.fontsLocal
source.fonts = this.fontsLocal
}
if (this.keepShadows || saveEverything) {
theme.shadows = this.shadowsLocal
source.shadows = this.shadowsLocal
}
if (this.keepOpacity || saveEverything) {
theme.opacity = this.currentOpacity
source.opacity = this.currentOpacity
}
if (this.keepColor || saveEverything) {
theme.colors = this.currentColors
source.colors = this.currentColors
}
if (this.keepRoundness || saveEverything) {
theme.radii = this.currentRadii
source.radii = this.currentRadii
}
const theme = {
themeEngineVersion: CURRENT_VERSION,
...this.previewTheme
}
return {
// To separate from other random JSON files and possible future theme formats
_pleroma_theme_version: 2, theme
// To separate from other random JSON files and possible future source formats
_pleroma_theme_version: 2, theme, source
}
}
},
@ -346,10 +376,128 @@ export default {
Checkbox
},
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 () {
this.$store.dispatch('setOption', {
name: 'customTheme',
value: {
themeEngineVersion: CURRENT_VERSION,
...this.previewTheme
}
})
this.$store.dispatch('setOption', {
name: 'customThemeSource',
value: {
themeEngineVersion: CURRENT_VERSION,
shadows: this.shadowsLocal,
fonts: this.fontsLocal,
opacity: this.currentOpacity,
@ -358,21 +506,27 @@ export default {
}
})
},
onImport (parsed) {
if (parsed._pleroma_theme_version === 1) {
this.normalizeLocalState(parsed, 1)
} else if (parsed._pleroma_theme_version === 2) {
this.normalizeLocalState(parsed.theme, 2)
}
updatePreviewColorsAndShadows () {
this.previewColors = generateColors({
opacity: this.currentOpacity,
colors: this.currentColors
})
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) {
const version = parsed._pleroma_theme_version
return version >= 1 || version <= 2
},
clearAll () {
const state = this.$store.getters.mergedConfig.customTheme
const version = state.colors ? 2 : 'l1'
this.normalizeLocalState(this.$store.getters.mergedConfig.customTheme, version)
this.loadThemeFromLocalStorage()
},
// 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:
* v3 (version >= 3) - newest version of themes which supports snapshots for better compatiblity
* v2 (version = 2) - newer version of themes.
* v1 (version = 1) - older version of themes (import from file)
* v1l (version = l1) - older version of theme (load from local storage)
* v1 and v1l differ because of way themes were stored/exported.
* @param {Object} 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 {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) {
const colors = input.colors || input
normalizeLocalState (theme, version = 0, source, forceSource = false) {
let input
if (typeof source !== 'undefined') {
if (forceSource || source.themeEngineVersion === CURRENT_VERSION) {
input = source
version = source.themeEngineVersion
} else {
input = theme
}
} else {
input = theme
}
const radii = input.radii || input
const opacity = input.opacity
const shadows = input.shadows || {}
const fonts = input.fonts || {}
const colors = !input.themeEngineVersion
? colors2to3(input.colors || input)
: input.colors || input
if (version === 0) {
if (input.version) version = input.version
@ -437,6 +609,8 @@ export default {
}
}
this.engineVersion = version
// Stuff that differs between V1 and V2
if (version === 1) {
this.fgColorLocal = rgb2hex(colors.btn)
@ -445,7 +619,7 @@ export default {
if (!this.keepColor) {
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') {
keys
.add('bg')
@ -457,7 +631,17 @@ export default {
}
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) {
this.clearShadows()
this.shadowsLocal = shadows
if (version === 2) {
this.shadowsLocal = shadows2to3(shadows, this.previewTheme.opacity)
} else {
this.shadowsLocal = shadows
}
this.shadowSelected = this.shadowsAvailable[0]
}
@ -480,14 +668,6 @@ export default {
this.clearFonts()
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: {
@ -502,8 +682,9 @@ export default {
},
shadowsLocal: {
handler () {
if (Object.getOwnPropertyNames(this.previewColors).length === 1) return
try {
this.previewShadows = generateShadows({ shadows: this.shadowsLocal })
this.updatePreviewColorsAndShadows()
this.shadowsInvalid = false
} catch (e) {
this.shadowsInvalid = true
@ -526,27 +707,24 @@ export default {
},
currentColors () {
try {
this.previewColors = generateColors({
opacity: this.currentOpacity,
colors: this.currentColors
})
this.updatePreviewColorsAndShadows()
this.colorsInvalid = false
this.shadowsInvalid = false
} catch (e) {
this.colorsInvalid = true
this.shadowsInvalid = true
console.warn(e)
}
},
currentOpacity () {
try {
this.previewColors = generateColors({
opacity: this.currentOpacity,
colors: this.currentColors
})
this.updatePreviewColorsAndShadows()
} catch (e) {
console.warn(e)
}
},
selected () {
this.dismissWarning()
if (this.selectedVersion === 1) {
if (!this.keepRoundness) {
this.clearRoundness()
@ -573,7 +751,7 @@ export default {
this.cOrangeColorLocal = this.selected[8]
}
} 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';
.style-switcher {
.theme-warning {
display: flex;
align-items: baseline;
margin-bottom: .5em;
.buttons {
.btn {
margin-bottom: .5em;
}
}
}
.preset-switcher {
margin-right: 1em;
}
@ -15,26 +25,23 @@
&.disabled {
input, select {
&:not(.exclude-disabled) {
opacity: .5
}
opacity: .5
}
}
.opt {
margin: .5em;
}
.color-input {
flex: 0 0 0;
}
input, select {
min-width: 3em;
margin: 0;
flex: 0;
&[type=color] {
padding: 1px;
cursor: pointer;
height: 29px;
min-width: 2em;
border: none;
align-self: stretch;
}
&[type=number] {
min-width: 5em;
}
@ -42,13 +49,6 @@
&[type=range] {
flex: 1;
min-width: 3em;
}
&[type=checkbox] + label {
margin: 6px 0;
}
&:not([type=number]):not([type=text]) {
align-self: flex-start;
}
}

View File

@ -2,7 +2,53 @@
<div class="style-switcher">
<div class="presets-container">
<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-label="$t(&quot;settings.export_theme&quot;)"
:import-label="$t(&quot;settings.import_theme&quot;)"
@ -27,8 +73,8 @@
:key="style.name"
:value="style"
:style="{
backgroundColor: style[1] || style.theme.colors.bg,
color: style[3] || style.theme.colors.text
backgroundColor: style[1] || (style.theme || style.source).colors.bg,
color: style[3] || (style.theme || style.source).colors.text
}"
>
{{ style[0] || style.name }}
@ -38,7 +84,7 @@
</label>
</div>
</template>
</export-import>
</ExportImport>
</div>
<div class="save-load-options">
<span class="keep-option">
@ -70,9 +116,7 @@
</div>
</div>
<div class="preview-container">
<preview :style="previewRules" />
</div>
<preview :style="previewRules" />
<keep-alive>
<tab-switcher key="style-tweak">
@ -106,7 +150,7 @@
<OpacityInput
v-model="bgOpacityLocal"
name="bgOpacity"
:fallback="previewTheme.opacity.bg || 1"
:fallback="previewTheme.opacity.bg"
/>
<ColorInput
v-model="textColorLocal"
@ -114,10 +158,19 @@
:label="$t('settings.text')"
/>
<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
v-model="linkColorLocal"
name="linkColor"
:fallback="previewTheme.colors.accent"
:label="$t('settings.links')"
:show-optional-tickbox="typeof accentColorLocal !== 'undefined'"
/>
<ContrastRatio :contrast="previewContrast.bgLink" />
</div>
@ -148,13 +201,13 @@
name="cRedColor"
:label="$t('settings.cRed')"
/>
<ContrastRatio :contrast="previewContrast.bgRed" />
<ContrastRatio :contrast="previewContrast.bgCRed" />
<ColorInput
v-model="cBlueColorLocal"
name="cBlueColor"
:label="$t('settings.cBlue')"
/>
<ContrastRatio :contrast="previewContrast.bgBlue" />
<ContrastRatio :contrast="previewContrast.bgCBlue" />
</div>
<div class="color-item">
<ColorInput
@ -162,13 +215,13 @@
name="cGreenColor"
:label="$t('settings.cGreen')"
/>
<ContrastRatio :contrast="previewContrast.bgGreen" />
<ContrastRatio :contrast="previewContrast.bgCGreen" />
<ColorInput
v-model="cOrangeColorLocal"
name="cOrangeColor"
:label="$t('settings.cOrange')"
/>
<ContrastRatio :contrast="previewContrast.bgOrange" />
<ContrastRatio :contrast="previewContrast.bgCOrange" />
</div>
<p>{{ $t('settings.theme_help_v2_2') }}</p>
</div>
@ -193,6 +246,14 @@
</button>
</div>
<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>
<ColorInput
v-model="alertErrorColorLocal"
@ -200,14 +261,53 @@
:label="$t('settings.style.advanced_colors.alert_error')"
: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
v-model="alertWarningColorLocal"
name="alertWarning"
:label="$t('settings.style.advanced_colors.alert_warning')"
: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 class="color-item">
<h4>{{ $t('settings.style.advanced_colors.badge') }}</h4>
@ -217,19 +317,30 @@
:label="$t('settings.style.advanced_colors.badge_notification')"
: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 class="color-item">
<h4>{{ $t('settings.style.advanced_colors.panel_header') }}</h4>
<ColorInput
v-model="panelColorLocal"
name="panelColor"
:fallback="fgColorLocal"
:fallback="previewTheme.colors.panel"
:label="$t('settings.background')"
/>
<OpacityInput
v-model="panelOpacityLocal"
name="panelOpacity"
:fallback="previewTheme.opacity.panel || 1"
:fallback="previewTheme.opacity.panel"
:disabled="panelColorLocal === 'transparent'"
/>
<ColorInput
v-model="panelTextColorLocal"
@ -239,7 +350,7 @@
/>
<ContrastRatio
:contrast="previewContrast.panelText"
large="1"
large="true"
/>
<ColorInput
v-model="panelLinkColorLocal"
@ -249,7 +360,7 @@
/>
<ContrastRatio
:contrast="previewContrast.panelLink"
large="1"
large="true"
/>
</div>
<div class="color-item">
@ -257,7 +368,7 @@
<ColorInput
v-model="topBarColorLocal"
name="topBarColor"
:fallback="fgColorLocal"
:fallback="previewTheme.colors.topBar"
:label="$t('settings.background')"
/>
<ColorInput
@ -280,13 +391,14 @@
<ColorInput
v-model="inputColorLocal"
name="inputColor"
:fallback="fgColorLocal"
:fallback="previewTheme.colors.input"
:label="$t('settings.background')"
/>
<OpacityInput
v-model="inputOpacityLocal"
name="inputOpacity"
:fallback="previewTheme.opacity.input || 1"
:fallback="previewTheme.opacity.input"
:disabled="inputColorLocal === 'transparent'"
/>
<ColorInput
v-model="inputTextColorLocal"
@ -301,13 +413,14 @@
<ColorInput
v-model="btnColorLocal"
name="btnColor"
:fallback="fgColorLocal"
:fallback="previewTheme.colors.btn"
:label="$t('settings.background')"
/>
<OpacityInput
v-model="btnOpacityLocal"
name="btnOpacity"
:fallback="previewTheme.opacity.btn || 1"
:fallback="previewTheme.opacity.btn"
:disabled="btnColorLocal === 'transparent'"
/>
<ColorInput
v-model="btnTextColorLocal"
@ -316,6 +429,124 @@
:label="$t('settings.text')"
/>
<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 class="color-item">
<h4>{{ $t('settings.style.advanced_colors.borders') }}</h4>
@ -328,7 +559,8 @@
<OpacityInput
v-model="borderOpacityLocal"
name="borderOpacity"
:fallback="previewTheme.opacity.border || 1"
:fallback="previewTheme.opacity.border"
:disabled="borderColorLocal === 'transparent'"
/>
</div>
<div class="color-item">
@ -336,7 +568,7 @@
<ColorInput
v-model="faintColorLocal"
name="faintColor"
:fallback="previewTheme.colors.faint || 1"
:fallback="previewTheme.colors.faint"
:label="$t('settings.text')"
/>
<ColorInput
@ -354,9 +586,146 @@
<OpacityInput
v-model="faintOpacityLocal"
name="faintOpacity"
:fallback="previewTheme.opacity.faint || 0.5"
:fallback="previewTheme.opacity.faint"
/>
</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
@ -491,7 +860,7 @@
{{ $t('settings.style.switcher.clear_all') }}
</button>
</div>
<shadow-control
<ShadowControl
v-model="currentShadow"
:ready="!!currentShadowFallback"
:fallback="currentShadowFallback"

View File

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

View File

@ -4,7 +4,6 @@ import ProgressButton from '../progress_button/progress_button.vue'
import FollowButton from '../follow_button/follow_button.vue'
import ModerationTools from '../moderation_tools/moderation_tools.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 { mapGetters } from 'vuex'
@ -30,21 +29,11 @@ export default {
}]
},
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 {
backgroundColor: `rgb(${Math.floor(rgb.r * 0.53)}, ${Math.floor(rgb.g * 0.56)}, ${Math.floor(rgb.b * 0.59)})`,
backgroundImage: [
`linear-gradient(to bottom, ${tintColor}, ${tintColor})`,
`url(${this.user.cover_photo})`
].join(', ')
}
return {
backgroundImage: [
`linear-gradient(to bottom, var(--profileTint), var(--profileTint))`,
`url(${this.user.cover_photo})`
].join(', ')
}
},
isOtherUser () {

View File

@ -151,7 +151,7 @@
</ProgressButton>
<ProgressButton
v-else
class="btn btn-default pressed"
class="btn btn-default toggled"
:click="unsubscribeUser"
:title="$t('user_card.unsubscribe')"
>
@ -162,7 +162,7 @@
<div>
<button
v-if="user.muted"
class="btn btn-default btn-block pressed"
class="btn btn-default btn-block toggled"
@click="unmuteUser"
>
{{ $t('user_card.muted') }}
@ -286,6 +286,7 @@
mask-size: 100% 60%;
border-top-left-radius: calc(var(--panelRadius) - 1px);
border-top-right-radius: calc(var(--panelRadius) - 1px);
background-color: var(--profileBg);
&.hide-bio {
mask-size: 100% 40px;
@ -299,6 +300,11 @@
&-bio {
text-align: center;
a {
color: $fallback--link;
color: var(--postLink, $fallback--link);
}
img {
object-fit: contain;
vertical-align: middle;
@ -460,14 +466,13 @@
color: var(--text, $fallback--text);
}
// TODO use proper colors
.staff {
flex: none;
text-transform: capitalize;
color: $fallback--text;
color: var(--btnText, $fallback--text);
color: var(--alertNeutralText, $fallback--text);
background-color: $fallback--fg;
background-color: var(--btn, $fallback--fg);
background-color: var(--alertNeutral, $fallback--fg);
}
}
@ -538,12 +543,6 @@
button {
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 BlockCard from '../block_card/block_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 ProgressButton from '../progress_button/progress_button.vue'
import EmojiInput from '../emoji_input/emoji_input.vue'
@ -32,6 +33,12 @@ const MuteList = withSubscription({
childPropName: 'items'
})(SelectableList)
const DomainMuteList = withSubscription({
fetch: (props, $store) => $store.dispatch('fetchDomainMutes'),
select: (props, $store) => get($store.state.users.currentUser, 'domainMutes', []),
childPropName: 'items'
})(SelectableList)
const UserSettings = {
data () {
return {
@ -48,6 +55,7 @@ const UserSettings = {
showRole: this.$store.state.users.currentUser.show_role,
role: this.$store.state.users.currentUser.role,
discoverable: this.$store.state.users.currentUser.discoverable,
allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
pickAvatarBtnVisible: true,
bannerUploading: false,
backgroundUploading: false,
@ -67,7 +75,8 @@ const UserSettings = {
changedPassword: false,
changePasswordError: false,
activeTab: 'profile',
notificationSettings: this.$store.state.users.currentUser.notification_settings
notificationSettings: this.$store.state.users.currentUser.notification_settings,
newDomainToMute: ''
}
},
created () {
@ -80,10 +89,12 @@ const UserSettings = {
ImageCropper,
BlockList,
MuteList,
DomainMuteList,
EmojiInput,
Autosuggest,
BlockCard,
MuteCard,
DomainMuteCard,
ProgressButton,
Importer,
Exporter,
@ -152,6 +163,7 @@ const UserSettings = {
hide_follows: this.hideFollows,
hide_followers: this.hideFollowers,
discoverable: this.discoverable,
allow_following_move: this.allowFollowingMove,
hide_follows_count: this.hideFollowsCount,
hide_followers_count: this.hideFollowersCount,
show_role: this.showRole
@ -297,7 +309,7 @@ const UserSettings = {
newPassword: this.changePasswordInputs[1],
newPasswordConfirmation: this.changePasswordInputs[2]
}
this.$store.state.api.backendInteractor.changePassword({ params })
this.$store.state.api.backendInteractor.changePassword(params)
.then((res) => {
if (res.status === 'success') {
this.changedPassword = true
@ -314,7 +326,7 @@ const UserSettings = {
email: this.newEmail,
password: this.changeEmailPassword
}
this.$store.state.api.backendInteractor.changeEmail({ params })
this.$store.state.api.backendInteractor.changeEmail(params)
.then((res) => {
if (res.status === 'success') {
this.changedEmail = true
@ -365,6 +377,13 @@ const UserSettings = {
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) {
return value
}

View File

@ -90,9 +90,7 @@
</Checkbox>
</p>
<p>
<Checkbox
v-model="hideFollowers"
>
<Checkbox v-model="hideFollowers">
{{ $t('settings.hide_followers_description') }}
</Checkbox>
</p>
@ -104,6 +102,11 @@
{{ $t('settings.hide_followers_count_description') }}
</Checkbox>
</p>
<p>
<Checkbox v-model="allowFollowingMove">
{{ $t('settings.allow_following_move') }}
</Checkbox>
</p>
<p v-if="role === 'admin' || role === 'moderator'">
<Checkbox v-model="showRole">
<template v-if="role === 'admin'">
@ -509,59 +512,114 @@
</div>
<div :label="$t('settings.mutes_tab')">
<div class="profile-edit-usersearch-wrapper">
<Autosuggest
:filter="filterUnMutedUsers"
:query="queryUserIds"
:placeholder="$t('settings.search_user_to_mute')"
>
<MuteCard
slot-scope="row"
:user-id="row.item"
/>
</Autosuggest>
</div>
<MuteList
: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="() => muteUsers(selected)"
<tab-switcher>
<div label="Users">
<div class="profile-edit-usersearch-wrapper">
<Autosuggest
:filter="filterUnMutedUsers"
:query="queryUserIds"
:placeholder="$t('settings.search_user_to_mute')"
>
{{ $t('user_card.mute') }}
<template slot="progress">
{{ $t('user_card.mute_progress') }}
</template>
</ProgressButton>
<ProgressButton
v-if="selected.length > 0"
class="btn btn-default"
:click="() => unmuteUsers(selected)"
<MuteCard
slot-scope="row"
:user-id="row.item"
/>
</Autosuggest>
</div>
<MuteList
:refresh="true"
:get-key="identity"
>
<template
slot="header"
slot-scope="{selected}"
>
{{ $t('user_card.unmute') }}
<div class="profile-edit-bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn btn-default"
:click="() => muteUsers(selected)"
>
{{ $t('user_card.mute') }}
<template slot="progress">
{{ $t('user_card.mute_progress') }}
</template>
</ProgressButton>
<ProgressButton
v-if="selected.length > 0"
class="btn btn-default"
:click="() => unmuteUsers(selected)"
>
{{ $t('user_card.unmute') }}
<template slot="progress">
{{ $t('user_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
</template>
<template
slot="item"
slot-scope="{item}"
>
<MuteCard :user-id="item" />
</template>
<template slot="empty">
{{ $t('settings.no_mutes') }}
</template>
</MuteList>
</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('user_card.unmute_progress') }}
{{ $t('domain_mute_card.mute_progress') }}
</template>
</ProgressButton>
</div>
</template>
<template
slot="item"
slot-scope="{item}"
>
<MuteCard :user-id="item" />
</template>
<template slot="empty">
{{ $t('settings.no_mutes') }}
</template>
</MuteList>
<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>
</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 {
margin-left: 1.75em;
}

View File

@ -1,26 +1,43 @@
{
"about": {
"staff": "Staff",
"federation": "Federation",
"mrf_policies": "Enabled MRF Policies",
"mrf_policies_desc": "MRF policies manipulate the federation behaviour of the instance. The following policies are enabled:",
"mrf_policy_simple": "Instance-specific Policies",
"mrf_policy_simple_accept": "Accept",
"mrf_policy_simple_accept_desc": "This instance only accepts messages from the following instances:",
"mrf_policy_simple_reject": "Reject",
"mrf_policy_simple_reject_desc": "This instance will not accept messages from the following instances:",
"mrf_policy_simple_quarantine": "Quarantine",
"mrf_policy_simple_quarantine_desc": "This instance will send only public posts to the following instances:",
"mrf_policy_simple_ftl_removal": "Removal from \"The Whole Known Network\" Timeline",
"mrf_policy_simple_ftl_removal_desc": "This instance removes these instances from \"The Whole Known Network\" timeline:",
"mrf_policy_simple_media_removal": "Media Removal",
"mrf_policy_simple_media_removal_desc": "This instance removes media from posts on the following instances:",
"mrf_policy_simple_media_nsfw": "Media Force-set As Sensitive",
"mrf_policy_simple_media_nsfw_desc": "This instance forces media to be set sensitive in posts on the following instances:"
"mrf": {
"federation": "Federation",
"keyword": {
"keyword_policies": "Keyword Policies",
"ftl_removal": "Removal from \"The Whole Known Network\" Timeline",
"reject": "Reject",
"replace": "Replace",
"is_replaced_by": "→"
},
"mrf_policies": "Enabled MRF Policies",
"mrf_policies_desc": "MRF policies manipulate the federation behaviour of the instance. The following policies are enabled:",
"simple": {
"simple_policies": "Instance-specific Policies",
"accept": "Accept",
"accept_desc": "This instance only accepts messages from the following instances:",
"reject": "Reject",
"reject_desc": "This instance will not accept messages from the following instances:",
"quarantine": "Quarantine",
"quarantine_desc": "This instance will send only public posts to the following instances:",
"ftl_removal": "Removal from \"The Whole Known Network\" Timeline",
"ftl_removal_desc": "This instance removes these instances from \"The Whole Known Network\" timeline:",
"media_removal": "Media Removal",
"media_removal_desc": "This instance removes media from posts on the following instances:",
"media_nsfw": "Media Force-set As Sensitive",
"media_nsfw_desc": "This instance forces media to be set sensitive in posts on the following instances:"
}
},
"staff": "Staff"
},
"chat": {
"title": "Chat"
},
"domain_mute_card": {
"mute": "Mute",
"mute_progress": "Muting...",
"unmute": "Unmute",
"unmute_progress": "Unmuting..."
},
"exporter": {
"export": "Export",
"processing": "Processing, you'll soon be asked to download your file"
@ -46,6 +63,7 @@
"optional": "optional",
"show_more": "Show more",
"show_less": "Show less",
"dismiss": "Dismiss",
"cancel": "Cancel",
"disable": "Disable",
"enable": "Enable",
@ -111,7 +129,8 @@
"read": "Read!",
"repeated_you": "repeated your status",
"no_more_notifications": "No more notifications",
"migrated_to": "migrated to"
"migrated_to": "migrated to",
"reacted_with": "reacted with {0}"
},
"polls": {
"add_poll": "Add Poll",
@ -226,6 +245,7 @@
"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",
"attachments": "Attachments",
"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_instructions": "Type your password in the input below to confirm account deletion.",
"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.",
"pad_emoji": "Pad emoji with spaces when adding from picker",
"emoji_reactions_on_timeline": "Show emoji reactions on timeline",
"export_theme": "Save preset",
"filtering": "Filtering",
"filtering_explanation": "All statuses containing these words will be muted, one per line",
@ -274,6 +296,7 @@
"follow_import": "Follow import",
"follow_import_error": "Error importing followers",
"follows_imported": "Follows imported! Processing them will take a while.",
"accent": "Accent",
"foreground": "Foreground",
"general": "General",
"hide_attachments_in_convo": "Hide attachments in conversations",
@ -314,6 +337,7 @@
"notification_visibility_mentions": "Mentions",
"notification_visibility_repeats": "Repeats",
"notification_visibility_moves": "User Migrates",
"notification_visibility_emoji_reactions": "Reactions",
"no_rich_text_description": "Strip rich text formatting from all posts",
"no_blocks": "No blocks",
"no_mutes": "No mutes",
@ -361,6 +385,7 @@
"post_status_content_type": "Post status content type",
"stop_gifs": "Play-on-hover GIFs",
"streaming": "Enable automatic streaming of new posts when scrolled to the top",
"user_mutes": "Users",
"useStreamingApi": "Receive posts and notifications real-time",
"useStreamingApiWarning": "(Not recommended, experimental, known to skip posts)",
"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_2": "Icons underneath some entries are background/text contrast indicators, hover over for detailed info. Please keep in mind that when using transparency contrast indicators show the worst possible case.",
"tooltipRadius": "Tooltips/alerts",
"type_domains_to_mute": "Type in domains to mute",
"upload_a_photo": "Upload a photo",
"user_settings": "User Settings",
"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.",
"reset": "Reset",
"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": {
"color": "Color",
@ -425,14 +468,27 @@
"alert": "Alert background",
"alert_error": "Error",
"alert_warning": "Warning",
"alert_neutral": "Neutral",
"post": "Posts/User bios",
"badge": "Badge background",
"popover": "Tooltips, menus, popovers",
"badge_notification": "Notification",
"panel_header": "Panel header",
"top_bar": "Top bar",
"borders": "Borders",
"buttons": "Buttons",
"inputs": "Input fields",
"faint_text": "Faded text"
"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": {
"_tab_label": "Roundness"
@ -445,7 +501,7 @@
"blur": "Blur",
"spread": "Spread",
"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": {
"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.",
@ -639,6 +695,7 @@
"repeat": "Repeat",
"reply": "Reply",
"favorite": "Favorite",
"add_reaction": "Add Reaction",
"user_settings": "User Settings"
},
"upload":{

View File

@ -53,7 +53,8 @@
"notifications": "Ilmoitukset",
"read": "Lue!",
"repeated_you": "toisti viestisi",
"no_more_notifications": "Ei enempää ilmoituksia"
"no_more_notifications": "Ei enempää ilmoituksia",
"reacted_with": "lisäsi reaktion {0}"
},
"polls": {
"add_poll": "Lisää äänestys",
@ -140,6 +141,7 @@
"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_instructions": "Syötä salasanasi vahvistaaksesi tilin poiston.",
"emoji_reactions_on_timeline": "Näytä emojireaktiot aikajanalla",
"export_theme": "Tallenna teema",
"filtering": "Suodatus",
"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_mentions": "Maininnat",
"notification_visibility_repeats": "Toistot",
"notification_visibility_emoji_reactions": "Reaktiot",
"no_rich_text_description": "Älä näytä tekstin muotoilua.",
"hide_network_description": "Älä näytä seurauksiani tai seuraajiani",
"nsfw_clickthrough": "Piilota NSFW liitteet klikkauksen taakse",

View File

@ -1,22 +1,26 @@
{
"about": {
"staff": "スタッフ",
"federation": "フェデレーション",
"mrf_policies": "ゆうこうなMRFポリシー",
"mrf_policies_desc": "MRFポリシーは、このインスタンスのフェデレーションのふるまいを、いじります。これらのMRFポリシーがゆうこうになっています:",
"mrf_policy_simple": "インスタンスのポリシー",
"mrf_policy_simple_accept": "うけいれ",
"mrf_policy_simple_accept_desc": "このインスンスは、これらのインスタンスからのメッセージのみをうけいれます:",
"mrf_policy_simple_reject": "おことわり",
"mrf_policy_simple_reject_desc": "このインスタンスは、これらのインスタンスからのメッセージをうけいれません:",
"mrf_policy_simple_quarantine": "けんえき",
"mrf_policy_simple_quarantine_desc": "このインスタンスは、これらのインスタンスに、パブリックなとうこうのみを、おくります:",
"mrf_policy_simple_ftl_removal": "「つながっているすべてのネットワーク」タイムラインからのぞく",
"mrf_policy_simple_ftl_removal_desc": "このインスタンスは、つながっているすべてのネットワーク」タイムラインから、これらのインスタンスを、とりのぞきます:",
"mrf_policy_simple_media_removal": "メディアをのぞく",
"mrf_policy_simple_media_removal_desc": "このインスタンスは、これらのインスタンスからおくられてきたメディアを、とりのぞきます:",
"mrf_policy_simple_media_nsfw": "メディアをすべてセンシティブにする",
"mrf_policy_simple_media_nsfw_desc": "このインスタンスは、これらのインスタンスからおくられてきたメディアを、すべて、センシティブにマークします:"
"mrf": {
"federation": "フェデレーション",
"mrf_policies": "ゆうこうなMRFポリシー",
"mrf_policies_desc": "MRFポリシーは、このインスタンスのフェデレーションのふるまいを、いじります。これらのMRFポリシーがゆうこうになっています:",
"simple": {
"simple_policies": "インスタンスのポリシー",
"accept": "うけいれ",
"accept_desc": "このインスンスは、これらのインスタンスからのメッセージのみをうけいれます:",
"reject": "おことわり",
"reject_desc": "このインスタンスは、これらのインスタンスからのメッセージをうけいれません:",
"quarantine": "けんえき",
"quarantine_desc": "このインスタンスは、これらのインスタンスに、パブリックなとうこうのみを、おくります:",
"ftl_removal": "「つながっているすべてのネットワーク」タイムラインからのぞく",
"ftl_removal_desc": "このインスタンスは、つながっているすべてのネットワーク」タイムラインから、これらのインスタンスを、とりのぞきます:",
"media_removal": "メディアをのぞく",
"media_removal_desc": "このインスタンスは、これらのインスタンスからおくられてきたメディアを、とりのぞきます:",
"media_nsfw": "メディアをすべてセンシティブにする",
"media_nsfw_desc": "このインスタンスは、これらのインスタンスからおくられてきたメディアを、すべて、センシティブにマークします:"
}
},
"staff": "スタッフ"
},
"chat": {
"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 Vuex from 'vuex'
import 'custom-event-polyfill'
import './lib/event_target_polyfill.js'
import interfaceModule from './modules/interface.js'
import instanceModule from './modules/instance.js'
import statusesModule from './modules/statuses.js'
@ -28,7 +31,6 @@ import VueChatScroll from 'vue-chat-scroll'
import VueClickOutside from 'v-click-outside'
import PortalVue from 'portal-vue'
import VBodyScrollLock from './directives/body_scroll_lock'
import VTooltip from 'v-tooltip'
import afterStoreSetup from './boot/after_store.js'
@ -41,13 +43,6 @@ Vue.use(VueChatScroll)
Vue.use(VueClickOutside)
Vue.use(PortalVue)
Vue.use(VBodyScrollLock)
Vue.use(VTooltip, {
popover: {
defaultTrigger: 'hover click',
defaultContainer: false,
defaultOffset: 5
}
})
const i18n = new VueI18n({
// By default, use the browser locale, we will update it if neccessary

View File

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

View File

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

View File

@ -1,5 +1,6 @@
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'
const defaultState = {
@ -10,6 +11,7 @@ const defaultState = {
textlimit: 5000,
server: 'http://localhost:4040/',
theme: 'pleroma-dark',
themeData: undefined,
background: '/static/aurora_borealis.jpg',
logo: '/static/logo.png',
logoMask: true,
@ -96,6 +98,9 @@ const instance = {
dispatch('initializeSocket')
}
break
case 'theme':
dispatch('setTheme', value)
break
}
},
async getStaticEmoji ({ commit }) {
@ -147,9 +152,23 @@ const instance = {
}
},
setTheme ({ commit }, themeName) {
setTheme ({ commit, rootState }, 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 }) {
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 apiService from '../services/api/api.service.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.repeats && 'repeat',
rootState.config.notificationVisibility.follows && 'follow',
rootState.config.notificationVisibility.moves && 'move'
rootState.config.notificationVisibility.moves && 'move',
rootState.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reactions'
].filter(_ => _)
}
@ -312,6 +326,10 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
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
if (!state.notifications.idStore.hasOwnProperty(notification.id)) {
state.notifications.maxId = notification.id > state.notifications.maxId
@ -345,7 +363,9 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
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)
} else {
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)) {
let notification = new window.Notification(title, notifObj)
let desktopNotification = new window.Notification(title, notifObj)
// Chrome is known for not closing notifications automatically
// according to MDN, anyway.
setTimeout(notification.close.bind(notification), 5000)
setTimeout(desktopNotification.close.bind(desktopNotification), 5000)
}
}
} else if (notification.seen) {
@ -518,6 +538,53 @@ export const mutations = {
newStatus.fave_num = newStatus.favoritedBy.length
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 }) {
const status = state.allStatusesObject[id]
status.poll = poll
@ -622,6 +689,35 @@ const statuses = {
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) {
rootState.api.backendInteractor.fetchFavoritedByUsers({ id })
.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]))
}
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 = {
setMuted (state, { user: { id }, muted }) {
const user = state.usersObject[id]
@ -177,6 +187,20 @@ export const mutations = {
state.currentUser.muteIds.push(muteId)
}
},
saveDomainMutes (state, domainMutes) {
state.currentUser.domainMutes = domainMutes
},
addDomainMute (state, domain) {
if (state.currentUser.domainMutes.indexOf(domain) === -1) {
state.currentUser.domainMutes.push(domain)
}
},
removeDomainMute (state, domain) {
const index = state.currentUser.domainMutes.indexOf(domain)
if (index !== -1) {
state.currentUser.domainMutes.splice(index, 1)
}
},
setPinnedToUser (state, status) {
const user = state.usersObject[status.user.id]
const index = user.pinnedStatusIds.indexOf(status.id)
@ -297,6 +321,25 @@ const users = {
unmuteUsers (store, ids = []) {
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) {
const user = rootState.users.usersObject[id]
const maxId = last(user.friendIds)
@ -331,9 +374,9 @@ const users = {
return rootState.api.backendInteractor.unsubscribeUser({ id })
.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
api(user)
api({ user })
.then(({ deactivated }) => commit('updateActivationStatus', { user, deactivated }))
},
registerPushNotifications (store) {
@ -460,6 +503,7 @@ const users = {
user.credentials = accessToken
user.blockIds = []
user.muteIds = []
user.domainMutes = []
commit('setCurrentUser', 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_SEARCH_2 = `/api/v2/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 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
@ -398,8 +402,8 @@ const fetchStatus = ({ id, credentials }) => {
.then((data) => parseStatus(data))
}
const tagUser = ({ tag, credentials, ...options }) => {
const screenName = options.screen_name
const tagUser = ({ tag, credentials, user }) => {
const screenName = user.screen_name
const form = {
nicknames: [screenName],
tags: [tag]
@ -415,8 +419,8 @@ const tagUser = ({ tag, credentials, ...options }) => {
})
}
const untagUser = ({ tag, credentials, ...options }) => {
const screenName = options.screen_name
const untagUser = ({ tag, credentials, user }) => {
const screenName = user.screen_name
const body = {
nicknames: [screenName],
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
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
return fetch(PERMISSION_GROUP_URL(screenName, right), {
@ -474,7 +478,7 @@ const deactivateUser = ({ credentials, user: { screen_name: nickname } }) => {
}).then(response => get(response, 'users.0'))
}
const deleteUser = ({ credentials, ...user }) => {
const deleteUser = ({ credentials, user }) => {
const screenName = user.screen_name
const headers = authHeaders(credentials)
@ -491,7 +495,8 @@ const fetchTimeline = ({
until = false,
userId = false,
tag = false,
withMuted = false
withMuted = false,
withMove = false
}) => {
const timelineUrls = {
public: MASTODON_PUBLIC_TIMELINE,
@ -531,6 +536,9 @@ const fetchTimeline = ({
if (timeline === 'public' || timeline === 'publicAndExternal') {
params.push(['only_media', false])
}
if (timeline === 'notifications') {
params.push(['with_move', withMove])
}
params.push(['count', 20])
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))
}
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 }) => {
return promisedRequest({
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 = {} }) => {
return Object.entries({
...(credentials
@ -1107,10 +1161,16 @@ const apiService = {
fetchPoll,
fetchFavoritedByUsers,
fetchRebloggedByUsers,
fetchEmojiReactions,
reactWithEmoji,
unreactWithEmoji,
reportUser,
updateNotificationSettings,
search2,
searchUsers
searchUsers,
fetchDomainMutes,
muteDomain,
unmuteDomain
}
export default apiService

View File

@ -16,7 +16,7 @@ const backendInteractorService = credentials => ({
return notificationsFetcher.fetchAndUpdate({ store, credentials })
},
startFetchingFollowRequest ({ store }) {
startFetchingFollowRequests ({ store }) {
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') {
return undefined
}
if (r[0] === '#') {
// TODO: clean up this mess
if (r[0] === '#' || r === 'transparent') {
return r
}
if (typeof r === 'object') {
({ 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 = val < 0 ? 0 : val
val = val > 255 ? 255 : val
@ -58,7 +69,7 @@ const srgbToLinear = (srgb) => {
* @param {Object} srgb - sRGB color
* @returns {Number} relative luminance
*/
const relativeLuminance = (srgb) => {
export const relativeLuminance = (srgb) => {
const { r, g, b } = srgbToLinear(srgb)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
@ -71,7 +82,7 @@ const relativeLuminance = (srgb) => {
* @param {Object} b - sRGB color
* @returns {Number} color ratio
*/
const getContrastRatio = (a, b) => {
export const getContrastRatio = (a, b) => {
const la = relativeLuminance(a)
const lb = relativeLuminance(b)
const [l1, l2] = la > lb ? [la, lb] : [lb, la]
@ -79,6 +90,17 @@ const getContrastRatio = (a, b) => {
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
*
@ -87,7 +109,7 @@ const getContrastRatio = (a, b) => {
* @param {Object} bg - bottom layer 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
return 'rgb'.split('').reduce((acc, c) => {
// 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) => {
acc[c] = 255 - rgb[c]
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)
return result ? {
r: parseInt(result[1], 16),
@ -113,18 +151,72 @@ const hex2rgb = (hex) => {
} : 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
return acc
}, {})
}
export {
rgb2hex,
hex2rgb,
mixrgb,
invert,
getContrastRatio,
alphaBlend
/**
* Converts rgb object into a CSS rgba() color
*
* @param {Object} color - rgb
* @returns {String} CSS rgba() color
*/
export const rgba2css = function (rgba) {
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) => {
if (status.is_post_verb) {
return 'status'
@ -41,7 +43,7 @@ export const parseUser = (data) => {
}
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_html = addEmojis(data.note, data.emojis)
@ -81,6 +83,8 @@ export const parseUser = (data) => {
output.subscribed = relationship.subscribing
}
output.allow_following_move = data.pleroma.allow_following_move
output.hide_follows = data.pleroma.hide_follows
output.hide_followers = data.pleroma.hide_followers
output.hide_follows_count = data.pleroma.hide_follows_count
@ -242,6 +246,7 @@ export const parseStatus = (data) => {
output.is_local = pleroma.local
output.in_reply_to_screen_name = data.pleroma.in_reply_to_account_acct
output.thread_muted = pleroma.thread_muted
output.emoji_reactions = pleroma.emoji_reactions
} else {
output.text = data.content
output.summary = data.spoiler_text
@ -255,7 +260,7 @@ export const parseStatus = (data) => {
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.poll = data.poll
output.pinned = data.pinned
@ -349,6 +354,7 @@ export const parseNotification = (data) => {
? null
: parseUser(data.target)
output.from_profile = parseUser(data.account)
output.emoji = data.emoji
} else {
const parsedNotice = parseStatus(data.notice)
output.type = data.ntype

View File

@ -7,7 +7,8 @@ export const visibleTypes = store => ([
store.state.config.notificationVisibility.mentions && 'mention',
store.state.config.notificationVisibility.repeats && 'repeat',
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(_ => _))
const sortById = (a, b) => {

View File

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

View File

@ -1,78 +1,9 @@
import { times } from 'lodash'
import { brightness, invertLightness, convert, contrastRatio } from 'chromatism'
import { rgb2hex, hex2rgb, mixrgb, getContrastRatio, alphaBlend } from '../color_convert/color_convert.js'
import { convert } from 'chromatism'
import { rgb2hex, hex2rgb, rgba2css, getCssColor, relativeLuminance } 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
// styles that aren't just colors, so user can pick from a few different distinct
// 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)
export const applyTheme = (input) => {
const { rules } = generatePreset(input)
const head = document.head
const body = document.body
body.classList.add('hidden')
@ -87,14 +18,9 @@ const applyTheme = (input, commit) => {
styleSheet.insertRule(`body { ${rules.shadows} }`, 'index-max')
styleSheet.insertRule(`body { ${rules.fonts} }`, 'index-max')
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) {
return 'none'
}
@ -132,122 +58,18 @@ const getCssShadowFilter = (input) => {
.join(' ')
}
const getCssColor = (input, a) => {
let rgb = {}
if (typeof input === 'object') {
rgb = input
} 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 })
}
export const generateColors = (themeData) => {
const sourceColors = !themeData.themeEngineVersion
? colors2to3(themeData.colors || themeData)
: themeData.colors || themeData
const generateColors = (input) => {
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 { colors, opacity } = getColors(sourceColors, themeData.opacity || {})
const htmlColors = Object.entries(colors)
.reduce((acc, [k, v]) => {
if (!v) return acc
acc.solid[k] = rgb2hex(v)
acc.complete[k] = typeof v.a === 'undefined' ? rgb2hex(v) : rgb2rgba(v)
acc.complete[k] = typeof v.a === 'undefined' ? rgb2hex(v) : rgba2css(v)
return acc
}, { complete: {}, solid: {} })
return {
@ -264,7 +86,7 @@ const generateColors = (input) => {
}
}
const generateRadii = (input) => {
export const generateRadii = (input) => {
let inputRadii = input.radii || {}
// v1 -> v2
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]) => {
acc[k] = Object.entries(v).filter(([k, v]) => v).reduce((acc, [k, v]) => {
acc[k] = v
@ -332,89 +154,123 @@ const generateFonts = (input) => {
}
}
const generateShadows = (input) => {
const border = (top, shadow) => ({
x: 0,
y: top ? 1 : -1,
blur: 0,
const border = (top, shadow) => ({
x: 0,
y: top ? 1 : -1,
blur: 0,
spread: 0,
color: shadow ? '#000000' : '#FFFFFF',
alpha: 0.2,
inset: true
})
const buttonInsetFakeBorders = [border(true, false), border(false, true)]
const inputInsetFakeBorders = [border(true, true), border(false, false)]
const hoverGlow = {
x: 0,
y: 0,
blur: 4,
spread: 0,
color: '--faint',
alpha: 1
}
export const DEFAULT_SHADOWS = {
panel: [{
x: 1,
y: 1,
blur: 4,
spread: 0,
color: shadow ? '#000000' : '#FFFFFF',
alpha: 0.2,
inset: true
})
const buttonInsetFakeBorders = [border(true, false), border(false, true)]
const inputInsetFakeBorders = [border(true, true), border(false, false)]
const hoverGlow = {
color: '#000000',
alpha: 0.6
}],
topBar: [{
x: 0,
y: 0,
blur: 4,
spread: 0,
color: '--faint',
color: '#000000',
alpha: 0.6
}],
popup: [{
x: 2,
y: 2,
blur: 3,
spread: 0,
color: '#000000',
alpha: 0.5
}],
avatar: [{
x: 0,
y: 1,
blur: 8,
spread: 0,
color: '#000000',
alpha: 0.7
}],
avatarStatus: [],
panelHeader: [],
button: [{
x: 0,
y: 0,
blur: 2,
spread: 0,
color: '#000000',
alpha: 1
}, ...buttonInsetFakeBorders],
buttonHover: [hoverGlow, ...buttonInsetFakeBorders],
buttonPressed: [hoverGlow, ...inputInsetFakeBorders],
input: [...inputInsetFakeBorders, {
x: 0,
y: 0,
blur: 2,
inset: true,
spread: 0,
color: '#000000',
alpha: 1
}]
}
export const generateShadows = (input, colors) => {
// TODO this is a small hack for `mod` to work with shadows
// this is used to get the "context" of shadow, i.e. for `mod` properly depend on background color of element
const hackContextDict = {
button: 'btn',
panel: 'bg',
top: 'topBar',
popup: 'popover',
avatar: 'bg',
panelHeader: 'panel',
input: 'input'
}
const shadows = {
panel: [{
x: 1,
y: 1,
blur: 4,
spread: 0,
color: '#000000',
alpha: 0.6
}],
topBar: [{
x: 0,
y: 0,
blur: 4,
spread: 0,
color: '#000000',
alpha: 0.6
}],
popup: [{
x: 2,
y: 2,
blur: 3,
spread: 0,
color: '#000000',
alpha: 0.5
}],
avatar: [{
x: 0,
y: 1,
blur: 8,
spread: 0,
color: '#000000',
alpha: 0.7
}],
avatarStatus: [],
panelHeader: [],
button: [{
x: 0,
y: 0,
blur: 2,
spread: 0,
color: '#000000',
alpha: 1
}, ...buttonInsetFakeBorders],
buttonHover: [hoverGlow, ...buttonInsetFakeBorders],
buttonPressed: [hoverGlow, ...inputInsetFakeBorders],
input: [...inputInsetFakeBorders, {
x: 0,
y: 0,
blur: 2,
inset: true,
spread: 0,
color: '#000000',
alpha: 1
}],
...(input.shadows || {})
}
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 {
rules: {
shadows: Object
.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
.map(([k, 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 {
rules: {
...shadows.rules,
@ -446,98 +302,110 @@ const composePreset = (colors, radii, shadows, fonts) => {
}
}
const generatePreset = (input) => {
const shadows = generateShadows(input)
export const generatePreset = (input) => {
const colors = generateColors(input)
const radii = generateRadii(input)
const fonts = generateFonts(input)
return composePreset(colors, radii, shadows, fonts)
return composePreset(
colors,
generateRadii(input),
generateShadows(input, colors.theme.colors, colors.mod),
generateFonts(input)
)
}
const getThemes = () => {
return window.fetch('/static/styles.json')
export const getThemes = () => {
const cache = 'no-store'
return window.fetch('/static/styles.json', { cache })
.then((data) => data.json())
.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') {
return Promise.resolve([k, v])
promise = Promise.resolve(v)
} else if (typeof v === 'string') {
return window.fetch(v)
promise = window.fetch(v, { cache })
.then((data) => data.json())
.then((theme) => {
return [k, theme]
})
.catch((e) => {
console.error(e)
return []
return null
})
}
}))
return [k, promise]
})
})
.then((promises) => {
return promises
.filter(([k, v]) => v)
.reduce((acc, [k, v]) => {
acc[k] = v
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) => {
const theme = themes[val] ? themes[val] : themes['pleroma-dark']
const isV1 = Array.isArray(theme)
const data = isV1 ? {} : theme.theme
if (isV1) {
const bgRgb = hex2rgb(theme[1])
const fgRgb = hex2rgb(theme[2])
const textRgb = hex2rgb(theme[3])
const linkRgb = hex2rgb(theme[4])
const cRedRgb = hex2rgb(theme[5] || '#FF0000')
const cGreenRgb = hex2rgb(theme[6] || '#00FF00')
const cBlueRgb = hex2rgb(theme[7] || '#0000FF')
const cOrangeRgb = hex2rgb(theme[8] || '#E3FF00')
data.colors = {
bg: bgRgb,
fg: fgRgb,
text: textRgb,
link: linkRgb,
cRed: cRedRgb,
cBlue: cBlueRgb,
cGreen: cGreenRgb,
cOrange: cOrangeRgb
/**
* This handles compatibility issues when importing v2 theme's shadows to current format
*
* Back in v2 shadows allowed you to use dynamic colors however those used pure CSS3 variables
*/
export const shadows2to3 = (shadows, opacity) => {
return Object.entries(shadows).reduce((shadowsAcc, [slotName, shadowDefs]) => {
const isDynamic = ({ color }) => 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
}
}
// This is a hack, this function is only called during initial load.
// 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)
}
})
], [])
return { ...shadowsAcc, [slotName]: newShadow }
}, {})
}
export {
setStyle,
setPreset,
applyTheme,
getTextColor,
generateColors,
generateRadii,
generateShadows,
generateFonts,
generatePreset,
getThemes,
composePreset,
getCssShadow,
getCssShadowFilter
export const getPreset = (val) => {
return getThemes()
.then((themes) => themes[val] ? themes[val] : themes['pleroma-dark'])
.then((theme) => {
const isV1 = Array.isArray(theme)
const data = isV1 ? {} : theme.theme
if (isV1) {
const bg = hex2rgb(theme[1])
const fg = hex2rgb(theme[2])
const text = hex2rgb(theme[3])
const link = hex2rgb(theme[4])
const cRed = hex2rgb(theme[5] || '#FF0000')
const cGreen = hex2rgb(theme[6] || '#00FF00')
const cBlue = hex2rgb(theme[7] || '#0000FF')
const cOrange = hex2rgb(theme[8] || '#E3FF00')
data.colors = { bg, fg, text, link, cRed, cBlue, cGreen, cOrange }
}
return { theme: data, source: theme.source }
})
}
export const setPreset = (val) => getPreset(val).then(data => applyTheme(data.theme))

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