Page MenuHomePhorge

No OneTemporary

Size
188 KB
Referenced Files
None
Subscribers
None
diff --git a/changelog.d/roundup5.skip b/changelog.d/roundup5.skip
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/public/static/styles/Redmond DX.iss b/public/static/styles/Redmond DX.iss
index 08d9103ff0..919be056b1 100644
--- a/public/static/styles/Redmond DX.iss
+++ b/public/static/styles/Redmond DX.iss
@@ -1,178 +1,201 @@
@meta {
name: Redmond DX;
author: HJ;
license: WTFPL;
website: ebin.club;
}
@palette.Modern {
bg: #D3CFC7;
fg: #092369;
text: #000000;
link: #0000FF;
accent: #A5C9F0;
cRed: #FF3000;
cBlue: #009EFF;
cGreen: #309E00;
cOrange: #FFCE00;
}
@palette.Classic {
bg: #BFBFBF;
fg: #000180;
text: #000000;
link: #0000FF;
accent: #A5C9F0;
cRed: #FF0000;
cBlue: #2E2ECE;
cGreen: #007E00;
cOrange: #CE8F5F;
}
@palette.Vapor {
bg: #F0ADCD;
fg: #bca4ee;
text: #602040;
link: #064745;
accent: #9DF7C8;
cRed: #86004a;
cBlue: #0e5663;
cGreen: #0a8b51;
cOrange: #787424;
}
Root {
--gradientColor: color | --accent;
--inputColor: color | #FFFFFF;
--bevelLight: color | $brightness(--bg 50);
--bevelDark: color | $brightness(--bg -20);
--bevelExtraDark: color | #404040;
--buttonDefaultBevel: shadow | $borderSide(--bevelExtraDark bottom-right 1 1), $borderSide(--bevelLight top-left 1 1), $borderSide(--bevelDark bottom-right 1 2);
--buttonPressedFocusedBevel: shadow | inset 0 0 0 1 #000000 / 1 #Outer , inset 0 0 0 2 --bevelExtraDark / 1 #inner;
--buttonPressedBevel: shadow | $borderSide(--bevelDark top-left 1 1), $borderSide(--bevelLight bottom-right 1 1), $borderSide(--bevelExtraDark top-left 1 2);
--defaultInputBevel: shadow | $borderSide(--bevelDark top-left 1 1), $borderSide(--bevelLight bottom-right 1 1), $borderSide(--bevelExtraDark top-left 1 2), $borderSide(--bg bottom-right 1 2);
}
Button:toggled {
background: --bg;
shadow: --buttonPressedBevel
}
Button:focused {
shadow: --buttonDefaultBevel, 0 0 0 1 #000000 / 1
}
Button:pressed {
shadow: --buttonPressedBevel
}
Button:hover {
shadow: --buttonDefaultBevel;
background: --bg
}
Button {
shadow: --buttonDefaultBevel;
background: --bg;
roundness: 0
}
Button:pressed:hover {
shadow: --buttonPressedBevel
}
Button:hover:pressed:focused {
shadow: --buttonPressedFocusedBevel
}
Button:pressed:focused {
shadow: --buttonPressedFocusedBevel
}
Button:toggled:pressed {
shadow: --buttonPressedFocusedBevel
}
Input {
background: $boost(--bg 20);
shadow: --defaultInputBevel;
roundness: 0
}
Input:focused {
shadow: inset 0 0 0 1 #000000 / 1, --defaultInputBevel
}
Input:focused:hover {
shadow: --defaultInputBevel
}
Input:focused:hover:disabled {
shadow: --defaultInputBevel
}
Input:hover {
shadow: --defaultInputBevel
}
Input:disabled {
shadow: --defaultInputBevel
}
Panel {
shadow: --buttonDefaultBevel;
roundness: 0
}
PanelHeader {
shadow: inset -1100 0 1000 -1000 --gradientColor / 1 #Gradient ;
background: --fg
}
+PanelHeader ButtonUnstyled Icon {
+ textColor: --text;
+ textAuto: 'no-preserve'
+}
+
+PanelHeader Button Icon {
+ textColor: --text;
+ textAuto: 'no-preserve'
+}
+
+PanelHeader Button Text {
+ textColor: --text;
+ textAuto: 'no-preserve'
+}
+
Tab:hover {
background: --bg;
shadow: --buttonDefaultBevel
}
Tab:active {
background: --bg
}
Tab:active:hover {
background: --bg;
shadow: --defaultButtonBevel
}
Tab:active:hover:disabled {
background: --bg
}
Tab:hover:disabled {
background: --bg
}
Tab:disabled {
background: --bg
}
Tab {
background: --bg;
shadow: --buttonDefaultBevel
}
Tab:hover:active {
shadow: --buttonDefaultBevel
}
TopBar Link {
textColor: #ffffff
}
MenuItem:hover {
background: --fg
}
+MenuItem:active {
+ background: --fg
+}
+
+MenuItem:active:hover {
+ background: --fg
+}
+
Popover {
shadow: --buttonDefaultBevel, 5 5 0 0 #000000 / 0.2;
roundness: 0
}
diff --git a/src/components/attachment/attachment.style.js b/src/components/attachment/attachment.style.js
index 45baa5546e..a9455e3679 100644
--- a/src/components/attachment/attachment.style.js
+++ b/src/components/attachment/attachment.style.js
@@ -1,25 +1,27 @@
export default {
name: 'Attachment',
selector: '.Attachment',
notEditable: true,
validInnerComponents: [
'Border',
'Button',
'Input'
],
defaultRules: [
{
directives: {
roundness: 3
}
},
{
component: 'Button',
- parent: { component: 'Attachment' },
+ parent: {
+ component: 'Attachment'
+ },
directives: {
background: '#FFFFFF',
opacity: 0.5
}
}
]
}
diff --git a/src/components/button.style.js b/src/components/button.style.js
index 410d2f7e09..fb5d30b64b 100644
--- a/src/components/button.style.js
+++ b/src/components/button.style.js
@@ -1,129 +1,129 @@
export default {
name: 'Button', // Name of the component
selector: '.button-default', // CSS selector/prefix
// outOfTreeSelector: '' // out-of-tree selector is used when other components are laid over it but it's not part of the tree, see Underlay component
// States, system witll calculate ALL possible combinations of those and prepend "normal" to them + standalone "normal" state
states: {
// States are a bit expensive - the amount of combinations generated is about (1/6)n^3+n, so adding more state increased number of combination by an order of magnitude!
// All states inherit from "normal" state, there is no other inheirtance, i.e. hover+disabled only inherits from "normal", not from hover nor disabled.
// However, cascading still works, so resulting state will be result of merging of all relevant states/variants
// normal: '' // normal state is implicitly added, it is always included
toggled: '.toggled',
focused: ':focus-visible',
pressed: ':focus:active',
hover: ':hover:not(:disabled)',
disabled: ':disabled'
},
// Variants are mutually exclusive, each component implicitly has "normal" variant, and all other variants inherit from it.
variants: {
// Variants save on computation time since adding new variant just adds one more "set".
// normal: '', // you can override normal variant, it will be appenended to the main class
danger: '.danger'
// Overall the compuation difficulty is N*((1/6)M^3+M) where M is number of distinct states and N is number of variants.
// This (currently) is further multipled by number of places where component can exist.
},
editor: {
aspect: '2 / 1'
},
// This lists all other components that can possibly exist within one. Recursion is currently not supported (and probably won't be supported ever).
validInnerComponents: [
'Text',
'Icon'
],
// Default rules, used as "default theme", essentially.
defaultRules: [
{
component: 'Root',
directives: {
'--buttonDefaultHoverGlow': 'shadow | 0 0 1 2 --text / 0.4',
'--buttonDefaultFocusGlow': 'shadow | 0 0 1 2 --link / 0.5',
'--buttonDefaultShadow': 'shadow | 0 0 2 #000000',
'--buttonDefaultBevel': 'shadow | $borderSide(#FFFFFF top 0.2 1), $borderSide(#000000 bottom 0.2 1)',
'--buttonPressedBevel': 'shadow | inset 0 0 4 #000000, $borderSide(#FFFFFF bottom 0.2 1), $borderSide(#000000 top 0.2 1)'
}
},
{
// component: 'Button', // no need to specify components every time unless you're specifying how other component should look
// like within it
directives: {
background: '--fg',
shadow: ['--buttonDefaultShadow', '--buttonDefaultBevel'],
roundness: 3
}
},
{
state: ['hover'],
directives: {
shadow: ['--buttonDefaultHoverGlow', '--buttonDefaultBevel']
}
},
{
state: ['focused'],
directives: {
shadow: ['--buttonDefaultFocusGlow', '--buttonDefaultBevel']
}
},
{
state: ['pressed'],
directives: {
shadow: ['--buttonDefaultShadow', '--buttonPressedBevel']
}
},
{
state: ['pressed', 'hover'],
directives: {
shadow: ['--buttonPressedBevel', '--buttonDefaultHoverGlow']
}
},
{
state: ['toggled'],
directives: {
background: '--accent,-24.2',
shadow: ['--buttonDefaultShadow', '--buttonPressedBevel']
}
},
{
state: ['toggled', 'hover'],
directives: {
background: '--accent,-24.2',
shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel']
}
},
{
state: ['toggled', 'disabled'],
directives: {
background: '$blend(--accent 0.25 --parent)',
shadow: ['--buttonPressedBevel']
}
},
{
state: ['disabled'],
directives: {
- background: '$blend(--accent 0.25 --parent)',
+ background: '$blend(--inheritedBackground 0.25 --parent)',
shadow: ['--buttonDefaultBevel']
}
},
{
component: 'Text',
parent: {
component: 'Button',
state: ['disabled']
},
directives: {
textOpacity: 0.25,
textOpacityMode: 'blend'
}
},
{
component: 'Icon',
parent: {
component: 'Button',
state: ['disabled']
},
directives: {
textOpacity: 0.25,
textOpacityMode: 'blend'
}
}
]
}
diff --git a/src/components/checkbox/checkbox.vue b/src/components/checkbox/checkbox.vue
index 0cd3d95a4f..c8bba4c444 100644
--- a/src/components/checkbox/checkbox.vue
+++ b/src/components/checkbox/checkbox.vue
@@ -1,143 +1,157 @@
<template>
<label
class="checkbox"
- :class="{ disabled, indeterminate, 'indeterminate-fix': indeterminateTransitionFix }"
+ :class="[{ disabled, indeterminate, 'indeterminate-fix': indeterminateTransitionFix }, radio ? '-radio' : '-checkbox']"
>
<span
v-if="!!$slots.before"
class="label -before"
:class="{ faint: disabled }"
>
<slot name="before" />
</span>
<input
type="checkbox"
class="visible-for-screenreader-only"
:disabled="disabled"
:checked="modelValue"
:indeterminate="indeterminate"
@change="$emit('update:modelValue', $event.target.checked)"
>
<i
- class="input -checkbox checkbox-indicator"
+ class="input checkbox-indicator"
:aria-hidden="true"
- :class="{ disabled }"
+ :class="[{ disabled }, radio ? '-radio' : '-checkbox']"
@transitionend.capture="onTransitionEnd"
/>
<span
v-if="!!$slots.default"
class="label -after"
:class="{ faint: disabled }"
>
<slot />
</span>
</label>
</template>
<script>
export default {
props: [
+ 'radio',
'modelValue',
'indeterminate',
'disabled'
],
emits: ['update:modelValue'],
data: (vm) => ({
indeterminateTransitionFix: vm.indeterminate
}),
watch: {
indeterminate (e) {
if (e) {
this.indeterminateTransitionFix = true
}
}
},
methods: {
onTransitionEnd () {
if (!this.indeterminate) {
this.indeterminateTransitionFix = false
}
}
}
}
</script>
<style lang="scss">
.checkbox {
position: relative;
display: inline-block;
min-height: 1.2em;
&-indicator,
& .label {
vertical-align: middle;
}
& > &-indicator {
/* Reset .input stuff */
padding: 0;
margin: 0;
position: relative;
line-height: inherit;
display: inline-block;
width: 1.2em;
height: 1.2em;
box-shadow: none;
--_shadow: var(--shadow);
}
&-indicator::before {
position: absolute;
inset: 0;
display: block;
content: "✓";
transition: color 200ms;
width: 1.1em;
height: 1.1em;
border-radius: var(--roundness);
box-shadow: var(--_shadow);
background-color: var(--background);
vertical-align: top;
text-align: center;
line-height: 1.1em;
font-size: 1.1em;
color: transparent;
overflow: hidden;
box-sizing: border-box;
}
+ &.-radio {
+ .checkbox-indicator {
+ &,
+ &::before {
+ border-radius: 9999px;
+ }
+
+ &::before {
+ content: "•";
+ }
+ }
+ }
+
.disabled {
.checkbox-indicator::before {
background-color: var(--background);
}
}
input[type="checkbox"] {
&:checked + .checkbox-indicator::before {
color: var(--text);
}
&:indeterminate + .checkbox-indicator::before {
content: "–";
color: var(--text);
}
}
&.indeterminate-fix {
input[type="checkbox"] + .checkbox-indicator::before {
content: "–";
}
}
& > .label {
&.-after {
margin-left: 0.5em;
}
&.-before {
margin-right: 0.5em;
}
}
}
</style>
diff --git a/src/components/login_form/login_form.scss b/src/components/login_form/login_form.scss
new file mode 100644
index 0000000000..83bc20fe45
--- /dev/null
+++ b/src/components/login_form/login_form.scss
@@ -0,0 +1,44 @@
+.login-panel {
+ .login-form {
+ display: flex;
+ flex-direction: column;
+ padding: 0.6em;
+ }
+
+ .btn {
+ min-height: 2em;
+ width: 10em;
+ }
+
+ .register {
+ flex: 1 1;
+ }
+
+ .login-bottom {
+ margin-top: 1em;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ .form-group {
+ display: flex;
+ flex-direction: column;
+ padding: 0.3em 0.5em 0.6em;
+ line-height: 24px;
+ }
+
+ .login-error {
+ display: flex;
+ line-height: 2;
+ margin: 0.5em;
+ animation-name: shakeError;
+ animation-duration: 0.4s;
+ animation-timing-function: ease-in-out;
+ }
+
+ .error-message {
+ flex: 1;
+ }
+}
diff --git a/src/components/login_form/login_form.vue b/src/components/login_form/login_form.vue
index 25392742fa..acf3e82bd4 100644
--- a/src/components/login_form/login_form.vue
+++ b/src/components/login_form/login_form.vue
@@ -1,150 +1,96 @@
<template>
- <div class="login panel panel-default">
+ <div class="login-panel panel panel-default">
<!-- Default panel contents -->
<div class="panel-heading">
<h1 class="title">
{{ $t('login.login') }}
</h1>
</div>
<div class="panel-body">
<form
class="login-form"
@submit.prevent="submit"
>
<template v-if="isPasswordAuth">
<div class="form-group">
<label for="username">{{ $t('login.username') }}</label>
<input
id="username"
v-model="user.username"
:disabled="loggingIn"
class="input form-control"
:placeholder="$t('login.placeholder')"
>
</div>
<div class="form-group">
<label for="password">{{ $t('login.password') }}</label>
<input
id="password"
ref="passwordInput"
v-model="user.password"
:disabled="loggingIn"
class="input form-control"
type="password"
>
</div>
<div class="form-group">
<router-link :to="{name: 'password-reset'}">
{{ $t('password_reset.forgot_password') }}
</router-link>
</div>
</template>
<div
v-if="isTokenAuth"
class="form-group"
>
<p>{{ $t('login.description') }}</p>
</div>
<div class="form-group">
<div class="login-bottom">
<div>
<router-link
v-if="registrationOpen"
:to="{name: 'registration'}"
class="register"
>
{{ $t('login.register') }}
</router-link>
</div>
<button
:disabled="loggingIn"
type="submit"
class="btn button-default"
>
{{ $t('login.login') }}
</button>
</div>
</div>
</form>
- </div>
-
- <div
- v-if="error"
- class="form-group"
- >
- <div class="alert error">
- {{ error }}
+ <div
+ v-if="error"
+ class="login-error alert error"
+ >
+ <span class="error-message">
+ {{ error }}
+ </span>
<button
class="button-unstyled"
@click="clearError"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
/>
</button>
</div>
</div>
</div>
</template>
<script src="./login_form.js"></script>
-<style lang="scss">
-.login-form {
- display: flex;
- flex-direction: column;
- padding: 0.6em;
-
- .btn {
- min-height: 2em;
- width: 10em;
- }
-
- .register {
- flex: 1 1;
- }
-
- .login-bottom {
- margin-top: 1em;
- display: flex;
- flex-direction: row;
- align-items: center;
- justify-content: space-between;
- }
-
- .form-group {
- display: flex;
- flex-direction: column;
- padding: 0.3em 0.5em 0.6em;
- line-height: 24px;
- }
-
- .form-bottom {
- display: flex;
- padding: 0.5em;
- height: 32px;
-
- button {
- width: 10em;
- }
-
- p {
- margin: 0.35em;
- padding: 0.35em;
- display: flex;
- }
- }
-
- .error {
- text-align: center;
- animation-name: shakeError;
- animation-duration: 0.4s;
- animation-timing-function: ease-in-out;
- }
-}
-</style>
+<style src="./login_form.scss"/>
diff --git a/src/components/mrf_transparency_panel/mrf_transparency_panel.vue b/src/components/mrf_transparency_panel/mrf_transparency_panel.vue
index 55bacee27b..cb24368068 100644
--- a/src/components/mrf_transparency_panel/mrf_transparency_panel.vue
+++ b/src/components/mrf_transparency_panel/mrf_transparency_panel.vue
@@ -1,241 +1,241 @@
<template>
<div
v-if="federationPolicy"
class="mrf-transparency-panel"
>
<div class="panel panel-default base01-background">
<div class="panel-heading timeline-heading base02-background -sticky">
<div class="title">
{{ $t("about.mrf.federation") }}
</div>
</div>
<div class="panel-body">
<div class="mrf-section">
<h2>{{ $t("about.mrf.mrf_policies") }}</h2>
<p>{{ $t("about.mrf.mrf_policies_desc") }}</p>
<ul>
<li
v-for="policy in mrfPolicies"
:key="policy"
v-text="policy"
/>
</ul>
<h2 v-if="hasInstanceSpecificPolicies">
{{ $t("about.mrf.simple.simple_policies") }}
</h2>
<div v-if="acceptInstances.length">
<h4>{{ $t("about.mrf.simple.accept") }}</h4>
<p>{{ $t("about.mrf.simple.accept_desc") }}</p>
<table>
<tbody>
<tr>
<th>{{ $t("about.mrf.simple.instance") }}</th>
<th>{{ $t("about.mrf.simple.reason") }}</th>
</tr>
<tr
v-for="entry in acceptInstances"
:key="entry.instance + '_accept'"
>
<td>{{ entry.instance }}</td>
<td v-if="entry.reason === ''">
{{ $t("about.mrf.simple.not_applicable") }}
</td>
<td v-else>
{{ entry.reason }}
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="rejectInstances.length">
<h4>{{ $t("about.mrf.simple.reject") }}</h4>
<p>{{ $t("about.mrf.simple.reject_desc") }}</p>
<table>
<tbody>
<tr>
<th>{{ $t("about.mrf.simple.instance") }}</th>
<th>{{ $t("about.mrf.simple.reason") }}</th>
</tr>
<tr
v-for="entry in rejectInstances"
:key="entry.instance + '_reject'"
>
<td>{{ entry.instance }}</td>
<td v-if="entry.reason === ''">
{{ $t("about.mrf.simple.not_applicable") }}
</td>
<td v-else>
{{ entry.reason }}
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="quarantineInstances.length">
<h4>{{ $t("about.mrf.simple.quarantine") }}</h4>
<p>{{ $t("about.mrf.simple.quarantine_desc") }}</p>
<table>
<tbody>
<tr>
<th>{{ $t("about.mrf.simple.instance") }}</th>
<th>{{ $t("about.mrf.simple.reason") }}</th>
</tr>
<tr
v-for="entry in quarantineInstances"
:key="entry.instance + '_quarantine'"
>
<td>{{ entry.instance }}</td>
<td v-if="entry.reason === ''">
{{ $t("about.mrf.simple.not_applicable") }}
</td>
<td v-else>
{{ entry.reason }}
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="ftlRemovalInstances.length">
<h4>{{ $t("about.mrf.simple.ftl_removal") }}</h4>
<p>{{ $t("about.mrf.simple.ftl_removal_desc") }}</p>
<table>
<tbody>
<tr>
<th>{{ $t("about.mrf.simple.instance") }}</th>
<th>{{ $t("about.mrf.simple.reason") }}</th>
</tr>
<tr
v-for="entry in ftlRemovalInstances"
:key="entry.instance + '_ftl_removal'"
>
<td>{{ entry.instance }}</td>
<td v-if="entry.reason === ''">
{{ $t("about.mrf.simple.not_applicable") }}
</td>
<td v-else>
{{ entry.reason }}
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="mediaNsfwInstances.length">
<h4>{{ $t("about.mrf.simple.media_nsfw") }}</h4>
<p>{{ $t("about.mrf.simple.media_nsfw_desc") }}</p>
<table>
<tbody>
<tr>
<th>{{ $t("about.mrf.simple.instance") }}</th>
<th>{{ $t("about.mrf.simple.reason") }}</th>
</tr>
<tr
v-for="entry in mediaNsfwInstances"
:key="entry.instance + '_media_nsfw'"
>
<td>{{ entry.instance }}</td>
<td v-if="entry.reason === ''">
{{ $t("about.mrf.simple.not_applicable") }}
</td>
<td v-else>
{{ entry.reason }}
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="mediaRemovalInstances.length">
<h4>{{ $t("about.mrf.simple.media_removal") }}</h4>
<p>{{ $t("about.mrf.simple.media_removal_desc") }}</p>
<table>
<tbody>
<tr>
<th>{{ $t("about.mrf.simple.instance") }}</th>
<th>{{ $t("about.mrf.simple.reason") }}</th>
</tr>
<tr
v-for="entry in mediaRemovalInstances"
:key="entry.instance + '_media_removal'"
>
<td>{{ entry.instance }}</td>
<td v-if="entry.reason === ''">
{{ $t("about.mrf.simple.not_applicable") }}
</td>
<td v-else>
{{ entry.reason }}
</td>
</tr>
</tbody>
</table>
</div>
<h2 v-if="hasKeywordPolicies">
{{ $t("about.mrf.keyword.keyword_policies") }}
</h2>
<div v-if="keywordsFtlRemoval.length">
<h4>{{ $t("about.mrf.keyword.ftl_removal") }}</h4>
<ul>
<li
v-for="keyword in keywordsFtlRemoval"
:key="keyword"
v-text="keyword"
/>
</ul>
</div>
<div v-if="keywordsReject.length">
<h4>{{ $t("about.mrf.keyword.reject") }}</h4>
<ul>
<li
v-for="keyword in keywordsReject"
:key="keyword"
v-text="keyword"
/>
</ul>
</div>
<div v-if="keywordsReplace.length">
<h4>{{ $t("about.mrf.keyword.replace") }}</h4>
<ul>
<li
v-for="keyword in keywordsReplace"
:key="keyword"
>
{{ keyword.pattern }}
{{ $t("about.mrf.keyword.is_replaced_by") }}
{{ keyword.replacement }}
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</template>
<script src="./mrf_transparency_panel.js"></script>
-<style src="./mrf_transparency_panel.scss" lang="scss"/>
+<style src="./mrf_transparency_panel.scss" lang="scss" />
diff --git a/src/components/poll/poll.js b/src/components/poll/poll.js
index 3384caec48..a1b7808f21 100644
--- a/src/components/poll/poll.js
+++ b/src/components/poll/poll.js
@@ -1,115 +1,112 @@
import Timeago from 'components/timeago/timeago.vue'
import genRandomSeed from '../../services/random_seed/random_seed.service.js'
import RichContent from 'components/rich_content/rich_content.jsx'
-import { forEach, map } from 'lodash'
+import Checkbox from 'components/checkbox/checkbox.vue'
import { usePollsStore } from 'src/stores/polls'
export default {
name: 'Poll',
props: ['basePoll', 'emoji'],
components: {
Timeago,
- RichContent
+ RichContent,
+ Checkbox
},
data () {
return {
loading: false,
choices: [],
randomSeed: genRandomSeed()
}
},
created () {
if (!usePollsStore().pollsObject[this.pollId]) {
usePollsStore().mergeOrAddPoll(this.basePoll)
}
usePollsStore().trackPoll(this.pollId)
},
unmounted () {
usePollsStore().untrackPoll(this.pollId)
},
computed: {
pollId () {
return this.basePoll.id
},
poll () {
const storePoll = usePollsStore().pollsObject[this.pollId]
return storePoll || {}
},
options () {
return (this.poll && this.poll.options) || []
},
expiresAt () {
return (this.poll && this.poll.expires_at) || null
},
expired () {
return (this.poll && this.poll.expired) || false
},
+ expirationLabel () {
+ if (this.$store.getters.mergedConfig.useAbsoluteTimeFormat) {
+ return this.expired ? 'polls.expired_at' : 'polls.expires_at'
+ } else {
+ return this.expired ? 'polls.expired' : 'polls.expires_in'
+ }
+ },
loggedIn () {
return this.$store.state.users.currentUser
},
showResults () {
return this.poll.voted || this.expired || !this.loggedIn
},
totalVotesCount () {
return this.poll.votes_count
},
containerClass () {
return {
loading: this.loading
}
},
choiceIndices () {
// Convert array of booleans into an array of indices of the
// items that were 'true', so [true, false, false, true] becomes
// [0, 3].
return this.choices
.map((entry, index) => entry && index)
.filter(value => typeof value === 'number')
},
isDisabled () {
const noChoice = this.choiceIndices.length === 0
return this.loading || noChoice
}
},
methods: {
percentageForOption (count) {
return this.totalVotesCount === 0 ? 0 : Math.round(count / this.totalVotesCount * 100)
},
resultTitle (option) {
return `${option.votes_count}/${this.totalVotesCount} ${this.$t('polls.votes')}`
},
- activateOption (index) {
- // forgive me father: doing checking the radio/checkboxes
- // in code because of customized input elements need either
- // a) an extra element for the actual graphic, or b) use a
- // pseudo element for the label. We use b) which mandates
- // using "for" and "id" matching which isn't nice when the
- // same poll appears multiple times on the site (notifs and
- // timeline for example). With code we can make sure it just
- // works without altering the pseudo element implementation.
- const allElements = this.$el.querySelectorAll('input')
- const clickedElement = this.$el.querySelector(`input[value="${index}"]`)
+ activateOption (index, value) {
+ let result
if (this.poll.multiple) {
- // Checkboxes, toggle only the clicked one
- clickedElement.checked = !clickedElement.checked
+ result = this.choices || this.options.map(() => false)
} else {
- // Radio button, uncheck everything and check the clicked one
- forEach(allElements, element => { element.checked = false })
- clickedElement.checked = true
+ result = this.options.map(() => false)
}
- this.choices = map(allElements, e => e.checked)
+ result[index] = value
+ this.choices = result
},
optionId (index) {
return `poll${this.poll.id}-${index}`
},
vote () {
if (this.choiceIndices.length === 0) return
this.loading = true
usePollsStore().votePoll(
{ id: this.statusId, pollId: this.poll.id, choices: this.choiceIndices }
).then(() => {
this.loading = false
})
}
}
}
diff --git a/src/components/poll/poll.scss b/src/components/poll/poll.scss
new file mode 100644
index 0000000000..d563584207
--- /dev/null
+++ b/src/components/poll/poll.scss
@@ -0,0 +1,62 @@
+.poll {
+ .votes {
+ display: flex;
+ flex-direction: column;
+ margin: 0 0 0.5em;
+ }
+
+ .poll-option {
+ margin: 0.75em 0.5em;
+
+ .input {
+ line-height: inherit;
+ }
+ }
+
+ .option-result {
+ height: 100%;
+ display: flex;
+ flex-direction: row;
+ position: relative;
+ color: var(--textLight);
+ }
+
+ .option-result-label {
+ display: flex;
+ align-items: center;
+ padding: 0.1em 0.25em;
+ z-index: 1;
+ word-break: break-word;
+ }
+
+ .result-percentage {
+ width: 3.5em;
+ flex-shrink: 0;
+ }
+
+ .result-fill {
+ height: 100%;
+ position: absolute;
+ border-radius: var(--roundness);
+ top: 0;
+ left: 0;
+ transition: width 0.5s;
+ }
+
+ input {
+ width: 3.5em;
+ }
+
+ &.loading * {
+ cursor: progress;
+ }
+
+ .poll-vote-button {
+ padding: 0 1em;
+ margin-right: 0.5em;
+ }
+
+ .poll-checkbox {
+ display: none;
+ }
+}
diff --git a/src/components/poll/poll.vue b/src/components/poll/poll.vue
index faaba99218..24de142002 100644
--- a/src/components/poll/poll.vue
+++ b/src/components/poll/poll.vue
@@ -1,188 +1,105 @@
<template>
<div
class="poll"
:class="containerClass"
>
<div
:role="showResults ? 'section' : (poll.multiple ? 'group' : 'radiogroup')"
>
<div
v-for="(option, index) in options"
:key="index"
class="poll-option"
>
<div
v-if="showResults"
:title="resultTitle(option)"
class="option-result"
>
<div class="option-result-label">
<span class="result-percentage">
{{ percentageForOption(option.votes_count) }}%
</span>
<RichContent
:html="option.title_html"
:handle-links="false"
:emoji="emoji"
/>
</div>
<div
class="result-fill"
:style="{ 'width': `${percentageForOption(option.votes_count)}%` }"
/>
</div>
<div
v-else
tabindex="0"
:role="poll.multiple ? 'checkbox' : 'radio'"
:aria-labelledby="`option-vote-${randomSeed}-${index}`"
:aria-checked="choices[index]"
- class="input unstyled"
- @click="activateOption(index)"
>
- <!-- TODO: USE CHECKBOX -->
- <input
- v-if="poll.multiple"
- type="checkbox"
- class="input -checkbox poll-checkbox"
+ <Checkbox
+ :radio="!poll.multiple"
:disabled="loading"
- :value="index"
+ :model-value="choices[index]"
+ @update:model-value="value => activateOption(index, value)"
>
- <input
- v-else
- type="radio"
- :disabled="loading"
- :value="index"
- class="input -radio"
- >
- <label class="option-vote">
<RichContent
:id="`option-vote-${randomSeed}-${index}`"
:html="option.title_html"
:handle-links="false"
:emoji="emoji"
/>
- </label>
+ </Checkbox>
</div>
</div>
</div>
<div class="footer faint">
+ <p>
+ <span
+ v-if="poll.pleroma?.non_anonymous"
+ :title="$t('polls.non_anonymous_title')"
+ >
+ {{ $t('polls.non_anonymous') }}
+ &nbsp;·&nbsp;
+ </span>
+ <span class="total">
+ <template v-if="typeof poll.voters_count === 'number'">
+ {{ $t("polls.people_voted_count", { count: poll.voters_count }, poll.voters_count) }}
+ </template>
+ <template v-else>
+ {{ $t("polls.votes_count", { count: poll.votes_count }, poll.votes_count) }}
+ </template>
+ <span v-if="expiresAt !== null">
+ &nbsp;·&nbsp;
+ </span>
+ </span>
+ <span v-if="expiresAt !== null">
+ <i18n-t
+ scope="global"
+ :keypath="expirationLabel"
+ >
+ <Timeago
+ :time="expiresAt"
+ :auto-update="60"
+ :now-threshold="0"
+ />
+ </i18n-t>
+ </span>
+ </p>
<button
v-if="!showResults"
class="btn button-default poll-vote-button"
type="button"
:disabled="isDisabled"
@click="vote"
>
{{ $t('polls.vote') }}
</button>
- <span
- v-if="poll.pleroma?.non_anonymous"
- :title="$t('polls.non_anonymous_title')"
- >
- {{ $t('polls.non_anonymous') }}
- &nbsp;·&nbsp;
- </span>
- <div class="total">
- <template v-if="typeof poll.voters_count === 'number'">
- {{ $t("polls.people_voted_count", { count: poll.voters_count }, poll.voters_count) }}
- </template>
- <template v-else>
- {{ $t("polls.votes_count", { count: poll.votes_count }, poll.votes_count) }}
- </template>
- <span v-if="expiresAt !== null">
- &nbsp;·&nbsp;
- </span>
- </div>
- <span v-if="expiresAt !== null">
- <i18n-t
- scope="global"
- :keypath="expired ? 'polls.expired' : 'polls.expires_in'"
- >
- <Timeago
- :time="expiresAt"
- :auto-update="60"
- :now-threshold="0"
- />
- </i18n-t>
- </span>
</div>
</div>
</template>
<script src="./poll.js"></script>
-<style lang="scss">
-.poll {
- .votes {
- display: flex;
- flex-direction: column;
- margin: 0 0 0.5em;
- }
-
- .poll-option {
- margin: 0.75em 0.5em;
-
- .input {
- line-height: inherit;
- }
- }
-
- .option-result {
- height: 100%;
- display: flex;
- flex-direction: row;
- position: relative;
- color: var(--textLight);
- }
-
- .option-result-label {
- display: flex;
- align-items: center;
- padding: 0.1em 0.25em;
- z-index: 1;
- word-break: break-word;
- }
-
- .result-percentage {
- width: 3.5em;
- flex-shrink: 0;
- }
-
- .result-fill {
- height: 100%;
- position: absolute;
- border-radius: var(--roundness);
- top: 0;
- left: 0;
- transition: width 0.5s;
- }
-
- .option-vote {
- display: flex;
- align-items: center;
- }
-
- input {
- width: 3.5em;
- }
-
- .footer {
- display: flex;
- align-items: center;
- }
-
- &.loading * {
- cursor: progress;
- }
-
- .poll-vote-button {
- padding: 0 0.5em;
- margin-right: 0.5em;
- }
-
- .poll-checkbox {
- display: none;
- }
-}
-</style>
+<style src="./poll.scss" lang="scss" />
diff --git a/src/components/search/search.vue b/src/components/search/search.vue
index 00e700cfb7..645a6fce86 100644
--- a/src/components/search/search.vue
+++ b/src/components/search/search.vue
@@ -1,225 +1,231 @@
<template>
<div class="Search panel panel-default">
<div class="panel-heading">
<h1 class="title">
{{ $t('nav.search') }}
</h1>
</div>
<div class="panel-body search-input-container">
<input
ref="searchInput"
v-model="searchTerm"
class="input search-input"
:placeholder="$t('nav.search')"
@keyup.enter="newQuery(searchTerm)"
>
<button
class="btn button-default search-button"
type="submit"
@click="newQuery(searchTerm)"
>
<FAIcon icon="search" />
</button>
</div>
<div
v-if="loading && statusesOffset == 0"
class="panel-body text-center loading-icon"
>
<FAIcon
icon="circle-notch"
spin
size="lg"
/>
</div>
<div v-else-if="loaded">
<div class="search-nav-heading">
<tab-switcher
ref="tabSwitcher"
:on-switch="onResultTabSwitch"
:active-tab="currenResultTab"
>
<span
key="statuses"
:label="$t('user_card.statuses') + resultCount('visibleStatuses')"
/>
<span
key="people"
:label="$t('search.people') + resultCount('users')"
/>
<span
key="hashtags"
:label="$t('search.hashtags') + resultCount('hashtags')"
/>
</tab-switcher>
</div>
</div>
<div class="panel-body">
<div v-if="currenResultTab === 'statuses'">
<Status
v-for="status in visibleStatuses"
:key="status.id"
:collapsable="false"
:expandable="false"
:compact="false"
class="search-result"
:statusoid="status"
:no-heading="false"
/>
<button
v-if="!loading && loaded && lastStatusFetchCount > 0"
class="more-statuses-button button-unstyled -link"
@click.prevent="search(searchTerm, 'statuses')"
>
<div class="new-status-notification text-center">
{{ $t('search.load_more') }}
</div>
</button>
<div
v-else-if="loading && statusesOffset > 0"
class="text-center loading-icon"
>
<FAIcon
icon="circle-notch"
spin
size="lg"
/>
</div>
<div
v-if="(visibleStatuses.length === 0 || lastStatusFetchCount === 0) && !loading && loaded"
class="search-result-heading"
>
<h4>
{{ visibleStatuses.length === 0 ? $t('search.no_results') : $t('search.no_more_results') }}
</h4>
</div>
</div>
<div v-else-if="currenResultTab === 'people'">
<div
v-if="users.length === 0 && !loading && loaded"
class="search-result-heading"
>
<h4>{{ $t('search.no_results') }}</h4>
</div>
<FollowCard
v-for="user in users"
:key="user.id"
:user="user"
class="list-item search-result"
/>
</div>
<div v-else-if="currenResultTab === 'hashtags'">
<div
v-if="hashtags.length === 0 && !loading && loaded"
class="search-result-heading"
>
<h4>{{ $t('search.no_results') }}</h4>
</div>
<div
v-for="hashtag in hashtags"
:key="hashtag.url"
class="status trend search-result"
>
- <div class="hashtag">
- <router-link :to="{ name: 'tag-timeline', params: { tag: hashtag.name } }">
+ <router-link
+ class="list-item hashtag"
+ :to="{ name: 'tag-timeline', params: { tag: hashtag.name } }"
+ >
+ <span class="name">
#{{ hashtag.name }}
- </router-link>
+ </span>
<div v-if="lastHistoryRecord(hashtag)">
<span v-if="lastHistoryRecord(hashtag).accounts == 1">
{{ $t('search.person_talking', { count: lastHistoryRecord(hashtag).accounts }) }}
</span>
<span v-else>
{{ $t('search.people_talking', { count: lastHistoryRecord(hashtag).accounts }) }}
</span>
</div>
- </div>
+ </router-link>
<div
v-if="lastHistoryRecord(hashtag)"
class="count"
>
{{ lastHistoryRecord(hashtag).uses }}
</div>
</div>
</div>
</div>
<div class="search-result-footer text-center panel-footer faint" />
</div>
</template>
<script src="./search.js"></script>
<style lang="scss">
.search-result-heading {
color: var(--faint);
padding: 0.75rem;
text-align: center;
}
@media all and (max-width: 800px) {
.search-nav-heading {
.tab-switcher .tabs .tab-wrapper {
display: block;
justify-content: center;
flex: 1 1 auto;
text-align: center;
}
}
}
.search-result {
box-sizing: border-box;
border-bottom: 1px solid;
border-color: var(--border);
}
.search-input-container {
padding: 0.8rem;
display: flex;
justify-content: center;
.search-input {
width: 100%;
line-height: 1.125rem;
font-size: 1rem;
padding: 0.5rem;
box-sizing: border-box;
}
.search-button {
margin-left: 0.5em;
}
}
.loading-icon {
padding: 1em;
}
.trend {
display: flex;
align-items: center;
.hashtag {
flex: 1 1 auto;
- color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+
+ .name {
+ color: var(--link);
+ }
}
.count {
flex: 0 0 auto;
width: 2rem;
font-size: 1.5rem;
line-height: 2.25rem;
font-weight: 500;
text-align: center;
color: var(--text);
}
}
.more-statuses-button {
height: 3.5em;
line-height: 3.5em;
width: 100%;
}
</style>
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index e78b0931ad..d0af91aef5 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -1,587 +1,588 @@
<template>
<div
v-if="!hideStatus"
ref="root"
class="Status"
:class="[{ '-focused': isFocused }, { '-conversation': inlineExpanded }]"
>
<div
v-if="error"
class="alert error"
>
{{ error }}
<span
class="fa-scale-110 fa-old-padding"
@click="clearError"
>
<FAIcon icon="times" />
</span>
</div>
<template v-if="muted && !isPreview">
<div class="status-container muted">
<small class="status-username">
<FAIcon
v-if="muted && retweet"
class="fa-scale-110 fa-old-padding repeat-icon"
icon="retweet"
/>
<user-link
:user="status.user"
:at="false"
/>
</small>
<small class="mute-reason">
{{ muteLocalized }}
</small>
<button
class="unmute button-unstyled"
@click.prevent="toggleMute"
>
<FAIcon
icon="eye-slash"
class="fa-scale-110 fa-old-padding"
/>
</button>
</div>
</template>
<template v-else>
<div
v-if="showPinned"
class="pin"
>
<FAIcon
icon="thumbtack"
class="faint"
/>
<span class="faint">{{ $t('status.pinned') }}</span>
</div>
<div
v-if="retweet && !noHeading && !inConversation"
:class="[repeaterClass, { highlighted: repeaterStyle }]"
:style="[repeaterStyle]"
class="status-container repeat-info"
>
<UserAvatar
v-if="retweet"
class="left-side repeater-avatar"
:show-actor-type-indicator="showActorTypeIndicator"
:user="statusoid.user"
/>
<div class="right-side faint">
<bdi
class="status-username repeater-name"
:title="retweeter"
>
<router-link
v-if="retweeterHtml"
:to="retweeterProfileLink"
>
<RichContent
:html="retweeterHtml"
:emoji="retweeterUser.emoji"
/>
</router-link>
<router-link
v-else
:to="retweeterProfileLink"
>{{ retweeter }}</router-link>
</bdi>
{{ ' ' }}
<FAIcon
icon="retweet"
class="repeat-icon"
:title="$t('tool_tip.repeat')"
/>
{{ $t('timeline.repeated') }}
</div>
</div>
<div
v-if="!deleted"
:class="[userClass, { highlighted: userStyle, '-repeat': retweet && !inConversation }]"
:style="[ userStyle ]"
class="status-container"
:data-tags="tags"
>
<div
v-if="!noHeading"
class="left-side"
>
<a
:href="$router.resolve(userProfileLink).href"
@click.prevent
>
<UserPopover
:user-id="status.user.id"
:overlay-centers="true"
>
<UserAvatar
class="post-avatar"
:show-actor-type-indicator="showActorTypeIndicator"
:compact="compact"
:user="status.user"
/>
</UserPopover>
</a>
</div>
<div class="right-side">
<div
v-if="!noHeading"
class="status-heading"
>
<div class="heading-name-row">
<div class="heading-left">
<h4
v-if="status.user.name_html"
class="status-username"
:title="status.user.name"
>
<RichContent
:html="status.user.name"
:emoji="status.user.emoji"
/>
</h4>
<h4
v-else
class="status-username"
:title="status.user.name"
>
{{ status.user.name }}
</h4>
<user-link
class="account-name"
:title="status.user.screen_name_ui"
:user="status.user"
:at="false"
/>
<img
v-if="!!(status.user && status.user.favicon)"
class="status-favicon"
:src="status.user.favicon"
>
</div>
<span class="heading-right">
<router-link
class="timeago faint"
:to="{ name: 'conversation', params: { id: status.id } }"
>
<Timeago
:time="status.created_at"
:auto-update="60"
/>
</router-link>
<span
v-if="status.visibility"
class="visibility-icon"
:title="visibilityLocalized"
>
<FAIcon
fixed-width
class="fa-scale-110"
:icon="visibilityIcon(status.visibility)"
/>
</span>
<button
v-if="expandable && !isPreview"
class="button-unstyled"
:title="$t('status.expand')"
@click.prevent="toggleExpanded"
>
<FAIcon
fixed-width
class="fa-scale-110"
icon="plus-square"
/>
</button>
<button
v-if="unmuted"
class="button-unstyled"
@click.prevent="toggleMute"
>
<FAIcon
fixed-width
icon="eye-slash"
class="fa-scale-110"
/>
</button>
<button
v-if="inThreadForest && replies && replies.length && !simpleTree"
class="button-unstyled"
:title="threadShowing ? $t('status.thread_hide') : $t('status.thread_show')"
:aria-expanded="threadShowing ? 'true' : 'false'"
@click.prevent="toggleThreadDisplay"
>
<FAIcon
fixed-width
class="fa-scale-110"
:icon="threadShowing ? 'chevron-up' : 'chevron-down'"
/>
</button>
<button
v-if="dive && !simpleTree"
class="button-unstyled"
:title="$t('status.show_only_conversation_under_this')"
@click.prevent="dive"
>
<FAIcon
fixed-width
class="fa-scale-110"
:icon="'angle-double-right'"
/>
</button>
</span>
</div>
<div
v-if="scrobblePresent"
class="status-rich-presence"
>
<a
v-if="scrobble.externalLink"
:href="scrobble.externalLink"
target="_blank"
>
{{ scrobble.artist }} — {{ scrobble.title }}
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="play"
/>
<span class="status-rich-presence-time">
<Timeago
template-key="time.in_past"
:time="scrobble.created_at"
:auto-update="60"
/>
</span>
</a>
<span v-if="!scrobble.externalLink">
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="music"
/>
{{ scrobble.artist }} — {{ scrobble.title }}
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="play"
/>
<span class="status-rich-presence-time">
<Timeago
template-key="time.in_past"
:time="scrobble.created_at"
:auto-update="60"
/>
</span>
</span>
</div>
<div
v-if="isReply || hasMentionsLine"
class="heading-reply-row"
>
<span
v-if="isReply"
class="glued-label reply-glued-label"
>
<i18n-t
keypath="status.reply_to_with_arg"
scope="global"
>
<template #replyToWithIcon>
<StatusPopover
v-if="!isPreview"
:status-id="status.parent_visible && status.in_reply_to_status_id"
class="reply-to-popover"
style="min-width: 0;"
:class="{ '-strikethrough': !status.parent_visible }"
>
<button
class="button-unstyled reply-to"
:aria-label="$t('tool_tip.reply')"
@click.prevent="gotoOriginal(status.in_reply_to_status_id)"
>
<i18n-t
keypath="status.reply_to_with_icon"
scope="global"
>
<template #icon>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="reply"
flip="horizontal"
/>
</template>
<template #replyTo>
<span
class="reply-to-text"
>
{{ $t('status.reply_to') }}
</span>
</template>
</i18n-t>
</button>
</StatusPopover>
<span
v-else
class="reply-to-no-popover"
>
<span class="reply-to-text">{{ $t('status.reply_to') }}</span>
</span>
</template>
<template #user>
<MentionLink
:content="replyToName"
:url="replyProfileLink"
:user-id="status.in_reply_to_user_id"
:user-screen-name="status.in_reply_to_screen_name"
/>
</template>
</i18n-t>
</span>
<!-- This little wrapper is made for sole purpose of "gluing" -->
<!-- "Mentions" label to the first mention -->
<span
v-if="hasMentionsLine"
class="glued-label"
>
<span
class="mentions"
:aria-label="$t('tool_tip.mentions')"
@click.prevent="gotoOriginal(status.in_reply_to_status_id)"
>
<span
class="mentions-text"
>
{{ $t('status.mentions') }}
</span>
</span>
<MentionsLine
v-if="hasMentionsLine"
:mentions="mentionsLine.slice(0, 1)"
class="mentions-line-first"
/>
</span>
{{ ' ' }}
<MentionsLine
v-if="hasMentionsLine"
:mentions="mentionsLine.slice(1)"
class="mentions-line"
/>
</div>
<div
v-if="isEdited && editingAvailable && !isPreview"
class="heading-edited-row"
>
<i18n-t
scope="global"
keypath="status.edited_at"
tag="span"
>
<template #time>
<Timeago
template-key="time.in_past"
:time="status.edited_at"
:auto-update="60"
:long-format="true"
/>
</template>
</i18n-t>
</div>
</div>
<StatusContent
ref="content"
:status="status"
:no-heading="noHeading"
:highlight="highlight"
:focused="isFocused"
:controlled-showing-tall="controlledShowingTall"
:controlled-expanding-subject="controlledExpandingSubject"
:controlled-showing-long-subject="controlledShowingLongSubject"
:controlled-toggle-showing-tall="controlledToggleShowingTall"
:controlled-toggle-expanding-subject="controlledToggleExpandingSubject"
:controlled-toggle-showing-long-subject="controlledToggleShowingLongSubject"
@mediaplay="addMediaPlaying($event)"
@mediapause="removeMediaPlaying($event)"
@parse-ready="setHeadTailLinks"
/>
<article
v-if="hasVisibleQuote"
class="quoted-status"
>
<button
class="button-unstyled -link display-quoted-status-button"
:aria-expanded="shouldDisplayQuote"
@click="toggleDisplayQuote"
>
{{ shouldDisplayQuote ? $t('status.hide_quote') : $t('status.display_quote') }}
<FAIcon
class="display-quoted-status-button-icon"
:icon="shouldDisplayQuote ? 'chevron-up' : 'chevron-down'"
/>
</button>
<Status
v-if="shouldDisplayQuote"
:statusoid="quotedStatus"
:in-quote="true"
/>
</article>
<p
v-else-if="hasInvisibleQuote"
class="quoted-status -unavailable-prompt"
>
<i18n-t
scope="global"
keypath="status.invisible_quote"
>
<template #link>
<bdi>
<a
:href="status.quote_url"
target="_blank"
>
{{ status.quote_url }}
</a>
</bdi>
</template>
</i18n-t>
</p>
<div
v-if="inConversation && !isPreview && replies && replies.length"
class="replies"
>
<button
v-if="showOtherRepliesAsButton && replies.length > 1"
class="button-unstyled -link"
:title="$t('status.ancestor_follow', { numReplies: replies.length - 1 }, replies.length - 1)"
@click.prevent="dive"
>
{{ $t('status.replies_list_with_others', { numReplies: replies.length - 1 }, replies.length - 1) }}
</button>
<span
v-else
class="faint"
>
{{ $t('status.replies_list') }}
</span>
<StatusPopover
v-for="reply in replies"
:key="reply.id"
:status-id="reply.id"
>
<button
class="button-unstyled -link reply-link"
@click.prevent="gotoOriginal(reply.id)"
>
{{ reply.name }}
</button>
</StatusPopover>
</div>
<transition name="fade">
<div
v-if="shouldDisplayFavsAndRepeats"
class="favs-repeated-users"
>
<div class="stats">
<UserListPopover
v-if="statusFromGlobalRepository.rebloggedBy && statusFromGlobalRepository.rebloggedBy.length > 0"
:users="statusFromGlobalRepository.rebloggedBy"
>
<div class="stat-count">
<a class="stat-title">{{ $t('status.repeats') }}</a>
<div class="stat-number">
{{ statusFromGlobalRepository.rebloggedBy.length }}
</div>
</div>
</UserListPopover>
<UserListPopover
v-if="statusFromGlobalRepository.favoritedBy && statusFromGlobalRepository.favoritedBy.length > 0"
:users="statusFromGlobalRepository.favoritedBy"
>
<div
class="stat-count"
>
<a class="stat-title">{{ $t('status.favorites') }}</a>
<div class="stat-number">
{{ statusFromGlobalRepository.favoritedBy.length }}
</div>
</div>
</UserListPopover>
<router-link
v-if="statusFromGlobalRepository.quotes_count > 0"
:to="{ name: 'quotes', params: { id: status.id } }"
>
<div
class="stat-count"
>
<a class="stat-title">{{ $t('status.quotes') }}</a>
<div class="stat-number">
{{ statusFromGlobalRepository.quotes_count }}
</div>
</div>
</router-link>
<div class="avatar-row">
<AvatarList :users="combinedFavsAndRepeatsUsers" />
</div>
</div>
</div>
</transition>
<EmojiReactions
v-if="(mergedConfig.emojiReactionsOnTimeline || isFocused) && (!noHeading && !isPreview)"
:status="status"
/>
<StatusActionButtons
v-if="!noHeading && !isPreview"
:status="status"
:replying="replying"
@toggle-replying="toggleReplying"
+ @interacted="e => $emit('interacted')"
/>
</div>
</div>
<div
v-else
class="gravestone"
>
<div class="left-side">
<UserAvatar
class="post-avatar"
:compact="compact"
:show-actor-type-indicator="showActorTypeIndicator"
/>
</div>
<div class="right-side">
<div class="deleted-text">
{{ $t('status.status_deleted') }}
</div>
</div>
</div>
<div
v-if="replying"
class="status-container reply-form"
>
<PostStatusForm
ref="postStatusForm"
class="reply-body"
:closeable="true"
:reply-to="status.id"
:attentions="status.attentions"
:replied-user="status.user"
:copy-message-scope="status.visibility"
:subject="replySubject"
@posted="doToggleReplying"
@draft-done="doToggleReplying"
@can-close="doToggleReplying"
/>
</div>
</template>
</div>
</template>
<script src="./status.js"></script>
<style src="./status.scss" lang="scss"></style>
diff --git a/src/components/status_action_buttons/action_button.js b/src/components/status_action_buttons/action_button.js
index 87c5bb1e5a..07affaafe3 100644
--- a/src/components/status_action_buttons/action_button.js
+++ b/src/components/status_action_buttons/action_button.js
@@ -1,136 +1,140 @@
import StatusBookmarkFolderMenu from 'src/components/status_bookmark_folder_menu/status_bookmark_folder_menu.vue'
import EmojiPicker from 'src/components/emoji_picker/emoji_picker.vue'
import Popover from 'src/components/popover/popover.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faPlus,
faMinus,
faCheck,
faTimes,
faWrench,
faChevronRight,
faChevronUp,
faReply,
faRetweet,
faStar,
faSmileBeam,
faBookmark,
faEyeSlash,
faThumbtack,
faShareAlt,
faExternalLinkAlt,
faHistory
} from '@fortawesome/free-solid-svg-icons'
import {
faStar as faStarRegular,
faBookmark as faBookmarkRegular
} from '@fortawesome/free-regular-svg-icons'
library.add(
faPlus,
faMinus,
faCheck,
faTimes,
faWrench,
faChevronRight,
faChevronUp,
faReply,
faRetweet,
faStar,
faStarRegular,
faSmileBeam,
faBookmark,
faBookmarkRegular,
faEyeSlash,
faThumbtack,
faShareAlt,
faExternalLinkAlt,
faHistory
)
export default {
props: [
'button',
'status',
'extra',
'status',
'funcArg',
'getClass',
'getComponent',
'doAction',
'outerClose'
],
+ emits: [
+ 'interacted'
+ ],
components: {
StatusBookmarkFolderMenu,
EmojiPicker,
Popover
},
data: () => ({
animationState: false
}),
computed: {
buttonClass () {
return [
this.button.name + '-button',
{
'-with-extra': this.button.name === 'bookmark',
'-extra': this.extra,
'-quick': !this.extra
}
]
},
userIsMuted () {
return this.$store.getters.relationship(this.status.user.id).muting
},
threadIsMuted () {
return this.status.thread_muted
},
hideCustomEmoji () {
return !this.$store.state.instance.pleromaCustomEmojiReactionsAvailable
},
buttonInnerClass () {
return [
this.button.name + '-button',
{
'main-button': this.extra,
'button-unstyled': !this.extra,
'-active': this.button.active?.(this.funcArg),
disabled: this.button.interactive ? !this.button.interactive(this.funcArg) : false
}
]
},
remoteInteractionLink () {
return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
}
},
methods: {
addReaction (event) {
const emoji = event.insertion
const existingReaction = this.status.emoji_reactions.find(r => r.name === emoji)
if (existingReaction && existingReaction.me) {
this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji })
} else {
this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji })
}
},
- doActionWrap (button, close) {
+ doActionWrap (button, close = () => {}) {
+ this.$emit('interacted')
if (button.name === 'emoji') {
this.$refs.picker.showPicker()
} else {
this.animationState = true
this.getComponent(button) === 'button' && this.doAction(button)
setTimeout(() => {
this.animationState = false
}, 500)
close()
}
}
}
}
diff --git a/src/components/status_action_buttons/action_button_container.js b/src/components/status_action_buttons/action_button_container.js
index bd91b94047..313e3022f4 100644
--- a/src/components/status_action_buttons/action_button_container.js
+++ b/src/components/status_action_buttons/action_button_container.js
@@ -1,89 +1,90 @@
import ActionButton from './action_button.vue'
import Popover from 'src/components/popover/popover.vue'
import MuteConfirm from 'src/components/confirm_modal/mute_confirm.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faUser,
faGlobe,
faFolderTree
} from '@fortawesome/free-solid-svg-icons'
library.add(
faUser,
faGlobe,
faFolderTree
)
export default {
components: {
ActionButton,
Popover,
MuteConfirm
},
props: ['button', 'status'],
+ emits: ['interacted'],
mounted () {
if (this.button.name === 'mute') {
this.$store.dispatch('fetchDomainMutes')
}
},
computed: {
buttonClass () {
return [
this.button.name + '-button',
{
'-with-extra': this.button.name === 'bookmark',
'-extra': this.extra,
'-quick': !this.extra
}
]
},
user () {
return this.status.user
},
userIsMuted () {
return this.$store.getters.relationship(this.user.id).muting
},
conversationIsMuted () {
return this.status.thread_muted
},
domain () {
return this.user.fqn.split('@')[1]
},
domainIsMuted () {
return new Set(this.$store.state.users.currentUser.domainMutes).has(this.domain)
}
},
methods: {
unmuteUser () {
return this.$store.dispatch('unmuteUser', this.user.id)
},
unmuteConversation () {
return this.$store.dispatch('unmuteConversation', { id: this.status.id })
},
unmuteDomain () {
return this.$store.dispatch('unmuteDomain', this.user.id)
},
toggleUserMute () {
if (this.userIsMuted) {
this.unmuteUser()
} else {
this.$refs.confirmUser.optionallyPrompt()
}
},
toggleConversationMute () {
if (this.conversationIsMuted) {
this.unmuteConversation()
} else {
this.$refs.confirmConversation.optionallyPrompt()
}
},
toggleDomainMute () {
if (this.domainIsMuted) {
this.unmuteDomain()
} else {
this.$refs.confirmDomain.optionallyPrompt()
}
}
}
}
diff --git a/src/components/status_action_buttons/action_button_container.vue b/src/components/status_action_buttons/action_button_container.vue
index deada02ae7..931a40349f 100644
--- a/src/components/status_action_buttons/action_button_container.vue
+++ b/src/components/status_action_buttons/action_button_container.vue
@@ -1,106 +1,107 @@
<template>
<div>
<Popover
v-if="button.dropdown?.()"
:trigger="$attrs.extra ? 'hover' : 'click'"
:offset="{ y: 5 }"
:placement="$attrs.extra ? 'right' : 'top'"
>
<template #trigger>
<ActionButton
:button="button"
:status="status"
v-bind.prop="$attrs"
/>
</template>
<template #content>
<div
v-if="button.name === 'mute'"
:id="`popup-menu-${randomSeed}`"
class="dropdown-menu"
role="menu"
>
<div class="menu-item dropdown-item extra-action -icon">
<button
class="main-button"
@click="toggleUserMute"
>
<FAIcon
icon="user"
fixed-width
/>
<template v-if="userIsMuted">
{{ $t('status.unmute_user') }}
</template>
<template v-else>
{{ $t('status.mute_user') }}
</template>
</button>
</div>
<div class="menu-item dropdown-item extra-action -icon">
<button
class="main-button"
@click="toggleConversationMute"
>
<FAIcon
icon="folder-tree"
fixed-width
/>
<template v-if="conversationIsMuted">
{{ $t('status.unmute_conversation') }}
</template>
<template v-else>
{{ $t('status.mute_conversation') }}
</template>
</button>
</div>
<div class="menu-item dropdown-item extra-action -icon">
<button
class="main-button"
@click="toggleDomainMute"
>
<FAIcon
icon="globe"
fixed-width
/>
<template v-if="domainIsMuted">
{{ $t('status.unmute_domain') }}
</template>
<template v-else>
{{ $t('status.mute_domain') }}
</template>
</button>
</div>
</div>
</template>
</Popover>
<ActionButton
v-else
:button="button"
:status="status"
v-bind="$attrs"
+ @interacted="e => $emit('interacted')"
/>
<teleport to="#modal">
<MuteConfirm
ref="confirmConversation"
type="conversation"
:status="status"
:user="user"
/>
<MuteConfirm
ref="confirmDomain"
type="domain"
:status="status"
:user="user"
/>
<MuteConfirm
ref="confirmUser"
type="user"
:status="status"
:user="user"
/>
</teleport>
</div>
</template>
<script src="./action_button_container.js" />
diff --git a/src/components/status_action_buttons/buttons_definitions.js b/src/components/status_action_buttons/buttons_definitions.js
index ff79442682..ecd4def700 100644
--- a/src/components/status_action_buttons/buttons_definitions.js
+++ b/src/components/status_action_buttons/buttons_definitions.js
@@ -1,229 +1,260 @@
import { useEditStatusStore } from 'src/stores/editStatus.js'
+import { useReportsStore } from 'src/stores/reports.js'
+import { useStatusHistoryStore } from 'src/stores/statusHistory.js'
+
const PRIVATE_SCOPES = new Set(['private', 'direct'])
const PUBLIC_SCOPES = new Set(['public', 'unlisted'])
export const BUTTONS = [{
// =========
// REPLY
// =========
name: 'reply',
label: 'tool_tip.reply',
icon: 'reply',
active: ({ replying }) => replying,
counter: ({ status }) => status.replies_count,
anon: true,
anonLink: true,
toggleable: true,
closeIndicator: 'times',
activeIndicator: 'none',
action ({ emit }) {
emit('toggleReplying')
return Promise.resolve()
}
}, {
// =========
// REPEAT
// =========
name: 'retweet',
label: ({ status }) => status.repeated
? 'tool_tip.unrepeat'
: 'tool_tip.repeat',
icon ({ status }) {
if (PRIVATE_SCOPES.has(status.visibility)) {
return 'lock'
}
return 'retweet'
},
animated: true,
active: ({ status }) => status.repeated,
counter: ({ status }) => status.repeat_num,
anonLink: true,
interactive: ({ status, loggedIn }) => loggedIn && !PRIVATE_SCOPES.has(status.visibility),
toggleable: true,
confirm: ({ status, getters }) => !status.repeated && getters.mergedConfig.modalOnRepeat,
confirmStrings: {
title: 'status.repeat_confirm_title',
body: 'status.repeat_confirm',
confirm: 'status.repeat_confirm_accept_button',
cancel: 'status.repeat_confirm_cancel_button'
},
action ({ status, dispatch }) {
if (!status.repeated) {
return dispatch('retweet', { id: status.id })
} else {
return dispatch('unretweet', { id: status.id })
}
}
}, {
// =========
// FAVORITE
// =========
name: 'favorite',
label: ({ status }) => status.favorited
? 'tool_tip.unfavorite'
: 'tool_tip.favorite',
icon: ({ status }) => status.favorited
? ['fas', 'star']
: ['far', 'star'],
animated: true,
active: ({ status }) => status.favorited,
counter: ({ status }) => status.fave_num,
anonLink: true,
toggleable: true,
action ({ status, dispatch }) {
if (!status.favorited) {
return dispatch('favorite', { id: status.id })
} else {
return dispatch('unfavorite', { id: status.id })
}
}
}, {
// =========
// EMOJI REACTIONS
// =========
name: 'emoji',
label: 'tool_tip.add_reaction',
icon: ['far', 'smile-beam'],
anonLink: true
}, {
// =========
// MUTE
// =========
name: 'mute',
icon: 'eye-slash',
label: 'status.mute_ellipsis',
if: ({ loggedIn }) => loggedIn,
toggleable: true,
dropdown: true
// action ({ status, dispatch, emit }) {
// }
}, {
// =========
// PIN STATUS
// =========
name: 'pin',
icon: 'thumbtack',
label: ({ status }) => status.pinned
? 'status.unpin'
: 'status.pin',
if ({ status, loggedIn, currentUser }) {
return loggedIn &&
status.user.id === currentUser.id &&
PUBLIC_SCOPES.has(status.visibility)
},
action ({ status, dispatch }) {
if (status.pinned) {
return dispatch('unpinStatus', status.id)
} else {
return dispatch('pinStatus', status.id)
}
}
}, {
// =========
// BOOKMARK
// =========
name: 'bookmark',
icon: ({ status }) => status.bookmarked
? ['fas', 'bookmark']
: ['far', 'bookmark'],
toggleable: true,
active: ({ status }) => status.bookmarked,
label: ({ status }) => status.bookmarked
? 'status.unbookmark'
: 'status.bookmark',
if: ({ loggedIn }) => loggedIn,
action ({ status, dispatch }) {
if (status.bookmarked) {
return dispatch('unbookmark', { id: status.id })
} else {
return dispatch('bookmark', { id: status.id })
}
}
+}, {
+ // =========
+ // EDIT HISTORY
+ // =========
+ name: 'editHistory',
+ icon: 'history',
+ label: 'status.status_history',
+ if ({ status, state }) {
+ return state.instance.editingAvailable &&
+ status.edited_at !== null
+ },
+ action ({ status }) {
+ const originalStatus = { ...status }
+ const stripFieldsList = [
+ 'attachments',
+ 'created_at',
+ 'emojis',
+ 'text',
+ 'raw_html',
+ 'nsfw',
+ 'poll',
+ 'summary',
+ 'summary_raw_html'
+ ]
+ stripFieldsList.forEach(p => delete originalStatus[p])
+ useStatusHistoryStore().openStatusHistoryModal(originalStatus)
+ return Promise.resolve()
+ }
}, {
// =========
// EDIT
// =========
name: 'edit',
icon: 'pen',
label: 'status.edit',
if ({ status, loggedIn, currentUser, state }) {
return loggedIn &&
state.instance.editingAvailable &&
status.user.id === currentUser.id
},
action ({ dispatch, status }) {
return dispatch('fetchStatusSource', { id: status.id })
.then(data => useEditStatusStore().openEditStatusModal({
statusId: status.id,
subject: data.spoiler_text,
statusText: data.text,
statusIsSensitive: status.nsfw,
statusPoll: status.poll,
statusFiles: [...status.attachments],
visibility: status.visibility,
statusContentType: data.content_type
}))
}
}, {
// =========
// DELETE
// =========
name: 'delete',
icon: 'times',
label: 'status.delete',
if ({ status, loggedIn, currentUser }) {
return loggedIn && (
status.user.id === currentUser.id ||
currentUser.privileges.includes('messages_delete')
)
},
confirm: ({ getters }) => getters.mergedConfig.modalOnDelete,
confirmStrings: {
title: 'status.delete_confirm_title',
body: 'status.delete_confirm',
confirm: 'status.delete_confirm_accept_button',
cancel: 'status.delete_confirm_cancel_button'
},
action ({ dispatch, status }) {
return dispatch('deleteStatus', { id: status.id })
}
}, {
// =========
// SHARE/COPY
// =========
name: 'share',
icon: 'share-alt',
label: 'status.copy_link',
action ({ state, status, router }) {
navigator.clipboard.writeText([
state.instance.server,
router.resolve({ name: 'conversation', params: { id: status.id } }).href
].join(''))
return Promise.resolve()
}
}, {
// =========
// EXTERNAL
// =========
name: 'external',
icon: 'external-link-alt',
label: 'status.external_source',
link: ({ status }) => status.external_url
}, {
// =========
// REPORT
// =========
name: 'report',
icon: 'flag',
label: 'user_card.report',
if: ({ loggedIn }) => loggedIn,
- action ({ dispatch, status }) {
- dispatch('openUserReportingModal', { userId: status.user.id, statusIds: [status.id] })
+ action ({ status }) {
+ return useReportsStore().openUserReportingModal({ userId: status.user.id, statusIds: [status.id] })
}
}].map(button => {
return Object.fromEntries(
Object.entries(button).map(([k, v]) => [
k,
(typeof v === 'function' || k === 'name') ? v : () => v
])
)
})
diff --git a/src/components/status_action_buttons/status_action_buttons.js b/src/components/status_action_buttons/status_action_buttons.js
index 622f21ca58..6d4e4fbc08 100644
--- a/src/components/status_action_buttons/status_action_buttons.js
+++ b/src/components/status_action_buttons/status_action_buttons.js
@@ -1,132 +1,132 @@
import { mapState } from 'vuex'
import ConfirmModal from 'src/components/confirm_modal/confirm_modal.vue'
import ActionButtonContainer from './action_button_container.vue'
import Popover from 'src/components/popover/popover.vue'
import genRandomSeed from 'src/services/random_seed/random_seed.service.js'
import { BUTTONS } from './buttons_definitions.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faEllipsisH
} from '@fortawesome/free-solid-svg-icons'
library.add(
faEllipsisH
)
const StatusActionButtons = {
props: ['status', 'replying'],
- emits: ['toggleReplying'],
+ emits: ['toggleReplying', 'interacted'],
data () {
return {
showPin: false,
showingConfirmDialog: false,
currentConfirmTitle: '',
currentConfirmOkText: '',
currentConfirmCancelText: '',
currentConfirmAction: () => {},
randomSeed: genRandomSeed()
}
},
components: {
Popover,
ConfirmModal,
ActionButtonContainer
},
computed: {
...mapState({
pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedStatusActions)
}),
buttons () {
return BUTTONS.filter(x => x.if ? x.if(this.funcArg) : true)
},
quickButtons () {
return this.buttons.filter(x => this.pinnedItems.has(x.name))
},
extraButtons () {
return this.buttons.filter(x => !this.pinnedItems.has(x.name))
},
currentUser () {
return this.$store.state.users.currentUser
},
funcArg () {
return {
status: this.status,
replying: this.replying,
emit: this.$emit,
dispatch: this.$store.dispatch,
state: this.$store.state,
getters: this.$store.getters,
router: this.$router,
currentUser: this.currentUser,
loggedIn: !!this.currentUser
}
},
triggerAttrs () {
return {
title: this.$t('status.more_actions'),
'aria-controls': `popup-menu-${this.randomSeed}`,
'aria-haspopup': 'menu'
}
}
},
methods: {
doAction (button) {
if (button.confirm?.(this.funcArg)) {
// TODO move to action_button
this.currentConfirmTitle = this.$t(button.confirmStrings(this.funcArg).title)
this.currentConfirmOkText = this.$t(button.confirmStrings(this.funcArg).confirm)
this.currentConfirmCancelText = this.$t(button.confirmStrings(this.funcArg).cancel)
this.currentConfirmBody = this.$t(button.confirmStrings(this.funcArg).body)
this.currentConfirmAction = () => {
this.showingConfirmDialog = false
this.doActionReal(button)
}
this.showingConfirmDialog = true
} else {
this.doActionReal(button)
}
},
doActionReal (button) {
button.action(this.funcArg)
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
},
onExtraClose () {
this.showPin = false
},
isPinned (button) {
return this.pinnedItems.has(button.name)
},
unpin (button) {
this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedStatusActions', value: button.name })
this.$store.dispatch('pushServerSideStorage')
},
pin (button) {
this.$store.commit('addCollectionPreference', { path: 'collections.pinnedStatusActions', value: button.name })
this.$store.dispatch('pushServerSideStorage')
},
getComponent (button) {
if (!this.$store.state.users.currentUser && button.anonLink) {
return 'a'
} else if (button.action == null && button.link != null) {
return 'a'
} else {
return 'button'
}
},
getClass (button) {
return {
[button.name + '-button']: true,
disabled: button.interactive ? !button.interactive(this.funcArg) : false,
'-pin-edit': this.showPin,
'-dropdown': button.dropdown?.(),
'-active': button.active?.(this.funcArg)
}
}
}
}
export default StatusActionButtons
diff --git a/src/components/status_action_buttons/status_action_buttons.scss b/src/components/status_action_buttons/status_action_buttons.scss
index 78fd7c33d4..23b86e5bd9 100644
--- a/src/components/status_action_buttons/status_action_buttons.scss
+++ b/src/components/status_action_buttons/status_action_buttons.scss
@@ -1,26 +1,26 @@
@use "../../mixins";
.StatusActionButtons {
.quick-action-buttons {
display: grid;
- grid-template-columns: repeat(auto-fill, 4em);
+ grid-template-columns: repeat(auto-fill, minmax(10%, 3em));
grid-auto-flow: row dense;
grid-auto-rows: 1fr;
grid-gap: 1.25em 1em;
margin-top: var(--status-margin);
}
.pin-action-button {
margin: -0.5em;
padding: 0.5em;
}
}
// popover
.extra-action-buttons {
.extra-action {
margin: 0;
padding-top: 0;
padding-bottom: 0;
padding-right: 0;
}
}
diff --git a/src/components/status_action_buttons/status_action_buttons.vue b/src/components/status_action_buttons/status_action_buttons.vue
index 6d5cd190c9..40884425c1 100644
--- a/src/components/status_action_buttons/status_action_buttons.vue
+++ b/src/components/status_action_buttons/status_action_buttons.vue
@@ -1,131 +1,133 @@
<template>
<div class="StatusActionButtons">
<span class="quick-action-buttons">
<span
v-for="button in quickButtons"
:key="button.name"
class="quick-action"
:class="{ '-pin': showPin, '-toggle': button.dropdown?.() }"
>
<ActionButtonContainer
:class="{ '-pin': showPin }"
:button="button"
:status="status"
:extra="false"
:func-arg="funcArg"
:get-class="getClass"
:get-component="getComponent"
:close="() => {}"
:do-action="doAction"
+ @interacted="e => $emit('interacted')"
/>
<button
v-if="showPin && currentUser"
type="button"
class="button-unstyled pin-action-button"
:title="$t('general.unpin')"
:aria-pressed="true"
@click.stop.prevent="unpin(button)"
>
<FAIcon
v-if="showPin && currentUser"
fixed-width
class="fa-scale-110"
icon="thumbtack"
/>
</button>
</span>
<Popover
trigger="click"
:trigger-attrs="triggerAttrs"
:tabindex="0"
placement="top"
:offset="{ y: 5 }"
remove-padding
@close="onExtraClose"
>
<template #trigger>
<FAIcon
class="fa-scale-110 "
icon="ellipsis-h"
/>
</template>
<template #content="{close, resize}">
<div
:id="`popup-menu-${randomSeed}`"
class="dropdown-menu extra-action-buttons"
role="menu"
>
<div
v-if="currentUser"
class="menu-item dropdown-item extra-action -icon"
>
<button
class="main-button"
role="menuitem"
:tabindex="0"
@click.stop="() => { resize(); showPin = !showPin }"
>
<FAIcon
class="fa-scale-110"
fixed-width
icon="wrench"
/><span>{{ $t('nav.edit_pinned') }}</span>
</button>
</div>
<div
v-for="button in extraButtons"
:key="button.name"
class="menu-item dropdown-item extra-action -icon"
:disabled="getClass(button).disabled"
:class="{ disabled: getClass(button).disabled }"
>
<ActionButtonContainer
:button="button"
:status="status"
:extra="true"
:func-arg="funcArg"
:get-class="getClass"
:get-component="getComponent"
- :outerClose="close"
+ :outer-close="close"
:do-action="doAction"
+ @interacted="e => $emit('interacted')"
/>
<button
v-if="showPin && currentUser"
type="button"
class="button-unstyled pin-action-button extra-button"
:title="$t('general.pin')"
:aria-pressed="false"
@click.stop.prevent="pin(button)"
>
<FAIcon
v-if="showPin && currentUser"
fixed-width
class="fa-scale-110"
transform="rotate-45"
icon="thumbtack"
/>
</button>
</div>
</div>
</template>
</Popover>
</span>
<teleport to="#modal">
<confirm-modal
v-if="showingConfirmDialog"
:title="currentConfirmTitle"
:confirm-text="currentConfirmOkText"
:cancel-text="currentConfirmCancelText"
@accepted="currentConfirmAction"
@cancelled="showingConfirmDialog = false"
>
{{ currentConfirmBody }}
</confirm-modal>
</teleport>
</div>
</template>
<script src="./status_action_buttons.js"></script>
<style lang="scss" src="./status_action_buttons.scss"></style>
diff --git a/src/components/status_bookmark_folder_menu/status_bookmark_folder_menu.vue b/src/components/status_bookmark_folder_menu/status_bookmark_folder_menu.vue
index e210fdaf1d..31478501b3 100644
--- a/src/components/status_bookmark_folder_menu/status_bookmark_folder_menu.vue
+++ b/src/components/status_bookmark_folder_menu/status_bookmark_folder_menu.vue
@@ -1,25 +1,28 @@
<template>
<div class="dropdown-menu bookmark-folder-menu">
<div
v-for="folder in folders"
:key="folder.id"
class="menu-item dropdown-item -icon-double"
>
<button
class="main-button"
@click="toggleFolder(folder.id)"
@click.stop="close"
>
<span
class="input menu-checkbox -radio"
:class="{ 'menu-checkbox-checked': status.bookmark_folder_id == folder.id }"
/>
- <StillImage :src="folder.emoji_url" class="emoji" />
+ <StillImage
+ :src="folder.emoji_url"
+ class="emoji"
+ />
{{ ' ' + folder.name }}
</button>
</div>
</div>
</template>
<script src="./status_bookmark_folder_menu.js"></script>
<style src="./status_bookmark_folder_menu.scss" />
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 545cdb1e19..d4585805b4 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -1,1578 +1,1579 @@
{
"about": {
"mrf": {
"federation": "Federation",
"keyword": {
"keyword_policies": "Keyword policies",
"ftl_removal": "Removal from \"The Whole Known Network\" Timeline",
"reject": "Reject",
"replace": "Replace",
"is_replaced_by": "→"
},
"mrf_policies": "Enabled MRF policies",
"mrf_policies_desc": "MRF policies manipulate the federation behaviour of the instance. The following policies are enabled:",
"simple": {
"simple_policies": "Instance-specific policies",
"instance": "Instance",
"reason": "Reason",
"not_applicable": "N/A",
"accept": "Accept",
"accept_desc": "This instance only accepts messages from the following instances:",
"reject": "Reject",
"reject_desc": "This instance will not accept messages from the following instances:",
"quarantine": "Quarantine",
"quarantine_desc": "This instance will send only public posts to the following instances:",
"ftl_removal": "Removal from \"Known Network\" Timeline",
"ftl_removal_desc": "This instance removes these instances from \"Known Network\" timeline:",
"media_removal": "Media Removal",
"media_removal_desc": "This instance removes media from posts on the following instances:",
"media_nsfw": "Media force-set as sensitive",
"media_nsfw_desc": "This instance forces media to be set sensitive in posts on the following instances:"
}
},
"staff": "Staff",
"terms": "Terms of Service"
},
"announcements": {
"page_header": "Announcements",
"title": "Announcement",
"mark_as_read_action": "Mark as read",
"post_form_header": "Post announcement",
"post_placeholder": "Type your announcement content here...",
"post_action": "Post",
"post_error": "Error: {error}",
"close_error": "Close",
"delete_action": "Delete",
"start_time_prompt": "Start time: ",
"end_time_prompt": "End time: ",
"all_day_prompt": "This is an all-day event",
"published_time_display": "Published at {time}",
"start_time_display": "Starts at {time}",
"end_time_display": "Ends at {time}",
"edit_action": "Edit",
"submit_edit_action": "Submit",
"cancel_edit_action": "Cancel",
"inactive_message": "This announcement is inactive"
},
"shoutbox": {
"title": "Shoutbox"
},
"domain_mute_card": {
"mute": "Mute",
"mute_progress": "Muting…",
"unmute": "Unmute",
"unmute_progress": "Unmuting…"
},
"exporter": {
"export": "Export",
"processing": "Processing, you'll soon be asked to download your file"
},
"features_panel": {
"shout": "Shoutbox",
"pleroma_chat_messages": "Pleroma Chat",
"gopher": "Gopher",
"media_proxy": "Media proxy",
"scope_options": "Scope options",
"text_limit": "Text limit",
"title": "Features",
"who_to_follow": "Who to follow",
"upload_limit": "Upload limit"
},
"finder": {
"error_fetching_user": "Error fetching user",
"find_user": "Find user"
},
"general": {
"apply": "Apply",
"submit": "Submit",
"more": "More",
"loading": "Loading…",
"generic_error": "An error occured",
"generic_error_message": "An error occured: {0}",
"error_retry": "Please try again",
"retry": "Try again",
"optional": "optional",
"show_more": "Show more",
"show_less": "Show less",
"never_show_again": "Never show again",
"dismiss": "Dismiss",
"cancel": "Cancel",
"disable": "Disable",
"enable": "Enable",
"confirm": "Confirm",
"verify": "Verify",
"close": "Close",
"undo": "Undo",
"yes": "Yes",
"no": "No",
"peek": "Peek",
"scroll_to_top": "Scroll to top",
"role": {
"admin": "Admin",
"moderator": "Moderator"
},
"unpin": "Unpin item",
"pin": "Pin item",
"flash_content": "Click to show Flash content using Ruffle (Experimental, may not work).",
"flash_security": "Note that this can be potentially dangerous since Flash content is still arbitrary code.",
"flash_fail": "Failed to load flash content, see console for details.",
"scope_in_timeline": {
"direct": "Direct",
"private": "Followers-only",
"public": "Public",
"unlisted": "Unlisted"
}
},
"image_cropper": {
"crop_picture": "Crop picture",
"save": "Save",
"save_without_cropping": "Save without cropping",
"cancel": "Cancel"
},
"importer": {
"submit": "Submit",
"success": "Imported successfully.",
"error": "An error occured while importing this file."
},
"login": {
"login": "Log in",
"description": "Log in with OAuth",
"logout": "Log out",
"logout_confirm_title": "Logout confirmation",
"logout_confirm": "Do you really want to logout?",
"logout_confirm_accept_button": "Logout",
"logout_confirm_cancel_button": "Do not logout",
"password": "Password",
"placeholder": "e.g. lain",
"register": "Register",
"username": "Username",
"hint": "Log in to join the discussion",
"authentication_code": "Authentication code",
"enter_recovery_code": "Enter a recovery code",
"enter_two_factor_code": "Enter a two-factor code",
"recovery_code": "Recovery code",
"heading": {
"totp": "Two-factor authentication",
"recovery": "Two-factor recovery"
}
},
"media_modal": {
"previous": "Previous",
"next": "Next",
"counter": "{current} / {total}",
"hide": "Close media viewer"
},
"nav": {
"about": "About",
"administration": "Administration",
"back": "Back",
"friend_requests": "Follow requests",
"mentions": "Mentions",
"interactions": "Interactions",
"dms": "Direct messages",
"public_tl": "Public timeline",
"timeline": "Timeline",
"home_timeline": "Home timeline",
"twkn": "Known Network",
"bookmarks": "Bookmarks",
"all_bookmarks": "All bookmarks",
"bookmark_folders": "Bookmark folders",
"user_search": "User Search",
"search": "Search",
"search_close": "Close search bar",
"who_to_follow": "Who to follow",
"preferences": "Preferences",
"timelines": "Timelines",
"chats": "Chats",
"lists": "Lists",
"edit_nav_mobile": "Customize navigation bar",
"edit_pinned": "Edit pinned items",
"edit_finish": "Done editing",
"mobile_sidebar": "Toggle mobile sidebar",
"mobile_notifications": "Open notifications (there are unread ones)",
"mobile_notifications_close": "Close notifications",
"mobile_notifications_mark_as_seen": "Mark all as seen",
"announcements": "Announcements",
"quotes": "Quotes",
"drafts": "Drafts"
},
"notifications": {
"broken_favorite": "Unknown status, searching for it…",
"error": "Error fetching notifications: {0}",
"favorited_you": "favorited your status",
"followed_you": "followed you",
"follow_request": "wants to follow you",
"load_older": "Load older notifications",
"notifications": "Notifications",
"read": "Read!",
"repeated_you": "repeated your status",
"no_more_notifications": "No more notifications",
"migrated_to": "migrated to",
"reacted_with": "reacted with {0}",
"submitted_report": "submitted a report",
"poll_ended": "poll has ended",
"unread_announcements": "{num} unread announcement | {num} unread announcements",
"unread_chats": "{num} unread chat | {num} unread chats",
"unread_follow_requests": "{num} new follow request | {num} new follow requests",
"configuration_tip": "You can customize what to display here in {theSettings}. {dismiss}",
"configuration_tip_settings": "the settings",
"configuration_tip_dismiss": "Do not show again",
"subscribed_status": "posted"
},
"polls": {
"add_poll": "Add poll",
"add_option": "Add option",
"option": "Option",
"votes": "votes",
"people_voted_count": "{count} person voted | {count} people voted",
"votes_count": "{count} vote | {count} votes",
"vote": "Vote",
"type": "Poll type",
"single_choice": "Single choice",
"multiple_choices": "Multiple choices",
"expiry": "Poll age",
- "expires_in": "Poll ends in {0}",
+ "expires_at": "Poll ends {0}",
"expired": "Poll ended {0} ago",
+ "expired_at": "Poll ended {0}",
"not_enough_options": "Too few unique options in poll",
"non_anonymous": "Public poll",
"non_anonymous_title": "Other instances may display the options you voted for"
},
"emoji": {
"stickers": "Stickers",
"emoji": "Emoji",
"keep_open": "Keep picker open",
"search_emoji": "Search for an emoji",
"add_emoji": "Insert emoji",
"custom": "Custom emoji",
"hide_custom_emoji": "Hide custom emojis",
"unpacked": "Unpacked emoji",
"unicode": "Unicode emoji",
"unicode_groups": {
"activities": "Activities",
"animals-and-nature": "Animals & Nature",
"flags": "Flags",
"food-and-drink": "Food & Drink",
"objects": "Objects",
"people-and-body": "People & Body",
"smileys-and-emotion": "Smileys & Emotion",
"symbols": "Symbols",
"travel-and-places": "Travel & Places"
},
"load_all_hint": "Loaded first {saneAmount} emoji, loading all emoji may cause performance issues.",
"load_all": "Loading all {emojiAmount} emoji",
"regional_indicator": "Regional indicator {letter}"
},
"errors": {
"storage_unavailable": "Pleroma could not access browser storage. Your login or your local settings won't be saved and you might encounter unexpected issues. Try enabling cookies."
},
"interactions": {
"favs_repeats": "Repeats and favorites",
"follows": "New follows",
"emoji_reactions": "Emoji Reactions",
"reports": "Reports",
"moves": "User migrates",
"load_older": "Load older interactions",
"statuses": "Subscriptions"
},
"post_status": {
"edit_status": "Edit status",
"new_status": "Post new status",
"reply_option": "Reply to this status",
"quote_option": "Quote this status",
"account_not_locked_warning": "Your account is not {0}. Anyone can follow you to view your follower-only posts.",
"account_not_locked_warning_link": "locked",
"attachments_sensitive": "Mark attachments as sensitive",
"media_description": "Media description",
"content_type": {
"text/plain": "Plain text",
"text/html": "HTML",
"text/markdown": "Markdown",
"text/bbcode": "BBCode"
},
"content_type_selection": "Post format",
"content_warning": "Subject (optional)",
"default": "Just landed in L.A.",
"direct_warning_to_all": "This post will be visible to all the mentioned users.",
"direct_warning_to_first_only": "This post will only be visible to the mentioned users at the beginning of the message.",
"edit_remote_warning": "Other remote instances may not support editing and unable to receive the latest version of your post.",
"edit_unsupported_warning": "Pleroma does not support editing mentions or polls.",
"posting": "Posting",
"post": "Post",
"preview": "Preview",
"preview_empty": "Empty",
"empty_status_error": "Can't post an empty status with no files",
"media_description_error": "Failed to update media, try again",
"scope_notice": {
"public": "This post will be visible to everyone",
"private": "This post will be visible to your followers only",
"unlisted": "This post will not be visible in Public Timeline and The Whole Known Network"
},
"scope_notice_dismiss": "Close this notice",
"scope": {
"direct": "Direct - post to mentioned users only",
"private": "Followers-only - post to followers only",
"public": "Public - post to public timelines",
"unlisted": "Unlisted - do not post to public timelines"
},
"close_confirm_title": "Closing post form",
"close_confirm": "What do you want to do with your current writing?",
"close_confirm_save_button": "Save",
"close_confirm_discard_button": "Discard",
"close_confirm_continue_composing_button": "Continue composing",
"auto_save_nothing_new": "Nothing new to save.",
"auto_save_saved": "Saved.",
"auto_save_saving": "Saving...",
"save_to_drafts_button": "Save to drafts",
"save_to_drafts_and_close_button": "Save to drafts and close",
"more_post_actions": "More post actions..."
},
"registration": {
"bio_optional": "Bio (optional)",
"email": "Email",
"email_optional": "Email (optional)",
"fullname": "Display name",
"password_confirm": "Password confirmation",
"registration": "Registration",
"token": "Invite token",
"captcha": "CAPTCHA",
"new_captcha": "Click the image to get a new captcha",
"username_placeholder": "e.g. lain",
"fullname_placeholder": "e.g. Lain Iwakura",
"bio_placeholder": "e.g.\nHi, I'm Lain.\nI’m an anime girl living in suburban Japan. You may know me from the Wired.",
"reason": "Reason to register",
"reason_placeholder": "This instance approves registrations manually.\nLet the administration know why you want to register.",
"register": "Register",
"validations": {
"username_required": "cannot be left blank",
"fullname_required": "cannot be left blank",
"email_required": "cannot be left blank",
"password_required": "cannot be left blank",
"password_confirmation_required": "cannot be left blank",
"password_confirmation_match": "should be the same as password",
"birthday_required": "cannot be left blank",
"birthday_min_age": "must be on or before {date}"
},
"email_language": "In which language do you want to receive emails from the server?",
"birthday": "Birthday:",
"birthday_optional": "Birthday (optional):"
},
"remote_user_resolver": {
"remote_user_resolver": "Remote user resolver",
"searching_for": "Searching for",
"error": "Not found."
},
"report": {
"reporter": "Reporter:",
"reported_user": "Reported user:",
"reported_statuses": "Reported statuses:",
"notes": "Notes:",
"state": "State:",
"state_open": "Open",
"state_closed": "Closed",
"state_resolved": "Resolved"
},
"selectable_list": {
"select_all": "Select all"
},
"settings": {
"add_language": "Add fallback language",
"remove_language": "Remove",
"primary_language": "Primary language:",
"fallback_language": "Fallback language {index}:",
"actor_type": "This account is:",
"actor_type_description": "Marking your account as a group will make it automatically repeat statuses that mention it.",
"actor_type_Person": "a normal user",
"actor_type_Service": "a bot",
"actor_type_Group": "a group",
"mobile_center_dialog": "Vertically center dialogs on mobile",
"app_name": "App name",
"expert_mode": "Show advanced",
"save": "Save changes",
"security": "Security",
"setting_changed": "Setting is different from default",
"setting_server_side": "This setting is tied to your profile and affects all sessions and clients",
"enter_current_password_to_confirm": "Enter your current password to confirm your identity",
"post_look_feel": "Posts Look & Feel",
"mention_links": "Mention links",
"appearance": "Appearance",
"confirm_new_setting": "Confirm new setting?",
"confirm_new_question": "Does this look ok? Setting will be reverted in 10 seconds.",
"revert": "Revert",
"confirm": "Confirm",
"text_size": "Text and interface size",
"text_size_tip": "Use {0} for absolute values, {1} will scale with browser default text size.",
"text_size_tip2": "Values other than {0} might break some things and themes",
"emoji_size": "Emoji size",
"navbar_size": "Top bar size",
"panel_header_size": "Panel header size",
"visual_tweaks": "Minor visual tweaks",
"theme_debug": "Show what background theme engine assumes when dealing with transparancy (DEBUG)",
"scale_and_layout": "Interface scale and layout",
"mfa": {
"otp": "OTP",
"setup_otp": "Setup OTP",
"wait_pre_setup_otp": "presetting OTP",
"confirm_and_enable": "Confirm & enable OTP",
"title": "Two-factor Authentication",
"generate_new_recovery_codes": "Generate new recovery codes",
"warning_of_generate_new_codes": "When you generate new recovery codes, your old codes won’t work anymore.",
"recovery_codes": "Recovery codes.",
"waiting_a_recovery_codes": "Receiving backup codes…",
"recovery_codes_warning": "Write the codes down or save them somewhere secure - otherwise you won't see them again. If you lose access to your 2FA app and recovery codes you'll be locked out of your account.",
"authentication_methods": "Authentication methods",
"scan": {
"title": "Scan",
"desc": "Using your two-factor app, scan this QR code or enter text key:",
"secret_code": "Key"
},
"verify": {
"desc": "To enable two-factor authentication, enter the code from your two-factor app:"
}
},
"units": {
"time": {
"m": "minutes",
"s": "seconds",
"h": "hours",
"d": "days"
}
},
"lists_navigation": "Show lists in navigation",
"allow_following_move": "Allow auto-follow when following account moves",
"attachmentRadius": "Attachments",
"attachments": "Attachments",
"image_compression": "Compress images before uploading",
"always_use_jpeg": "Always convert images to JPEG format",
"avatar": "Avatar",
"avatarAltRadius": "Avatars (notifications)",
"avatarRadius": "Avatars",
"background": "Background",
"bio": "Bio",
"email_language": "Language for receiving emails from the server",
"block_export": "Block export",
"block_export_button": "Export your blocks to a csv file",
"block_import": "Block import",
"block_import_error": "Error importing blocks",
"blocks_imported": "Blocks imported! Processing them will take a while.",
"mute_export": "Mute export",
"mute_export_button": "Export your mutes to a csv file",
"mute_import": "Mute import",
"mute_import_error": "Error importing mutes",
"mutes_imported": "Mutes imported! Processing them will take a while.",
"import_mutes_from_a_csv_file": "Import mutes from a csv file",
"account_backup": "Account backup",
"account_backup_description": "This allows you to download an archive of your account information and your posts, but they cannot yet be imported into a Pleroma account.",
"account_backup_table_head": "Backup",
"download_backup": "Download",
"backup_not_ready": "This backup is not ready yet.",
"backup_running": "This backup is in progress, processed {number} record. | This backup is in progress, processed {number} records.",
"backup_failed": "This backup has failed.",
"remove_backup": "Remove",
"list_backups_error": "Error fetching backup list: {error}",
"add_backup": "Create a new backup",
"added_backup": "Added a new backup.",
"add_backup_error": "Error adding a new backup: {error}",
"blocks_tab": "Blocks",
"btnRadius": "Buttons",
"cBlue": "Blue (Reply, follow)",
"cGreen": "Green (Retweet)",
"cOrange": "Orange (Favorite)",
"cRed": "Red (Cancel)",
"change_email": "Change email",
"change_email_error": "There was an issue changing your email.",
"changed_email": "Email changed successfully!",
"change_password": "Change password",
"change_password_error": "There was an issue changing your password.",
"changed_password": "Password changed successfully!",
"chatMessageRadius": "Chat message",
"collapse_subject": "Collapse posts with subjects",
"composing": "Composing",
"confirm_new_password": "Confirm new password",
"current_password": "Current password",
"confirm_dialogs": "Ask for confirmation when",
"confirm_dialogs_repeat": "repeating a status",
"confirm_dialogs_unfollow": "unfollowing a user",
"confirm_dialogs_block": "blocking a user",
"confirm_dialogs_mute": "muting a user",
"confirm_dialogs_mute_domain": "muting domains",
"confirm_dialogs_mute_conversation": "muting conversations",
"confirm_dialogs_delete": "deleting a status",
"confirm_dialogs_logout": "logging out",
"confirm_dialogs_approve_follow": "approving a follower",
"confirm_dialogs_deny_follow": "denying a follower",
"confirm_dialogs_remove_follower": "removing a follower",
"mutes_and_blocks": "Mutes and Blocks",
"data_import_export_tab": "Data import / export",
"default_vis": "Default visibility scope",
"delete_account": "Delete account",
"delete_account_description": "Permanently delete your data and deactivate your account.",
"delete_account_error": "There was an issue deleting your account. If this persists please contact your instance administrator.",
"delete_account_instructions": "Type your password in the input below to confirm account deletion.",
"account_alias": "Account aliases",
"account_alias_table_head": "Alias",
"list_aliases_error": "Error fetching aliases: {error}",
"hide_list_aliases_error_action": "Close",
"remove_alias": "Remove this alias",
"new_alias_target": "Add a new alias (e.g. {example})",
"added_alias": "Alias is added.",
"add_alias_error": "Error adding alias: {error}",
"move_account": "Move account",
"move_account_notes": "If you want to move the account somewhere else, you must go to your target account and add an alias pointing here.",
"move_account_target": "Target account (e.g. {example})",
"moved_account": "Account is moved.",
"move_account_error": "Error moving account: {error}",
"discoverable": "Allow discovery of this account in search results and other services",
"domain_mutes": "Domains",
"avatar_size_instruction": "The recommended minimum size for avatar images is 150x150 pixels.",
"pad_emoji": "Pad emoji with spaces when adding from picker",
"autocomplete_select_first": "Automatically select the first candidate when autocomplete results are available",
"unsaved_post_action": "When you try to close an unsaved posting form",
"unsaved_post_action_save": "Save it to drafts",
"unsaved_post_action_discard": "Discard it",
"unsaved_post_action_confirm": "Ask every time",
"auto_save_draft": "Save drafts as you compose",
"emoji_reactions_on_timeline": "Show emoji reactions on timeline",
"emoji_reactions_scale": "Reactions scale factor",
"absolute_time_format": "Use absolute time format",
"absolute_time_format_min_age": "Only use for time older than this amount of time",
"absolute_time_format_12h": "Time format",
"absolute_time_format_12h_12h": "12 hour format (i.e. 10:00 PM)",
"absolute_time_format_12h_24h": "24 hour format (i.e. 22:00)",
"export_theme": "Save preset",
"filtering": "Filtering",
"wordfilter": "Wordfilter",
"filtering_explanation": "All statuses containing these words will be muted, one per line",
"word_filter_and_more": "Word filter and more...",
"follow_export": "Follow export",
"follow_export_button": "Export your follows to a csv file",
"follow_import": "Follow import",
"follow_import_error": "Error importing followers",
"follows_imported": "Follows imported! Processing them will take a while.",
"accent": "Accent",
"foreground": "Foreground",
"general": "General",
"hide_attachments_in_convo": "Hide attachments in conversations",
"hide_attachments_in_tl": "Hide attachments in timeline",
"hide_media_previews": "Hide media previews",
"hide_muted_posts": "Hide posts of muted users",
"mute_bot_posts": "Mute bot posts",
"hide_actor_type_indication": "Hide actor type (bots, groups, etc.) indication in posts",
"hide_scrobbles": "Hide scrobbles",
"hide_scrobbles_after": "Hide scrobbles older than",
"mute_sensitive_posts": "Mute sensitive posts",
"hide_all_muted_posts": "Hide muted posts",
"max_thumbnails": "Maximum amount of thumbnails per post (empty = no limit)",
"hide_isp": "Hide instance-specific panel",
"hide_shoutbox": "Hide instance shoutbox",
"right_sidebar": "Reverse order of columns",
"navbar_column_stretch": "Stretch navbar to columns width",
"always_show_post_button": "Always show floating New Post button",
"hide_wallpaper": "Hide instance wallpaper",
"preload_images": "Preload images",
"use_one_click_nsfw": "Open NSFW attachments with just one click",
"hide_post_stats": "Hide post statistics (e.g. the number of favorites)",
"hide_user_stats": "Hide user statistics (e.g. the number of followers)",
"hide_filtered_statuses": "Hide all filtered posts",
"hide_wordfiltered_statuses": "Hide word-filtered statuses",
"hide_muted_threads": "Hide muted threads",
"import_blocks_from_a_csv_file": "Import blocks from a csv file",
"import_followers_from_a_csv_file": "Import follows from a csv file",
"import_theme": "Load preset",
"inputRadius": "Input fields",
"checkboxRadius": "Checkboxes",
"instance_default": "(default: {value})",
"instance_default_simple": "(default)",
"interface": "Interface",
"interfaceLanguage": "Interface language",
"invalid_theme_imported": "The selected file is not a supported Pleroma theme. No changes to your theme were made.",
"limited_availability": "Unavailable in your browser",
"links": "Links",
"lock_account_description": "Restrict your account to approved followers only",
"loop_video": "Loop videos",
"loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")",
"mutes_tab": "Mutes",
"play_videos_in_modal": "Play videos in a popup frame",
"url": "URL",
"preview": "Preview",
"file_export_import": {
"backup_restore": "Settings backup",
"backup_settings": "Backup settings to file",
"backup_settings_theme": "Backup settings and theme to file",
"restore_settings": "Restore settings from file",
"errors": {
"invalid_file": "The selected file is not a supported Pleroma settings backup. No changes were made.",
"file_too_new": "Incompatile major version: {fileMajor}, this PleromaFE (settings ver {feMajor}) is too old to handle it",
"file_too_old": "Incompatile major version: {fileMajor}, file version is too old and not supported (min. set. ver. {feMajor})",
"file_slightly_new": "File minor version is different, some settings might not load"
}
},
"profile_fields": {
"label": "Profile metadata",
"add_field": "Add field",
"name": "Label",
"value": "Content"
},
"birthday": {
"label": "Birthday",
"show_birthday": "Show my birthday"
},
"account_privacy": "Privacy",
"use_contain_fit": "Don't crop the attachment in thumbnails",
"name": "Name",
"name_bio": "Name & bio",
"new_email": "New email",
"new_password": "New password",
"posts": "Posts",
"user_profiles": "User Profiles",
"notification_visibility": "Types of notifications to show",
"notification_visibility_in_column": "Show in notifications column/drawer",
"notification_visibility_native_notifications": "Show a native notification",
"notification_visibility_follows": "Follows",
"notification_visibility_follow_requests": "Follow requests",
"notification_visibility_likes": "Favorites",
"notification_visibility_mentions": "Mentions",
"notification_visibility_repeats": "Repeats",
"notification_visibility_reports": "Reports",
"notification_visibility_moves": "User Migrates",
"notification_visibility_emoji_reactions": "Reactions",
"notification_visibility_polls": "Ends of polls you voted in",
"notification_visibility_statuses": "Subscriptions",
"notification_show_extra": "Show extra notifications in the notifications column",
"notification_extra_chats": "Show unread chats",
"notification_extra_announcements": "Show unread announcements",
"notification_extra_follow_requests": "Show new follow requests",
"notification_extra_tip": "Show the customization tip for extra notifications",
"no_rich_text_description": "Strip rich text formatting from all posts",
"no_blocks": "No blocks",
"no_mutes": "No mutes",
"hide_favorites_description": "Don't show list of my favorites (people still get notified)",
"hide_follows_description": "Don't show who I'm following",
"hide_followers_description": "Don't show who's following me",
"hide_follows_count_description": "Don't show follow count",
"hide_followers_count_description": "Don't show follower count",
"show_admin_badge": "Show \"Admin\" badge in my profile",
"show_moderator_badge": "Show \"Moderator\" badge in my profile",
"nsfw_clickthrough": "Hide sensitive/NSFW media",
"oauth_tokens": "OAuth tokens",
"token": "Token",
"refresh_token": "Refresh token",
"valid_until": "Valid until",
"revoke_token": "Revoke",
"panelRadius": "Panels",
"pause_on_unfocused": "Pause when tab is not focused",
"presets": "Presets",
"profile_background": "Profile background",
"profile_banner": "Profile banner",
"profile_tab": "Profile",
"radii_help": "Set up interface edge rounding (in pixels)",
"replies_in_timeline": "Replies in timeline",
"reply_visibility_all": "Show all replies",
"reply_visibility_following": "Only show replies directed at me or users I'm following",
"reply_visibility_self": "Only show replies directed at me",
"reply_visibility_following_short": "Show replies to my follows",
"reply_visibility_self_short": "Show replies to self only",
"autohide_floating_post_button": "Automatically hide New Post button (mobile)",
"saving_err": "Error saving settings",
"saving_ok": "Settings saved",
"search_user_to_block": "Search whom you want to block",
"search_user_to_mute": "Search whom you want to mute",
"security_tab": "Security",
"scope_copy": "Copy scope when replying (DMs are always copied)",
"minimal_scopes_mode": "Minimize post scope selection options",
"set_new_avatar": "Set new avatar",
"set_new_profile_background": "Set new profile background",
"set_new_profile_banner": "Set new profile banner",
"reset_avatar": "Reset avatar",
"reset_profile_background": "Reset profile background",
"reset_profile_banner": "Reset profile banner",
"reset_avatar_confirm": "Do you really want to reset the avatar?",
"reset_banner_confirm": "Do you really want to reset the banner?",
"reset_background_confirm": "Do you really want to reset the background?",
"settings": "Settings",
"subject_input_always_show": "Always show subject field",
"subject_line_behavior": "Copy subject when replying",
"subject_line_email": "Like email: \"re: subject\"",
"subject_line_mastodon": "Like mastodon: copy as is",
"subject_line_noop": "Do not copy",
"force_theme_recompilation_debug": "Disable theme cahe, force recompile on each boot (DEBUG)",
"conversation_display": "Conversation display style",
"conversation_display_tree": "Tree-style",
"conversation_display_tree_quick": "Tree view",
"disable_sticky_headers": "Don't stick column headers to top of the screen",
"show_scrollbars": "Show side column's scrollbars",
"third_column_mode": "When there's enough space, show third column containing",
"third_column_mode_none": "Don't show third column at all",
"third_column_mode_notifications": "Notifications column",
"third_column_mode_postform": "Main post form and navigation",
"columns": "Columns",
"column_sizes": "Column sizes",
"column_sizes_sidebar": "Sidebar",
"column_sizes_content": "Content",
"column_sizes_notifs": "Notifications",
"theme_editor_min_width": "Minimum width of theme editor (0 for \"fit-content\")",
"tree_advanced": "Allow more flexible navigation in tree view",
"tree_fade_ancestors": "Display ancestors of the current status in faint text",
"conversation_display_linear": "Linear-style",
"conversation_display_linear_quick": "Linear view",
"conversation_other_replies_button": "Show the \"other replies\" button",
"conversation_other_replies_button_below": "Below statuses",
"conversation_other_replies_button_inside": "Inside statuses",
"max_depth_in_thread": "Maximum number of levels in thread to display by default",
"post_status_content_type": "Post status content type",
"sensitive_by_default": "Mark posts as sensitive by default",
"stop_gifs": "Pause animated images until you hover on them",
"streaming": "Automatically show new posts when scrolled to the top",
"auto_update": "Show new posts automatically",
"user_mutes": "Users",
"useStreamingApi": "Receive posts and notifications real-time",
"use_websockets": "Use websockets (Realtime updates)",
"text": "Text",
"theme": "Theme",
"theme_old": "Theme editor (old)",
"theme_help": "Use hex color codes (#rrggbb) to customize your color theme.",
"theme_help_v2_1": "You can also override certain component's colors and opacity by toggling the checkbox, use \"Clear all\" button to clear all overrides.",
"theme_help_v2_2": "Icons underneath some entries are background/text contrast indicators, hover over for detailed info. Please keep in mind that when using transparency contrast indicators show the worst possible case.",
"tooltipRadius": "Tooltips/alerts",
"type_domains_to_mute": "Search domains to mute",
"upload_a_photo": "Upload a photo",
"user_settings": "User Settings",
"values": {
"false": "no",
"true": "yes"
},
"virtual_scrolling": "Optimize timeline rendering",
"use_at_icon": "Display {'@'} symbol as an icon instead of text",
"mention_link_display": "Display mention links",
"mention_link_display_short": "always as short names (e.g. {'@'}foo)",
"mention_link_display_full_for_remote": "as full names only for remote users (e.g. {'@'}foo{'@'}example.org)",
"mention_link_display_full": "always as full names (e.g. {'@'}foo{'@'}example.org)",
"mention_link_use_tooltip": "Show user card when clicking mention links",
"mention_link_show_avatar": "Show user avatar beside the link",
"mention_link_show_avatar_quick": "Show user avatar next to mentions",
"mention_link_fade_domain": "Fade domains (e.g. {'@'}example.org in {'@'}foo{'@'}example.org)",
"mention_link_bolden_you": "Highlight mention of you when you are mentioned",
"user_popover_avatar_action": "Popover avatar click action",
"user_popover_avatar_action_zoom": "Zoom the avatar",
"user_popover_avatar_action_close": "Close the popover",
"user_popover_avatar_action_open": "Open profile",
"user_popover_avatar_overlay": "Show user popover over user avatar",
"fun": "Fun",
"greentext": "Meme arrows",
"show_yous": "Show (You)s",
"notifications": "Notifications",
"notification_setting_annoyance": "Annoyance",
"notification_setting_drawer_marks_as_seen": "Closing drawer (mobile) marks all notifications as read",
"notification_setting_ignore_inactionable_seen": "Ignore read state of inactionable notifications (likes, repeats etc)",
"notification_setting_ignore_inactionable_seen_tip": "This will not actually mark those notifications as read, and you'll still get desktop notifications about them if you chose so",
"notification_setting_unseen_at_top": "Show unread notifications above others",
"notification_setting_filters": "Filters",
"notification_setting_filters_chrome_push": "On some browsers (chrome) it might be impossible to completely filter out notifications by type when they arrive by Push",
"notification_setting_block_from_strangers": "Block notifications from users who you do not follow",
"notification_setting_privacy": "Privacy",
"notification_setting_hide_notification_contents": "Hide the sender and contents of push notifications",
"notification_mutes": "To stop receiving notifications from a specific user, use a mute.",
"notification_blocks": "Blocking a user stops all notifications as well as unsubscribes them.",
"enable_web_push_notifications": "Enable web push notifications",
"enable_web_push_always_show": "Always show web push notifications",
"enable_web_push_always_show_tip": "Some browsers (Chromium, Chrome) require that push messages always result in a notification, otherwise generic 'Website was updated in background' is shown, enable this to prevent this notification from showing, as Chrome seem to hide push notifications if tab is in focus. Can result in showing duplicate notifications on other browsers.",
"more_settings": "More settings",
"style": {
"custom_theme_used": "(Custom theme)",
"custom_style_used": "(Custom style)",
"stock_theme_used": "(Stock theme)",
"themes2_outdated": "Editor for Themes V2 is being phased out and will eventually be replaced with a new one that takes advantage of new Themes V3 engine. It should still work but experience might be degraded and inconsistent.",
"appearance_tab_note": "Changes on this tab do not affect the theme used, so exported theme will be different from what seen in the UI",
"update_preview": "Update preview",
"themes3": {
"define": "Override",
"palette": {
"label": "Color schemes",
"name_label": "Color scheme name",
"import": "Import palette",
"export": "Export palette",
"apply": "Apply palette",
"bg": "Panel background",
"fg": "Buttons etc.",
"text": "Text",
"link": "Links",
"accent": "Accent color",
"cRed": "Red color",
"cBlue": "Blue color",
"cGreen": "Green color",
"cOrange": "Orange color",
"wallpaper": "Wallpaper",
"v2_unsupported": "Older v2 themes don't support palettes. Switch to v3 theme to make use of palettes",
"bundled": "Bundled palettes",
"style": "Palettes provided by selected style",
"user": "Custom palette",
"imported": "Imported"
},
"editor": {
"title": "Style editor",
"reset_style": "Reset",
"load_style": "Open from file",
"save_style": "Save",
"style_name": "Stylesheet name",
"style_author": "Made by",
"style_license": "License",
"style_website": "Website",
"component_selector": "Component",
"variant_selector": "Variant",
"states_selector": "States",
"main_tab": "Main",
"shadows_tab": "Shadows",
"background": "Background color",
"text_color": "Text color",
"icon_color": "Icon color",
"link_color": "Link color",
"contrast": "Text contrast",
"roundness": "Roundness",
"opacity": "Opacity",
"border_color": "Border color",
"include_in_rule": "Add to rule",
"test_string": "TEST",
"invalid": "Invalid",
"refresh_preview": "Refresh preview",
"apply_preview": "Apply",
"text_auto": {
"label": "Auto-contrast",
"no-preserve": "Black or White",
"preserve": "Keep color",
"no-auto": "Disabled"
},
"component_tab": "Components style",
"palette_tab": "Color schemes",
"variables_tab": "Variables (Advanced)",
"variables": {
"label": "Variables",
"name_label": "Name:",
"type_label": "Type:",
"type_shadow": "Shadow",
"type_color": "Color",
"type_generic": "Generic",
"virtual_color": "Variable color value"
}
},
"hacks": {
"underlay_overrides": "Change underlay",
"underlay_override_mode_none": "Theme default",
"underlay_override_mode_opaque": "Replace with solid color",
"underlay_override_mode_transparent": "Remove entirely (might break some themes)",
"force_interface_roundness": "Override interface roundness/sharpness",
"forced_roundness_mode_disabled": "Use theme defaults",
"forced_roundness_mode_sharp": "Force sharp edges",
"forced_roundness_mode_nonsharp": "Force not-so-sharp (1px roundness) edges",
"forced_roundness_mode_round": "Force round edges"
},
"font": {
"group-builtin": "Browser default fonts",
"builtin" : {
"serif": "Serif",
"sans-serif": "Sans-serif",
"monospace": "Monospace",
"inherit": "Unchanged"
},
"group-local": "Locally installed fonts",
"local-unavailable1": "List of locally installed fonts unavailalbe",
"local-unavailable2": "Use manual entry to specify custom font",
"font_list_unavailable": "Couldn't get locally installed fonts: {error}",
"lookup_local_fonts": "Load list of fonts installed on this computer",
"enter_manually": "Enter font name family manually",
"entry": "Enter {fontFamily}",
"select": "Select font",
"label": "{label} font"
}
},
"interface_font_user_override": "Override theme/browser font used",
"switcher": {
"keep_color": "Keep colors",
"keep_shadows": "Keep shadows",
"keep_opacity": "Keep opacity",
"keep_roundness": "Keep roundness",
"keep_fonts": "Keep fonts",
"save_load_hint": "\"Keep\" options preserve currently set options when selecting or loading themes, it also stores said options when exporting a theme. When all checkboxes unset, exporting theme will save everything.",
"reset": "Reset",
"clear_all": "Clear all",
"clear_opacity": "Clear opacity",
"load_theme": "Load theme",
"keep_as_is": "Keep as is",
"use_snapshot": "Old version",
"use_source": "New version",
"help": {
"upgraded_from_v2": "PleromaFE has been upgraded, theme could look a little bit different than you remember.",
"v2_imported": "File you imported was made for older FE. We try to maximize compatibility but there still could be inconsistencies.",
"future_version_imported": "File you imported was made in newer version of FE.",
"older_version_imported": "File you imported was made in older version of FE.",
"snapshot_present": "Theme snapshot is loaded, so all values are overriden. You can load theme's actual data instead.",
"snapshot_missing": "No theme snapshot was in the file so it could look different than originally envisioned.",
"fe_upgraded": "PleromaFE's theme engine upgraded after version update.",
"fe_downgraded": "PleromaFE's version rolled back.",
"migration_snapshot_ok": "Just to be safe, theme snapshot loaded. You can try loading theme data.",
"migration_napshot_gone": "For whatever reason snapshot was missing, some stuff could look different than you remember.",
"snapshot_source_mismatch": "Versions conflict: most likely FE was rolled back and updated again, if you changed theme using older version of FE you most likely want to use old version, otherwise use new version."
}
},
"common": {
"color": "Color",
"opacity": "Opacity",
"contrast": {
"hint": "Contrast ratio is {ratio}, it {level} {context}",
"level": {
"aa": "meets Level AA guideline (minimal)",
"aaa": "meets Level AAA guideline (recommended)",
"bad": "doesn't meet any accessibility guidelines"
},
"context": {
"18pt": "for large (18pt+) text",
"text": "for text"
}
}
},
"common_colors": {
"_tab_label": "Common",
"main": "Common colors",
"foreground_hint": "See \"Advanced\" tab for more detailed control",
"rgbo": "Icons, accents, badges"
},
"advanced_colors": {
"_tab_label": "Advanced",
"alert": "Alert background",
"alert_error": "Error",
"alert_warning": "Warning",
"alert_neutral": "Neutral",
"post": "Posts/User bios",
"badge": "Badge background",
"popover": "Tooltips, menus, popovers",
"badge_notification": "Notification",
"panel_header": "Panel header",
"top_bar": "Top bar",
"borders": "Borders",
"buttons": "Buttons",
"inputs": "Input fields",
"faint_text": "Faded text",
"underlay": "Underlay",
"wallpaper": "Wallpaper",
"poll": "Poll graph",
"icons": "Icons",
"highlight": "Highlighted elements",
"pressed": "Pressed",
"selectedPost": "Selected post",
"selectedMenu": "Selected menu item",
"disabled": "Disabled",
"toggled": "Toggled",
"tabs": "Tabs",
"chat": {
"incoming": "Incoming",
"outgoing": "Outgoing",
"border": "Border"
}
},
"radii": {
"_tab_label": "Roundness"
},
"shadows": {
"_tab_label": "Shadow and lighting",
"component": "Component",
"override": "Override",
"shadow_id": "Shadow #{value}",
"offset": "Shadow offset",
"zoom": "Zoom",
"offset-x": "x:",
"offset-y": "y:",
"light_grid": "Use light checkerboard",
"color_override": "Use different color",
"name": "Name",
"blur": "Blur",
"spread": "Spread",
"inset": "Inset",
"raw": "Plain shadow",
"expression": "Expression (advanced)",
"empty_expression": "Empty expression",
"hintV3": "For shadows you can also use the {0} notation to use other color slot.",
"filter_hint": {
"always_drop_shadow": "Warning, this shadow always uses {0} when browser supports it.",
"drop_shadow_syntax": "{0} does not support {1} parameter and {2} keyword.",
"avatar_inset_short": "Separate inset shadow",
"avatar_inset": "Please note that combining both inset and non-inset shadows on avatars might give unexpected results with transparent avatars.",
"spread_zero": "Shadows with spread > 0 will appear as if it was set to zero",
"inset_classic": "Inset shadows will be using {0}"
},
"components": {
"panel": "Panel",
"panelHeader": "Panel header",
"topBar": "Top bar",
"avatar": "User avatar (in profile view)",
"avatarStatus": "User avatar (in post display)",
"popup": "Popups and tooltips",
"button": "Button",
"buttonHover": "Button (hover)",
"buttonPressed": "Button (pressed)",
"buttonPressedHover": "Button (pressed+hover)",
"input": "Input field"
}
},
"fonts": {
"_tab_label": "Fonts",
"help": "Select font to use for elements of UI. For \"custom\" you have to enter exact font name as it appears in system.",
"components": {
"interface": "Interface",
"input": "Input fields",
"post": "Post text",
"monospace": "Monospaced text"
},
"family": "Font name",
"size": "Size (in px)",
"weight": "Weight (boldness)",
"custom": "Custom"
},
"preview": {
"header": "Preview",
"content": "Content",
"error": "Example error",
"button": "Button",
"text": "A bunch of more {0} and {1}",
"mono": "content",
"input": "Just landed in L.A.",
"faint_link": "helpful manual",
"fine_print": "Read our {0} to learn nothing useful!",
"header_faint": "This is fine",
"checkbox": "I have skimmed over terms and conditions",
"link": "a nice lil' link"
}
},
"version": {
"title": "Version",
"backend_version": "Backend version",
"frontend_version": "Frontend version"
},
"commit_value": "Save",
"commit_value_tooltip": "Value is not saved, press this button to commit your changes",
"reset_value": "Reset",
"reset_value_tooltip": "Reset draft",
"hard_reset_value": "Hard reset",
"hard_reset_value_tooltip": "Remove setting from storage, forcing use of default value"
},
"admin_dash": {
"window_title": "Administration",
"wip_notice": "This admin dashboard is experimental and WIP, {adminFeLink}.",
"old_ui_link": "old admin UI available here",
"reset_all": "Reset all",
"commit_all": "Save all",
"tabs": {
"nodb": "No DB Config",
"instance": "Instance",
"limits": "Limits",
"frontends": "Front-ends",
"emoji": "Emoji"
},
"nodb": {
"heading": "Database config is disabled",
"text": "You need to change backend config files so that {property} is set to {value}, see more in {documentation}.",
"documentation": "documentation",
"text2": "Most configuration options will be unavailable."
},
"captcha": {
"native": "Native",
"kocaptcha": "KoCaptcha"
},
"instance": {
"instance": "Instance information",
"registrations": "User sign-ups",
"captcha_header": "CAPTCHA",
"kocaptcha": "KoCaptcha settings",
"access": "Instance access",
"restrict": {
"header": "Restrict access for anonymous visitors",
"description": "Detailed setting for allowing/disallowing access to certain aspects of API. By default (indeterminate state) it will disallow if instance is not public, ticked checkbox means disallow access even if instance is public, unticked means allow access even if instance is private. Please note that unexpected behavior might happen if some settings are set, i.e. if profile access is disabled posts will show without profile information.",
"timelines": "Timelines access",
"profiles": "User profiles access",
"activities": "Statuses/activities access"
}
},
"limits": {
"arbitrary_limits": "Arbitrary limits",
"posts": "Post limits",
"uploads": "Attachments limits",
"users": "User profile limits",
"profile_fields": "Profile fields limits",
"user_uploads": "Profile media limits"
},
"frontend": {
"repository": "Repository link",
"versions": "Available versions",
"build_url": "Build URL",
"reinstall": "Reinstall",
"is_default": "(Default)",
"is_default_custom": "(Default, version: {version})",
"install": "Install",
"install_version": "Install version {version}",
"more_install_options": "More install options",
"more_default_options": "More default setting options",
"set_default": "Set default",
"set_default_version": "Set version {version} as default",
"wip_notice": "Please note that this section is a WIP and lacks certain features as backend implementation of front-end management is incomplete.",
"default_frontend": "Default frontend",
"default_frontend_tip": "Default frontend will be shown to all users. Currently there's no way to for a user to select personal frontend. If you switch away from PleromaFE you'll most likely have to use old and buggy AdminFE to do instance configuration until we replace it.",
"default_frontend_unavail": "Default frontend settings are not available, as this requires configuration in the database",
"available_frontends": "Available for install",
"failure_installing_frontend": "Failed to install frontend {version}: {reason}",
"success_installing_frontend": "Frontend {version} successfully installed"
},
"emoji": {
"global_actions": "Global actions",
"reload": "Reload emoji",
"importFS": "Import emoji from filesystem",
"error": "Error: {0}",
"create_pack": "Create pack",
"delete_pack": "Delete pack",
"new_pack_name": "New pack name",
"create": "Create",
"emoji_packs": "Emoji packs",
"remote_packs": "Remote packs",
"do_list": "List",
"remote_pack_instance": "Remote pack instance",
"emoji_pack": "Emoji pack",
"edit_pack": "Edit pack",
"description": "Description",
"homepage": "Homepage",
"fallback_src": "Fallback source",
"fallback_sha256": "Fallback SHA256",
"share": "Share",
"save": "Save",
"save_meta": "Save metadata",
"revert_meta": "Revert metadata",
"delete": "Delete",
"revert": "Revert",
"add_file": "Add file",
"adding_new": "Adding new emoji",
"shortcode": "Shortcode",
"filename": "Filename",
"new_shortcode": "Shortcode, leave blank to infer",
"new_filename": "Filename, leave blank to infer",
"delete_confirm": "Are you sure you want to delete {0}?",
"download_pack": "Download pack",
"downloading_pack": "Downloading {0}",
"download": "Download",
"download_as_name": "New name",
"download_as_name_full": "New name, leave blank to reuse",
"files": "Files",
"editing": "Editing {0}",
"delete_title": "Delete?",
"metadata_changed": "Metadata different from saved",
"emoji_changed": "Unsaved emoji file changes, check highlighted emoji",
"replace_warning": "This will REPLACE the local pack of the same name"
},
"temp_overrides": {
":pleroma": {
":instance": {
":public": {
"label": "Instance is public",
"description": "Disabling this will make all API accessible only for logged-in users, this will make Public and Federated timelines inaccessible to anonymous visitors."
},
":limit_to_local_content": {
"label": "Limit search to local content",
"description": "Disables global network search for unauthenticated (default), all users or none"
},
":description_limit": {
"label": "Limit",
"description": "Character limit for attachment descriptions"
},
":background_image": {
"label": "Background image",
"description": "Background image (primarily used by PleromaFE)"
}
}
}
}
},
"time": {
"unit": {
"days": "{0} day | {0} days",
"days_short": "{0}d",
"hours": "{0} hour | {0} hours",
"hours_short": "{0}h",
"minutes": "{0} minute | {0} minutes",
"minutes_short": "{0}min",
"months": "{0} month | {0} months",
"months_short": "{0}mo",
"seconds": "{0} second | {0} seconds",
"seconds_short": "{0}s",
"weeks": "{0} week | {0} weeks",
"weeks_short": "{0}w",
"years": "{0} year | {0} years",
"years_short": "{0}y"
},
"in_future": "in {0}",
"in_past": "{0} ago",
"now": "just now",
"now_short": "now"
},
"timeline": {
"collapse": "Collapse",
"conversation": "Conversation",
"error": "Error fetching timeline: {0}",
"load_older": "Load older statuses",
"no_retweet_hint": "Post is marked as followers-only or direct and cannot be repeated",
"repeated": "repeated",
"show_new": "Show new",
"reload": "Reload",
"up_to_date": "Up-to-date",
"no_more_statuses": "No more statuses",
"no_statuses": "No statuses",
"socket_reconnected": "Realtime connection established",
"socket_broke": "Realtime connection lost: CloseEvent code {0}",
"quick_view_settings": "Quick view settings",
"quick_filter_settings": "Quick filter settings",
"filter_settings": "Filter"
},
"status": {
"favorites": "Favorites",
"repeats": "Repeats",
"quotes": "Quotes",
"repeat_confirm": "Do you really want to repeat this status?",
"repeat_confirm_title": "Repeat confirmation",
"repeat_confirm_accept_button": "Repeat",
"repeat_confirm_cancel_button": "Do not repeat",
"delete": "Delete status",
"delete_error": "Error deleting status: {0}",
"edit": "Edit status",
"edited_at": "(last edited {time})",
"pin": "Pin on profile",
"unpin": "Unpin from profile",
"pinned": "Pinned",
"bookmark": "Bookmark",
"unbookmark": "Unbookmark",
"delete_confirm": "Do you really want to delete this status?",
"delete_confirm_title": "Delete confirmation",
"delete_confirm_accept_button": "Delete",
"delete_confirm_cancel_button": "Keep",
"reply_to": "Reply to",
"reply_to_with_icon": "{icon} {replyTo}",
"reply_to_with_arg": "{replyToWithIcon} {user}",
"mentions": "Mentions",
"replies_list": "Replies:",
"replies_list_with_others": "Replies (+{numReplies} other): | Replies (+{numReplies} others):",
"mute_ellipsis": "Mute…",
"mute_user": "Mute user",
"unmute_user": "Unmute user",
"mute_domain": "Mute domain",
"unmute_domain": "Unmute domain",
"mute_conversation": "Mute conversation",
"unmute_conversation": "Unmute conversation",
"status_unavailable": "Status unavailable",
"copy_link": "Copy link to status",
"external_source": "External source",
"muted_words": "Wordfiltered: {word} | Wordfiltered: {word} and {numWordsMore} more words",
"multi_reason_mute": "{main} + one more reason | {main} + {numReasonsMore} more reasons",
"muted_user": "User muted",
"thread_muted": "Thread muted",
"thread_muted_and_words": ", has words:",
"sensitive_muted": "Muting sensitive content",
"bot_muted": "Muting bot content",
"show_full_subject": "Show full subject",
"hide_full_subject": "Hide full subject",
"show_content": "Show content",
"hide_content": "Hide content",
"status_deleted": "This post was deleted",
"nsfw": "NSFW",
"expand": "Expand",
"you": "(You)",
"plus_more": "+{number} more",
"many_attachments": "Post has {number} attachment(s)",
"collapse_attachments": "Collapse attachments",
"show_all_attachments": "Show all attachments",
"show_attachment_in_modal": "Show in media modal",
"show_attachment_description": "Preview description (open attachment for full description)",
"hide_attachment": "Hide attachment",
"remove_attachment": "Remove attachment",
"attachment_stop_flash": "Stop Flash player",
"move_up": "Shift attachment left",
"move_down": "Shift attachment right",
"open_gallery": "Open gallery",
"thread_hide": "Hide this thread",
"thread_show": "Show this thread",
"thread_show_full": "Show everything under this thread ({numStatus} status in total, max depth {depth}) | Show everything under this thread ({numStatus} statuses in total, max depth {depth})",
"thread_show_full_with_icon": "{icon} {text}",
"thread_follow": "See the remaining part of this thread ({numStatus} status in total) | See the remaining part of this thread ({numStatus} statuses in total)",
"thread_follow_with_icon": "{icon} {text}",
"ancestor_follow": "See {numReplies} other reply under this status | See {numReplies} other replies under this status",
"ancestor_follow_with_icon": "{icon} {text}",
"show_all_conversation_with_icon": "{icon} {text}",
"show_all_conversation": "Show full conversation ({numStatus} other status) | Show full conversation ({numStatus} other statuses)",
"show_only_conversation_under_this": "Only show replies to this status",
"status_history": "Status history",
"reaction_count_label": "{num} person reacted | {num} people reacted",
"hide_quote": "Hide the quoted status",
"display_quote": "Display the quoted status",
"invisible_quote": "Quoted status unavailable: {link}",
"more_actions": "More actions on this status",
"loading": "Loading...",
"load_error": "Unable to load status: {error}"
},
"user_card": {
"approve": "Approve",
"approve_confirm_title": "Approve confirmation",
"approve_confirm_accept_button": "Approve",
"approve_confirm_cancel_button": "Do not approve",
"approve_confirm": "Do you want to approve {user}'s follow request?",
"block": "Block",
"blocked": "Blocked!",
"block_confirm_title": "Block confirmation",
"block_confirm": "Do you really want to block {user}?",
"block_confirm_accept_button": "Block",
"block_confirm_cancel_button": "Do not block",
"deactivated": "Deactivated",
"deny": "Deny",
"deny_confirm_title": "Deny confirmation",
"deny_confirm_accept_button": "Deny",
"deny_confirm_cancel_button": "Do not deny",
"deny_confirm": "Do you want to deny {user}'s follow request?",
"edit_profile": "Edit profile",
"favorites": "Favorites",
"follow": "Follow",
"follow_cancel": "Cancel request",
"follow_sent": "Request sent!",
"follow_progress": "Requesting…",
"follow_unfollow": "Unfollow",
"unfollow_confirm_title": "Unfollow confirmation",
"unfollow_confirm": "Do you really want to unfollow {user}?",
"unfollow_confirm_accept_button": "Unfollow",
"unfollow_confirm_cancel_button": "Do not unfollow",
"followees": "Following",
"followers": "Followers",
"following": "Following!",
"follows_you": "Follows you!",
"hidden": "Hidden",
"its_you": "It's you!",
"media": "Media",
"mention": "Mention",
"message": "Message",
"mute": "Mute",
"muted": "Muted",
"mute_confirm_title": "Mute confirmation",
"mute_confirm": "Do you really want to mute {user}?",
"mute_confirm_accept_button": "Mute",
"mute_confirm_cancel_button": "Do not mute",
"mute_duration_prompt": "Mute this user for (0 for indefinite time):",
"per_day": "per day",
"remote_follow": "Remote follow",
"remove_follower": "Remove follower",
"remove_follower_confirm_title": "Remove follower confirmation",
"remove_follower_confirm_accept_button": "Remove",
"remove_follower_confirm_cancel_button": "Keep",
"remove_follower_confirm": "Do you really want to remove {user} from your followers?",
"report": "Report",
"statuses": "Statuses",
"subscribe": "Subscribe",
"unsubscribe": "Unsubscribe",
"unblock": "Unblock",
"unblock_progress": "Unblocking…",
"block_progress": "Blocking…",
"unmute": "Unmute",
"unmute_progress": "Unmuting…",
"mute_progress": "Muting…",
"hide_repeats": "Hide repeats",
"show_repeats": "Show repeats",
"bot": "Bot",
"group": "Group",
"birthday": "Born {birthday}",
"admin_menu": {
"moderation": "Moderation",
"grant_admin": "Grant Admin",
"revoke_admin": "Revoke Admin",
"grant_moderator": "Grant Moderator",
"revoke_moderator": "Revoke Moderator",
"activate_account": "Activate account",
"deactivate_account": "Deactivate account",
"delete_account": "Delete account",
"force_nsfw": "Mark all posts as NSFW",
"strip_media": "Remove media from posts",
"force_unlisted": "Force posts to be unlisted",
"sandbox": "Force posts to be followers-only",
"disable_remote_subscription": "Disallow following user from remote instances",
"disable_any_subscription": "Disallow following user at all",
"quarantine": "Disallow user posts from federating",
"delete_user": "Delete user",
"delete_user_data_and_deactivate_confirmation": "This will permanently delete the data from this account and deactivate it. Are you absolutely sure?"
},
"highlight": {
"disabled": "No highlight",
"solid": "Solid bg",
"striped": "Striped bg",
"side": "Side stripe"
},
"note": "Note",
"note_blank": "(None)",
"edit_note": "Edit note",
"edit_note_apply": "Apply",
"edit_note_cancel": "Cancel"
},
"user_profile": {
"timeline_title": "User timeline",
"profile_does_not_exist": "Sorry, this profile does not exist.",
"profile_loading_error": "Sorry, there was an error loading this profile."
},
"user_reporting": {
"title": "Reporting {0}",
"add_comment_description": "The report will be sent to your instance moderators. You can provide an explanation of why you are reporting this account below:",
"additional_comments": "Additional comments",
"forward_description": "The account is from another server. Send a copy of the report there as well?",
"forward_to": "Forward to {0}",
"submit": "Submit",
"generic_error": "An error occurred while processing your request."
},
"who_to_follow": {
"more": "More",
"who_to_follow": "Who to follow"
},
"tool_tip": {
"media_upload": "Upload media",
"mentions": "Mentions",
"repeat": "Repeat",
"unrepeat": "Unrepeat",
"reply": "Reply",
"add_reaction": "Add reaction",
"favorite": "Favorite",
"unfavorite": "Unfavorite",
"add_reaction": "Add Reaction",
"user_settings": "User Settings",
"accept_follow_request": "Accept follow request",
"reject_follow_request": "Reject follow request",
"bookmark": "Bookmark",
"toggle_expand": "Expand or collapse notification to show post in full",
"toggle_mute": "Expand or collapse notification to reveal muted content",
"autocomplete_available": "{number} result is available. Use up and down keys to navigate through them. | {number} results are available. Use up and down keys to navigate through them."
},
"upload": {
"error": {
"base": "Upload failed.",
"message": "Upload failed: {0}",
"file_too_big": "File too big [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"default": "Try again later"
},
"file_size_units": {
"B": "B",
"KiB": "KiB",
"MiB": "MiB",
"GiB": "GiB",
"TiB": "TiB"
}
},
"search": {
"people": "People",
"hashtags": "Hashtags",
"person_talking": "{count} person talking",
"people_talking": "{count} people talking",
"no_results": "No results",
"no_more_results": "No more results",
"load_more": "Load more results"
},
"password_reset": {
"forgot_password": "Forgot password?",
"password_reset": "Password reset",
"instruction": "Enter your email address or username. We will send you a link to reset your password.",
"placeholder": "Your email or username",
"check_email": "Check your email for a link to reset your password.",
"return_home": "Return to the home page",
"too_many_requests": "You have reached the limit of attempts, try again later.",
"password_reset_disabled": "Password reset is disabled. Please contact your instance administrator.",
"password_reset_required": "You must reset your password to log in.",
"password_reset_required_but_mailer_is_disabled": "You must reset your password, but password reset is disabled. Please contact your instance administrator."
},
"chats": {
"you": "You:",
"message_user": "Message {nickname}",
"delete": "Delete",
"chats": "Chats",
"new": "New Chat",
"empty_message_error": "Cannot post empty message",
"more": "More",
"delete_confirm": "Do you really want to delete this message?",
"error_loading_chat": "Something went wrong when loading the chat.",
"error_sending_message": "Something went wrong when sending the message.",
"empty_chat_list_placeholder": "You don't have any chats yet. Start a new chat!"
},
"bookmarks": {
"manage_bookmark_folders": "Manage bookmark folders"
},
"lists": {
"lists": "Lists",
"new": "New List",
"title": "List title",
"search": "Search users",
"create": "Create",
"save": "Save changes",
"delete": "Delete list",
"following_only": "Limit to Following",
"manage_lists": "Manage lists",
"manage_members": "Manage list members",
"add_members": "Search for more users",
"remove_from_list": "Remove from list",
"add_to_list": "Add to list",
"is_in_list": "Already in list",
"editing_list": "Editing list {listTitle}",
"creating_list": "Creating new list",
"update_title": "Save Title",
"really_delete": "Really delete list?",
"error": "Error manipulating lists: {0}"
},
"file_type": {
"audio": "Audio",
"video": "Video",
"image": "Image",
"file": "File"
},
"display_date": {
"today": "Today"
},
"update": {
"big_update_title": "Please bear with us",
"big_update_content": "We haven't had a release in a while, so things might look and feel different than what you're used to.",
"update_bugs": "Please report any issues and bugs on {pleromaGitlab}, as we have changed a lot, and although we test thoroughly and use development versions ourselves, we may have missed some things. We welcome your feedback and suggestions on issues you might encounter, or how to improve Pleroma and Pleroma-FE.",
"update_bugs_gitlab": "Pleroma GitLab",
"update_changelog": "For more details on what's changed, see {theFullChangelog}.",
"update_changelog_here": "the full changelog",
"art_by": "Art by {linkToArtist}"
},
"unicode_domain_indicator": {
"tooltip": "This domain contains non-ascii characters."
},
"drafts": {
"drafts": "Drafts",
"no_drafts": "You have no drafts",
"empty": "(No content)",
"poll_tooltip": "Draft contains a poll",
"continue": "Continue composing",
"save": "Save without posting",
"abandon": "Abandon draft",
"abandon_confirm_title": "Abandon confirmation",
"abandon_confirm": "Do you really want to abandon this draft?",
"abandon_confirm_accept_button": "Abandon",
"abandon_confirm_cancel_button": "Keep",
"replying": "Replying to {statusLink}",
"editing": "Editing {statusLink}",
"unavailable": "(unavailable)"
},
"splash": {
"loading": "Loading...",
"theme": "Applying theme, please wait warmly...",
"fun_1": "Drink more water",
"fun_2": "Take it easy!",
"fun_3": "Suya...",
"fun_4": "My Pleroma machine is full power!",
"error": "Something went wrong"
},
"bookmark_folders": {
"select_folder": "Select bookmark folder",
"creating_folder": "Creating bookmark folder",
"editing_folder": "Editing folder {folderName}",
"emoji": "Emoji",
"name": "Folder name",
"new": "New Folder",
"create": "Create folder",
"delete": "Delete folder",
"update_folder": "Save changes",
"really_delete": "Do you really want to delete the folder?",
"error": "Error manipulating bookmark folders: {0}"
}
}
diff --git a/src/services/theme_data/theme_data_3.service.js b/src/services/theme_data/theme_data_3.service.js
index 691edc2803..320196493b 100644
--- a/src/services/theme_data/theme_data_3.service.js
+++ b/src/services/theme_data/theme_data_3.service.js
@@ -1,634 +1,634 @@
import { convert, brightness } from 'chromatism'
import sum from 'hash-sum'
import { flattenDeep, sortBy } from 'lodash'
import {
alphaBlend,
getTextColor,
rgba2css,
mixrgb,
relativeLuminance
} from '../color_convert/color_convert.js'
import {
colorFunctions,
shadowFunctions,
process
} from './theme3_slot_functions.js'
import {
unroll,
getAllPossibleCombinations,
genericRuleToSelector,
normalizeCombination,
findRules
} from './iss_utils.js'
import { deserializeShadow } from './iss_deserializer.js'
// Ensuring the order of components
const components = {
Root: null,
Text: null,
FunText: null,
Link: null,
Icon: null,
Border: null,
PanelHeader: null,
Attachment: null,
Panel: null,
Chat: null,
ChatMessage: null,
Button: null
}
export const findShadow = (shadows, { dynamicVars, staticVars }) => {
return (shadows || []).map(shadow => {
let targetShadow
if (typeof shadow === 'string') {
if (shadow.startsWith('$')) {
targetShadow = process(shadow, shadowFunctions, { findColor, findShadow }, { dynamicVars, staticVars })
} else if (shadow.startsWith('--')) {
// modifiers are completely unsupported here
const variableSlot = shadow.substring(2)
return findShadow(staticVars[variableSlot], { dynamicVars, staticVars })
} else {
targetShadow = deserializeShadow(shadow)
}
} else {
targetShadow = shadow
}
const shadowArray = Array.isArray(targetShadow) ? targetShadow : [targetShadow]
return shadowArray.map(s => ({
...s,
color: findColor(s.color, { dynamicVars, staticVars })
}))
})
}
export const findColor = (color, { dynamicVars, staticVars }) => {
try {
if (typeof color !== 'string' || (!color.startsWith('--') && !color.startsWith('$'))) return color
let targetColor = null
if (color.startsWith('--')) {
// Modifier support is pretty much for v2 themes only
const [variable, modifier] = color.split(/,/g).map(str => str.trim())
const variableSlot = variable.substring(2)
if (variableSlot === 'stack') {
const { r, g, b } = dynamicVars.stacked
targetColor = { r, g, b }
} else if (variableSlot.startsWith('parent')) {
if (variableSlot === 'parent') {
const { r, g, b } = dynamicVars.lowerLevelBackground
targetColor = { r, g, b }
} else {
const virtualSlot = variableSlot.replace(/^parent/, '')
targetColor = convert(dynamicVars.lowerLevelVirtualDirectivesRaw[virtualSlot]).rgb
}
} else {
switch (variableSlot) {
case 'inheritedBackground':
targetColor = convert(dynamicVars.inheritedBackground).rgb
break
case 'background':
targetColor = convert(dynamicVars.background).rgb
break
default:
targetColor = convert(staticVars[variableSlot]).rgb
}
}
if (modifier) {
const effectiveBackground = dynamicVars.lowerLevelBackground ?? targetColor
const isLightOnDark = relativeLuminance(convert(effectiveBackground).rgb) < 0.5
const mod = isLightOnDark ? 1 : -1
targetColor = brightness(Number.parseFloat(modifier) * mod, targetColor).rgb
}
}
if (color.startsWith('$')) {
try {
targetColor = process(color, colorFunctions, { findColor }, { dynamicVars, staticVars })
} catch (e) {
console.error('Failure executing color function', e)
targetColor = '#FF00FF'
}
}
// Color references other color
return targetColor
} catch (e) {
throw new Error(`Couldn't find color "${color}", variables are:
Static:
${JSON.stringify(staticVars, null, 2)}
Dynamic:
${JSON.stringify(dynamicVars, null, 2)}`, e)
}
}
const getTextColorAlpha = (directives, intendedTextColor, dynamicVars, staticVars) => {
const opacity = directives.textOpacity
const backgroundColor = convert(dynamicVars.lowerLevelBackground).rgb
const textColor = convert(findColor(intendedTextColor, { dynamicVars, staticVars })).rgb
if (opacity === null || opacity === undefined || opacity >= 1) {
return convert(textColor).hex
}
if (opacity === 0) {
return convert(backgroundColor).hex
}
const opacityMode = directives.textOpacityMode
switch (opacityMode) {
case 'fake':
return convert(alphaBlend(textColor, opacity, backgroundColor)).hex
case 'mixrgb':
return convert(mixrgb(backgroundColor, textColor)).hex
default:
return rgba2css({ a: opacity, ...textColor })
}
}
// Loading all style.js[on] files dynamically
const componentsContext = import.meta.glob(
['/src/**/*.style.js', '/src/**/*.style.json'],
{ eager: true }
)
Object.keys(componentsContext).forEach(key => {
const component = componentsContext[key].default
if (components[component.name] != null) {
console.warn(`Component in file ${key} is trying to override existing component ${component.name}! You have collisions/duplicates!`)
}
components[component.name] = component
})
Object.keys(components).forEach(key => {
if (key === 'Root') return
components.Root.validInnerComponents = components.Root.validInnerComponents || []
components.Root.validInnerComponents.push(key)
})
Object.keys(components).forEach(key => {
const component = components[key]
const { validInnerComponents = [] } = component
validInnerComponents.forEach(inner => {
const child = components[inner]
component.possibleChildren = component.possibleChildren || []
component.possibleChildren.push(child)
child.possibleParents = child.possibleParents || []
child.possibleParents.push(component)
})
})
const engineChecksum = sum(components)
const ruleToSelector = genericRuleToSelector(components)
export const getEngineChecksum = () => engineChecksum
/**
* Initializes and compiles the theme according to the ruleset
*
* @param {Object[]} inputRuleset - set of rules to compile theme against. Acts as an override to
* component default rulesets
* @param {string} ultimateBackgroundColor - Color that will be the "final" background for
* calculating contrast ratios and making text automatically accessible. Really used for cases when
* stuff is transparent.
* @param {boolean} debug - print out debug information in console, mostly just performance stuff
* @param {boolean} liteMode - use validInnerComponentsLite instead of validInnerComponents, meant to
* generatate theme previews and such that need to be compiled faster and don't require a lot of other
* components present in "normal" mode
* @param {boolean} onlyNormalState - only use components 'normal' states, meant for generating theme
* previews since states are the biggest factor for compilation time and are completely unnecessary
* when previewing multiple themes at same time
*/
export const init = ({
inputRuleset,
ultimateBackgroundColor,
debug = false,
liteMode = false,
editMode = false,
onlyNormalState = false,
initialStaticVars = {}
}) => {
const rootComponentName = 'Root'
if (!inputRuleset) throw new Error('Ruleset is null or undefined!')
const staticVars = { ...initialStaticVars }
const stacked = {}
const computed = {}
const rulesetUnsorted = [
...Object.values(components)
.map(c => (c.defaultRules || []).map(r => ({ source: 'Built-in', component: c.name, ...r })))
.reduce((acc, arr) => [...acc, ...arr], []),
...inputRuleset
].map(rule => {
normalizeCombination(rule)
let currentParent = rule.parent
while (currentParent) {
normalizeCombination(currentParent)
currentParent = currentParent.parent
}
return rule
})
const ruleset = rulesetUnsorted
.map((data, index) => ({ data, index }))
.toSorted(({ data: a, index: ai }, { data: b, index: bi }) => {
const parentsA = unroll(a).length
const parentsB = unroll(b).length
let aScore = 0
let bScore = 0
aScore += parentsA * 1000
bScore += parentsB * 1000
aScore += a.variant !== 'normal' ? 100 : 0
bScore += b.variant !== 'normal' ? 100 : 0
aScore += a.state.filter(x => x !== 'normal').length * 1000
bScore += b.state.filter(x => x !== 'normal').length * 1000
aScore += a.component === 'Text' ? 1 : 0
bScore += b.component === 'Text' ? 1 : 0
// Debug
a._specificityScore = aScore
b._specificityScore = bScore
if (aScore === bScore) {
return ai - bi
}
return aScore - bScore
})
.map(({ data }) => data)
if (!ultimateBackgroundColor) {
console.warn('No ultimate background color provided, falling back to panel color')
const rootRule = ruleset.findLast((x) => (x.component === 'Root' && x.directives?.['--bg']))
ultimateBackgroundColor = rootRule.directives['--bg'].split('|')[1].trim()
}
const virtualComponents = new Set(Object.values(components).filter(c => c.virtual).map(c => c.name))
const transparentComponents = new Set(Object.values(components).filter(c => c.transparent).map(c => c.name))
const nonEditableComponents = new Set(Object.values(components).filter(c => c.notEditable).map(c => c.name))
const extraCompileComponents = new Set([])
Object.values(components).forEach(component => {
const relevantRules = ruleset.filter(r => r.component === component.name)
const backgrounds = relevantRules.map(r => r.directives.background).filter(x => x)
const opacities = relevantRules.map(r => r.directives.opacity).filter(x => x)
if (
backgrounds.some(x => x.match(/--parent/)) ||
opacities.some(x => x != null && x < 1))
{
extraCompileComponents.add(component.name)
}
})
const processCombination = (combination) => {
try {
const selector = ruleToSelector(combination, true)
const cssSelector = ruleToSelector(combination)
const parentSelector = selector.split(/ /g).slice(0, -1).join(' ')
const soloSelector = selector.split(/ /g).slice(-1)[0]
const lowerLevelSelector = parentSelector
let lowerLevelBackground = computed[lowerLevelSelector]?.background
if (editMode && !lowerLevelBackground) {
// FIXME hack for editor until it supports handling component backgrounds
lowerLevelBackground = '#00FFFF'
}
const lowerLevelVirtualDirectives = computed[lowerLevelSelector]?.virtualDirectives
const lowerLevelVirtualDirectivesRaw = computed[lowerLevelSelector]?.virtualDirectivesRaw
const dynamicVars = computed[selector] || {
lowerLevelSelector,
lowerLevelBackground,
lowerLevelVirtualDirectives,
lowerLevelVirtualDirectivesRaw
}
// Inheriting all of the applicable rules
const existingRules = ruleset.filter(findRules(combination))
const computedDirectives =
existingRules
.map(r => r.directives)
.reduce((acc, directives) => ({ ...acc, ...directives }), {})
const computedRule = {
...combination,
directives: computedDirectives
}
computed[selector] = computed[selector] || {}
computed[selector].computedRule = computedRule
computed[selector].dynamicVars = dynamicVars
// avoid putting more stuff into actual CSS
computed[selector].virtualDirectives = {}
// but still be able to access it i.e. from --parent
computed[selector].virtualDirectivesRaw = computed[lowerLevelSelector]?.virtualDirectivesRaw || {}
if (virtualComponents.has(combination.component)) {
const virtualName = [
'--',
combination.component.toLowerCase(),
combination.variant === 'normal'
? ''
: combination.variant[0].toUpperCase() + combination.variant.slice(1).toLowerCase(),
...sortBy(combination.state.filter(x => x !== 'normal')).map(state => state[0].toUpperCase() + state.slice(1).toLowerCase())
].join('')
let inheritedTextColor = computedDirectives.textColor
let inheritedTextAuto = computedDirectives.textAuto
let inheritedTextOpacity = computedDirectives.textOpacity
let inheritedTextOpacityMode = computedDirectives.textOpacityMode
const lowerLevelTextSelector = [...selector.split(/ /g).slice(0, -1), soloSelector].join(' ')
const lowerLevelTextRule = computed[lowerLevelTextSelector]
if (inheritedTextColor == null || inheritedTextOpacity == null || inheritedTextOpacityMode == null) {
inheritedTextColor = computedDirectives.textColor ?? lowerLevelTextRule.textColor
inheritedTextAuto = computedDirectives.textAuto ?? lowerLevelTextRule.textAuto
inheritedTextOpacity = computedDirectives.textOpacity ?? lowerLevelTextRule.textOpacity
inheritedTextOpacityMode = computedDirectives.textOpacityMode ?? lowerLevelTextRule.textOpacityMode
}
const newTextRule = {
...computedRule,
directives: {
...computedRule.directives,
textColor: inheritedTextColor,
textAuto: inheritedTextAuto ?? 'preserve',
textOpacity: inheritedTextOpacity,
textOpacityMode: inheritedTextOpacityMode
}
}
dynamicVars.inheritedBackground = lowerLevelBackground
dynamicVars.stacked = convert(stacked[lowerLevelSelector]).rgb
const intendedTextColor = convert(findColor(inheritedTextColor, { dynamicVars, staticVars })).rgb
const textColor = newTextRule.directives.textAuto === 'no-auto'
? intendedTextColor
: getTextColor(
convert(stacked[lowerLevelSelector]).rgb,
intendedTextColor,
newTextRule.directives.textAuto === 'preserve'
)
const virtualDirectives = { ...(computed[lowerLevelSelector].virtualDirectives || {}) }
const virtualDirectivesRaw = { ...(computed[lowerLevelSelector].virtualDirectivesRaw || {}) }
// Storing color data in lower layer to use as custom css properties
virtualDirectives[virtualName] = getTextColorAlpha(newTextRule.directives, textColor, dynamicVars)
virtualDirectivesRaw[virtualName] = textColor
computed[lowerLevelSelector].virtualDirectives = virtualDirectives
computed[lowerLevelSelector].virtualDirectivesRaw = virtualDirectivesRaw
return {
dynamicVars,
selector: cssSelector.split(/ /g).slice(0, -1).join(' '),
...combination,
directives: {},
virtualDirectives,
virtualDirectivesRaw
}
} else {
computed[selector] = computed[selector] || {}
// TODO: DEFAULT TEXT COLOR
const lowerLevelStackedBackground = stacked[lowerLevelSelector] || convert(ultimateBackgroundColor).rgb
if (computedDirectives.background) {
let inheritRule = null
const variantRules = ruleset.filter(
findRules({
component: combination.component,
variant: combination.variant,
parent: combination.parent
})
)
const lastVariantRule = variantRules[variantRules.length - 1]
if (lastVariantRule) {
inheritRule = lastVariantRule
} else {
const normalRules = ruleset.filter(findRules({
component: combination.component,
parent: combination.parent
}))
const lastNormalRule = normalRules[normalRules.length - 1]
inheritRule = lastNormalRule
}
const inheritSelector = ruleToSelector({ ...inheritRule, parent: combination.parent }, true)
const inheritedBackground = computed[inheritSelector].background
dynamicVars.inheritedBackground = inheritedBackground
const rgb = convert(findColor(computedDirectives.background, { dynamicVars, staticVars })).rgb
if (!stacked[selector]) {
let blend
const alpha = computedDirectives.opacity ?? 1
if (alpha >= 1) {
blend = rgb
} else if (alpha <= 0) {
blend = lowerLevelStackedBackground
} else {
blend = alphaBlend(rgb, computedDirectives.opacity, lowerLevelStackedBackground)
}
stacked[selector] = blend
computed[selector].background = { ...rgb, a: computedDirectives.opacity ?? 1 }
}
}
if (computedDirectives.shadow) {
dynamicVars.shadow = flattenDeep(findShadow(flattenDeep(computedDirectives.shadow), { dynamicVars, staticVars }))
}
if (!stacked[selector]) {
computedDirectives.background = 'transparent'
computedDirectives.opacity = 0
stacked[selector] = lowerLevelStackedBackground
computed[selector].background = { ...lowerLevelStackedBackground, a: 0 }
}
dynamicVars.stacked = stacked[selector]
dynamicVars.background = computed[selector].background
const dynamicSlots = Object.entries(computedDirectives).filter(([k]) => k.startsWith('--'))
dynamicSlots.forEach(([k, v]) => {
const [type, value] = v.split('|').map(x => x.trim()) // woah, Extreme!
switch (type) {
case 'color': {
const color = findColor(value, { dynamicVars, staticVars })
dynamicVars[k] = color
if (combination.component === rootComponentName) {
staticVars[k.substring(2)] = color
}
break
}
case 'shadow': {
const shadow = value.split(/,/g).map(s => s.trim()).filter(x => x)
dynamicVars[k] = shadow
if (combination.component === rootComponentName) {
staticVars[k.substring(2)] = shadow
}
break
}
case 'generic': {
dynamicVars[k] = value
if (combination.component === rootComponentName) {
staticVars[k.substring(2)] = value
}
break
}
}
})
const rule = {
dynamicVars,
selector: cssSelector,
...combination,
directives: computedDirectives
}
return rule
}
} catch (e) {
const { component, variant, state } = combination
throw new Error(`Error processing combination ${component}.${variant}:${state.join(':')}: ${e}`)
}
}
const processInnerComponent = (component, parent) => {
const combinations = []
const {
states: originalStates = {},
variants: originalVariants = {}
} = component
let validInnerComponents
if (editMode) {
const temp = (component.validInnerComponentsLite || component.validInnerComponents || [])
validInnerComponents = temp
.filter(c => virtualComponents.has(c) && !nonEditableComponents.has(c))
} else if (liteMode) {
validInnerComponents = (component.validInnerComponentsLite || component.validInnerComponents || [])
- } else if (component.name === 'Root') {
+ } else if (component.name === 'Root' || component.states != null) {
validInnerComponents = component.validInnerComponents || []
} else {
validInnerComponents = component
.validInnerComponents
?.filter(
c => virtualComponents.has(c)
|| transparentComponents.has(c)
|| extraCompileComponents.has(c)
)
|| []
}
// Normalizing states and variants to always include "normal"
const states = { normal: '', ...originalStates }
const variants = { normal: '', ...originalVariants }
const innerComponents = (validInnerComponents).map(name => {
const result = components[name]
if (result === undefined) console.error(`Component ${component.name} references a component ${name} which does not exist!`)
return result
})
// Optimization: we only really need combinations without "normal" because all states implicitly have it
const permutationStateKeys = Object.keys(states).filter(s => s !== 'normal')
const stateCombinations = (onlyNormalState && !virtualComponents.has(component.name))
? [
['normal']
]
: [
['normal'],
...getAllPossibleCombinations(permutationStateKeys)
.map(combination => ['normal', ...combination])
.filter(combo => {
// Optimization: filter out some hard-coded combinations that don't make sense
if (combo.indexOf('disabled') >= 0) {
return !(
combo.indexOf('hover') >= 0 ||
combo.indexOf('focused') >= 0 ||
combo.indexOf('pressed') >= 0
)
}
return true
})
]
const stateVariantCombination = Object.keys(variants).map(variant => {
return stateCombinations.map(state => ({ variant, state }))
}).reduce((acc, x) => [...acc, ...x], [])
stateVariantCombination.forEach(combination => {
combination.component = component.name
combination.lazy = component.lazy || parent?.lazy
combination.parent = parent
if (!liteMode && combination.state.indexOf('hover') >= 0) {
combination.lazy = true
}
if (
!liteMode &&
parent?.component !== 'Root' &&
!virtualComponents.has(component.name) &&
!transparentComponents.has(component.name) &&
extraCompileComponents.has(component.name)
) {
combination.lazy = true
}
combinations.push(combination)
innerComponents.forEach(innerComponent => {
combinations.push(...processInnerComponent(innerComponent, combination))
})
})
return combinations
}
const t0 = performance.now()
const combinations = processInnerComponent(components[rootComponentName] ?? components.Root)
const t1 = performance.now()
if (debug) {
console.debug('Tree traveral took ' + (t1 - t0) + ' ms')
}
const result = combinations.map((combination) => {
if (combination.lazy) {
return async () => processCombination(combination)
} else {
return processCombination(combination)
}
}).filter(x => x)
const t2 = performance.now()
if (debug) {
console.debug('Eager processing took ' + (t2 - t1) + ' ms')
}
// optimization to traverse big-ass array only once instead of twice
const eager = []
const lazy = []
result.forEach(x => {
if (typeof x === 'function') {
lazy.push(x)
} else {
eager.push(x)
}
})
return {
lazy,
eager,
staticVars,
engineChecksum,
themeChecksum: sum([lazy, eager])
}
}
diff --git a/src/stores/reports.js b/src/stores/reports.js
index a84442eca6..18080d4e72 100644
--- a/src/stores/reports.js
+++ b/src/stores/reports.js
@@ -1,52 +1,52 @@
import { defineStore } from 'pinia'
import filter from 'lodash/filter'
import { useInterfaceStore } from 'src/stores/interface'
export const useReportsStore = defineStore('reports', {
state: () => ({
reportModal: {
userId: null,
statuses: [],
preTickedIds: [],
activated: false
},
reports: {}
}),
actions: {
openUserReportingModal ({ userId, statusIds = [] }) {
const preTickedStatuses = statusIds.map(id => window.vuex.state.statuses.allStatusesObject[id])
const preTickedIds = statusIds
const statuses = preTickedStatuses.concat(
filter(window.vuex.state.statuses.allStatuses,
status => status.user.id === userId && !preTickedIds.includes(status.id)
)
)
this.reportModal.userId = userId
this.reportModal.statuses = statuses
this.reportModal.preTickedIds = preTickedIds
this.reportModal.activated = true
},
closeUserReportingModal () {
this.reportModal.activated = false
},
setReportState ({ id, state }) {
- const oldState = window.vuex.state.reports.reports[id].state
+ const oldState = this.reports[id].state
this.reports[id].state = state
window.vuex.state.api.backendInteractor.setReportState({ id, state }).catch(e => {
console.error('Failed to set report state', e)
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'general.generic_error_message',
messageArgs: [e.message],
timeout: 5000
})
this.reports[id].state = oldState
})
},
addReport (report) {
this.reports[report.id] = report
}
}
})
diff --git a/tools/emoji_merger.js b/tools/emoji_merger.js
index 78a798dfe3..4d30396127 100644
--- a/tools/emoji_merger.js
+++ b/tools/emoji_merger.js
@@ -1,67 +1,67 @@
/*
Emoji merger script, quick hack of a tool to:
- update some missing emoji from an external source
- sort the emoji
- remove all multipart emoji (reactions don't allow them)
Merges emoji from here: https://gist.github.com/oliveratgithub/0bf11a9aff0d6da7b46f1490f86a71eb
to the simpler format we're using.
*/
// Existing emojis we have
-const oldEmojiFilename = '../static/emoji.json'
+const oldEmojiFilename = '../src/assets/emoji.json'
// The file downloaded from https://gist.github.com/oliveratgithub/0bf11a9aff0d6da7b46f1490f86a71eb
const newEmojiFilename = 'emojis.json'
// Output, replace the static/emoji.json with this file if it looks correct
const outputFilename = 'output.json'
const run = () => {
const fs = require('fs')
let newEmojisObject = {}
let emojisObject = {}
let data = fs.readFileSync(newEmojiFilename, 'utf8')
// First filter out anything that's more than one codepoint
const newEmojis = JSON.parse(data).emojis.filter(e => e.emoji.length <= 2)
// Create a table with format { shortname: emoji }, remove the :
newEmojis.forEach(e => {
const name = e.shortname.slice(1, e.shortname.length - 1).toLowerCase()
if (name.length > 0) {
newEmojisObject[name] = e.emoji
}
})
data = fs.readFileSync(oldEmojiFilename, 'utf8')
emojisObject = JSON.parse(data)
// Get rid of longer emojis that don't play nice with reactions
Object.keys(emojisObject).forEach(e => {
if (emojisObject[e].length > 2) emojisObject[e] = undefined
})
// Add new emojis from the new tables to the old table
Object.keys(newEmojisObject).forEach(e => {
if (!emojisObject[e] && newEmojisObject[e].length <= 2) {
emojisObject[e] = newEmojisObject[e]
}
})
// Sort by key
const sorted = Object.keys(emojisObject).sort().reduce((acc, key) => {
if (key.length === 0) return acc
acc[key] = emojisObject[key]
return acc
}, {})
fs.writeFile(outputFilename, JSON.stringify(sorted, null, 2), 'utf8', (err) => {
if (err) console.error('Error writing file', err)
})
}
run()

File Metadata

Mime Type
text/x-diff
Expires
Wed, Oct 14, 6:51 AM (1 d, 20 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1787037
Default Alt Text
(188 KB)

Event Timeline