Merge remote-tracking branch 'origin/develop' into from/develop/tusooa/sw-cache-assets

This commit is contained in:
Henry Jameson 2025-06-25 15:40:56 +03:00
commit a1f43234cd
235 changed files with 6354 additions and 4065 deletions

View file

@ -56,7 +56,7 @@
.post-textarea {
resize: vertical;
height: 10em;
overflow: none;
overflow: visible;
box-sizing: content-box;
}
}

View file

@ -177,7 +177,8 @@
.text {
flex: 2;
margin: 8px;
word-break: break-all;
overflow-wrap: break-word;
text-wrap: pretty;
h1 {
font-size: 1rem;

View file

@ -15,7 +15,9 @@ export default {
},
{
component: 'Button',
parent: { component: 'Attachment' },
parent: {
component: 'Attachment'
},
directives: {
background: '#FFFFFF',
opacity: 0.5

View file

@ -0,0 +1,18 @@
import Timeline from '../timeline/timeline.vue'
const BubbleTimeline = {
components: {
Timeline
},
computed: {
timeline () { return this.$store.state.statuses.timelines.bubble }
},
created () {
this.$store.dispatch('startFetchingTimeline', { timeline: 'bubble' })
},
unmounted () {
this.$store.dispatch('stopFetchingTimeline', 'bubble')
}
}
export default BubbleTimeline

View file

@ -0,0 +1,9 @@
<template>
<Timeline
:title="$t('nav.bubble')"
:timeline="timeline"
:timeline-name="'bubble'"
/>
</template>
<script src="./bubble_timeline.js"></script>

View file

@ -9,9 +9,9 @@ export default {
// 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',
focused: ':focus-within',
pressed: ':focus:active',
hover: ':hover:not(:disabled)',
hover: ':is(:hover, :focus-visible):not(:disabled)',
disabled: ':disabled'
},
// Variants are mutually exclusive, each component implicitly has "normal" variant, and all other variants inherit from it.
@ -89,6 +89,13 @@ export default {
shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel']
}
},
{
state: ['toggled', 'focused'],
directives: {
background: '--accent,-24.2',
shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel']
}
},
{
state: ['toggled', 'disabled'],
directives: {
@ -99,7 +106,7 @@ export default {
{
state: ['disabled'],
directives: {
background: '$blend(--accent 0.25 --parent)',
background: '$blend(--inheritedBackground 0.25 --parent)',
shadow: ['--buttonDefaultBevel']
}
},

View file

@ -6,8 +6,8 @@ export default {
states: {
toggled: '.toggled',
disabled: ':disabled',
hover: ':hover:not(:disabled)',
focused: ':focus-within'
hover: ':is(:hover, :focus-visible):not(:disabled)',
focused: ':focus-within:not(:is(:focus-visible))'
},
validInnerComponents: [
'Text',

View file

@ -17,7 +17,6 @@
width: 100%;
box-sizing: border-box;
overflow: hidden;
word-wrap: break-word;
}
.heading {

View file

@ -107,8 +107,7 @@
.outgoing {
display: flex;
flex-flow: row wrap;
align-content: end;
justify-content: flex-end;
place-content: end flex-end;
.chat-message-inner {
align-items: flex-end;

View file

@ -39,7 +39,6 @@
text-overflow: ellipsis;
white-space: nowrap;
display: inline;
word-wrap: break-word;
overflow: hidden;
}

View file

@ -1,7 +1,7 @@
<template>
<label
class="checkbox"
:class="{ disabled, indeterminate, 'indeterminate-fix': indeterminateTransitionFix }"
:class="[{ disabled, indeterminate, 'indeterminate-fix': indeterminateTransitionFix }, radio ? '-radio' : '-checkbox']"
>
<span
v-if="!!$slots.before"
@ -19,9 +19,9 @@
@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
@ -37,6 +37,7 @@
<script>
export default {
props: [
'radio',
'modelValue',
'indeterminate',
'disabled'
@ -107,6 +108,19 @@ export default {
box-sizing: border-box;
}
&.-radio {
.checkbox-indicator {
&,
&::before {
border-radius: 9999px;
}
&::before {
content: "•";
}
}
}
.disabled {
.checkbox-indicator::before {
background-color: var(--background);

View file

@ -190,21 +190,16 @@ export default {
.header {
grid-area: header;
justify-self: center;
align-self: baseline;
place-self: baseline center;
line-height: 2;
}
.invalid-container {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
inset: 0;
display: grid;
align-items: center;
justify-items: center;
background-color: rgba(100 0 0 / 50%);
place-items: center center;
background-color: rgb(100 0 0 / 50%);
.alert {
padding: 0.5em 1em;
@ -214,7 +209,7 @@ export default {
.assists {
grid-area: assists;
display: grid;
grid-auto-flow: rows;
grid-auto-flow: row;
grid-auto-rows: 2em;
grid-gap: 0.5em;
}
@ -266,14 +261,14 @@ export default {
.preview-window {
--__grid-color1: rgb(102 102 102);
--__grid-color2: rgb(153 153 153);
--__grid-color1-disabled: rgba(102 102 102 / 20%);
--__grid-color2-disabled: rgba(153 153 153 / 20%);
--__grid-color1-disabled: rgb(102 102 102 / 20%);
--__grid-color2-disabled: rgb(153 153 153 / 20%);
&.-light-grid {
--__grid-color1: rgb(205 205 205);
--__grid-color2: rgb(255 255 255);
--__grid-color1-disabled: rgba(205 205 205 / 20%);
--__grid-color2-disabled: rgba(255 255 255 / 20%);
--__grid-color1-disabled: rgb(205 205 205 / 20%);
--__grid-color2-disabled: rgb(255 255 255 / 20%);
}
position: relative;

View file

@ -339,11 +339,6 @@ const conversation = {
canDive () {
return this.isTreeView && this.isExpanded
},
focused () {
return (id) => {
return (this.isExpanded) && id === this.highlight
}
},
maybeHighlight () {
return this.isExpanded ? this.highlight : null
},
@ -406,6 +401,9 @@ const conversation = {
})
}
},
isFocused (id) {
return (this.isExpanded) && id === this.highlight
},
getReplies (id) {
return this.replies[id] || []
},

View file

@ -94,7 +94,7 @@
:statusoid="status"
:expandable="!isExpanded"
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:focused="focused(status.id)"
:focused="isFocused(status.id)"
:in-conversation="isExpanded"
:highlight="getHighlight()"
:replies="getReplies(status.id)"
@ -168,7 +168,7 @@
:pinned-status-ids-object="pinnedStatusIdsObject"
:profile-user-id="profileUserId"
:focused="focused"
:is-focused-function="isFocused"
:get-replies="getReplies"
:highlight="maybeHighlight"
:set-highlight="setHighlight"
@ -199,7 +199,7 @@
:statusoid="status"
:expandable="!isExpanded"
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:focused="focused(status.id)"
:focused="isFocused(status.id)"
:in-conversation="isExpanded"
:highlight="getHighlight()"
:replies="getReplies(status.id)"
@ -322,10 +322,7 @@
content: "";
display: block;
position: absolute;
top: calc(var(--___margin) * -1);
bottom: calc(var(--___margin) * -1);
left: calc(var(--___margin) * -1);
right: calc(var(--___margin) * -1);
inset: calc(var(--___margin) * -1);
background: var(--background);
backdrop-filter: var(--__panel-backdrop-filter);
}

View file

@ -59,7 +59,7 @@
transition-timing-function: ease-out;
transition-duration: 100ms;
@media all and (min-width: 800px) {
@media all and (width >= 800px) {
/* stylelint-disable-next-line declaration-no-important */
opacity: 1 !important;
}
@ -70,10 +70,7 @@
mask-size: contain;
background-color: var(--text);
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
inset: 0;
}
img {

View file

@ -29,14 +29,11 @@
// TODO: unify with other modals.
.dark-overlay {
&::before {
bottom: 0;
inset: 0;
content: " ";
display: block;
cursor: default;
left: 0;
position: fixed;
right: 0;
top: 0;
background: rgb(27 31 35 / 50%);
z-index: 2000;
}
@ -45,13 +42,9 @@
.dialog-container {
display: grid;
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
inset: 0;
justify-content: center;
align-items: center;
justify-items: center;
place-items: center center;
}
.dialog-modal.panel {
@ -98,8 +91,7 @@
#modal.-mobile {
.dialog-container {
justify-content: stretch;
align-items: end;
justify-items: stretch;
place-items: end stretch;
&.-center-mobile {
align-items: center;
@ -114,7 +106,6 @@
flex-direction: column;
justify-content: flex-end;
grid-template-columns: 1fr;
grid-auto-columns: none;
grid-auto-rows: auto;
grid-auto-flow: row dense;

View file

@ -127,7 +127,6 @@
max-width: 100%;
p {
word-wrap: break-word;
white-space: normal;
overflow-x: hidden;
}
@ -135,8 +134,7 @@
.poll-indicator-container {
border-radius: var(--roundness);
display: grid;
justify-items: center;
align-items: center;
place-items: center center;
align-self: start;
height: 0;
padding-bottom: 62.5%;
@ -147,13 +145,9 @@
box-sizing: border-box;
border: 1px solid var(--border);
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
inset: 0;
display: grid;
justify-items: center;
align-items: center;
place-items: center center;
width: 100%;
height: 100%;
}

View file

@ -55,7 +55,7 @@
ref="suggestorPopover"
class="autocomplete-panel"
placement="bottom"
:trigger-attrs="{ 'aria-hidden': true }"
:hide-trigger="true"
>
<template #content>
<div
@ -159,10 +159,7 @@
opacity: 0;
pointer-events: none;
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
inset: 0;
overflow: hidden;
/* DEBUG STUFF */

View file

@ -64,8 +64,7 @@
flex-grow: 1;
display: flex;
flex-flow: row nowrap;
overflow-x: auto;
overflow-y: hidden;
overflow: auto hidden;
}
.additional-tabs {
@ -153,7 +152,13 @@
transition: mask-size 150ms;
mask-size: 100% 20px, 100% 20px, auto;
// Autoprefixed seem to ignore this one, and also syntax is different
/* stylelint-disable mask-composite */
/* stylelint-disable declaration-property-value-no-unknown */
/* TODO check if this is still needed */
mask-composite: xor;
/* stylelint-enable declaration-property-value-no-unknown */
/* stylelint-enable mask-composite */
mask-composite: exclude;
&.scrolled {
@ -197,8 +202,7 @@
&-group {
display: grid;
grid-template-columns: repeat(var(--__amount), 1fr);
align-items: center;
justify-items: center;
place-items: center center;
justify-content: center;
grid-template-rows: repeat(1, auto);

View file

@ -3,7 +3,7 @@
ref="popover"
trigger="click"
popover-class="emoji-picker popover-default"
:trigger-attrs="{ 'aria-hidden': true, tabindex: -1 }"
:hide-trigger="true"
@show="onPopoverShown"
@close="onPopoverClosed"
>

View file

@ -84,8 +84,6 @@ const EmojiReactions = {
counterTriggerAttrs (reaction) {
return {
class: [
'btn',
'button-default',
'emoji-reaction-count-button',
{
'-picked-reaction': this.reactedWith(reaction.name),

View file

@ -52,6 +52,7 @@
<UserListPopover
:users="accountsForEmoji[reaction.name]"
class="emoji-reaction-popover"
:normal-button="true"
:trigger-attrs="counterTriggerAttrs(reaction)"
@show="fetchEmojiReactionsByIfMissing()"
>

View file

@ -72,12 +72,11 @@
flex: 1 1 0;
line-height: 1.2;
white-space: normal;
word-wrap: normal;
}
.hidden {
display: none;
visibility: "hidden";
visibility: hidden;
}
}
</style>

View file

@ -101,10 +101,7 @@
.gallery-row-inner {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
inset: 0;
display: flex;
flex-flow: row wrap;
align-content: stretch;
@ -160,7 +157,13 @@
linear-gradient(to top, white, white);
/* Autoprefixed seem to ignore this one, and also syntax is different */
/* stylelint-disable mask-composite */
/* stylelint-disable declaration-property-value-no-unknown */
/* TODO check if this is still needed */
mask-composite: xor;
/* stylelint-enable declaration-property-value-no-unknown */
/* stylelint-enable mask-composite */
mask-composite: exclude;
}
}

View file

@ -1,5 +1,4 @@
import Cropper from 'cropperjs'
import 'cropperjs/dist/cropper.css'
import 'cropperjs' // This adds all of the cropperjs's components into DOM
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faCircleNotch
@ -19,19 +18,6 @@ const ImageCropper = {
type: Function,
required: true
},
cropperOptions: {
type: Object,
default () {
return {
aspectRatio: 1,
autoCropArea: 1,
viewMode: 1,
movable: false,
zoomable: false,
guides: false
}
}
},
mimes: {
type: String,
default: 'image/png, image/gif, image/jpeg, image/bmp, image/x-icon'
@ -48,7 +34,6 @@ const ImageCropper = {
},
data () {
return {
cropper: undefined,
dataUrl: undefined,
filename: undefined,
submitting: false
@ -67,27 +52,30 @@ const ImageCropper = {
},
methods: {
destroy () {
if (this.cropper) {
this.cropper.destroy()
}
this.$refs.input.value = ''
this.dataUrl = undefined
this.$emit('close')
},
submit (cropping = true) {
this.submitting = true
this.submitHandler(cropping && this.cropper, this.file)
.then(() => this.destroy())
.finally(() => {
this.submitting = false
})
let cropperPromise
if (cropping) {
cropperPromise = this.$refs.cropperSelection.$toCanvas()
} else {
cropperPromise = Promise.resolve()
}
cropperPromise.then(canvas => {
this.submitHandler(canvas, this.file)
.then(() => this.destroy())
.finally(() => {
this.submitting = false
})
})
},
pickImage () {
this.$refs.input.click()
},
createCropper () {
this.cropper = new Cropper(this.$refs.img, this.cropperOptions)
},
getTriggerDOM () {
return typeof this.trigger === 'object' ? this.trigger : document.querySelector(this.trigger)
},
@ -103,6 +91,29 @@ const ImageCropper = {
reader.readAsDataURL(this.file)
this.$emit('changed', this.file, reader)
}
},
inSelection(selection, maxSelection) {
return (
selection.x >= maxSelection.x
&& selection.y >= maxSelection.y
&& (selection.x + selection.width) <= (maxSelection.x + maxSelection.width)
&& (selection.y + selection.height) <= (maxSelection.y + maxSelection.height)
)
},
onCropperSelectionChange(event) {
const cropperCanvas = this.$refs.cropperCanvas
const cropperCanvasRect = cropperCanvas.getBoundingClientRect()
const selection = event.detail
const maxSelection = {
x: 0,
y: 0,
width: cropperCanvasRect.width,
height: cropperCanvasRect.height,
}
if (!this.inSelection(selection, maxSelection)) {
event.preventDefault();
}
}
},
mounted () {

View file

@ -1,14 +1,52 @@
<template>
<div class="image-cropper">
<div v-if="dataUrl">
<div class="image-cropper-image-container">
<img
ref="img"
<cropper-canvas
ref="cropperCanvas"
background
class="image-cropper-canvas"
height="25em"
>
<cropper-image
ref="cropperImage"
:src="dataUrl"
alt=""
@load.stop="createCropper"
alt="Picture"
class="image-cropper-image"
translatable
scalable
/>
<cropper-shade hidden />
<cropper-handle
action="select"
plain
/>
<cropper-selection
ref="cropperSelection"
initial-coverage="1"
aspect-ratio="1"
movable
resizable
@change="onCropperSelectionChange"
>
</div>
<cropper-grid
role="grid"
covered
/>
<cropper-crosshair centered />
<cropper-handle
action="move"
theme-color="rgba(255, 255, 255, 0.35)"
/>
<cropper-handle action="n-resize" />
<cropper-handle action="e-resize" />
<cropper-handle action="s-resize" />
<cropper-handle action="w-resize" />
<cropper-handle action="ne-resize" />
<cropper-handle action="nw-resize" />
<cropper-handle action="se-resize" />
<cropper-handle action="sw-resize" />
</cropper-selection>
</cropper-canvas>
<div class="image-cropper-buttons-wrapper">
<button
class="button-default btn"
@ -55,20 +93,18 @@
display: none;
}
&-image-container {
position: relative;
img {
display: block;
max-width: 100%;
}
&-canvas {
height: 25em;
width: 25em;
}
&-buttons-wrapper {
margin-top: 10px;
display: grid;
grid-gap: 0.5em;
grid-template-columns: 1fr 1fr 1fr;
button {
margin-top: 5px;
margin-top: 1em;
}
}
}

View file

@ -2,7 +2,7 @@ export default {
name: 'Input',
selector: '.input',
states: {
hover: ':hover:not(.disabled)',
hover: ':is(:hover, :focus-visible):not(.disabled)',
focused: ':focus-within',
disabled: '.disabled'
},

View file

@ -68,10 +68,13 @@
margin: 0.5em 0 0;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
overflow-wrap: break-word;
text-wrap: pretty;
line-height: 1.2em;
// cap description at 3 lines, the 1px is to clean up some stray pixels
// TODO: fancier fade-out at the bottom to show off that it's too long?
/* cap description at 3 lines, the 1px is to clean up some stray pixels
TODO: fancier fade-out at the bottom to show off that it's too long?
*/
max-height: calc(1.2em * 3 - 1px);
}

View file

@ -3,7 +3,7 @@ export default {
selector: '.list-item',
states: {
active: '.-active',
hover: ':hover:not(.-non-interactive)'
hover: ':is(:hover, :focus-visible, :has(:focus-visible)):not(.-non-interactive)'
},
validInnerComponents: [
'Text',

View file

@ -1,5 +1,7 @@
import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'
import { mapStores } from 'pinia'
import oauthApi from '../../services/new_api/oauth.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faTimes
@ -17,11 +19,11 @@ const LoginForm = {
computed: {
isPasswordAuth () { return this.requiredPassword },
isTokenAuth () { return this.requiredToken },
...mapStores(useOAuthStore),
...mapState({
registrationOpen: state => state.instance.registrationOpen,
instance: state => state.instance,
loggingIn: state => state.users.loggingIn,
oauth: state => state.oauth
}),
...mapGetters(
'authFlow', ['requiredPassword', 'requiredToken', 'requiredMFA']
@ -34,32 +36,37 @@ const LoginForm = {
this.isTokenAuth ? this.submitToken() : this.submitPassword()
},
submitToken () {
const { clientId, clientSecret } = this.oauth
const data = {
clientId,
clientSecret,
instance: this.instance.server,
commit: this.$store.commit
}
oauthApi.getOrCreateApp(data)
.then((app) => { oauthApi.login({ ...app, ...data }) })
// NOTE: we do not really need the app token, but obtaining a token and
// calling verify_credentials is the only way to ensure the app still works.
this.oauthStore.ensureAppToken()
.then(() => {
const app = {
clientId: this.oauthStore.clientId,
clientSecret: this.oauthStore.clientSecret,
}
oauthApi.login({ ...app, ...data })
})
},
submitPassword () {
const { clientId } = this.oauth
const data = {
clientId,
oauth: this.oauth,
instance: this.instance.server,
commit: this.$store.commit
}
this.error = false
oauthApi.getOrCreateApp(data).then((app) => {
// NOTE: we do not really need the app token, but obtaining a token and
// calling verify_credentials is the only way to ensure the app still works.
this.oauthStore.ensureAppToken().then(() => {
const app = {
clientId: this.oauthStore.clientId,
clientSecret: this.oauthStore.clientSecret,
}
oauthApi.getTokenWithCredentials(
{
...app,
instance: data.instance,
instance: this.instance.server,
username: this.user.username,
password: this.user.password
}

View file

@ -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;
}
}

View file

@ -1,5 +1,5 @@
<template>
<div class="login panel panel-default">
<div class="login-panel panel panel-default">
<!-- Default panel contents -->
<div class="panel-heading">
@ -70,14 +70,13 @@
</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"
@ -94,57 +93,4 @@
<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" />

View file

@ -170,7 +170,8 @@ $modal-view-button-icon-margin: 0.5em;
min-height: 1em;
max-width: 500px;
max-height: 9.5em;
word-break: break-all;
overflow-wrap: break-word;
text-wrap: pretty;
}
.modal-image {

View file

@ -27,7 +27,6 @@
top: 100%;
left: 0;
height: 100%;
word-wrap: normal;
white-space: nowrap;
transition: opacity 0.2s ease;
z-index: 1;

View file

@ -1,5 +1,6 @@
.MentionsLine {
word-break: break-all;
overflow-wrap: break-word;
text-wrap: pretty;
.mention-link:not(:first-child)::before {
content: " ";

View file

@ -11,7 +11,7 @@ export default {
'Avatar'
],
states: {
hover: ':hover:not(.disabled)',
hover: ':is(:hover, :focus-visible, :has(:focus-visible)):not(.disabled)',
active: '.-active',
disabled: '.disabled'
},

View file

@ -1,5 +1,7 @@
import mfaApi from '../../services/new_api/mfa.js'
import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'
import { mapStores } from 'pinia'
import { useOAuthStore } from 'src/stores/oauth.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faTimes
@ -18,17 +20,24 @@ export default {
...mapGetters({
authSettings: 'authFlow/settings'
}),
...mapStores(useOAuthStore),
...mapState({
instance: 'instance',
oauth: 'oauth'
})
},
methods: {
...mapMutations('authFlow', ['requireTOTP', 'abortMFA']),
...mapActions({ login: 'authFlow/login' }),
clearError () { this.error = false },
focusOnCodeInput () {
const codeInput = this.$refs.codeInput
codeInput.focus()
codeInput.setSelectionRange(0, codeInput.value.length)
},
submit () {
const { clientId, clientSecret } = this.oauth
const { clientId, clientSecret } = this.oauthStore
const data = {
clientId,
@ -42,6 +51,7 @@ export default {
if (result.error) {
this.error = result.error
this.code = null
this.focusOnCodeInput()
return
}

View file

@ -1,5 +1,5 @@
<template>
<div class="login panel panel-default">
<div class="login-panel panel panel-default">
<!-- Default panel contents -->
<div class="panel-heading">
@ -17,6 +17,7 @@
<label for="code">{{ $t('login.recovery_code') }}</label>
<input
id="code"
ref="codeInput"
v-model="code"
class="input form-control"
>
@ -71,4 +72,5 @@
</div>
</div>
</template>
<script src="./recovery_form.js"></script>

View file

@ -1,5 +1,7 @@
import mfaApi from '../../services/new_api/mfa.js'
import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'
import { mapStores } from 'pinia'
import { useOAuthStore } from 'src/stores/oauth.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faTimes
@ -18,17 +20,24 @@ export default {
...mapGetters({
authSettings: 'authFlow/settings'
}),
...mapStores(useOAuthStore),
...mapState({
instance: 'instance',
oauth: 'oauth'
})
},
methods: {
...mapMutations('authFlow', ['requireRecovery', 'abortMFA']),
...mapActions({ login: 'authFlow/login' }),
clearError () { this.error = false },
focusOnCodeInput () {
const codeInput = this.$refs.codeInput
codeInput.focus()
codeInput.setSelectionRange(0, codeInput.value.length)
},
submit () {
const { clientId, clientSecret } = this.oauth
const { clientId, clientSecret } = this.oauthStore
const data = {
clientId,
@ -42,6 +51,7 @@ export default {
if (result.error) {
this.error = result.error
this.code = null
this.focusOnCodeInput()
return
}

View file

@ -1,5 +1,5 @@
<template>
<div class="login panel panel-default">
<div class="login-panel panel panel-default">
<!-- Default panel contents -->
<div class="panel-heading">
@ -19,6 +19,7 @@
</label>
<input
id="code"
ref="codeInput"
v-model="code"
class="input form-control"
>
@ -74,4 +75,5 @@
</div>
</div>
</template>
<script src="./totp_form.js"></script>

View file

@ -1,14 +1,19 @@
import SideDrawer from '../side_drawer/side_drawer.vue'
import Notifications from '../notifications/notifications.vue'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import GestureService from '../../services/gesture_service/gesture_service'
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
import {
unseenNotificationsFromStore,
countExtraNotifications
} from '../../services/notification_utils/notification_utils'
import GestureService from '../../services/gesture_service/gesture_service'
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
import { mapGetters } from 'vuex'
import { mapState } from 'pinia'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faTimes,
@ -18,7 +23,6 @@ import {
faMinus,
faCheckDouble
} from '@fortawesome/free-solid-svg-icons'
import { useAnnouncementsStore } from 'src/stores/announcements'
library.add(
faTimes,
@ -71,10 +75,9 @@ const MobileNav = {
return this.$route.name === 'chat'
},
...mapState(useAnnouncementsStore, ['unreadAnnouncementCount']),
...mapGetters(['unreadChatCount']),
chatsPinned () {
return new Set(this.$store.state.serverSideStorage.prefsStorage.collections.pinnedNavItems).has('chats')
},
...mapState(useServerSideStorageStore, {
pinnedItems: store => new Set(store.prefsStorage.collections.pinnedNavItems).has('chats')
}),
shouldConfirmLogout () {
return this.$store.getters.mergedConfig.modalOnLogout
},

View file

@ -220,8 +220,7 @@
margin-top: 3.5em;
width: 100vw;
height: calc(100vh - var(--navbar-height));
overflow-x: hidden;
overflow-y: scroll;
overflow: hidden scroll;
.notifications {
padding: 0;

View file

@ -42,7 +42,7 @@
}
}
@media all and (min-width: 801px) {
@media all and (width >= 801px) {
.new-status-button:not(.always-show) {
display: none;
}

View file

@ -41,10 +41,7 @@ export default {
.modal-view {
z-index: var(--ZI_modals);
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
inset: 0;
display: flex;
justify-content: center;
align-items: center;

View file

@ -238,4 +238,4 @@
<script src="./mrf_transparency_panel.js"></script>
<style src="./mrf_transparency_panel.scss" lang="scss"/>
<style src="./mrf_transparency_panel.scss" lang="scss" />

View file

@ -7,12 +7,15 @@ import { filterNavigation } from 'src/components/navigation/filter.js'
import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faUsers,
faGlobe,
faCity,
faBookmark,
faEnvelope,
faChevronDown,
@ -29,6 +32,7 @@ import {
library.add(
faUsers,
faGlobe,
faCity,
faBookmark,
faEnvelope,
faChevronDown,
@ -76,19 +80,19 @@ const NavPanel = {
this.editMode = !this.editMode
},
toggleCollapse () {
this.$store.commit('setPreference', { path: 'simple.collapseNav', value: !this.collapsed })
this.$store.dispatch('pushServerSideStorage')
useServerSideStorageStore().setPreference({ path: 'simple.collapseNav', value: !this.collapsed })
useServerSideStorageStore().pushServerSideStorage()
},
isPinned (item) {
return this.pinnedItems.has(item)
},
togglePin (item) {
if (this.isPinned(item)) {
this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedNavItems', value: item })
useServerSideStorageStore().removeCollectionPreference({ path: 'collections.pinnedNavItems', value: item })
} else {
this.$store.commit('addCollectionPreference', { path: 'collections.pinnedNavItems', value: item })
useServerSideStorageStore().addCollectionPreference({ path: 'collections.pinnedNavItems', value: item })
}
this.$store.dispatch('pushServerSideStorage')
useServerSideStorageStore().pushServerSideStorage()
}
},
computed: {
@ -96,20 +100,25 @@ const NavPanel = {
unreadAnnouncementCount: 'unreadAnnouncementCount',
supportsAnnouncements: store => store.supportsAnnouncements
}),
...mapPiniaState(useServerSideStorageStore, {
collapsed: store => store.prefsStorage.simple.collapseNav,
pinnedItems: store => new Set(store.prefsStorage.collections.pinnedNavItems)
}),
...mapState({
currentUser: state => state.users.currentUser,
followRequestCount: state => state.api.followRequests.length,
privateMode: state => state.instance.private,
federating: state => state.instance.federating,
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable,
pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems),
collapsed: state => state.serverSideStorage.prefsStorage.simple.collapseNav,
bookmarkFolders: state => state.instance.pleromaBookmarkFoldersAvailable
bookmarkFolders: state => state.instance.pleromaBookmarkFoldersAvailable,
bubbleTimeline: state => state.instance.localBubbleInstances.length > 0
}),
timelinesItems () {
return filterNavigation(
Object
.entries({ ...TIMELINES })
// do not show in timeliens list since it's in a better place now
.filter(([key]) => key !== 'bookmarks')
.map(([k, v]) => ({ ...v, name: k })),
{
hasChats: this.pleromaChatMessagesAvailable,
@ -117,6 +126,7 @@ const NavPanel = {
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser,
supportsBubbleTimeline: this.bubbleTimeline,
supportsBookmarkFolders: this.bookmarkFolders
}
)
@ -132,6 +142,7 @@ const NavPanel = {
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser,
supportsBubbleTimeline: this.bubbleTimeline,
supportsBookmarkFolders: this.bookmarkFolders
}
)

View file

@ -1,4 +1,12 @@
export const filterNavigation = (list = [], { hasChats, hasAnnouncements, isFederating, isPrivate, currentUser, supportsBookmarkFolders }) => {
export const filterNavigation = (list = [], {
hasChats,
hasAnnouncements,
isFederating,
isPrivate,
currentUser,
supportsBookmarkFolders,
supportsBubbleTimeline
}) => {
return list.filter(({ criteria, anon, anonRoute }) => {
const set = new Set(criteria || [])
if (!isFederating && set.has('federating')) return false
@ -7,6 +15,8 @@ export const filterNavigation = (list = [], { hasChats, hasAnnouncements, isFede
if ((!currentUser || !currentUser.locked) && set.has('lockedUser')) return false
if (!hasChats && set.has('chats')) return false
if (!hasAnnouncements && set.has('announcements')) return false
if (!supportsBubbleTimeline && set.has('supportsBubbleTimeline')) return false
if (!supportsBookmarkFolders && set.has('supportsBookmarkFolders')) return false
if (supportsBookmarkFolders && set.has('!supportsBookmarkFolders')) return false
return true
})
@ -19,11 +29,11 @@ export const getListEntries = store => store.allLists.map(list => ({
iconLetter: list.title[0]
}))
export const getBookmarkFolderEntries = store => store.allFolders.map(folder => ({
export const getBookmarkFolderEntries = store => store.allFolders ? store.allFolders.map(folder => ({
name: 'bookmark-folder-' + folder.id,
routeObject: { name: 'bookmark-folder', params: { id: folder.id } },
labelRaw: folder.name,
iconEmoji: folder.emoji,
iconEmojiUrl: folder.emoji_url,
iconLetter: folder.name[0]
}))
})) : []

View file

@ -27,6 +27,13 @@ export const TIMELINES = {
label: 'nav.public_tl',
criteria: ['!private']
},
bubble: {
route: 'bubble',
anon: true,
icon: 'city',
label: 'nav.bubble',
criteria: ['!private', 'federating', 'supportsBubbleTimeline']
},
twkn: {
route: 'public-external-timeline',
anon: true,
@ -34,11 +41,11 @@ export const TIMELINES = {
label: 'nav.twkn',
criteria: ['!private', 'federating']
},
// bookmarks are still technically a timeline so we should show it in the dropdown
bookmarks: {
route: 'bookmarks',
icon: 'bookmark',
label: 'nav.bookmarks',
criteria: ['!supportsBookmarkFolders']
},
favorites: {
routeObject: { name: 'user-profile', query: { tab: 'favorites' } },
@ -53,6 +60,15 @@ export const TIMELINES = {
}
export const ROOT_ITEMS = {
bookmarks: {
route: 'bookmarks',
icon: 'bookmark',
label: 'nav.bookmarks',
// shows bookmarks entry in a better suited location
// hides it when bookmark folders are supported since
// we show custom component instead of it
criteria: ['!supportsBookmarkFolders']
},
interactions: {
route: 'interactions',
icon: 'bell',

View file

@ -3,8 +3,10 @@ import { routeTo } from 'src/components/navigation/navigation.js'
import OptionalRouterLink from 'src/components/optional_router_link/optional_router_link.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faThumbtack } from '@fortawesome/free-solid-svg-icons'
import { mapStores } from 'pinia'
import { mapStores, mapState as mapPiniaState } from 'pinia'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
library.add(faThumbtack)
@ -19,11 +21,11 @@ const NavigationEntry = {
},
togglePin (value) {
if (this.isPinned(value)) {
this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedNavItems', value })
useServerSideStorageStore().removeCollectionPreference({ path: 'collections.pinnedNavItems', value })
} else {
this.$store.commit('addCollectionPreference', { path: 'collections.pinnedNavItems', value })
useServerSideStorageStore().addCollectionPreference({ path: 'collections.pinnedNavItems', value })
}
this.$store.dispatch('pushServerSideStorage')
useServerSideStorageStore().pushServerSideStorage()
}
},
computed: {
@ -35,9 +37,11 @@ const NavigationEntry = {
},
...mapStores(useAnnouncementsStore),
...mapState({
currentUser: state => state.users.currentUser,
pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems)
})
currentUser: state => state.users.currentUser
}),
...mapPiniaState(useServerSideStorageStore, {
pinnedItems: store => new Set(store.prefsStorage.collections.pinnedNavItems)
}),
}
}

View file

@ -9,6 +9,7 @@ import { library } from '@fortawesome/fontawesome-svg-core'
import {
faUsers,
faGlobe,
faCity,
faBookmark,
faEnvelope,
faComments,
@ -20,10 +21,12 @@ import {
import { useListsStore } from 'src/stores/lists'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
library.add(
faUsers,
faGlobe,
faCity,
faBookmark,
faEnvelope,
faComments,
@ -54,7 +57,10 @@ const NavPanel = {
supportsAnnouncements: store => store.supportsAnnouncements
}),
...mapPiniaState(useBookmarkFoldersStore, {
bookmarks: getBookmarkFolderEntries
bookmarks: getBookmarkFolderEntries,
}),
...mapPiniaState(useServerSideStorageStore, {
pinnedItems: store => new Set(store.prefsStorage.collections.pinnedNavItems)
}),
...mapState({
currentUser: state => state.users.currentUser,
@ -62,7 +68,7 @@ const NavPanel = {
privateMode: state => state.instance.private,
federating: state => state.instance.federating,
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable,
pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems)
bubbleTimeline: state => state.instance.localBubbleInstances.length > 0
}),
pinnedList () {
if (!this.currentUser) {
@ -76,7 +82,9 @@ const NavPanel = {
hasAnnouncements: this.supportsAnnouncements,
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser
currentUser: this.currentUser,
supportsBubbleTimeline: this.bubbleTimeline,
supportsBookmarkFolders: this.bookmarks
})
}
return filterNavigation(
@ -95,6 +103,8 @@ const NavPanel = {
{
hasChats: this.pleromaChatMessagesAvailable,
hasAnnouncements: this.supportsAnnouncements,
supportsBubbleTimeline: this.bubbleTimeline,
supportsBookmarkFolders: this.bookmarks,
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser

View file

@ -2,8 +2,8 @@
.Notification {
border-bottom: 1px solid;
border-color: var(--border);
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
text-wrap: pretty;
&.Status {
/* stylelint-disable-next-line declaration-no-important */
@ -31,8 +31,6 @@
& .status-username,
& .mute-thread,
& .mute-words {
word-wrap: normal;
word-break: normal;
white-space: nowrap;
}

View file

@ -13,10 +13,7 @@
.notification-overlay {
position: absolute;
top: 0;
right: 0;
left: 0;
bottom: 0;
inset: 0;
pointer-events: none;
}
@ -131,7 +128,6 @@
.notification-details {
min-width: 0;
word-wrap: break-word;
line-height: var(--post-line-height);
position: relative;
overflow: hidden;
@ -164,7 +160,8 @@
}
h1 {
word-break: break-all;
overflow-wrap: break-word;
text-wrap: pretty;
margin: 0 0 0.3em;
padding: 0;
font-size: 1em;

View file

@ -1,10 +1,12 @@
import oauth from '../../services/new_api/oauth.js'
import { useOAuthStore } from 'src/stores/oauth.js'
const oac = {
props: ['code'],
mounted () {
if (this.code) {
const { clientId, clientSecret } = this.$store.state.oauth
const oauthStore = useOAuthStore()
const { clientId, clientSecret } = oauthStore
oauth.getToken({
clientId,
@ -12,7 +14,7 @@ const oac = {
instance: this.$store.state.instance.server,
code: this.code
}).then((result) => {
this.$store.commit('setToken', result.access_token)
oauthStore.setToken(result.access_token)
this.$store.dispatch('loginUser', result.access_token)
this.$router.push({ name: 'friends' })
})

View file

@ -1,7 +1,7 @@
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 {
@ -9,7 +9,8 @@ export default {
props: ['basePoll', 'emoji'],
components: {
Timeago,
RichContent
RichContent,
Checkbox
},
data () {
return {
@ -44,6 +45,13 @@ export default {
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
},
@ -78,26 +86,15 @@ export default {
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}`

View file

@ -0,0 +1,63 @@
.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;
overflow-wrap: break-word;
text-wrap: pretty;
}
.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;
}
}

View file

@ -37,36 +37,56 @@
: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"
@ -76,113 +96,10 @@
>
{{ $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" />

View file

@ -1,3 +1,4 @@
/* global process */
const Popover = {
name: 'Popover',
props: {
@ -48,6 +49,12 @@ const Popover = {
// Use styled button (to avoid nested buttons)
normalButton: Boolean,
// Whether to hide the trigger totally
hideTrigger: {
type: Boolean,
default: false,
},
triggerAttrs: {
type: Object,
default: {}
@ -79,6 +86,26 @@ const Popover = {
childrenShown: new Set()
}
},
computed: {
allTriggerAttrs () {
if (process.env.NODE_ENV === 'development') {
if ('aria-hidden' in this.triggerAttrs) {
throw new Error('Do not use aria-hidden in triggerAttrs. Instead set hideTrigger to true')
}
}
const attrs = {
...this.triggerAttrs,
}
if (this.hideTrigger) {
attrs['aria-hidden'] = true
attrs.tabindex = 1
}
return attrs
}
},
methods: {
setAnchorEl (el) {
this.anchorEl = el

View file

@ -15,11 +15,7 @@
&::after {
content: "";
position: absolute;
top: -1px;
bottom: -1px;
left: -1px;
right: -1px;
z-index: -1px;
inset: -1px;
box-shadow: var(--_shadow);
pointer-events: none;
}

View file

@ -9,7 +9,7 @@
class="popover-trigger-button"
:class="normalButton ? 'button-default btn' : 'button-unstyled'"
type="button"
v-bind="triggerAttrs"
v-bind="allTriggerAttrs"
@click="onClick"
>
<slot name="trigger" />

View file

@ -363,12 +363,6 @@ const PostStatusForm = {
}
},
safeToSaveDraft () {
console.log('safe', (
this.newStatus.status ||
this.newStatus.spoilerText ||
this.newStatus.files?.length ||
this.newStatus.hasPoll
) && this.saveable)
return (
this.newStatus.status ||
this.newStatus.spoilerText ||

View file

@ -205,6 +205,7 @@
min-height: calc(var(--post-line-height) * 1em);
resize: none;
background: transparent;
text-wrap: stable;
&.scrollable-form {
overflow-y: auto;

View file

@ -418,7 +418,7 @@
margin: 0.6em;
}
@media all and (max-width: 800px) {
@media all and (width <= 800px) {
.registration-form .container {
flex-direction: column-reverse;

View file

@ -0,0 +1,18 @@
const ScrollTopButton = {
props: {
fast: {
type: Boolean,
required: false,
default: false
}
},
methods: {
scrollToTop() {
const speed = this.fast ? 'instant' : 'smooth';
window.scrollTo({ top: 0, behavior: speed })
}
}
}
export default ScrollTopButton

View file

@ -0,0 +1,29 @@
<template>
<div class="rightside-button scroll-to-top">
<button
class="button-unstyled scroll-to-top-button"
type="button"
:title="$t('general.scroll_to_top')"
@click="scrollToTop"
>
<FALayers class="fa-scale-110 fa-old-padding-layer">
<FAIcon icon="arrow-up" />
<FAIcon
icon="minus"
transform="up-7"
/>
</FALayers>
</button>
</div>
</template>
<script src="./scroll_top_button.js"></script>
<style lang="scss">
.scroll-to-top {
display: none;
}
.-scrolled .scroll-to-top {
display: inline-block;
}
</style>

View file

@ -34,7 +34,7 @@ export default {
notEditable: true, // for now
states: {
pressed: ':active',
hover: ':hover:not(:disabled)',
hover: ':is(:hover, :focus-visible, :has(:focus-visible)):not(:disabled)',
disabled: ':disabled'
},
validInnerComponents: [

View file

@ -119,10 +119,13 @@
: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 }) }}
@ -131,7 +134,7 @@
{{ $t('search.people_talking', { count: lastHistoryRecord(hashtag).accounts }) }}
</span>
</div>
</div>
</router-link>
<div
v-if="lastHistoryRecord(hashtag)"
class="count"
@ -154,7 +157,7 @@
text-align: center;
}
@media all and (max-width: 800px) {
@media all and (width <= 800px) {
.search-nav-heading {
.tab-switcher .tabs .tab-wrapper {
display: block;
@ -199,10 +202,13 @@
.hashtag {
flex: 1 1 auto;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.name {
color: var(--link);
}
}
.count {

View file

@ -8,6 +8,7 @@ import Popover from 'components/popover/popover.vue'
import ConfirmModal from 'components/confirm_modal/confirm_modal.vue'
import ModifiedIndicator from '../helpers/modified_indicator.vue'
import EmojiEditingPopover from '../helpers/emoji_editing_popover.vue'
import { useInterfaceStore } from 'src/stores/interface'
const EmojiTab = {
components: {
@ -232,7 +233,7 @@ const EmojiTab = {
})
},
displayError (msg) {
this.$store.useInterfaceStore().pushGlobalNotice({
useInterfaceStore().pushGlobalNotice({
messageKey: 'admin_dash.emoji.error',
messageArgs: [msg],
level: 'error'

View file

@ -29,7 +29,7 @@
.emoji-list {
display: flex;
flex-wrap: wrap;
gap: 1em 1em;
gap: 1em;
}
}

View file

@ -5,6 +5,7 @@ import StringSetting from '../helpers/string_setting.vue'
import GroupSetting from '../helpers/group_setting.vue'
import Popover from 'src/components/popover/popover.vue'
import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
import { useInterfaceStore } from 'src/stores/interface'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import { library } from '@fortawesome/fontawesome-svg-core'
@ -80,7 +81,7 @@ const FrontendsTab = {
this.$store.dispatch('loadFrontendsStuff')
if (response.error) {
const reason = await response.error.json()
this.$store.useInterfaceStore().pushGlobalNotice({
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'admin_dash.frontend.failure_installing_frontend',
messageArgs: {
@ -90,7 +91,7 @@ const FrontendsTab = {
timeout: 5000
})
} else {
this.$store.useInterfaceStore().pushGlobalNotice({
useInterfaceStore().pushGlobalNotice({
level: 'success',
messageKey: 'admin_dash.frontend.success_installing_frontend',
messageArgs: {

View file

@ -13,15 +13,11 @@
// fix buttons showing through
z-index: 2;
opacity: 0.9;
top: 0;
bottom: 0;
left: 0;
right: 0;
inset: 0;
}
dd {
text-overflow: ellipsis;
word-wrap: nowrap;
white-space: nowrap;
overflow-x: hidden;
max-width: 10em;

View file

@ -0,0 +1,46 @@
<template>
<span class="HelpIndicator">
<Popover
trigger="click"
:trigger-attrs="{ 'aria-label': $t('settings.setting_changed') }"
>
<template #trigger>
&nbsp;
<FAIcon icon="circle-question" />
</template>
<template #content>
<div class="help-tooltip">
<slot />
</div>
</template>
</Popover>
</span>
</template>
<script>
import Popover from 'src/components/popover/popover.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleQuestion } from '@fortawesome/free-solid-svg-icons'
library.add(
faCircleQuestion
)
export default {
components: { Popover }
}
</script>
<style lang="scss">
.HelpIndicator {
display: inline-block;
position: relative;
}
.help-tooltip {
margin: 0.5em 1em;
min-width: 10em;
max-width: 30vw;
text-align: center;
}
</style>

View file

@ -78,7 +78,6 @@ export default {
},
computed: {
draft: {
// TODO allow passing shared draft object?
get () {
if (this.realSource === 'admin' || this.path == null) {
return get(this.$store.state.adminSettings.draft, this.canonPath)

View file

@ -46,7 +46,7 @@
max-width: 90vw;
height: 90vh;
@media all and (max-width: 800px) {
@media all and (width <= 800px) {
max-width: 100vw;
height: 100%;
}
@ -84,7 +84,7 @@
> li {
margin: 1em 0;
line-height: 1.5em;
vertical-align: center;
vertical-align: middle;
}
&.two-column {
@ -105,7 +105,7 @@
*/
transform: translateY(calc(((100vh - 100%) / 2 + 100%) - 50px));
@media all and (max-width: 800px) {
@media all and (width <= 800px) {
/* For mobile, the modal takes 100% of the available screen.
This ensures the minimized modal is always 50px above the browser bottom
bar regardless of whether or not it is visible.

View file

@ -22,7 +22,7 @@
<AppearanceTab />
</div>
<div
v-if="expertLevel > 0 && !isMobileLayout"
v-if="expertLevel > 0"
:label="$t('settings.style.themes3.editor.title')"
icon="palette"
data-tab-name="style"
@ -31,7 +31,7 @@
<StyleTab />
</div>
<div
v-if="expertLevel > 0 && !isMobileLayout"
v-if="expertLevel > 0"
:label="$t('settings.theme_old')"
icon="paint-brush"
data-tab-name="theme"

View file

@ -315,7 +315,7 @@ const AppearanceTab = {
},
onImportFailure (result) {
console.error('Failure importing theme:', result)
this.$store.useInterfaceStore().pushGlobalNotice({ messageKey: 'settings.invalid_theme_imported', level: 'error' })
useInterfaceStore().pushGlobalNotice({ messageKey: 'settings.invalid_theme_imported', level: 'error' })
},
importValidator (parsed, filename) {
if (filename.endsWith('.json')) {

View file

@ -26,8 +26,7 @@
.palettes-container {
height: 15em;
overflow-y: auto;
overflow-x: hidden;
overflow: hidden auto;
scrollbar-gutter: stable;
border-radius: var(--roundness);
border: 1px solid var(--border);
@ -112,8 +111,7 @@
flex-wrap: wrap;
margin: -0.5em 0;
height: 25em;
overflow-x: hidden;
overflow-y: auto;
overflow: hidden auto;
scrollbar-gutter: stable;
border-radius: var(--roundness);
border: 1px solid var(--border);

View file

@ -336,6 +336,15 @@
{{ $t('settings.show_scrollbars') }}
</BooleanSetting>
</li>
<li>
<UnitSetting
path="themeEditorMinWidth"
:units="['px', 'rem']"
expert="1"
>
{{ $t('settings.theme_editor_min_width') }}
</UnitSetting>
</li>
</ul>
</div>
<div class="setting-item">

View file

@ -2,6 +2,7 @@ import Importer from 'src/components/importer/importer.vue'
import Exporter from 'src/components/exporter/exporter.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import { mapState } from 'vuex'
import { useOAuthTokensStore } from 'src/stores/oauth_tokens'
const DataImportExportTab = {
data () {
@ -15,7 +16,7 @@ const DataImportExportTab = {
}
},
created () {
this.$store.dispatch('fetchTokens')
useOAuthTokensStore().fetchTokens()
this.fetchBackups()
},
components: {

View file

@ -1,48 +1,209 @@
import { filter, trim, debounce } from 'lodash'
import { cloneDeep } from 'lodash'
import { mapState, mapActions } from 'pinia'
import { v4 as uuidv4 } from 'uuid';
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import { useInterfaceStore } from 'src/stores/interface'
import {
newImporter,
newExporter
} from 'src/services/export_import/export_import.js'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import UnitSetting from '../helpers/unit_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import HelpIndicator from '../helpers/help_indicator.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Select from 'src/components/select/select.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
const SUPPORTED_TYPES = new Set(['word', 'regexp', 'user', 'user_regexp'])
const FilteringTab = {
data () {
return {
muteWordsStringLocal: this.$store.getters.mergedConfig.muteWords.join('\n'),
replyVisibilityOptions: ['all', 'following', 'self'].map(mode => ({
key: mode,
value: mode,
label: this.$t(`settings.reply_visibility_${mode}`)
}))
})),
muteFiltersDraftObject: cloneDeep(useServerSideStorageStore().prefsStorage.simple.muteFilters),
muteFiltersDraftDirty: Object.fromEntries(
Object.entries(
useServerSideStorageStore().prefsStorage.simple.muteFilters
).map(([k]) => [k, false])
),
exportedFilter: null,
filterImporter: newImporter({
validator (parsed) {
if (Array.isArray(parsed)) return false
if (!SUPPORTED_TYPES.has(parsed.type)) return false
return true
},
onImport: (data) => {
const {
enabled = true,
expires = null,
hide = false,
name = '',
value = ''
} = data
this.createFilter({
enabled,
expires,
hide,
name,
value
})
},
onImportFailure (result) {
console.error('Failure importing filter:', result)
useInterfaceStore()
.pushGlobalNotice({
messageKey: 'settings.filter.import_failure',
level: 'error'
})
}
}),
filterExporter: newExporter({
filename: 'pleromafe_mute-filter',
getExportedObject: () => this.exportedFilter
})
}
},
components: {
BooleanSetting,
ChoiceSetting,
UnitSetting,
IntegerSetting
IntegerSetting,
Checkbox,
Select,
HelpIndicator
},
computed: {
...SharedComputedObject(),
muteWordsString: {
get () {
return this.muteWordsStringLocal
},
set (value) {
this.muteWordsStringLocal = value
this.debouncedSetMuteWords(value)
...mapState(
useServerSideStorageStore,
{
muteFilters: store => Object.entries(store.prefsStorage.simple.muteFilters),
muteFiltersObject: store => store.prefsStorage.simple.muteFilters
}
),
muteFiltersDraft () {
return Object.entries(this.muteFiltersDraftObject)
},
debouncedSetMuteWords () {
return debounce((value) => {
this.$store.dispatch('setOption', {
name: 'muteWords',
value: filter(value.split('\n'), (word) => trim(word).length > 0)
})
}, 1000)
muteFiltersExpired () {
const now = Date.now()
return Object
.entries(this.muteFiltersDraftObject)
.filter(([, { expires }]) => expires != null && expires <= now)
}
},
methods: {
...mapActions(useServerSideStorageStore, ['setPreference', 'unsetPreference', 'pushServerSideStorage']),
getDatetimeLocal (timestamp) {
const date = new Date(timestamp)
let fmt = new Intl.NumberFormat("en-US", {minimumIntegerDigits: 2})
const datetime = [
date.getFullYear(),
'-',
fmt.format(date.getMonth() + 1),
'-',
fmt.format(date.getDate()),
'T',
fmt.format(date.getHours()),
':',
fmt.format(date.getMinutes())
].join('')
return datetime
},
checkRegexValid (id) {
const filter = this.muteFiltersObject[id]
if (filter.type !== 'regexp') return true
if (filter.type !== 'user_regexp') return true
const { value } = filter
let valid = true
try {
new RegExp(value)
} catch {
valid = false
console.error('Invalid RegExp: ' + value)
}
return valid
},
createFilter (filter = {
type: 'word',
value: '',
name: 'New Filter',
enabled: true,
expires: null,
hide: false,
}) {
const newId = uuidv4()
filter.order = this.muteFilters.length + 2
this.muteFiltersDraftObject[newId] = filter
this.setPreference({ path: 'simple.muteFilters.' + newId , value: filter })
this.pushServerSideStorage()
},
exportFilter(id) {
this.exportedFilter = { ...this.muteFiltersDraftObject[id] }
delete this.exportedFilter.order
this.filterExporter.exportData()
},
importFilter() {
this.filterImporter.importData()
},
copyFilter (id) {
const filter = { ...this.muteFiltersDraftObject[id] }
const newId = uuidv4()
this.muteFiltersDraftObject[newId] = filter
this.setPreference({ path: 'simple.muteFilters.' + newId , value: filter })
this.pushServerSideStorage()
},
deleteFilter (id) {
delete this.muteFiltersDraftObject[id]
this.unsetPreference({ path: 'simple.muteFilters.' + id , value: null })
this.pushServerSideStorage()
},
purgeExpiredFilters () {
this.muteFiltersExpired.forEach(([id]) => {
console.log(id)
delete this.muteFiltersDraftObject[id]
this.unsetPreference({ path: 'simple.muteFilters.' + id , value: null })
})
this.pushServerSideStorage()
},
updateFilter(id, field, value) {
const filter = { ...this.muteFiltersDraftObject[id] }
if (field === 'expires-never') {
if (!value) {
const offset = 1000 * 60 * 60 * 24 * 14 // 2 weeks
const date = Date.now() + offset
filter.expires = date
} else {
filter.expires = null
}
} else if (field === 'expires') {
const parsed = Date.parse(value)
filter.expires = parsed.valueOf()
} else {
filter[field] = value
}
this.muteFiltersDraftObject[id] = filter
this.muteFiltersDraftDirty[id] = true
},
saveFilter(id) {
this.setPreference({ path: 'simple.muteFilters.' + id , value: this.muteFiltersDraftObject[id] })
this.pushServerSideStorage()
this.muteFiltersDraftDirty[id] = false
},
},
// Updating nested properties
watch: {
replyVisibility () {

View file

@ -0,0 +1,89 @@
.filtering-tab {
.muteFilterContainer {
border: 1px solid var(--border);
border-radius: var(--roundness);
height: 33vh;
overflow-y: auto;
}
.mute-filter {
border: 1px solid var(--border);
border-radius: var(--roundness);
margin: 0.5em;
padding: 0.5em;
display: grid;
align-items: baseline;
grid-gap: 0.5em;
}
.never {
margin-left: 0.5em;
}
.filter-name {
grid-column: 1 / span 2;
grid-row: 1;
}
.alert,
.button-default {
display: inline-block;
line-height: 2;
padding: 0 0.5em;
}
.filter-enabled {
grid-column: 3;
grid-row: 1;
text-align: right;
line-height: 2;
}
.filter-field {
display: grid;
grid-template-columns: subgrid;
grid-template-rows: subgrid;
grid-column: 1 / span 3;
align-items: baseline;
label {
grid-column: 1;
text-align: right;
}
.filter-field-value {
grid-column: 2 / span 2;
}
}
.filter-buttons {
grid-column: 1 / span 3;
justify-self: end;
display: inline-grid;
grid-gap: 0.5em;
grid-template-columns: repeat(4, 1fr);
max-width: 100%;
justify-content: end;
}
.muteFiltersActions,
.muteFiltersActionsBottom {
display: grid;
align-items: baseline;
grid-auto-flow: column;
grid-template-columns: 1fr;
grid-gap: 0.5em;
margin: 0.5em 0;
.total {
flex: 1;
}
}
}
.settings-modal.-mobile .filtering-tab {
.filter-buttons {
grid-template-columns: repeat(1, 1fr);
}
}

View file

@ -1,11 +1,88 @@
<template>
<div :label="$t('settings.filtering')">
<div
:label="$t('settings.filtering')"
class="filtering-tab"
>
<div class="setting-item">
<h2>{{ $t('settings.posts') }}</h2>
<h2>{{ $t('settings.filter.clutter') }}</h2>
<ul class="setting-list">
<li>
<ChoiceSetting
v-if="user"
id="replyVisibility"
path="replyVisibility"
:options="replyVisibilityOptions"
>
{{ $t('settings.replies_in_timeline') }}
</ChoiceSetting>
</li>
<li>
<BooleanSetting
expert="1"
path="hidePostStats"
>
{{ $t('settings.hide_post_stats') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
expert="1"
path="hideUserStats"
>
{{ $t('settings.hide_user_stats') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="hideBotIndication">
{{ $t('settings.hide_actor_type_indication') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="hideScrobbles">
{{ $t('settings.hide_scrobbles') }}
</BooleanSetting>
<ul class="setting-list suboptions">
<li>
<UnitSetting
key="hideScrobblesAfter"
path="hideScrobblesAfter"
:units="['m', 'h', 'd']"
unit-set="time"
expert="1"
>
{{ $t('settings.hide_scrobbles_after') }}
</UnitSetting>
</li>
</ul>
</li>
<h3>{{ $t('settings.attachments') }}</h3>
<li>
<IntegerSetting
path="maxThumbnails"
expert="1"
:min="0"
>
{{ $t('settings.max_thumbnails') }}
</IntegerSetting>
</li>
<li>
<BooleanSetting path="hideAttachments">
{{ $t('settings.hide_attachments_in_tl') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="hideAttachmentsInConv">
{{ $t('settings.hide_attachments_in_convo') }}
</BooleanSetting>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.filter.mute_filter') }}</h2>
<ul class="setting-list">
<li>
<BooleanSetting path="hideFilteredStatuses">
{{ $t('settings.hide_filtered_statuses') }}
{{ $t('settings.hide_muted_statuses') }}
</BooleanSetting>
<ul class="setting-list suboptions">
<li>
@ -13,8 +90,9 @@
parent-path="hideFilteredStatuses"
:parent-invert="true"
path="hideWordFilteredPosts"
expert="1"
>
{{ $t('settings.hide_wordfiltered_statuses') }}
{{ $t('settings.hide_filtered_statuses') }}
</BooleanSetting>
</li>
<li>
@ -50,83 +128,218 @@
</BooleanSetting>
</li>
<li>
<BooleanSetting path="hidePostStats">
{{ $t('settings.hide_post_stats') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="hideBotIndication">
{{ $t('settings.hide_actor_type_indication') }}
</BooleanSetting>
</li>
<ChoiceSetting
v-if="user"
id="replyVisibility"
path="replyVisibility"
:options="replyVisibilityOptions"
>
{{ $t('settings.replies_in_timeline') }}
</ChoiceSetting>
<li>
<h3>{{ $t('settings.wordfilter') }}</h3>
<textarea
id="muteWords"
v-model="muteWordsString"
class="input resize-height"
/>
<div>{{ $t('settings.filtering_explanation') }}</div>
</li>
<h3>{{ $t('settings.attachments') }}</h3>
<li>
<IntegerSetting
path="maxThumbnails"
expert="1"
:min="0"
>
{{ $t('settings.max_thumbnails') }}
</IntegerSetting>
</li>
<li>
<BooleanSetting path="hideAttachments">
{{ $t('settings.hide_attachments_in_tl') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="hideAttachmentsInConv">
{{ $t('settings.hide_attachments_in_convo') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="hideScrobbles">
{{ $t('settings.hide_scrobbles') }}
</BooleanSetting>
</li>
<li>
<UnitSetting
key="hideScrobblesAfter"
path="hideScrobblesAfter"
:units="['m', 'h', 'd']"
unit-set="time"
expert="1"
>
{{ $t('settings.hide_scrobbles_after') }}
</UnitSetting>
</li>
</ul>
</div>
<div
v-if="expertLevel > 0"
class="setting-item"
>
<h2>{{ $t('settings.user_profiles') }}</h2>
<ul class="setting-list">
<li>
<BooleanSetting path="hideUserStats">
{{ $t('settings.hide_user_stats') }}
</BooleanSetting>
<h3>{{ $t('settings.filter.custom_filters') }}</h3>
<p class="muteFiltersActions">
<span class="total">
{{ $t('settings.filter.total_count', { count: muteFiltersDraft.length }) }}
</span>
<button
class="add-button button-default"
type="button"
@click="createFilter()"
>
{{ $t('settings.filter.new') }}
</button>
<button
class="add-button button-default"
type="button"
@click="importFilter()"
>
{{ $t('settings.filter.import') }}
</button>
</p>
<div class="muteFilterContainer">
<div
v-for="filter in muteFiltersDraft"
:key="filter[0]"
class="mute-filter"
:style="{ order: filter[1].order }"
>
<div class="filter-name">
<label
:for="'filterName' + filter[0]"
>
{{ $t('settings.filter.name') }}
</label>
{{ ' ' }}
<input
:id="'filterName' + filter[0]"
class="input"
:value="filter[1].name"
@input="updateFilter(filter[0], 'name', $event.target.value)"
>
<span
v-if="filter[1].expires !== null && Date.now() > filter[1].expires"
class="alert neutral"
>
{{ $t('settings.filter.expired') }}
</span>
</div>
<div class="filter-enabled">
<Checkbox
:id="'filterHide' + filter[0]"
:model-value="filter[1].hide"
:name="'filterHide' + filter[0]"
@update:model-value="updateFilter(filter[0], 'hide', $event)"
>
{{ $t('settings.filter.hide') }}
</Checkbox>
{{ ' ' }}
<Checkbox
:id="'filterEnabled' + filter[0]"
:model-value="filter[1].enabled"
:name="'filterEnabled' + filter[0]"
@update:model-value="updateFilter(filter[0], 'enabled', $event)"
>
{{ $t('settings.enabled') }}
</Checkbox>
</div>
<div class="filter-type filter-field">
<label :for="'filterType' + filter[0]">
<HelpIndicator>
<p>
{{ $t('settings.filter.help.word') }}
</p>
<p>
{{ $t('settings.filter.help.user') }}
</p>
<i18n-t
keypath="settings.filter.help.regexp"
tag="p"
>
<template #link>
<a
:href="$t('settings.filter.help.regexp_url')"
target="_blank"
>
{{ $t('settings.filter.help.regexp_link') }}
</a>
</template>
</i18n-t>
</HelpIndicator>
{{ $t('settings.filter.type') }}
</label>
<Select
:id="'filterType' + filter[0]"
class="filter-field-value"
:model-value="filter[1].type"
@update:model-value="updateFilter(filter[0], 'type', $event)"
>
<option value="word">
{{ $t('settings.filter.plain') }}
</option>
<option value="regexp">
{{ $t('settings.filter.regexp') }}
</option>
<option value="user">
{{ $t('settings.filter.user') }}
</option>
<option value="user_regexp">
{{ $t('settings.filter.user_regexp') }}
</option>
</Select>
</div>
<div class="filter-value filter-field">
<label
:for="'filterValue' + filter[0]"
>
{{ $t('settings.filter.value') }}
</label>
{{ ' ' }}
<input
:id="'filterValue' + filter[0]"
class="input filter-field-value"
:value="filter[1].value"
@input="updateFilter(filter[0], 'value', $event.target.value)"
>
</div>
<div class="filter-expires filter-field">
<label
:for="'filterExpires' + filter[0]"
>
{{ $t('settings.filter.expires') }}
</label>
{{ ' ' }}
<div class="filter-field-value">
<input
:id="'filterExpires' + filter[0]"
class="input"
:class="{ disabled: filter[1].expires === null }"
type="datetime-local"
:disabled="filter[1].expires === null"
:value="filter[1].expires ? getDatetimeLocal(filter[1].expires) : null"
@input="updateFilter(filter[0], 'expires', $event.target.value)"
>
{{ ' ' }}
<Checkbox
:id="'filterExpiresNever' + filter[0]"
:model-value="filter[1].expires === null"
:name="'filterExpiresNever' + filter[0]"
class="input-inset input-boolean never"
@update:model-value="updateFilter(filter[0], 'expires-never', $event)"
>
{{ $t('settings.filter.never_expires') }}
</Checkbox>
</div>
</div>
<div
v-if="!checkRegexValid(filter[0])"
class="alert error"
>
{{ $t('settings.filter.regexp_error') }}
</div>
<div class="filter-buttons">
<button
class="delete-button button-default -danger"
type="button"
@click="deleteFilter(filter[0])"
>
{{ $t('settings.filter.delete') }}
</button>
<button
class="export-button button-default"
type="button"
@click="exportFilter(filter[0])"
>
{{ $t('settings.filter.export') }}
</button>
<button
class="copy-button button-default"
type="button"
@click="copyFilter(filter[0])"
>
{{ $t('settings.filter.copy') }}
</button>
<button
class="save-button button-default"
:class="{ disabled: !muteFiltersDraftDirty[filter[0]] }"
:disabled="!muteFiltersDraftDirty[filter[0]]"
type="button"
@click="saveFilter(filter[0])"
>
{{ $t('settings.filter.save') }}
</button>
</div>
</div>
</div>
<p class="muteFiltersActionsBottom">
<span class="total">
{{ $t('settings.filter.expired_count', { count: muteFiltersExpired.length }) }}
</span>
<button
class="add-button button-default"
type="button"
:class="{ disabled: muteFiltersExpired.length === 0 }"
:disabled="muteFiltersExpired.length === 0"
@click="purgeExpiredFilters()"
>
{{ $t('settings.filter.purge_expired') }}
</button>
</p>
</li>
</ul>
</div>
</div>
</template>
<script src="./filtering_tab.js"></script>
<style src="./filtering_tab.scss"></style>

View file

@ -11,6 +11,7 @@ import ProgressButton from 'src/components/progress_button/progress_button.vue'
import withSubscription from 'src/components/../hocs/with_subscription/with_subscription'
import withLoadMore from 'src/components/../hocs/with_load_more/with_load_more'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import { useOAuthTokensStore } from 'src/stores/oauth_tokens'
const BlockList = withLoadMore({
fetch: (props, $store) => $store.dispatch('fetchBlocks'),
@ -39,7 +40,7 @@ const MutesAndBlocks = {
}
},
created () {
this.$store.dispatch('fetchTokens')
useOAuthTokensStore().fetchTokens()
this.$store.dispatch('getKnownDomains')
},
components: {

View file

@ -216,7 +216,7 @@ const ProfileTab = {
this.submitBackground('')
}
},
submitAvatar (cropper, file) {
submitAvatar (canvas, file) {
const that = this
return new Promise((resolve, reject) => {
function updateAvatar (avatar, avatarName) {
@ -232,8 +232,8 @@ const ProfileTab = {
})
}
if (cropper) {
cropper.getCroppedCanvas().toBlob((data) => updateAvatar(data, file.name), file.type)
if (canvas) {
canvas.toBlob((data) => updateAvatar(data, file.name), file.type)
} else {
updateAvatar(file, file.name)
}

View file

@ -2,6 +2,7 @@ import ProgressButton from 'src/components/progress_button/progress_button.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Mfa from './mfa.vue'
import localeService from 'src/services/locale/locale.service.js'
import { useOAuthTokensStore } from 'src/stores/oauth_tokens'
const SecurityTab = {
data () {
@ -28,7 +29,7 @@ const SecurityTab = {
}
},
created () {
this.$store.dispatch('fetchTokens')
useOAuthTokensStore().fetchTokens()
this.fetchAliases()
},
components: {
@ -40,11 +41,11 @@ const SecurityTab = {
user () {
return this.$store.state.users.currentUser
},
pleromaBackend () {
return this.$store.state.instance.pleromaBackend
pleromaExtensionsAvailable () {
return this.$store.state.instance.pleromaExtensionsAvailable
},
oauthTokens () {
return this.$store.state.oauthTokens.tokens.map(oauthToken => {
return useOAuthTokensStore().tokens.map(oauthToken => {
return {
id: oauthToken.id,
appName: oauthToken.app_name,
@ -151,7 +152,7 @@ const SecurityTab = {
},
revokeToken (id) {
if (window.confirm(`${this.$i18n.t('settings.revoke_token')}?`)) {
this.$store.dispatch('revokeToken', id)
useOAuthTokensStore().revokeToken(id)
}
}
}

View file

@ -643,7 +643,7 @@ export default {
parser (string) { return deserialize(string) },
onImportFailure (result) {
console.error('Failure importing style:', result)
this.$store.useInterfaceStore().pushGlobalNotice({ messageKey: 'settings.invalid_theme_imported', level: 'error' })
useInterfaceStore().pushGlobalNotice({ messageKey: 'settings.invalid_theme_imported', level: 'error' })
},
onImport
})

View file

@ -1,4 +1,6 @@
.StyleTab {
min-width: var(--themeEditorMinWidth, fit-content);
.style-control {
display: flex;
flex-wrap: wrap;
@ -185,7 +187,7 @@
.state-selector,
.variant-selector {
display: grid;
grid-template-columns: 1fr minmax(1fr, 10em);
grid-template-columns: 1fr minmax(10em, 1fr);
grid-template-rows: auto;
grid-auto-flow: column;
grid-gap: 0.5em;

View file

@ -241,10 +241,7 @@ export default {
.underlay-preview {
position: absolute;
top: 0;
bottom: 0;
left: 10px;
right: 10px;
inset: 0 10px;
}
}
</style>

View file

@ -1,4 +1,6 @@
.theme-tab {
min-width: var(--themeEditorMinWidth, fit-content);
.deprecation-warning {
padding: 0.5em;
margin: 2em;

View file

@ -112,8 +112,7 @@
grid-area: preview;
min-width: 25em;
margin-left: 0.125em;
align-self: start;
justify-self: center;
place-self: start center;
}
}

View file

@ -107,8 +107,7 @@
}
.shout-window {
overflow-y: auto;
overflow-x: hidden;
overflow: hidden auto;
max-height: 20em;
}

View file

@ -14,8 +14,9 @@ import MentionLink from 'src/components/mention_link/mention_link.vue'
import StatusActionButtons from 'src/components/status_action_buttons/status_action_buttons.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import { muteWordHits } from '../../services/status_parser/status_parser.js'
import { muteFilterHits } from '../../services/status_parser/status_parser.js'
import { unescape, uniqBy } from 'lodash'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
@ -23,6 +24,7 @@ import {
faLock,
faLockOpen,
faGlobe,
faIgloo,
faTimes,
faRetweet,
faReply,
@ -42,6 +44,7 @@ import {
library.add(
faEnvelope,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faTimes,
@ -161,9 +164,6 @@ const Status = {
},
computed: {
...controlledOrUncontrolledGetters(['replying', 'mediaPlaying']),
muteWords () {
return this.mergedConfig.muteWords
},
showReasonMutedThread () {
return (
this.status.thread_muted ||
@ -221,8 +221,11 @@ const Status = {
loggedIn () {
return !!this.currentUser
},
muteWordHits () {
return muteWordHits(this.status, this.muteWords)
muteFilterHits () {
return muteFilterHits(
Object.values(useServerSideStorageStore().prefsStorage.simple.muteFilters),
this.status
)
},
botStatus () {
return this.status.user.actor_type === 'Service'
@ -256,7 +259,7 @@ const Status = {
return [
this.userIsMuted ? 'user' : null,
this.status.thread_muted ? 'thread' : null,
(this.muteWordHits.length > 0) ? 'wordfilter' : null,
(this.muteFilterHits.length > 0) ? 'filtered' : null,
(this.muteBotStatuses && this.botStatus) ? 'bot' : null,
(this.muteSensitiveStatuses && this.sensitiveStatus) ? 'nsfw' : null
].filter(_ => _)
@ -267,14 +270,14 @@ const Status = {
switch (this.muteReasons[0]) {
case 'user': return this.$t('status.muted_user')
case 'thread': return this.$t('status.thread_muted')
case 'wordfilter':
case 'filtered':
return this.$t(
'status.muted_words',
'status.muted_filters',
{
word: this.muteWordHits[0],
numWordsMore: this.muteWordHits.length - 1
name: this.muteFilterHits[0].name,
filterMore: this.muteFilterHits.length - 1
},
this.muteWordHits.length
this.muteFilterHits.length
)
case 'bot': return this.$t('status.bot_muted')
case 'nsfw': return this.$t('status.sensitive_muted')
@ -312,6 +315,7 @@ const Status = {
(relationshipReblog && relationshipReblog.muting)
},
shouldNotMute () {
if (this.isFocused) return true
const { status } = this
const { reblog } = status
return (
@ -326,7 +330,7 @@ const Status = {
// Don't mute statuses in muted conversation when said conversation is opened
(this.inConversation && status.thread_muted)
// No excuses if post has muted words
) && !this.muteWordHits.length > 0
) && !this.muteFilterHits.length > 0
},
hideMutedUsers () {
return this.mergedConfig.hideMutedPosts
@ -345,7 +349,8 @@ const Status = {
(this.muted && this.hideFilteredStatuses) ||
(this.userIsMuted && this.hideMutedUsers) ||
(this.status.thread_muted && this.hideMutedThreads) ||
(this.muteWordHits.length > 0 && this.hideWordFilteredPosts)
(this.muteFilterHits.length > 0 && this.hideWordFilteredPosts) ||
(this.muteFilterHits.some(x => x.hide))
)
},
isFocused () {
@ -481,6 +486,8 @@ const Status = {
return 'lock-open'
case 'direct':
return 'envelope'
case 'local':
return 'igloo'
default:
return 'globe'
}
@ -533,6 +540,7 @@ const Status = {
this.controlledToggleThreadDisplay()
},
scrollIfHighlighted (highlightId) {
if (this.$el.getBoundingClientRect == null) return
const id = highlightId
if (this.status.id === id) {
const rect = this.$el.getBoundingClientRect()

View file

@ -1,8 +1,8 @@
.Status {
min-width: 0;
white-space: normal;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
text-wrap: pretty;
&:hover {
--_still-image-img-visibility: visible;
@ -92,7 +92,10 @@
a {
display: inline-block;
word-break: break-all;
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
width: 100%
}
}
@ -283,8 +286,6 @@
& .status-username,
& .mute-reason {
word-wrap: normal;
word-break: normal;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
@ -364,7 +365,7 @@
}
}
@media all and (max-width: 800px) {
@media all and (width <= 800px) {
.repeater-avatar {
margin-left: 20px;
}
@ -374,7 +375,6 @@
height: 40px;
// TODO define those other way somehow?
// stylelint-disable rscss/class-format
&.-compact {
width: 32px;
height: 32px;

View file

@ -540,6 +540,7 @@
:status="status"
:replying="replying"
@toggle-replying="toggleReplying"
@interacted="e => $emit('interacted')"
/>
</div>
</div>

View file

@ -67,6 +67,9 @@ export default {
'doAction',
'outerClose'
],
emits: [
'interacted'
],
components: {
StatusBookmarkFolderMenu,
EmojiPicker,
@ -120,7 +123,9 @@ export default {
this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji })
}
},
doActionWrap (button, close) {
doActionWrap (button, close = () => {}) {
if (this.button.interactive ? !this.button.interactive(this.funcArg) : false) return
this.$emit('interacted')
if (button.name === 'emoji') {
this.$refs.picker.showPicker()
} else {

View file

@ -12,7 +12,7 @@
:title="$t(button.label(funcArg))"
target="_blank"
:tabindex="0"
:disabled="buttonClass.disabled"
:disabled="button.interactive ? !button.interactive(funcArg) : false"
:href="getComponent(button) == 'a' ? button.link?.(funcArg) || remoteInteractionLink : undefined"
@click="doActionWrap(button, outerClose)"
>
@ -24,7 +24,7 @@
:style="{ '--fa-animation-duration': '750ms' }"
fixed-width
/>
<template v-if="!buttonClass.disabled && button.toggleable?.(funcArg) && button.active">
<template v-if="!buttonClass.disabled && (!button.interactive || button?.interactive(funcArg)) && button.toggleable?.(funcArg) && button.active">
<FAIcon
v-if="button.active(funcArg)"
class="active-marker"

View file

@ -22,6 +22,7 @@ export default {
MuteConfirm
},
props: ['button', 'status'],
emits: ['interacted'],
mounted () {
if (this.button.name === 'mute') {
this.$store.dispatch('fetchDomainMutes')

View file

@ -79,6 +79,7 @@
:button="button"
:status="status"
v-bind="$attrs"
@interacted="e => $emit('interacted')"
/>
<teleport to="#modal">
<MuteConfirm

View file

@ -1,4 +1,7 @@
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 = [{
@ -27,8 +30,8 @@ export const BUTTONS = [{
label: ({ status }) => status.repeated
? 'tool_tip.unrepeat'
: 'tool_tip.repeat',
icon ({ status }) {
if (PRIVATE_SCOPES.has(status.visibility)) {
icon ({ status, currentUser }) {
if (currentUser.id !== status.user.id && PRIVATE_SCOPES.has(status.visibility)) {
return 'lock'
}
return 'retweet'
@ -37,7 +40,7 @@ export const BUTTONS = [{
active: ({ status }) => status.repeated,
counter: ({ status }) => status.repeat_num,
anonLink: true,
interactive: ({ status, loggedIn }) => loggedIn && !PRIVATE_SCOPES.has(status.visibility),
interactive: ({ status, currentUser }) => !!currentUser && (currentUser.id === status.user.id || !PRIVATE_SCOPES.has(status.visibility)),
toggleable: true,
confirm: ({ status, getters }) => !status.repeated && getters.mergedConfig.modalOnRepeat,
confirmStrings: {
@ -138,6 +141,34 @@ export const BUTTONS = [{
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
@ -216,8 +247,8 @@ export const BUTTONS = [{
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(

View file

@ -1,10 +1,12 @@
import { mapState } from 'vuex'
import { mapState } from 'pinia'
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 { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import { BUTTONS } from './buttons_definitions.js'
import { library } from '@fortawesome/fontawesome-svg-core'
@ -18,7 +20,7 @@ library.add(
const StatusActionButtons = {
props: ['status', 'replying'],
emits: ['toggleReplying'],
emits: ['toggleReplying', 'interacted'],
data () {
return {
showPin: false,
@ -36,8 +38,8 @@ const StatusActionButtons = {
ActionButtonContainer
},
computed: {
...mapState({
pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedStatusActions)
...mapState(useServerSideStorageStore, {
pinnedItems: store => new Set(store.prefsStorage.collections.pinnedStatusActions)
}),
buttons () {
return BUTTONS.filter(x => x.if ? x.if(this.funcArg) : true)
@ -101,12 +103,12 @@ const StatusActionButtons = {
return this.pinnedItems.has(button.name)
},
unpin (button) {
this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedStatusActions', value: button.name })
this.$store.dispatch('pushServerSideStorage')
useServerSideStorageStore().removeCollectionPreference({ path: 'collections.pinnedStatusActions', value: button.name })
useServerSideStorageStore().pushServerSideStorage()
},
pin (button) {
this.$store.commit('addCollectionPreference', { path: 'collections.pinnedStatusActions', value: button.name })
this.$store.dispatch('pushServerSideStorage')
useServerSideStorageStore().addCollectionPreference({ path: 'collections.pinnedStatusActions', value: button.name })
useServerSideStorageStore().pushServerSideStorage()
},
getComponent (button) {
if (!this.$store.state.users.currentUser && button.anonLink) {

View file

@ -3,7 +3,7 @@
.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;

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