aboutsummaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/account_actions/account_actions.js43
-rw-r--r--src/components/account_actions/account_actions.vue42
-rw-r--r--src/components/checkbox/checkbox.vue9
-rw-r--r--src/components/confirm_modal/confirm_modal.js37
-rw-r--r--src/components/confirm_modal/confirm_modal.vue29
-rw-r--r--src/components/desktop_nav/desktop_nav.js26
-rw-r--r--src/components/desktop_nav/desktop_nav.vue19
-rw-r--r--src/components/dialog_modal/dialog_modal.vue4
-rw-r--r--src/components/emoji_input/emoji_input.js57
-rw-r--r--src/components/emoji_input/emoji_input.vue21
-rw-r--r--src/components/emoji_input/suggestor.js5
-rw-r--r--src/components/emoji_picker/emoji_picker.js8
-rw-r--r--src/components/emoji_picker/emoji_picker.vue1
-rw-r--r--src/components/emoji_reactions/emoji_reactions.vue38
-rw-r--r--src/components/extra_buttons/extra_buttons.js31
-rw-r--r--src/components/extra_buttons/extra_buttons.vue12
-rw-r--r--src/components/favorite_button/favorite_button.vue17
-rw-r--r--src/components/follow_button/follow_button.js25
-rw-r--r--src/components/follow_button/follow_button.vue21
-rw-r--r--src/components/follow_card/follow_card.vue1
-rw-r--r--src/components/follow_request_card/follow_request_card.js49
-rw-r--r--src/components/follow_request_card/follow_request_card.vue22
-rw-r--r--src/components/font_control/font_control.vue5
-rw-r--r--src/components/interface_language_switcher/interface_language_switcher.vue80
-rw-r--r--src/components/list/list.vue6
-rw-r--r--src/components/mobile_nav/mobile_nav.js27
-rw-r--r--src/components/mobile_nav/mobile_nav.vue22
-rw-r--r--src/components/navigation/navigation.js18
-rw-r--r--src/components/navigation/navigation_entry.js14
-rw-r--r--src/components/navigation/navigation_pins.js11
-rw-r--r--src/components/notification/notification.js45
-rw-r--r--src/components/notification/notification.vue37
-rw-r--r--src/components/notifications/notifications.scss7
-rw-r--r--src/components/poll/poll.js3
-rw-r--r--src/components/poll/poll.vue92
-rw-r--r--src/components/poll/poll_form.js13
-rw-r--r--src/components/post_status_form/post_status_form.js4
-rw-r--r--src/components/post_status_form/post_status_form.vue80
-rw-r--r--src/components/quick_filter_settings/quick_filter_settings.vue29
-rw-r--r--src/components/quick_view_settings/quick_view_settings.vue65
-rw-r--r--src/components/range_input/range_input.vue8
-rw-r--r--src/components/react_button/react_button.js96
-rw-r--r--src/components/react_button/react_button.vue109
-rw-r--r--src/components/registration/registration.js34
-rw-r--r--src/components/registration/registration.vue35
-rw-r--r--src/components/remove_follower_button/remove_follower_button.js27
-rw-r--r--src/components/remove_follower_button/remove_follower_button.vue21
-rw-r--r--src/components/reply_button/reply_button.vue18
-rw-r--r--src/components/retweet_button/retweet_button.js24
-rw-r--r--src/components/retweet_button/retweet_button.vue29
-rw-r--r--src/components/screen_reader_notice/screen_reader_notice.js21
-rw-r--r--src/components/screen_reader_notice/screen_reader_notice.vue10
-rw-r--r--src/components/search_bar/search_bar.vue4
-rw-r--r--src/components/select/select.js3
-rw-r--r--src/components/select/select.vue1
-rw-r--r--src/components/settings_modal/helpers/float_setting.vue16
-rw-r--r--src/components/settings_modal/helpers/integer_setting.vue36
-rw-r--r--src/components/settings_modal/helpers/modified_indicator.vue2
-rw-r--r--src/components/settings_modal/helpers/number_setting.js (renamed from src/components/settings_modal/helpers/integer_setting.js)22
-rw-r--r--src/components/settings_modal/helpers/number_setting.vue27
-rw-r--r--src/components/settings_modal/tabs/general_tab.js2
-rw-r--r--src/components/settings_modal/tabs/general_tab.vue67
-rw-r--r--src/components/settings_modal/tabs/mutes_and_blocks_tab.js7
-rw-r--r--src/components/settings_modal/tabs/profile_tab.js14
-rw-r--r--src/components/settings_modal/tabs/profile_tab.scss5
-rw-r--r--src/components/settings_modal/tabs/profile_tab.vue58
-rw-r--r--src/components/shadow_control/shadow_control.vue3
-rw-r--r--src/components/tab_switcher/tab_switcher.jsx8
-rw-r--r--src/components/user_card/user_card.js35
-rw-r--r--src/components/user_card/user_card.scss5
-rw-r--r--src/components/user_card/user_card.vue47
-rw-r--r--src/components/user_profile/user_profile.js11
-rw-r--r--src/components/user_profile/user_profile.vue14
73 files changed, 1459 insertions, 435 deletions
diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js
index c23407f9..acd93e06 100644
--- a/src/components/account_actions/account_actions.js
+++ b/src/components/account_actions/account_actions.js
@@ -2,6 +2,7 @@ import { mapState } from 'vuex'
import ProgressButton from '../progress_button/progress_button.vue'
import Popover from '../popover/popover.vue'
import UserListMenu from 'src/components/user_list_menu/user_list_menu.vue'
+import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faEllipsisV
@@ -16,14 +17,30 @@ const AccountActions = {
'user', 'relationship'
],
data () {
- return { }
+ return {
+ showingConfirmBlock: false,
+ showingConfirmRemoveFollower: false
+ }
},
components: {
ProgressButton,
Popover,
- UserListMenu
+ UserListMenu,
+ ConfirmModal
},
methods: {
+ showConfirmBlock () {
+ this.showingConfirmBlock = true
+ },
+ hideConfirmBlock () {
+ this.showingConfirmBlock = false
+ },
+ showConfirmRemoveUserFromFollowers () {
+ this.showingConfirmRemoveFollower = true
+ },
+ hideConfirmRemoveUserFromFollowers () {
+ this.showingConfirmRemoveFollower = false
+ },
showRepeats () {
this.$store.dispatch('showReblogs', this.user.id)
},
@@ -31,13 +48,29 @@ const AccountActions = {
this.$store.dispatch('hideReblogs', this.user.id)
},
blockUser () {
+ if (!this.shouldConfirmBlock) {
+ this.doBlockUser()
+ } else {
+ this.showConfirmBlock()
+ }
+ },
+ doBlockUser () {
this.$store.dispatch('blockUser', this.user.id)
+ this.hideConfirmBlock()
},
unblockUser () {
this.$store.dispatch('unblockUser', this.user.id)
},
removeUserFromFollowers () {
+ if (!this.shouldConfirmRemoveUserFromFollowers) {
+ this.doRemoveUserFromFollowers()
+ } else {
+ this.showConfirmRemoveUserFromFollowers()
+ }
+ },
+ doRemoveUserFromFollowers () {
this.$store.dispatch('removeUserFromFollowers', this.user.id)
+ this.hideConfirmRemoveUserFromFollowers()
},
reportUser () {
this.$store.dispatch('openUserReportingModal', { userId: this.user.id })
@@ -50,6 +83,12 @@ const AccountActions = {
}
},
computed: {
+ shouldConfirmBlock () {
+ return this.$store.getters.mergedConfig.modalOnBlock
+ },
+ shouldConfirmRemoveUserFromFollowers () {
+ return this.$store.getters.mergedConfig.modalOnRemoveUserFromFollowers
+ },
...mapState({
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
})
diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue
index 973a5935..ce19291a 100644
--- a/src/components/account_actions/account_actions.vue
+++ b/src/components/account_actions/account_actions.vue
@@ -74,6 +74,48 @@
</button>
</template>
</Popover>
+ <teleport to="#modal">
+ <confirm-modal
+ v-if="showingConfirmBlock"
+ :title="$t('user_card.block_confirm_title')"
+ :confirm-text="$t('user_card.block_confirm_accept_button')"
+ :cancel-text="$t('user_card.block_confirm_cancel_button')"
+ @accepted="doBlockUser"
+ @cancelled="hideConfirmBlock"
+ >
+ <i18n-t
+ keypath="user_card.block_confirm"
+ tag="span"
+ >
+ <template #user>
+ <span
+ v-text="user.screen_name_ui"
+ />
+ </template>
+ </i18n-t>
+ </confirm-modal>
+ </teleport>
+ <teleport to="#modal">
+ <confirm-modal
+ v-if="showingConfirmRemoveFollower"
+ :title="$t('user_card.remove_follower_confirm_title')"
+ :confirm-text="$t('user_card.remove_follower_confirm_accept_button')"
+ :cancel-text="$t('user_card.remove_follower_confirm_cancel_button')"
+ @accepted="doRemoveUserFromFollowers"
+ @cancelled="hideConfirmRemoveUserFromFollowers"
+ >
+ <i18n-t
+ keypath="user_card.remove_follower_confirm"
+ tag="span"
+ >
+ <template #user>
+ <span
+ v-text="user.screen_name_ui"
+ />
+ </template>
+ </i18n-t>
+ </confirm-modal>
+ </teleport>
</div>
</template>
diff --git a/src/components/checkbox/checkbox.vue b/src/components/checkbox/checkbox.vue
index 7139d4fc..42f89be9 100644
--- a/src/components/checkbox/checkbox.vue
+++ b/src/components/checkbox/checkbox.vue
@@ -5,12 +5,16 @@
>
<input
type="checkbox"
+ class="visible-for-screenreader-only"
:disabled="disabled"
:checked="modelValue"
:indeterminate="indeterminate"
@change="$emit('update:modelValue', $event.target.checked)"
>
- <i class="checkbox-indicator" />
+ <i
+ class="checkbox-indicator"
+ :aria-hidden="true"
+ />
<span
v-if="!!$slots.default"
class="label"
@@ -33,6 +37,7 @@ export default {
<style lang="scss">
@import "../../variables";
+@import "../../mixins";
.checkbox {
position: relative;
@@ -81,8 +86,6 @@ export default {
}
input[type="checkbox"] {
- display: none;
-
&:checked + .checkbox-indicator::before {
color: $fallback--text;
color: var(--inputText, $fallback--text);
diff --git a/src/components/confirm_modal/confirm_modal.js b/src/components/confirm_modal/confirm_modal.js
new file mode 100644
index 00000000..96ddc118
--- /dev/null
+++ b/src/components/confirm_modal/confirm_modal.js
@@ -0,0 +1,37 @@
+import DialogModal from '../dialog_modal/dialog_modal.vue'
+
+/**
+ * This component emits the following events:
+ * cancelled, emitted when the action should not be performed;
+ * accepted, emitted when the action should be performed;
+ *
+ * The caller should close this dialog after receiving any of the two events.
+ */
+const ConfirmModal = {
+ components: {
+ DialogModal
+ },
+ props: {
+ title: {
+ type: String
+ },
+ cancelText: {
+ type: String
+ },
+ confirmText: {
+ type: String
+ }
+ },
+ computed: {
+ },
+ methods: {
+ onCancel () {
+ this.$emit('cancelled')
+ },
+ onAccept () {
+ this.$emit('accepted')
+ }
+ }
+}
+
+export default ConfirmModal
diff --git a/src/components/confirm_modal/confirm_modal.vue b/src/components/confirm_modal/confirm_modal.vue
new file mode 100644
index 00000000..3b98174a
--- /dev/null
+++ b/src/components/confirm_modal/confirm_modal.vue
@@ -0,0 +1,29 @@
+<template>
+ <dialog-modal
+ v-body-scroll-lock="true"
+ class="confirm-modal"
+ :on-cancel="onCancel"
+ >
+ <template #header>
+ <span v-text="title" />
+ </template>
+
+ <slot />
+
+ <template #footer>
+ <button
+ class="btn button-default"
+ @click.prevent="onAccept"
+ v-text="confirmText"
+ />
+
+ <button
+ class="btn button-default"
+ @click.prevent="onCancel"
+ v-text="cancelText"
+ />
+ </template>
+ </dialog-modal>
+</template>
+
+<script src="./confirm_modal.js"></script>
diff --git a/src/components/desktop_nav/desktop_nav.js b/src/components/desktop_nav/desktop_nav.js
index 08c0e44e..745b1a81 100644
--- a/src/components/desktop_nav/desktop_nav.js
+++ b/src/components/desktop_nav/desktop_nav.js
@@ -1,4 +1,5 @@
import SearchBar from 'components/search_bar/search_bar.vue'
+import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faSignInAlt,
@@ -30,7 +31,8 @@ library.add(
export default {
components: {
- SearchBar
+ SearchBar,
+ ConfirmModal
},
data: () => ({
searchBarHidden: true,
@@ -40,7 +42,8 @@ export default {
window.CSS.supports('-moz-mask-size', 'contain') ||
window.CSS.supports('-ms-mask-size', 'contain') ||
window.CSS.supports('-o-mask-size', 'contain')
- )
+ ),
+ showingConfirmLogout: false
}),
computed: {
enableMask () { return this.supportsMask && this.$store.state.instance.logoMask },
@@ -73,15 +76,32 @@ export default {
hideSitename () { return this.$store.state.instance.hideSitename },
logoLeft () { return this.$store.state.instance.logoLeft },
currentUser () { return this.$store.state.users.currentUser },
- privateMode () { return this.$store.state.instance.private }
+ privateMode () { return this.$store.state.instance.private },
+ shouldConfirmLogout () {
+ return this.$store.getters.mergedConfig.modalOnLogout
+ }
},
methods: {
scrollToTop () {
window.scrollTo(0, 0)
},
+ showConfirmLogout () {
+ this.showingConfirmLogout = true
+ },
+ hideConfirmLogout () {
+ this.showingConfirmLogout = false
+ },
logout () {
+ if (!this.shouldConfirmLogout) {
+ this.doLogout()
+ } else {
+ this.showConfirmLogout()
+ }
+ },
+ doLogout () {
this.$router.replace('/main/public')
this.$store.dispatch('logout')
+ this.hideConfirmLogout()
},
onSearchBarToggled (hidden) {
this.searchBarHidden = hidden
diff --git a/src/components/desktop_nav/desktop_nav.vue b/src/components/desktop_nav/desktop_nav.vue
index 07bf8005..dc8bbfd3 100644
--- a/src/components/desktop_nav/desktop_nav.vue
+++ b/src/components/desktop_nav/desktop_nav.vue
@@ -20,6 +20,7 @@
class="logo"
:to="{ name: 'root' }"
:style="logoBgStyle"
+ :title="sitename"
>
<div
class="mask"
@@ -38,13 +39,13 @@
/>
<button
class="button-unstyled nav-icon"
+ :title="$t('nav.preferences')"
@click.stop="openSettingsModal"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="cog"
- :title="$t('nav.preferences')"
/>
</button>
<a
@@ -52,30 +53,42 @@
href="/pleroma/admin/#/login-pleroma"
class="nav-icon"
target="_blank"
+ :title="$t('nav.administration')"
@click.stop
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="tachometer-alt"
- :title="$t('nav.administration')"
/>
</a>
<span class="spacer" />
<button
v-if="currentUser"
class="button-unstyled nav-icon"
+ :title="$t('login.logout')"
@click.stop.prevent="logout"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="sign-out-alt"
- :title="$t('login.logout')"
/>
</button>
</div>
</div>
+ <teleport to="#modal">
+ <confirm-modal
+ v-if="showingConfirmLogout"
+ :title="$t('login.logout_confirm_title')"
+ :confirm-text="$t('login.logout_confirm_accept_button')"
+ :cancel-text="$t('login.logout_confirm_cancel_button')"
+ @accepted="doLogout"
+ @cancelled="hideConfirmLogout"
+ >
+ {{ $t('login.logout_confirm') }}
+ </confirm-modal>
+ </teleport>
</nav>
</template>
<script src="./desktop_nav.js"></script>
diff --git a/src/components/dialog_modal/dialog_modal.vue b/src/components/dialog_modal/dialog_modal.vue
index 24d65142..341cf105 100644
--- a/src/components/dialog_modal/dialog_modal.vue
+++ b/src/components/dialog_modal/dialog_modal.vue
@@ -39,7 +39,7 @@
right: 0;
top: 0;
background: rgb(27 31 35 / 50%);
- z-index: 99;
+ z-index: 2000;
}
}
@@ -51,7 +51,7 @@
margin: 15vh auto;
position: fixed;
transform: translateX(-50%);
- z-index: 999;
+ z-index: 2001;
cursor: default;
display: block;
background-color: $fallback--bg;
diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js
index ba5f7552..68654f69 100644
--- a/src/components/emoji_input/emoji_input.js
+++ b/src/components/emoji_input/emoji_input.js
@@ -1,6 +1,7 @@
import Completion from '../../services/completion/completion.js'
import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import Popover from 'src/components/popover/popover.vue'
+import ScreenReaderNotice from 'src/components/screen_reader_notice/screen_reader_notice.vue'
import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
import { take } from 'lodash'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
@@ -109,9 +110,10 @@ const EmojiInput = {
},
data () {
return {
+ randomSeed: `${Math.random()}`.replace('.', '-'),
input: undefined,
caretEl: undefined,
- highlighted: 0,
+ highlighted: -1,
caret: 0,
focused: false,
blurTimeout: null,
@@ -125,12 +127,16 @@ const EmojiInput = {
components: {
Popover,
EmojiPicker,
- UnicodeDomainIndicator
+ UnicodeDomainIndicator,
+ ScreenReaderNotice
},
computed: {
padEmoji () {
return this.$store.getters.mergedConfig.padEmoji
},
+ defaultCandidateIndex () {
+ return this.$store.getters.mergedConfig.autocompleteSelect ? 0 : -1
+ },
preText () {
return this.modelValue.slice(0, this.caret)
},
@@ -203,6 +209,12 @@ const EmojiInput = {
top: this.input.scrollTop,
left: this.input.scrollLeft
})
+ },
+ suggestionListId () {
+ return `suggestions-${this.randomSeed}`
+ },
+ suggestionItemId () {
+ return (index) => `suggestion-item-${index}-${this.randomSeed}`
}
},
mounted () {
@@ -278,6 +290,11 @@ const EmojiInput = {
...rest,
img: imageUrl || ''
}))
+ this.highlighted = this.defaultCandidateIndex
+ this.$refs.screenReaderNotice.announce(
+ this.$tc('tool_tip.autocomplete_available',
+ this.suggestions.length,
+ { number: this.suggestions.length }))
}
},
methods: {
@@ -374,26 +391,27 @@ const EmojiInput = {
},
cycleBackward (e) {
const len = this.suggestions.length || 0
- if (len > 1) {
- this.highlighted -= 1
- if (this.highlighted < 0) {
- this.highlighted = this.suggestions.length - 1
- }
+
+ this.highlighted -= 1
+ if (this.highlighted === -1) {
+ this.input.focus()
+ } else if (this.highlighted < -1) {
+ this.highlighted = len - 1
+ }
+ if (len > 0) {
e.preventDefault()
- } else {
- this.highlighted = 0
}
},
cycleForward (e) {
const len = this.suggestions.length || 0
- if (len > 1) {
- this.highlighted += 1
- if (this.highlighted >= len) {
- this.highlighted = 0
- }
+
+ this.highlighted += 1
+ if (this.highlighted >= len) {
+ this.highlighted = -1
+ this.input.focus()
+ }
+ if (len > 0) {
e.preventDefault()
- } else {
- this.highlighted = 0
}
},
scrollIntoView () {
@@ -540,6 +558,13 @@ const EmojiInput = {
})
},
resize () {
+ },
+ autoCompleteItemLabel (suggestion) {
+ if (suggestion.user) {
+ return suggestion.displayText + ' ' + suggestion.detailText
+ } else {
+ return this.maybeLocalizedEmojiName(suggestion)
+ }
}
}
}
diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue
index ccba0393..7f9ecc99 100644
--- a/src/components/emoji_input/emoji_input.vue
+++ b/src/components/emoji_input/emoji_input.vue
@@ -4,12 +4,19 @@
class="emoji-input"
:class="{ 'with-picker': !hideEmojiButton }"
>
- <slot />
+ <slot
+ :id="'textbox-' + randomSeed"
+ :aria-owns="suggestionListId"
+ aria-autocomplete="both"
+ :aria-expanded="showSuggestions"
+ :aria-activedescendant="(!showSuggestions || highlighted === -1) ? '' : suggestionItemId(highlighted)"
+ />
<!-- TODO: make the 'x' disappear if at the end maybe? -->
<div
ref="hiddenOverlay"
class="hidden-overlay"
:style="overlayStyle"
+ :aria-hidden="true"
>
<span>{{ preText }}</span>
<span
@@ -18,11 +25,16 @@
>x</span>
<span>{{ postText }}</span>
</div>
+ <screen-reader-notice
+ ref="screenReaderNotice"
+ aria-live="assertive"
+ />
<template v-if="enableEmojiPicker">
<button
v-if="!hideEmojiButton"
class="button-unstyled emoji-picker-icon"
type="button"
+ :title="$t('emoji.add_emoji')"
@click.prevent="togglePicker"
>
<FAIcon :icon="['far', 'smile-beam']" />
@@ -43,17 +55,24 @@
ref="suggestorPopover"
class="autocomplete-panel"
placement="bottom"
+ :trigger-attrs="{ 'aria-hidden': true }"
>
<template #content>
<div
+ :id="suggestionListId"
ref="panel-body"
class="autocomplete-panel-body"
+ role="listbox"
>
<div
v-for="(suggestion, index) in suggestions"
+ :id="suggestionItemId(index)"
:key="index"
class="autocomplete-item"
+ role="option"
:class="{ highlighted: index === highlighted }"
+ :aria-label="autoCompleteItemLabel(suggestion)"
+ :aria-selected="index === highlighted"
@click.stop.prevent="onClick($event, suggestion)"
>
<span class="image">
diff --git a/src/components/emoji_input/suggestor.js b/src/components/emoji_input/suggestor.js
index adaa879e..e746dcd7 100644
--- a/src/components/emoji_input/suggestor.js
+++ b/src/components/emoji_input/suggestor.js
@@ -94,8 +94,9 @@ export const suggestUsers = ({ dispatch, state }) => {
const newSuggestions = state.users.users.filter(
user =>
- user.screen_name.toLowerCase().startsWith(noPrefix) ||
- user.name.toLowerCase().startsWith(noPrefix)
+ user.screen_name && user.name && (
+ user.screen_name.toLowerCase().startsWith(noPrefix) ||
+ user.name.toLowerCase().startsWith(noPrefix))
).slice(0, 20).sort((a, b) => {
let aScore = 0
let bScore = 0
diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js
index 0d7ca812..349b043d 100644
--- a/src/components/emoji_picker/emoji_picker.js
+++ b/src/components/emoji_picker/emoji_picker.js
@@ -98,6 +98,11 @@ const EmojiPicker = {
required: false,
type: Boolean,
default: false
+ },
+ hideCustomEmoji: {
+ required: false,
+ type: Boolean,
+ default: false
}
},
data () {
@@ -280,6 +285,9 @@ const EmojiPicker = {
return 0
},
allCustomGroups () {
+ if (this.hideCustomEmoji) {
+ return {}
+ }
const emojis = this.$store.getters.groupedCustomEmojis
if (emojis.unpacked) {
emojis.unpacked.text = this.$t('emoji.unpacked')
diff --git a/src/components/emoji_picker/emoji_picker.vue b/src/components/emoji_picker/emoji_picker.vue
index ca90bf26..6972164b 100644
--- a/src/components/emoji_picker/emoji_picker.vue
+++ b/src/components/emoji_picker/emoji_picker.vue
@@ -3,6 +3,7 @@
ref="popover"
trigger="click"
popover-class="emoji-picker popover-default"
+ :trigger-attrs="{ 'aria-hidden': true }"
@show="onPopoverShown"
@close="onPopoverClosed"
>
diff --git a/src/components/emoji_reactions/emoji_reactions.vue b/src/components/emoji_reactions/emoji_reactions.vue
index a63daa97..eb46018e 100644
--- a/src/components/emoji_reactions/emoji_reactions.vue
+++ b/src/components/emoji_reactions/emoji_reactions.vue
@@ -2,7 +2,7 @@
<div class="EmojiReactions">
<UserListPopover
v-for="(reaction) in emojiReactions"
- :key="reaction.name"
+ :key="reaction.url || reaction.name"
:users="accountsForEmoji[reaction.name]"
>
<button
@@ -11,7 +11,21 @@
@click="emojiOnClick(reaction.name, $event)"
@mouseenter="fetchEmojiReactionsByIfMissing()"
>
- <span class="reaction-emoji">{{ reaction.name }}</span>
+ <span
+ class="reaction-emoji"
+ >
+ <img
+ v-if="reaction.url"
+ :src="reaction.url"
+ :title="reaction.name"
+ class="reaction-emoji-content"
+ width="1em"
+ >
+ <span
+ v-else
+ class="reaction-emoji reaction-emoji-content"
+ >{{ reaction.name }}</span>
+ </span>
<span>{{ reaction.count }}</span>
</button>
</UserListPopover>
@@ -35,6 +49,8 @@
margin-top: 0.25em;
flex-wrap: wrap;
+ --emoji-size: calc(1.25em * var(--emojiReactionsScale, 1));
+
.emoji-reaction {
padding: 0 0.5em;
margin-right: 0.5em;
@@ -45,8 +61,24 @@
box-sizing: border-box;
.reaction-emoji {
- width: 1.25em;
+ width: var(--emoji-size);
+ height: var(--emoji-size);
margin-right: 0.25em;
+ line-height: var(--emoji-size);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+
+ .reaction-emoji-content {
+ max-width: 100%;
+ max-height: 100%;
+ width: auto;
+ height: auto;
+ line-height: inherit;
+ overflow: hidden;
+ font-size: calc(var(--emoji-size) * 0.8);
+ margin: 0;
}
&:focus {
diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js
index 3dc968c9..48b960b2 100644
--- a/src/components/extra_buttons/extra_buttons.js
+++ b/src/components/extra_buttons/extra_buttons.js
@@ -1,4 +1,5 @@
import Popover from '../popover/popover.vue'
+import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faEllipsisH,
@@ -32,10 +33,14 @@ library.add(
const ExtraButtons = {
props: ['status'],
- components: { Popover },
+ components: {
+ Popover,
+ ConfirmModal
+ },
data () {
return {
- expanded: false
+ expanded: false,
+ showingDeleteDialog: false
}
},
methods: {
@@ -46,11 +51,22 @@ const ExtraButtons = {
this.expanded = false
},
deleteStatus () {
- const confirmed = window.confirm(this.$t('status.delete_confirm'))
- if (confirmed) {
- this.$store.dispatch('deleteStatus', { id: this.status.id })
+ if (this.shouldConfirmDelete) {
+ this.showDeleteStatusConfirmDialog()
+ } else {
+ this.doDeleteStatus()
}
},
+ doDeleteStatus () {
+ this.$store.dispatch('deleteStatus', { id: this.status.id })
+ this.hideDeleteStatusConfirmDialog()
+ },
+ showDeleteStatusConfirmDialog () {
+ this.showingDeleteDialog = true
+ },
+ hideDeleteStatusConfirmDialog () {
+ this.showingDeleteDialog = false
+ },
pinStatus () {
this.$store.dispatch('pinStatus', this.status.id)
.then(() => this.$emit('onSuccess'))
@@ -133,7 +149,10 @@ const ExtraButtons = {
isEdited () {
return this.status.edited_at !== null
},
- editingAvailable () { return this.$store.state.instance.editingAvailable }
+ editingAvailable () { return this.$store.state.instance.editingAvailable },
+ shouldConfirmDelete () {
+ return this.$store.getters.mergedConfig.modalOnDelete
+ }
}
}
diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue
index a84d47f6..c1c15c0f 100644
--- a/src/components/extra_buttons/extra_buttons.vue
+++ b/src/components/extra_buttons/extra_buttons.vue
@@ -165,6 +165,18 @@
/>
</FALayers>
</span>
+ <teleport to="#modal">
+ <ConfirmModal
+ v-if="showingDeleteDialog"
+ :title="$t('status.delete_confirm_title')"
+ :cancel-text="$t('status.delete_confirm_cancel_button')"
+ :confirm-text="$t('status.delete_confirm_accept_button')"
+ @cancelled="hideDeleteStatusConfirmDialog"
+ @accepted="doDeleteStatus"
+ >
+ {{ $t('status.delete_confirm') }}
+ </ConfirmModal>
+ </teleport>
</template>
</Popover>
</template>
diff --git a/src/components/favorite_button/favorite_button.vue b/src/components/favorite_button/favorite_button.vue
index 58d14945..8c883c13 100644
--- a/src/components/favorite_button/favorite_button.vue
+++ b/src/components/favorite_button/favorite_button.vue
@@ -38,13 +38,20 @@
class="button-unstyled interactive"
target="_blank"
role="button"
+ :title="$t('tool_tip.favorite')"
:href="remoteInteractionLink"
>
- <FAIcon
- class="fa-scale-110 fa-old-padding"
- :title="$t('tool_tip.favorite')"
- :icon="['far', 'star']"
- />
+ <FALayers class="fa-scale-110 fa-old-padding-layer">
+ <FAIcon
+ class="fa-scale-110"
+ :icon="['far', 'star']"
+ />
+ <FAIcon
+ class="focus-marker"
+ transform="shrink-6 up-9 right-12"
+ icon="plus"
+ />
+ </FALayers>
</a>
<span
v-if="!mergedConfig.hidePostStats && status.fave_num > 0"
diff --git a/src/components/follow_button/follow_button.js b/src/components/follow_button/follow_button.js
index 3edbcb86..443aa9bc 100644
--- a/src/components/follow_button/follow_button.js
+++ b/src/components/follow_button/follow_button.js
@@ -1,12 +1,20 @@
+import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
export default {
props: ['relationship', 'user', 'labelFollowing', 'buttonClass'],
+ components: {
+ ConfirmModal
+ },
data () {
return {
- inProgress: false
+ inProgress: false,
+ showingConfirmUnfollow: false
}
},
computed: {
+ shouldConfirmUnfollow () {
+ return this.$store.getters.mergedConfig.modalOnUnfollow
+ },
isPressed () {
return this.inProgress || this.relationship.following
},
@@ -35,6 +43,12 @@ export default {
}
},
methods: {
+ showConfirmUnfollow () {
+ this.showingConfirmUnfollow = true
+ },
+ hideConfirmUnfollow () {
+ this.showingConfirmUnfollow = false
+ },
onClick () {
this.relationship.following || this.relationship.requested ? this.unfollow() : this.follow()
},
@@ -45,12 +59,21 @@ export default {
})
},
unfollow () {
+ if (this.shouldConfirmUnfollow) {
+ this.showConfirmUnfollow()
+ } else {
+ this.doUnfollow()
+ }
+ },
+ doUnfollow () {
const store = this.$store
this.inProgress = true
requestUnfollow(this.relationship.id, store).then(() => {
this.inProgress = false
store.commit('removeStatus', { timeline: 'friends', userId: this.relationship.id })
})
+
+ this.hideConfirmUnfollow()
}
}
}
diff --git a/src/components/follow_button/follow_button.vue b/src/components/follow_button/follow_button.vue
index 965d5256..e421c15b 100644
--- a/src/components/follow_button/follow_button.vue
+++ b/src/components/follow_button/follow_button.vue
@@ -7,6 +7,27 @@
@click="onClick"
>
{{ label }}
+ <teleport to="#modal">
+ <confirm-modal
+ v-if="showingConfirmUnfollow"
+ :title="$t('user_card.unfollow_confirm_title')"
+ :confirm-text="$t('user_card.unfollow_confirm_accept_button')"
+ :cancel-text="$t('user_card.unfollow_confirm_cancel_button')"
+ @accepted="doUnfollow"
+ @cancelled="hideConfirmUnfollow"
+ >
+ <i18n-t
+ keypath="user_card.unfollow_confirm"
+ tag="span"
+ >
+ <template #user>
+ <span
+ v-text="user.screen_name_ui"
+ />
+ </template>
+ </i18n-t>
+ </confirm-modal>
+ </teleport>
</button>
</template>
diff --git a/src/components/follow_card/follow_card.vue b/src/components/follow_card/follow_card.vue
index eff69fb2..bdb6b809 100644
--- a/src/components/follow_card/follow_card.vue
+++ b/src/components/follow_card/follow_card.vue
@@ -24,6 +24,7 @@
/>
<RemoveFollowerButton
v-if="noFollowsYou && relationship.followed_by"
+ :user="user"
:relationship="relationship"
class="follow-card-button"
/>
diff --git a/src/components/follow_request_card/follow_request_card.js b/src/components/follow_request_card/follow_request_card.js
index cbd75311..b0873bb1 100644
--- a/src/components/follow_request_card/follow_request_card.js
+++ b/src/components/follow_request_card/follow_request_card.js
@@ -1,10 +1,18 @@
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
+import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import { notificationsFromStore } from '../../services/notification_utils/notification_utils.js'
const FollowRequestCard = {
props: ['user'],
components: {
- BasicUserCard
+ BasicUserCard,
+ ConfirmModal
+ },
+ data () {
+ return {
+ showingApproveConfirmDialog: false,
+ showingDenyConfirmDialog: false
+ }
},
methods: {
findFollowRequestNotificationId () {
@@ -13,7 +21,26 @@ const FollowRequestCard = {
)
return notif && notif.id
},
+ showApproveConfirmDialog () {
+ this.showingApproveConfirmDialog = true
+ },
+ hideApproveConfirmDialog () {
+ this.showingApproveConfirmDialog = false
+ },
+ showDenyConfirmDialog () {
+ this.showingDenyConfirmDialog = true
+ },
+ hideDenyConfirmDialog () {
+ this.showingDenyConfirmDialog = false
+ },
approveUser () {
+ if (this.shouldConfirmApprove) {
+ this.showApproveConfirmDialog()
+ } else {
+ this.doApprove()
+ }
+ },
+ doApprove () {
this.$store.state.api.backendInteractor.approveUser({ id: this.user.id })
this.$store.dispatch('removeFollowRequest', this.user)
@@ -25,14 +52,34 @@ const FollowRequestCard = {
notification.type = 'follow'
}
})
+ this.hideApproveConfirmDialog()
},
denyUser () {
+ if (this.shouldConfirmDeny) {
+ this.showDenyConfirmDialog()
+ } else {
+ this.doDeny()
+ }
+ },
+ doDeny () {
const notifId = this.findFollowRequestNotificationId()
this.$store.state.api.backendInteractor.denyUser({ id: this.user.id })
.then(() => {
this.$store.dispatch('dismissNotificationLocal', { id: notifId })
this.$store.dispatch('removeFollowRequest', this.user)
})
+ this.hideDenyConfirmDialog()
+ }
+ },
+ computed: {
+ mergedConfig () {
+ return this.$store.getters.mergedConfig
+ },
+ shouldConfirmApprove () {
+ return this.mergedConfig.modalOnApproveFollow
+ },
+ shouldConfirmDeny () {
+ return this.mergedConfig.modalOnDenyFollow
}
}
}
diff --git a/src/components/follow_request_card/follow_request_card.vue b/src/components/follow_request_card/follow_request_card.vue
index eb222cc7..55b65112 100644
--- a/src/components/follow_request_card/follow_request_card.vue
+++ b/src/components/follow_request_card/follow_request_card.vue
@@ -14,6 +14,28 @@
{{ $t('user_card.deny') }}
</button>
</div>
+ <teleport to="#modal">
+ <confirm-modal
+ v-if="showingApproveConfirmDialog"
+ :title="$t('user_card.approve_confirm_title')"
+ :confirm-text="$t('user_card.approve_confirm_accept_button')"
+ :cancel-text="$t('user_card.approve_confirm_cancel_button')"
+ @accepted="doApprove"
+ @cancelled="hideApproveConfirmDialog"
+ >
+ {{ $t('user_card.approve_confirm', { user: user.screen_name_ui }) }}
+ </confirm-modal>
+ <confirm-modal
+ v-if="showingDenyConfirmDialog"
+ :title="$t('user_card.deny_confirm_title')"
+ :confirm-text="$t('user_card.deny_confirm_accept_button')"
+ :cancel-text="$t('user_card.deny_confirm_cancel_button')"
+ @accepted="doDeny"
+ @cancelled="hideDenyConfirmDialog"
+ >
+ {{ $t('user_card.deny_confirm', { user: user.screen_name_ui }) }}
+ </confirm-modal>
+ </teleport>
</basic-user-card>
</template>
diff --git a/src/components/font_control/font_control.vue b/src/components/font_control/font_control.vue
index bb7e64bc..e2ba74d1 100644
--- a/src/components/font_control/font_control.vue
+++ b/src/components/font_control/font_control.vue
@@ -4,6 +4,7 @@
:class="{ custom: isCustom }"
>
<label
+ :id="name + '-label'"
:for="preset === 'custom' ? name : name + '-font-switcher'"
class="label"
>
@@ -12,7 +13,8 @@
<input
v-if="typeof fallback !== 'undefined'"
:id="name + '-o'"
- class="opt exlcude-disabled"
+ :aria-labelledby="name + '-label'"
+ class="opt exlcude-disabled visible-for-screenreader-only"
type="checkbox"
:checked="present"
@change="$emit('update:modelValue', typeof modelValue === 'undefined' ? fallback : undefined)"
@@ -21,6 +23,7 @@
v-if="typeof fallback !== 'undefined'"
class="opt-l"
:for="name + '-o'"
+ :aria-hidden="true"
/>
{{ ' ' }}
<Select
diff --git a/src/components/interface_language_switcher/interface_language_switcher.vue b/src/components/interface_language_switcher/interface_language_switcher.vue
index 6997f149..a57e8761 100644
--- a/src/components/interface_language_switcher/interface_language_switcher.vue
+++ b/src/components/interface_language_switcher/interface_language_switcher.vue
@@ -1,21 +1,44 @@
<template>
- <div>
- <label for="interface-language-switcher">
+ <div class="interface-language-switcher">
+ <label>
{{ promptText }}
</label>
- {{ ' ' }}
- <Select
- id="interface-language-switcher"
- v-model="controlledLanguage"
- >
- <option
- v-for="lang in languages"
- :key="lang.code"
- :value="lang.code"
+ <ul class="setting-list">
+ <li
+ v-for="index of controlledLanguage.keys()"
+ :key="index"
>
- {{ lang.name }}
- </option>
- </Select>
+ <label>
+ {{ index === 0 ? $t('settings.primary_language') : $tc('settings.fallback_language', index, { index }) }}
+ <Select
+ class="language-select"
+ :model-value="controlledLanguage[index]"
+ @update:modelValue="val => setLanguageAt(index, val)"
+ >
+ <option
+ v-for="lang in languages"
+ :key="lang.code"
+ :value="lang.code"
+ >
+ {{ lang.name }}
+ </option>
+ </Select>
+ </label>
+ <button
+ v-if="controlledLanguage.length > 1 && index !== 0"
+ class="button-default btn"
+ @click="() => removeLanguageAt(index)"
+ >
+ {{ $t('settings.remove_language') }}
+ </button>
+ </li>
+ <li>
+ <button
+ class="button-default btn"
+ @click="addLanguage"
+ >{{ $t('settings.add_language') }}</button>
+ </li>
+ </ul>
</div>
</template>
@@ -34,7 +57,7 @@ export default {
required: true
},
language: {
- type: String,
+ type: [Array, String],
required: true
},
setLanguage: {
@@ -48,7 +71,9 @@ export default {
},
controlledLanguage: {
- get: function () { return this.language },
+ get: function () {
+ return Array.isArray(this.language) ? this.language : [this.language]
+ },
set: function (val) {
this.setLanguage(val)
}
@@ -58,7 +83,30 @@ export default {
methods: {
getLanguageName (code) {
return localeService.getLanguageName(code)
+ },
+ addLanguage () {
+ this.controlledLanguage = [...this.controlledLanguage, '']
+ },
+ setLanguageAt (index, val) {
+ const lang = [...this.controlledLanguage]
+ lang[index] = val
+ this.controlledLanguage = lang
+ },
+ removeLanguageAt (index) {
+ const lang = [...this.controlledLanguage]
+ lang.splice(index, 1)
+ this.controlledLanguage = lang
}
}
}
</script>
+
+<style lang="scss">
+@import "../../variables";
+
+.interface-language-switcher {
+ .language-select {
+ margin-right: 1em;
+ }
+}
+</style>
diff --git a/src/components/list/list.vue b/src/components/list/list.vue
index f17766b4..a3562c5d 100644
--- a/src/components/list/list.vue
+++ b/src/components/list/list.vue
@@ -1,9 +1,13 @@
<template>
- <div class="list">
+ <div
+ class="list"
+ role="list"
+ >
<div
v-for="item in items"
:key="getKey(item)"
class="list-item"
+ role="listitem"
>
<slot
name="item"
diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js
index cdbbb812..dad1f6aa 100644
--- a/src/components/mobile_nav/mobile_nav.js
+++ b/src/components/mobile_nav/mobile_nav.js
@@ -1,5 +1,6 @@
import SideDrawer from '../side_drawer/side_drawer.vue'
import Notifications from '../notifications/notifications.vue'
+import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
import GestureService from '../../services/gesture_service/gesture_service'
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
@@ -25,12 +26,14 @@ const MobileNav = {
components: {
SideDrawer,
Notifications,
- NavigationPins
+ NavigationPins,
+ ConfirmModal
},
data: () => ({
notificationsCloseGesture: undefined,
notificationsOpen: false,
- notificationsAtTop: true
+ notificationsAtTop: true,
+ showingConfirmLogout: false
}),
created () {
this.notificationsCloseGesture = GestureService.swipeGesture(
@@ -57,7 +60,11 @@ const MobileNav = {
...mapGetters(['unreadChatCount', 'unreadAnnouncementCount']),
chatsPinned () {
return new Set(this.$store.state.serverSideStorage.prefsStorage.collections.pinnedNavItems).has('chats')
- }
+ },
+ shouldConfirmLogout () {
+ return this.$store.getters.mergedConfig.modalOnLogout
+ },
+ ...mapGetters(['unreadChatCount'])
},
methods: {
toggleMobileSidebar () {
@@ -88,9 +95,23 @@ const MobileNav = {
scrollMobileNotificationsToTop () {
this.$refs.mobileNotifications.scrollTo(0, 0)
},
+ showConfirmLogout () {
+ this.showingConfirmLogout = true
+ },
+ hideConfirmLogout () {
+ this.showingConfirmLogout = false
+ },
logout () {
+ if (!this.shouldConfirmLogout) {
+ this.doLogout()
+ } else {
+ this.showConfirmLogout()
+ }
+ },
+ doLogout () {
this.$router.replace('/main/public')
this.$store.dispatch('logout')
+ this.hideConfirmLogout()
},
markNotificationsAsSeen () {
// this.$refs.notifications.markAsSeen()
diff --git a/src/components/mobile_nav/mobile_nav.vue b/src/components/mobile_nav/mobile_nav.vue
index d6fe102c..c2746abe 100644
--- a/src/components/mobile_nav/mobile_nav.vue
+++ b/src/components/mobile_nav/mobile_nav.vue
@@ -88,6 +88,18 @@
ref="sideDrawer"
:logout="logout"
/>
+ <teleport to="#modal">
+ <confirm-modal
+ v-if="showingConfirmLogout"
+ :title="$t('login.logout_confirm_title')"
+ :confirm-text="$t('login.logout_confirm_accept_button')"
+ :cancel-text="$t('login.logout_confirm_cancel_button')"
+ @accepted="doLogout"
+ @cancelled="hideConfirmLogout"
+ >
+ {{ $t('login.logout_confirm') }}
+ </confirm-modal>
+ </teleport>
</div>
</template>
@@ -235,6 +247,16 @@
}
}
}
+
+ .confirm-modal.dark-overlay {
+ &::before {
+ z-index: 3000;
+ }
+
+ .dialog-modal.panel {
+ z-index: 3001;
+ }
+ }
}
</style>
diff --git a/src/components/navigation/navigation.js b/src/components/navigation/navigation.js
index 7f096316..face430e 100644
--- a/src/components/navigation/navigation.js
+++ b/src/components/navigation/navigation.js
@@ -80,3 +80,21 @@ export const ROOT_ITEMS = {
criteria: ['announcements']
}
}
+
+export function routeTo (item, currentUser) {
+ if (!item.route && !item.routeObject) return null
+
+ let route
+
+ if (item.routeObject) {
+ route = item.routeObject
+ } else {
+ route = { name: (item.anon || currentUser) ? item.route : item.anonRoute }
+ }
+
+ if (USERNAME_ROUTES.has(route.name)) {
+ route.params = { username: currentUser.screen_name, name: currentUser.screen_name }
+ }
+
+ return route
+}
diff --git a/src/components/navigation/navigation_entry.js b/src/components/navigation/navigation_entry.js
index 81cc936a..22ed77d9 100644
--- a/src/components/navigation/navigation_entry.js
+++ b/src/components/navigation/navigation_entry.js
@@ -1,5 +1,5 @@
import { mapState } from 'vuex'
-import { USERNAME_ROUTES } from 'src/components/navigation/navigation.js'
+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'
@@ -26,17 +26,7 @@ const NavigationEntry = {
},
computed: {
routeTo () {
- if (!this.item.route && !this.item.routeObject) return null
- let route
- if (this.item.routeObject) {
- route = this.item.routeObject
- } else {
- route = { name: (this.item.anon || this.currentUser) ? this.item.route : this.item.anonRoute }
- }
- if (USERNAME_ROUTES.has(route.name)) {
- route.params = { username: this.currentUser.screen_name, name: this.currentUser.screen_name }
- }
- return route
+ return routeTo(this.item, this.currentUser)
},
getters () {
return this.$store.getters
diff --git a/src/components/navigation/navigation_pins.js b/src/components/navigation/navigation_pins.js
index 9dd795aa..ef78e44c 100644
--- a/src/components/navigation/navigation_pins.js
+++ b/src/components/navigation/navigation_pins.js
@@ -1,5 +1,5 @@
import { mapState } from 'vuex'
-import { TIMELINES, ROOT_ITEMS, USERNAME_ROUTES } from 'src/components/navigation/navigation.js'
+import { TIMELINES, ROOT_ITEMS, routeTo } from 'src/components/navigation/navigation.js'
import { getListEntries, filterNavigation } from 'src/components/navigation/filter.js'
import { library } from '@fortawesome/fontawesome-svg-core'
@@ -31,14 +31,7 @@ const NavPanel = {
props: ['limit'],
methods: {
getRouteTo (item) {
- if (item.routeObject) {
- return item.routeObject
- }
- const route = { name: (item.anon || this.currentUser) ? item.route : item.anonRoute }
- if (USERNAME_ROUTES.has(route.name)) {
- route.params = { username: this.currentUser.screen_name }
- }
- return route
+ return routeTo(item, this.currentUser)
}
},
computed: {
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index 265aaee0..420db4f0 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -8,6 +8,7 @@ import Report from '../report/report.vue'
import UserLink from '../user_link/user_link.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import UserPopover from '../user_popover/user_popover.vue'
+import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@@ -43,7 +44,9 @@ const Notification = {
return {
statusExpanded: false,
betterShadow: this.$store.state.interface.browserSupport.cssFilter,
- unmuted: false
+ unmuted: false,
+ showingApproveConfirmDialog: false,
+ showingDenyConfirmDialog: false
}
},
props: ['notification'],
@@ -56,7 +59,8 @@ const Notification = {
Report,
RichContent,
UserPopover,
- UserLink
+ UserLink,
+ ConfirmModal
},
methods: {
toggleStatusExpanded () {
@@ -71,7 +75,26 @@ const Notification = {
toggleMute () {
this.unmuted = !this.unmuted
},
+ showApproveConfirmDialog () {
+ this.showingApproveConfirmDialog = true
+ },
+ hideApproveConfirmDialog () {
+ this.showingApproveConfirmDialog = false
+ },
+ showDenyConfirmDialog () {
+ this.showingDenyConfirmDialog = true
+ },
+ hideDenyConfirmDialog () {
+ this.showingDenyConfirmDialog = false
+ },
approveUser () {
+ if (this.shouldConfirmApprove) {
+ this.showApproveConfirmDialog()
+ } else {
+ this.doApprove()
+ }
+ },
+ doApprove () {
this.$store.state.api.backendInteractor.approveUser({ id: this.user.id })
this.$store.dispatch('removeFollowRequest', this.user)
this.$store.dispatch('markSingleNotificationAsSeen', { id: this.notification.id })
@@ -81,13 +104,22 @@ const Notification = {
notification.type = 'follow'
}
})
+ this.hideApproveConfirmDialog()
},
denyUser () {
+ if (this.shouldConfirmDeny) {
+ this.showDenyConfirmDialog()
+ } else {
+ this.doDeny()
+ }
+ },
+ doDeny () {
this.$store.state.api.backendInteractor.denyUser({ id: this.user.id })
.then(() => {
this.$store.dispatch('dismissNotificationLocal', { id: this.notification.id })
this.$store.dispatch('removeFollowRequest', this.user)
})
+ this.hideDenyConfirmDialog()
}
},
computed: {
@@ -117,6 +149,15 @@ const Notification = {
isStatusNotification () {
return isStatusNotification(this.notification.type)
},
+ mergedConfig () {
+ return this.$store.getters.mergedConfig
+ },
+ shouldConfirmApprove () {
+ return this.mergedConfig.modalOnApproveFollow
+ },
+ shouldConfirmDeny () {
+ return this.mergedConfig.modalOnDenyFollow
+ },
...mapState({
currentUser: state => state.users.currentUser
})
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index f1aa5420..4d801c5e 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -121,7 +121,16 @@
scope="global"
keypath="notifications.reacted_with"
>
- <span class="emoji-reaction-emoji">{{ notification.emoji }}</span>
+ <img
+ v-if="notification.emoji_url"
+ class="emoji-reaction-emoji emoji-reaction-emoji-image"
+ :src="notification.emoji_url"
+ :name="notification.emoji"
+ >
+ <span
+ v-else
+ class="emoji-reaction-emoji"
+ >{{ notification.emoji }}</span>
</i18n-t>
</small>
</span>
@@ -153,9 +162,9 @@
</router-link>
<button
class="button-unstyled expand-icon"
- @click.prevent="toggleStatusExpanded"
- :title="$t('tool_tip.toggle_expand')"
:aria-expanded="statusExpanded"
+ :title="$t('tool_tip.toggle_expand')"
+ @click.prevent="toggleStatusExpanded"
>
<FAIcon
class="fa-scale-110"
@@ -243,6 +252,28 @@
</template>
</div>
</div>
+ <teleport to="#modal">
+ <confirm-modal
+ v-if="showingApproveConfirmDialog"
+ :title="$t('user_card.approve_confirm_title')"
+ :confirm-text="$t('user_card.approve_confirm_accept_button')"
+ :cancel-text="$t('user_card.approve_confirm_cancel_button')"
+ @accepted="doApprove"
+ @cancelled="hideApproveConfirmDialog"
+ >
+ {{ $t('user_card.approve_confirm', { user: user.screen_name_ui }) }}
+ </confirm-modal>
+ <confirm-modal
+ v-if="showingDenyConfirmDialog"
+ :title="$t('user_card.deny_confirm_title')"
+ :confirm-text="$t('user_card.deny_confirm_accept_button')"
+ :cancel-text="$t('user_card.deny_confirm_cancel_button')"
+ @accepted="doDeny"
+ @cancelled="hideDenyConfirmDialog"
+ >
+ {{ $t('user_card.deny_confirm', { user: user.screen_name_ui }) }}
+ </confirm-modal>
+ </teleport>
</article>
</template>
diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss
index 41cfcef0..61f7317e 100644
--- a/src/components/notifications/notifications.scss
+++ b/src/components/notifications/notifications.scss
@@ -129,6 +129,13 @@
.emoji-reaction-emoji {
font-size: 1.3em;
+ max-width: 1.25em;
+ height: 1.25em;
+ width: auto;
+ }
+
+ .emoji-reaction-emoji-image {
+ vertical-align: middle;
}
.notification-details {
diff --git a/src/components/poll/poll.js b/src/components/poll/poll.js
index eda1733a..e4d6869a 100644
--- a/src/components/poll/poll.js
+++ b/src/components/poll/poll.js
@@ -12,7 +12,8 @@ export default {
data () {
return {
loading: false,
- choices: []
+ choices: [],
+ randomSeed: `${Math.random()}`.replace('.', '-')
}
},
created () {
diff --git a/src/components/poll/poll.vue b/src/components/poll/poll.vue
index cacc3298..b3a74c49 100644
--- a/src/components/poll/poll.vue
+++ b/src/components/poll/poll.vue
@@ -4,53 +4,63 @@
:class="containerClass"
>
<div
- v-for="(option, index) in options"
- :key="index"
- class="poll-option"
+ :role="showResults ? 'section' : (poll.multiple ? 'group' : 'radiogroup')"
>
<div
- v-if="showResults"
- :title="resultTitle(option)"
- class="option-result"
+ v-for="(option, index) in options"
+ :key="index"
+ class="poll-option"
>
- <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
+ 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
- class="result-fill"
- :style="{ 'width': `${percentageForOption(option.votes_count)}%` }"
- />
- </div>
- <div
- v-else
- @click="activateOption(index)"
- >
- <input
- v-if="poll.multiple"
- type="checkbox"
- :disabled="loading"
- :value="index"
- >
- <input
v-else
- type="radio"
- :disabled="loading"
- :value="index"
+ tabindex="0"
+ :role="poll.multiple ? 'checkbox' : 'radio'"
+ :aria-labelledby="`option-vote-${randomSeed}-${index}`"
+ :aria-checked="choices[index]"
+ @click="activateOption(index)"
>
- <label class="option-vote">
- <RichContent
- :html="option.title_html"
- :handle-links="false"
- :emoji="emoji"
- />
- </label>
+ <input
+ v-if="poll.multiple"
+ type="checkbox"
+ class="poll-checkbox"
+ :disabled="loading"
+ :value="index"
+ >
+ <input
+ v-else
+ type="radio"
+ :disabled="loading"
+ :value="index"
+ >
+ <label class="option-vote">
+ <RichContent
+ :id="`option-vote-${randomSeed}-${index}`"
+ :html="option.title_html"
+ :handle-links="false"
+ :emoji="emoji"
+ />
+ </label>
+ </div>
</div>
</div>
<div class="footer faint">
@@ -161,5 +171,9 @@
padding: 0 0.5em;
margin-right: 0.5em;
}
+
+ .poll-checkbox {
+ display: none;
+ }
}
</style>
diff --git a/src/components/poll/poll_form.js b/src/components/poll/poll_form.js
index e30645c3..a2070155 100644
--- a/src/components/poll/poll_form.js
+++ b/src/components/poll/poll_form.js
@@ -94,19 +94,10 @@ export default {
},
convertExpiryToUnit (unit, amount) {
// Note: we want seconds and not milliseconds
- switch (unit) {
- case 'minutes': return (1000 * amount) / DateUtils.MINUTE
- case 'hours': return (1000 * amount) / DateUtils.HOUR
- case 'days': return (1000 * amount) / DateUtils.DAY
- }
+ return DateUtils.secondsToUnit(unit, amount)
},
convertExpiryFromUnit (unit, amount) {
- // Note: we want seconds and not milliseconds
- switch (unit) {
- case 'minutes': return 0.001 * amount * DateUtils.MINUTE
- case 'hours': return 0.001 * amount * DateUtils.HOUR
- case 'days': return 0.001 * amount * DateUtils.DAY
- }
+ return DateUtils.unitToSeconds(unit, amount)
},
expiryAmountChange () {
this.expiryAmount =
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index eb55cfcc..b75fee69 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -8,6 +8,7 @@ import Gallery from 'src/components/gallery/gallery.vue'
import StatusContent from '../status_content/status_content.vue'
import fileTypeService from '../../services/file_type/file_type.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
+import { propsToNative } from '../../services/attributes_helper/attributes_helper.service.js'
import { reject, map, uniqBy, debounce } from 'lodash'
import suggestor from '../emoji_input/suggestor.js'
import { mapGetters, mapState } from 'vuex'
@@ -629,6 +630,9 @@ const PostStatusForm = {
},
openProfileTab () {
this.$store.dispatch('openSettingsModalTab', 'profile')
+ },
+ propsToNative (props) {
+ return propsToNative(props)
}
}
}
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index c51639db..86c1f907 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -30,6 +30,9 @@
<span>{{ $t('post_status.scope_notice.public') }}</span>
<a
class="fa-scale-110 fa-old-padding dismiss"
+ :title="$t('post_status.scope_notice_dismiss')"
+ role="button"
+ tabindex="0"
@click.prevent="dismissScopeNotice()"
>
<FAIcon icon="times" />
@@ -42,6 +45,9 @@
<span>{{ $t('post_status.scope_notice.unlisted') }}</span>
<a
class="fa-scale-110 fa-old-padding dismiss"
+ :title="$t('post_status.scope_notice_dismiss')"
+ role="button"
+ tabindex="0"
@click.prevent="dismissScopeNotice()"
>
<FAIcon icon="times" />
@@ -54,6 +60,9 @@
<span>{{ $t('post_status.scope_notice.private') }}</span>
<a
class="fa-scale-110 fa-old-padding dismiss"
+ :title="$t('post_status.scope_notice_dismiss')"
+ role="button"
+ tabindex="0"
@click.prevent="dismissScopeNotice()"
>
<FAIcon icon="times" />
@@ -124,14 +133,17 @@
:suggest="emojiSuggestor"
class="form-control"
>
- <input
- v-model="newStatus.spoilerText"
- type="text"
- :placeholder="$t('post_status.content_warning')"
- :disabled="posting && !optimisticPosting"
- size="1"
- class="form-post-subject"
- >
+ <template #default="inputProps">
+ <input
+ v-model="newStatus.spoilerText"
+ type="text"
+ :placeholder="$t('post_status.content_warning')"
+ :disabled="posting && !optimisticPosting"
+ v-bind="propsToNative(inputProps)"
+ size="1"
+ class="form-post-subject"
+ >
+ </template>
</EmojiInput>
<EmojiInput
ref="emoji-input"
@@ -148,29 +160,32 @@
@sticker-upload-failed="uploadFailed"
@shown="handleEmojiInputShow"
>
- <textarea
- ref="textarea"
- v-model="newStatus.status"
- :placeholder="placeholder || $t('post_status.default')"
- rows="1"
- cols="1"
- :disabled="posting && !optimisticPosting"
- class="form-post-body"
- :class="{ 'scrollable-form': !!maxHeight }"
- @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
- @keydown.meta.enter="postStatus($event, newStatus)"
- @keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)"
- @input="resize"
- @compositionupdate="resize"
- @paste="paste"
- />
- <p
- v-if="hasStatusLengthLimit"
- class="character-counter faint"
- :class="{ error: isOverLengthLimit }"
- >
- {{ charactersLeft }}
- </p>
+ <template #default="inputProps">
+ <textarea
+ ref="textarea"
+ v-model="newStatus.status"
+ :placeholder="placeholder || $t('post_status.default')"
+ rows="1"
+ cols="1"
+ :disabled="posting && !optimisticPosting"
+ class="form-post-body"
+ :class="{ 'scrollable-form': !!maxHeight }"
+ v-bind="propsToNative(inputProps)"
+ @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
+ @keydown.meta.enter="postStatus($event, newStatus)"
+ @keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)"
+ @input="resize"
+ @compositionupdate="resize"
+ @paste="paste"
+ />
+ <p
+ v-if="hasStatusLengthLimit"
+ class="character-counter faint"
+ :class="{ error: isOverLengthLimit }"
+ >
+ {{ charactersLeft }}
+ </p>
+ </template>
</EmojiInput>
<div
v-if="!disableScopeSelector"
@@ -193,6 +208,7 @@
id="post-content-type"
v-model="newStatus.contentType"
class="form-control"
+ :attrs="{ 'aria-label': $t('post_status.content_type_selection') }"
>
<option
v-for="postFormat in postFormats"
@@ -265,12 +281,10 @@
>
{{ $t('post_status.post') }}
</button>
- <!-- touchstart is used to keep the OSK at the same position after a message send -->
<button
v-else
:disabled="uploadingFiles || disableSubmit"
class="btn button-default"
- @touchstart.stop.prevent="postStatus($event, newStatus)"
@click.stop.prevent="postStatus($event, newStatus)"
>
{{ $t('post_status.post') }}
diff --git a/src/components/quick_filter_settings/quick_filter_settings.vue b/src/components/quick_filter_settings/quick_filter_settings.vue
index f2aa61ee..b81215a1 100644
--- a/src/components/quick_filter_settings/quick_filter_settings.vue
+++ b/src/components/quick_filter_settings/quick_filter_settings.vue
@@ -6,36 +6,51 @@
:trigger-attrs="{ title: $t('timeline.quick_filter_settings') }"
>
<template #content>
- <div class="dropdown-menu">
- <div v-if="loggedIn">
+ <div
+ class="dropdown-menu"
+ role="menu"
+ >
+ <div
+ v-if="loggedIn"
+ role="group"
+ >
<button
v-if="!conversation"
class="button-default dropdown-item"
+ :aria-checked="replyVisibilityAll"
+ role="menuitemradio"
@click="replyVisibilityAll = true"
>
<span
class="menu-checkbox -radio"
:class="{ 'menu-checkbox-checked': replyVisibilityAll }"
+ :aria-hidden="true"
/>{{ $t('settings.reply_visibility_all') }}
</button>
<button
v-if="!conversation"
class="button-default dropdown-item"
+ :aria-checked="replyVisibilityFollowing"
+ role="menuitemradio"
@click="replyVisibilityFollowing = true"
>
<span
class="menu-checkbox -radio"
:class="{ 'menu-checkbox-checked': replyVisibilityFollowing }"
+ :aria-hidden="true"
/>{{ $t('settings.reply_visibility_following_short') }}
</button>
<button
v-if="!conversation"
class="button-default dropdown-item"
+ :aria-checked="replyVisibilitySelf"
+ role="menuitemradio"
@click="replyVisibilitySelf = true"
>
<span
class="menu-checkbox -radio"
:class="{ 'menu-checkbox-checked': replyVisibilitySelf }"
+ :aria-hidden="true"
/>{{ $t('settings.reply_visibility_self_short') }}
</button>
<div
@@ -46,33 +61,43 @@
</div>
<button
class="button-default dropdown-item"
+ role="menuitemcheckbox"
+ :aria-checked="muteBotStatuses"
@click="muteBotStatuses = !muteBotStatuses"
>
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': muteBotStatuses }"
+ :aria-hidden="true"
/>{{ $t('settings.mute_bot_posts') }}
</button>
<button
class="button-default dropdown-item"
+ role="menuitemcheckbox"
+ :aria-checked="hideMedia"
@click="hideMedia = !hideMedia"
>
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hideMedia }"
+ :aria-hidden="true"
/>{{ $t('settings.hide_media_previews') }}
</button>
<button
class="button-default dropdown-item"
+ role="menuitemcheckbox"
+ :aria-checked="hideMutedPosts"
@click="hideMutedPosts = !hideMutedPosts"
>
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hideMutedPosts }"
+ :aria-hidden="true"
/>{{ $t('settings.hide_all_muted_posts') }}
</button>
<button
class="button-default dropdown-item dropdown-item-icon"
+ role="menuitem"
@click="openTab('filtering')"
>
<FAIcon icon="font" />{{ $t('settings.word_filter_and_more') }}
diff --git a/src/components/quick_view_settings/quick_view_settings.vue b/src/components/quick_view_settings/quick_view_settings.vue
index 4bd81c5b..9f5cdabc 100644
--- a/src/components/quick_view_settings/quick_view_settings.vue
+++ b/src/components/quick_view_settings/quick_view_settings.vue
@@ -6,60 +6,87 @@
:trigger-attrs="{ title: $t('timeline.quick_view_settings') }"
>
<template #content>
- <div class="dropdown-menu">
- <button
- class="button-default dropdown-item"
- @click="conversationDisplay = 'tree'"
- >
- <span
- class="menu-checkbox -radio"
- :class="{ 'menu-checkbox-checked': conversationDisplay === 'tree' }"
- /><FAIcon icon="folder-tree" /> {{ $t('settings.conversation_display_tree_quick') }}
- </button>
- <button
- class="button-default dropdown-item"
- @click="conversationDisplay = 'linear'"
- >
- <span
- class="menu-checkbox -radio"
- :class="{ 'menu-checkbox-checked': conversationDisplay === 'linear' }"
- /><FAIcon icon="list" /> {{ $t('settings.conversation_display_linear_quick') }}
- </button>
+ <div
+ class="dropdown-menu"
+ role="menu"
+ >
+ <div role="group">
+ <button
+ class="button-default dropdown-item"
+ :aria-checked="conversationDisplay === 'tree'"
+ role="menuitemradio"
+ @click="conversationDisplay = 'tree'"
+ >
+ <span
+ class="menu-checkbox -radio"
+ :aria-hidden="true"
+ :class="{ 'menu-checkbox-checked': conversationDisplay === 'tree' }"
+ /><FAIcon
+ icon="folder-tree"
+ :aria-hidden="true"
+ /> {{ $t('settings.conversation_display_tree_quick') }}
+ </button>
+ <button
+ class="button-default dropdown-item"
+ :aria-checked="conversationDisplay === 'linear'"
+ role="menuitemradio"
+ @click="conversationDisplay = 'linear'"
+ >
+ <span
+ class="menu-checkbox -radio"
+ :class="{ 'menu-checkbox-checked': conversationDisplay === 'linear' }"
+ :aria-hidden="true"
+ /><FAIcon
+ icon="list"
+ :aria-hidden="true"
+ /> {{ $t('settings.conversation_display_linear_quick') }}
+ </button>
+ </div>
<div
role="separator"
class="dropdown-divider"
/>
<button
class="button-default dropdown-item"
+ role="menuitemcheckbox"
+ :aria-checked="showUserAvatars"
@click="showUserAvatars = !showUserAvatars"
>
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': showUserAvatars }"
+ :aria-hidden="true"
/>{{ $t('settings.mention_link_show_avatar_quick') }}
</button>
<button
v-if="!conversation"
class="button-default dropdown-item"
+ role="menuitemcheckbox"
+ :aria-checked="autoUpdate"
@click="autoUpdate = !autoUpdate"
>
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': autoUpdate }"
+ :aria-hidden="true"
/>{{ $t('settings.auto_update') }}
</button>
<button
v-if="!conversation"
class="button-default dropdown-item"
+ role="menuitemcheckbox"
+ :aria-checked="collapseWithSubjects"
@click="collapseWithSubjects = !collapseWithSubjects"
>
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': collapseWithSubjects }"
+ :aria-hidden="true"
/>{{ $t('settings.collapse_subject') }}
</button>
<button
class="button-default dropdown-item dropdown-item-icon"
+ role="menuitem"
@click="openTab('general')"
>
<FAIcon icon="wrench" />{{ $t('settings.more_settings') }}
diff --git a/src/components/range_input/range_input.vue b/src/components/range_input/range_input.vue
index 1e7e42d5..1e720105 100644
--- a/src/components/range_input/range_input.vue
+++ b/src/components/range_input/range_input.vue
@@ -4,6 +4,7 @@
:class="{ disabled: !present || disabled }"
>
<label
+ :id="name + '-label'"
:for="name"
class="label"
>
@@ -12,7 +13,8 @@
<input
v-if="typeof fallback !== 'undefined'"
:id="name + '-o'"
- class="opt"
+ :aria-labelledby="name + '-label'"
+ class="opt visible-for-screenreader-only"
type="checkbox"
:checked="present"
@change="$emit('update:modelValue', !present ? fallback : undefined)"
@@ -21,6 +23,7 @@
v-if="typeof fallback !== 'undefined'"
class="opt-l"
:for="name + '-o'"
+ :aria-hidden="true"
/>
<input
:id="name"
@@ -34,9 +37,10 @@
@input="$emit('update:modelValue', $event.target.value)"
>
<input
- :id="name"
+ :id="name + '-numeric'"
class="input-number"
type="number"
+ :aria-labelledby="name + '-label'"
:value="modelValue || fallback"
:disabled="!present || disabled"
:max="hardMax"
diff --git a/src/components/react_button/react_button.js b/src/components/react_button/react_button.js
index 47a48623..8eed4b60 100644
--- a/src/components/react_button/react_button.js
+++ b/src/components/react_button/react_button.js
@@ -1,9 +1,8 @@
import Popover from '../popover/popover.vue'
-import { ensureFinalFallback } from '../../i18n/languages.js'
+import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faPlus, faTimes } from '@fortawesome/free-solid-svg-icons'
import { faSmileBeam } from '@fortawesome/free-regular-svg-icons'
-import { trim } from 'lodash'
library.add(
faPlus,
@@ -20,105 +19,34 @@ const ReactButton = {
}
},
components: {
- Popover
+ Popover,
+ EmojiPicker
},
methods: {
- addReaction (event, emoji, close) {
+ 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 })
}
- close()
+ },
+ show () {
+ if (!this.expanded) {
+ this.$refs.picker.showPicker()
+ }
},
onShow () {
this.expanded = true
- this.focusInput()
},
onClose () {
this.expanded = false
- },
- focusInput () {
- this.$nextTick(() => {
- const input = document.querySelector('.reaction-picker-filter > input')
- if (input) input.focus()
- })
- },
- // Vaguely adjusted copypaste from emoji_input and emoji_picker!
- maybeLocalizedEmojiNamesAndKeywords (emoji) {
- const names = [emoji.displayText]
- const keywords = []
-
- if (emoji.displayTextI18n) {
- names.push(this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args))
- }
-
- if (emoji.annotations) {
- this.languages.forEach(lang => {
- names.push(emoji.annotations[lang]?.name)
-
- keywords.push(...(emoji.annotations[lang]?.keywords || []))
- })
- }
-
- return {
- names: names.filter(k => k),
- keywords: keywords.filter(k => k)
- }
- },
- maybeLocalizedEmojiName (emoji) {
- if (!emoji.annotations) {
- return emoji.displayText
- }
-
- if (emoji.displayTextI18n) {
- return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)
- }
-
- for (const lang of this.languages) {
- if (emoji.annotations[lang]?.name) {
- return emoji.annotations[lang].name
- }
- }
-
- return emoji.displayText
}
},
computed: {
- commonEmojis () {
- const hardcodedSet = new Set(['👍', '😠', '👀', '😂', '🔥'])
- return this.$store.getters.standardEmojiList.filter(emoji => hardcodedSet.has(emoji.replacement))
- },
- languages () {
- return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage)
- },
- emojis () {
- if (this.filterWord !== '') {
- const keywordLowercase = trim(this.filterWord.toLowerCase())
-
- const orderedEmojiList = []
- for (const emoji of this.$store.getters.standardEmojiList) {
- const indices = this.maybeLocalizedEmojiNamesAndKeywords(emoji)
- .keywords
- .map(k => k.toLowerCase().indexOf(keywordLowercase))
- .filter(k => k > -1)
-
- const indexOfKeyword = indices.length ? Math.min(...indices) : -1
-
- if (indexOfKeyword > -1) {
- if (!Array.isArray(orderedEmojiList[indexOfKeyword])) {
- orderedEmojiList[indexOfKeyword] = []
- }
- orderedEmojiList[indexOfKeyword].push(emoji)
- }
- }
- return orderedEmojiList.flat()
- }
- return this.$store.getters.standardEmojiList || []
- },
- mergedConfig () {
- return this.$store.getters.mergedConfig
+ hideCustomEmoji () {
+ return !this.$store.state.instance.pleromaChatMessagesAvailable
}
}
}
diff --git a/src/components/react_button/react_button.vue b/src/components/react_button/react_button.vue
index a813b6fd..947536a1 100644
--- a/src/components/react_button/react_button.vue
+++ b/src/components/react_button/react_button.vue
@@ -1,73 +1,39 @@
<template>
- <Popover
- trigger="click"
- class="ReactButton"
- placement="top"
- :offset="{ y: 5 }"
- :bound-to="{ x: 'container' }"
- remove-padding
- popover-class="ReactButton popover-default"
- @show="onShow"
- @close="onClose"
- >
- <template #content="{close}">
- <div class="reaction-picker-filter">
- <input
- v-model="filterWord"
- size="1"
- :placeholder="$t('emoji.search_emoji')"
- @input="$event.target.composing = false"
- >
- </div>
- <div class="reaction-picker">
- <span
- v-for="emoji in commonEmojis"
- :key="emoji.replacement"
- class="emoji-button"
- :title="maybeLocalizedEmojiName(emoji)"
- @click="addReaction($event, emoji.replacement, close)"
- >
- {{ emoji.replacement }}
- </span>
- <div class="reaction-picker-divider" />
- <span
- v-for="(emoji, key) in emojis"
- :key="key"
- class="emoji-button"
- :title="maybeLocalizedEmojiName(emoji)"
- @click="addReaction($event, emoji.replacement, close)"
- >
- {{ emoji.replacement }}
- </span>
- <div class="reaction-bottom-fader" />
- </div>
- </template>
- <template #trigger>
- <span
- class="button-unstyled popover-trigger"
- :title="$t('tool_tip.add_reaction')"
- >
- <FALayers>
- <FAIcon
- class="fa-scale-110 fa-old-padding"
- :icon="['far', 'smile-beam']"
- />
- <FAIcon
- v-show="!expanded"
- class="focus-marker"
- transform="shrink-6 up-9 right-17"
- icon="plus"
- />
- <FAIcon
- v-show="expanded"
- class="focus-marker"
- transform="shrink-6 up-9 right-17"
- icon="times"
- />
- </FALayers>
- </span>
- </template>
- </Popover>
+ <span class="ReactButton">
+ <EmojiPicker
+ ref="picker"
+ :enable-sticker-picker="enableStickerPicker"
+ :hide-custom-emoji="hideCustomEmoji"
+ class="emoji-picker-panel"
+ @emoji="addReaction"
+ @show="onShow"
+ @close="onClose"
+ />
+ <span
+ class="button-unstyled popover-trigger"
+ :title="$t('tool_tip.add_reaction')"
+ @click.stop.prevent="show"
+ >
+ <FALayers>
+ <FAIcon
+ class="fa-scale-110 fa-old-padding"
+ :icon="['far', 'smile-beam']"
+ />
+ <FAIcon
+ v-show="!expanded"
+ class="focus-marker"
+ transform="shrink-6 up-9 right-17"
+ icon="plus"
+ />
+ <FAIcon
+ v-show="expanded"
+ class="focus-marker"
+ transform="shrink-6 up-9 right-17"
+ icon="times"
+ />
+ </FALayers>
+ </span>
+ </span>
</template>
<script src="./react_button.js"></script>
@@ -135,11 +101,6 @@
color: $fallback--text;
color: var(--text, $fallback--text);
}
- }
-
- .popover-trigger-button {
- /* override of popover internal stuff */
- width: auto;
@include unfocused-style {
.focus-marker {
diff --git a/src/components/registration/registration.js b/src/components/registration/registration.js
index 6eb316d0..b88bdeec 100644
--- a/src/components/registration/registration.js
+++ b/src/components/registration/registration.js
@@ -3,6 +3,7 @@ import { required, requiredIf, sameAs } from '@vuelidate/validators'
import { mapActions, mapState } from 'vuex'
import InterfaceLanguageSwitcher from '../interface_language_switcher/interface_language_switcher.vue'
import localeService from '../../services/locale/locale.service.js'
+import { DAY } from 'src/services/date_utils/date_utils.js'
const registration = {
setup () { return { v$: useVuelidate() } },
@@ -13,8 +14,9 @@ const registration = {
username: '',
password: '',
confirm: '',
+ birthday: '',
reason: '',
- language: ''
+ language: ['']
},
captcha: {}
}),
@@ -32,6 +34,12 @@ const registration = {
required,
sameAs: sameAs(this.user.password)
},
+ birthday: {
+ required: requiredIf(() => this.birthdayRequired),
+ maxValue: value => {
+ return !this.birthdayRequired || new Date(value).getTime() <= this.birthdayMin.getTime()
+ }
+ },
reason: { required: requiredIf(() => this.accountApprovalRequired) },
language: {}
}
@@ -52,6 +60,24 @@ const registration = {
reasonPlaceholder () {
return this.replaceNewlines(this.$t('registration.reason_placeholder'))
},
+ birthdayMin () {
+ const minAge = this.birthdayMinAge
+ const today = new Date()
+ today.setUTCMilliseconds(0)
+ today.setUTCSeconds(0)
+ today.setUTCMinutes(0)
+ today.setUTCHours(0)
+ const minDate = new Date()
+ minDate.setTime(today.getTime() - minAge * DAY)
+ return minDate
+ },
+ birthdayMinAttr () {
+ return this.birthdayMin.toJSON().replace(/T.+$/, '')
+ },
+ birthdayMinFormatted () {
+ const browserLocale = localeService.internalToBrowserLocale(this.$i18n.locale)
+ return this.user.birthday && new Date(Date.parse(this.birthdayMin)).toLocaleDateString(browserLocale, { timeZone: 'UTC', day: 'numeric', month: 'long', year: 'numeric' })
+ },
...mapState({
registrationOpen: (state) => state.instance.registrationOpen,
signedIn: (state) => !!state.users.currentUser,
@@ -59,7 +85,9 @@ const registration = {
serverValidationErrors: (state) => state.users.signUpErrors,
termsOfService: (state) => state.instance.tos,
accountActivationRequired: (state) => state.instance.accountActivationRequired,
- accountApprovalRequired: (state) => state.instance.accountApprovalRequired
+ accountApprovalRequired: (state) => state.instance.accountApprovalRequired,
+ birthdayRequired: (state) => state.instance.birthdayRequired,
+ birthdayMinAge: (state) => state.instance.birthdayMinAge
})
},
methods: {
@@ -72,7 +100,7 @@ const registration = {
this.user.captcha_token = this.captcha.token
this.user.captcha_answer_data = this.captcha.answer_data
if (this.user.language) {
- this.user.language = localeService.internalToBackendLocale(this.user.language)
+ this.user.language = localeService.internalToBackendLocaleMulti(this.user.language.filter(k => k))
}
this.v$.$touch()
diff --git a/src/components/registration/registration.vue b/src/components/registration/registration.vue
index a26162f0..7438a5f4 100644
--- a/src/components/registration/registration.vue
+++ b/src/components/registration/registration.vue
@@ -169,6 +169,40 @@
<div
class="form-group"
+ :class="{ 'form-group--error': v$.user.birthday.$error }"
+ >
+ <label
+ class="form--label"
+ for="sign-up-birthday"
+ >
+ {{ birthdayRequired ? $t('registration.birthday') : $t('registration.birthday_optional') }}
+ </label>
+ <input
+ id="sign-up-birthday"
+ v-model="user.birthday"
+ :disabled="isPending"
+ class="form-control"
+ type="date"
+ :max="birthdayRequired ? birthdayMinAttr : undefined"
+ :aria-required="birthdayRequired"
+ >
+ </div>
+ <div
+ v-if="v$.user.birthday.$dirty"
+ class="form-error"
+ >
+ <ul>
+ <li v-if="v$.user.birthday.required.$invalid">
+ <span>{{ $t('registration.validations.birthday_required') }}</span>
+ </li>
+ <li v-if="v$.user.birthday.maxValue.$invalid">
+ <span>{{ $tc('registration.validations.birthday_min_age', { date: birthdayMinFormatted }) }}</span>
+ </li>
+ </ul>
+ </div>
+
+ <div
+ class="form-group"
:class="{ 'form-group--error': v$.user.language.$error }"
>
<interface-language-switcher
@@ -176,6 +210,7 @@
:prompt-text="$t('registration.email_language')"
:language="v$.user.language.$model"
:set-language="val => v$.user.language.$model = val"
+ @click.stop.prevent
/>
</div>
diff --git a/src/components/remove_follower_button/remove_follower_button.js b/src/components/remove_follower_button/remove_follower_button.js
index e1a7531b..052a519f 100644
--- a/src/components/remove_follower_button/remove_follower_button.js
+++ b/src/components/remove_follower_button/remove_follower_button.js
@@ -1,10 +1,16 @@
+import ConfirmModal from '../confirm_modal/confirm_modal.vue'
+
export default {
- props: ['relationship'],
+ props: ['user', 'relationship'],
data () {
return {
- inProgress: false
+ inProgress: false,
+ showingConfirmRemoveFollower: false
}
},
+ components: {
+ ConfirmModal
+ },
computed: {
label () {
if (this.inProgress) {
@@ -12,14 +18,31 @@ export default {
} else {
return this.$t('user_card.remove_follower')
}
+ },
+ shouldConfirmRemoveUserFromFollowers () {
+ return this.$store.getters.mergedConfig.modalOnRemoveUserFromFollowers
}
},
methods: {
+ showConfirmRemoveUserFromFollowers () {
+ this.showingConfirmRemoveFollower = true
+ },
+ hideConfirmRemoveUserFromFollowers () {
+ this.showingConfirmRemoveFollower = false
+ },
onClick () {
+ if (!this.shouldConfirmRemoveUserFromFollowers) {
+ this.doRemoveUserFromFollowers()
+ } else {
+ this.showConfirmRemoveUserFromFollowers()
+ }
+ },
+ doRemoveUserFromFollowers () {
this.inProgress = true
this.$store.dispatch('removeUserFromFollowers', this.relationship.id).then(() => {
this.inProgress = false
})
+ this.hideConfirmRemoveUserFromFollowers()
}
}
}
diff --git a/src/components/remove_follower_button/remove_follower_button.vue b/src/components/remove_follower_button/remove_follower_button.vue
index a3a4c242..0012aebd 100644
--- a/src/components/remove_follower_button/remove_follower_button.vue
+++ b/src/components/remove_follower_button/remove_follower_button.vue
@@ -7,6 +7,27 @@
@click="onClick"
>
{{ label }}
+ <teleport to="#modal">
+ <confirm-modal
+ v-if="showingConfirmRemoveFollower"
+ :title="$t('user_card.remove_follower_confirm_title')"
+ :confirm-text="$t('user_card.remove_follower_confirm_accept_button')"
+ :cancel-text="$t('user_card.remove_follower_confirm_cancel_button')"
+ @accepted="doRemoveUserFromFollowers"
+ @cancelled="hideConfirmRemoveUserFromFollowers"
+ >
+ <i18n-t
+ keypath="user_card.remove_follower_confirm"
+ tag="span"
+ >
+ <template #user>
+ <span
+ v-text="user.screen_name_ui"
+ />
+ </template>
+ </i18n-t>
+ </confirm-modal>
+ </teleport>
</button>
</template>
diff --git a/src/components/reply_button/reply_button.vue b/src/components/reply_button/reply_button.vue
index 6e3964b7..60a40a08 100644
--- a/src/components/reply_button/reply_button.vue
+++ b/src/components/reply_button/reply_button.vue
@@ -32,12 +32,20 @@
target="_blank"
role="button"
:href="remoteInteractionLink"
+ :title="$t('tool_tip.reply')"
>
- <FAIcon
- icon="reply"
- class="fa-scale-110 fa-old-padding"
- :title="$t('tool_tip.reply')"
- />
+ <FALayers class="fa-old-padding-layer">
+ <FAIcon
+ class="fa-scale-110"
+ icon="reply"
+ />
+ <FAIcon
+ v-if="!replying"
+ class="focus-marker"
+ transform="shrink-6 up-8 right-16"
+ icon="plus"
+ />
+ </FALayers>
</a>
<span
v-if="status.replies_count > 0"
diff --git a/src/components/retweet_button/retweet_button.js b/src/components/retweet_button/retweet_button.js
index 4d92b5fa..198b6c14 100644
--- a/src/components/retweet_button/retweet_button.js
+++ b/src/components/retweet_button/retweet_button.js
@@ -1,3 +1,4 @@
+import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faRetweet,
@@ -15,13 +16,24 @@ library.add(
const RetweetButton = {
props: ['status', 'loggedIn', 'visibility'],
+ components: {
+ ConfirmModal
+ },
data () {
return {
- animated: false
+ animated: false,
+ showingConfirmDialog: false
}
},
methods: {
retweet () {
+ if (!this.status.repeated && this.shouldConfirmRepeat) {
+ this.showConfirmDialog()
+ } else {
+ this.doRetweet()
+ }
+ },
+ doRetweet () {
if (!this.status.repeated) {
this.$store.dispatch('retweet', { id: this.status.id })
} else {
@@ -31,6 +43,13 @@ const RetweetButton = {
setTimeout(() => {
this.animated = false
}, 500)
+ this.hideConfirmDialog()
+ },
+ showConfirmDialog () {
+ this.showingConfirmDialog = true
+ },
+ hideConfirmDialog () {
+ this.showingConfirmDialog = false
}
},
computed: {
@@ -39,6 +58,9 @@ const RetweetButton = {
},
remoteInteractionLink () {
return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
+ },
+ shouldConfirmRepeat () {
+ return this.mergedConfig.modalOnRepeat
}
}
}
diff --git a/src/components/retweet_button/retweet_button.vue b/src/components/retweet_button/retweet_button.vue
index 7700ee0d..e1b6b153 100644
--- a/src/components/retweet_button/retweet_button.vue
+++ b/src/components/retweet_button/retweet_button.vue
@@ -45,13 +45,20 @@
class="button-unstyled interactive"
target="_blank"
role="button"
+ :title="$t('tool_tip.repeat')"
:href="remoteInteractionLink"
>
- <FAIcon
- class="fa-scale-110 fa-old-padding"
- icon="retweet"
- :title="$t('tool_tip.repeat')"
- />
+ <FALayers class="fa-old-padding-layer">
+ <FAIcon
+ class="fa-scale-110"
+ icon="retweet"
+ />
+ <FAIcon
+ class="focus-marker"
+ transform="shrink-6 up-9 right-12"
+ icon="plus"
+ />
+ </FALayers>
</a>
<span
v-if="!mergedConfig.hidePostStats && status.repeat_num > 0"
@@ -59,6 +66,18 @@
>
{{ status.repeat_num }}
</span>
+ <teleport to="#modal">
+ <confirm-modal
+ v-if="showingConfirmDialog"
+ :title="$t('status.repeat_confirm_title')"
+ :confirm-text="$t('status.repeat_confirm_accept_button')"
+ :cancel-text="$t('status.repeat_confirm_cancel_button')"
+ @accepted="doRetweet"
+ @cancelled="hideConfirmDialog"
+ >
+ {{ $t('status.repeat_confirm') }}
+ </confirm-modal>
+ </teleport>
</div>
</template>
diff --git a/src/components/screen_reader_notice/screen_reader_notice.js b/src/components/screen_reader_notice/screen_reader_notice.js
new file mode 100644
index 00000000..3b8eaf37
--- /dev/null
+++ b/src/components/screen_reader_notice/screen_reader_notice.js
@@ -0,0 +1,21 @@
+const ScreenReaderNotice = {
+ props: {
+ ariaLive: {
+ type: String,
+ defualt: 'assertive'
+ }
+ },
+ data () {
+ return {
+ currentText: ''
+ }
+ },
+ methods: {
+ announce (text) {
+ this.currentText = text
+ setTimeout(() => { this.currentText = '' }, 1000)
+ }
+ }
+}
+
+export default ScreenReaderNotice
diff --git a/src/components/screen_reader_notice/screen_reader_notice.vue b/src/components/screen_reader_notice/screen_reader_notice.vue
new file mode 100644
index 00000000..8384ae6b
--- /dev/null
+++ b/src/components/screen_reader_notice/screen_reader_notice.vue
@@ -0,0 +1,10 @@
+<template>
+ <div
+ class="visible-for-screenreader-only"
+ :aria-live="ariaLive"
+ >
+ {{ currentText }}
+ </div>
+</template>
+
+<script src="./screen_reader_notice.js"></script>
diff --git a/src/components/search_bar/search_bar.vue b/src/components/search_bar/search_bar.vue
index 3969d8de..9da2b272 100644
--- a/src/components/search_bar/search_bar.vue
+++ b/src/components/search_bar/search_bar.vue
@@ -8,6 +8,7 @@
class="button-unstyled nav-icon"
:title="$t('nav.search')"
type="button"
+ :aria-expanded="!hidden"
@click.prevent.stop="toggleHidden"
>
<FAIcon
@@ -29,6 +30,7 @@
<button
class="button-default search-button"
type="submit"
+ :title="$t('nav.search')"
@click="find(searchTerm)"
>
<FAIcon
@@ -39,6 +41,8 @@
<button
class="button-unstyled cancel-search"
type="button"
+ :title="$t('nav.search_close')"
+ :aria-expanded="!hidden"
@click.prevent.stop="toggleHidden"
>
<FAIcon
diff --git a/src/components/select/select.js b/src/components/select/select.js
index ec571a14..34d64fd2 100644
--- a/src/components/select/select.js
+++ b/src/components/select/select.js
@@ -13,6 +13,7 @@ export default {
'modelValue',
'disabled',
'unstyled',
- 'kind'
+ 'kind',
+ 'attrs'
]
}
diff --git a/src/components/select/select.vue b/src/components/select/select.vue
index 0ce4ea82..1797afc8 100644
--- a/src/components/select/select.vue
+++ b/src/components/select/select.vue
@@ -6,6 +6,7 @@
<select
:disabled="disabled"
:value="modelValue"
+ v-bind="attrs"
@change="$emit('update:modelValue', $event.target.value)"
>
<slot />
diff --git a/src/components/settings_modal/helpers/float_setting.vue b/src/components/settings_modal/helpers/float_setting.vue
new file mode 100644
index 00000000..15edb3c3
--- /dev/null
+++ b/src/components/settings_modal/helpers/float_setting.vue
@@ -0,0 +1,16 @@
+<template>
+ <NumberSetting
+ v-bind="$attrs"
+ >
+ <slot />
+ </NumberSetting>
+</template>
+
+<script>
+import NumberSetting from './number_setting.vue'
+export default {
+ components: {
+ NumberSetting
+ }
+}
+</script>
diff --git a/src/components/settings_modal/helpers/integer_setting.vue b/src/components/settings_modal/helpers/integer_setting.vue
index 695e2673..43fa7e1a 100644
--- a/src/components/settings_modal/helpers/integer_setting.vue
+++ b/src/components/settings_modal/helpers/integer_setting.vue
@@ -1,27 +1,17 @@
<template>
- <span
- v-if="matchesExpertLevel"
- class="IntegerSetting"
+ <NumberSetting
+ v-bind="$attrs"
+ truncate="1"
>
- <label :for="path">
- <slot />
- </label>
- <input
- :id="path"
- class="number-input"
- type="number"
- step="1"
- :disabled="disabled"
- :min="min || 0"
- :value="state"
- @change="update"
- >
- {{ ' ' }}
- <ModifiedIndicator
- :changed="isChanged"
- :onclick="reset"
- />
- </span>
+ <slot />
+ </NumberSetting>
</template>
-<script src="./integer_setting.js"></script>
+<script>
+import NumberSetting from './number_setting.vue'
+export default {
+ components: {
+ NumberSetting
+ }
+}
+</script>
diff --git a/src/components/settings_modal/helpers/modified_indicator.vue b/src/components/settings_modal/helpers/modified_indicator.vue
index 8311533a..45db3fc2 100644
--- a/src/components/settings_modal/helpers/modified_indicator.vue
+++ b/src/components/settings_modal/helpers/modified_indicator.vue
@@ -5,12 +5,12 @@
>
<Popover
trigger="hover"
+ :trigger-attrs="{ 'aria-label': $t('settings.setting_changed') }"
>
<template #trigger>
&nbsp;
<FAIcon
icon="wrench"
- :aria-label="$t('settings.setting_changed')"
/>
</template>
<template #content>
diff --git a/src/components/settings_modal/helpers/integer_setting.js b/src/components/settings_modal/helpers/number_setting.js
index e64d0cee..73c39948 100644
--- a/src/components/settings_modal/helpers/integer_setting.js
+++ b/src/components/settings_modal/helpers/number_setting.js
@@ -8,6 +8,8 @@ export default {
path: String,
disabled: Boolean,
min: Number,
+ step: Number,
+ truncate: Number,
expert: [Number, String]
},
computed: {
@@ -15,8 +17,11 @@ export default {
const [firstSegment, ...rest] = this.path.split('.')
return [firstSegment + 'DefaultValue', ...rest].join('.')
},
+ parent () {
+ return this.$parent.$parent
+ },
state () {
- const value = get(this.$parent, this.path)
+ const value = get(this.parent, this.path)
if (value === undefined) {
return this.defaultState
} else {
@@ -24,21 +29,28 @@ export default {
}
},
defaultState () {
- return get(this.$parent, this.pathDefault)
+ return get(this.parent, this.pathDefault)
},
isChanged () {
return this.state !== this.defaultState
},
matchesExpertLevel () {
- return (this.expert || 0) <= this.$parent.expertLevel
+ return (this.expert || 0) <= this.parent.expertLevel
}
},
methods: {
+ truncateValue (value) {
+ if (!this.truncate) {
+ return value
+ }
+
+ return Math.trunc(value / this.truncate) * this.truncate
+ },
update (e) {
- set(this.$parent, this.path, parseInt(e.target.value))
+ set(this.parent, this.path, this.truncateValue(parseFloat(e.target.value)))
},
reset () {
- set(this.$parent, this.path, this.defaultState)
+ set(this.parent, this.path, this.defaultState)
}
}
}
diff --git a/src/components/settings_modal/helpers/number_setting.vue b/src/components/settings_modal/helpers/number_setting.vue
new file mode 100644
index 00000000..3eab5178
--- /dev/null
+++ b/src/components/settings_modal/helpers/number_setting.vue
@@ -0,0 +1,27 @@
+<template>
+ <span
+ v-if="matchesExpertLevel"
+ class="NumberSetting"
+ >
+ <label :for="path">
+ <slot />
+ </label>
+ <input
+ :id="path"
+ class="number-input"
+ type="number"
+ :step="step || 1"
+ :disabled="disabled"
+ :min="min || 0"
+ :value="state"
+ @change="update"
+ >
+ {{ ' ' }}
+ <ModifiedIndicator
+ :changed="isChanged"
+ :onclick="reset"
+ />
+ </span>
+</template>
+
+<script src="./number_setting.js"></script>
diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js
index ea24d6ad..be97710f 100644
--- a/src/components/settings_modal/tabs/general_tab.js
+++ b/src/components/settings_modal/tabs/general_tab.js
@@ -2,6 +2,7 @@ import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
+import FloatSetting from '../helpers/float_setting.vue'
import SizeSetting, { defaultHorizontalUnits } from '../helpers/size_setting.vue'
import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
@@ -62,6 +63,7 @@ const GeneralTab = {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
+ FloatSetting,
SizeSetting,
InterfaceLanguageSwitcher,
ScopeSelector,
diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue
index 582cb288..21e2d855 100644
--- a/src/components/settings_modal/tabs/general_tab.vue
+++ b/src/components/settings_modal/tabs/general_tab.vue
@@ -148,6 +148,56 @@
</SizeSetting>
</div>
</li>
+ <li class="select-multiple">
+ <span class="label">{{ $t('settings.confirm_dialogs') }}</span>
+ <ul class="option-list">
+ <li>
+ <BooleanSetting path="modalOnRepeat">
+ {{ $t('settings.confirm_dialogs_repeat') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="modalOnUnfollow">
+ {{ $t('settings.confirm_dialogs_unfollow') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="modalOnBlock">
+ {{ $t('settings.confirm_dialogs_block') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="modalOnMute">
+ {{ $t('settings.confirm_dialogs_mute') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="modalOnDelete">
+ {{ $t('settings.confirm_dialogs_delete') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="modalOnLogout">
+ {{ $t('settings.confirm_dialogs_logout') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="modalOnApproveFollow">
+ {{ $t('settings.confirm_dialogs_approve_follow') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="modalOnDenyFollow">
+ {{ $t('settings.confirm_dialogs_deny_follow') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="modalOnRemoveUserFromFollowers">
+ {{ $t('settings.confirm_dialogs_remove_follower') }}
+ </BooleanSetting>
+ </li>
+ </ul>
+ </li>
</ul>
</div>
<div class="setting-item">
@@ -221,6 +271,15 @@
{{ $t('settings.no_rich_text_description') }}
</BooleanSetting>
</li>
+ <li>
+ <FloatSetting
+ v-if="user"
+ path="emojiReactionsScale"
+ expert="1"
+ >
+ {{ $t('settings.emoji_reactions_scale') }}
+ </FloatSetting>
+ </li>
<h3>{{ $t('settings.attachments') }}</h3>
<li>
<BooleanSetting
@@ -451,6 +510,14 @@
{{ $t('settings.pad_emoji') }}
</BooleanSetting>
</li>
+ <li>
+ <BooleanSetting
+ path="autocompleteSelect"
+ expert="1"
+ >
+ {{ $t('settings.autocomplete_select_first') }}
+ </BooleanSetting>
+ </li>
</ul>
</div>
</div>
diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.js b/src/components/settings_modal/tabs/mutes_and_blocks_tab.js
index 6cfeea35..51974f9f 100644
--- a/src/components/settings_modal/tabs/mutes_and_blocks_tab.js
+++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.js
@@ -9,17 +9,20 @@ import DomainMuteCard from 'src/components/domain_mute_card/domain_mute_card.vue
import SelectableList from 'src/components/selectable_list/selectable_list.vue'
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'
-const BlockList = withSubscription({
+const BlockList = withLoadMore({
fetch: (props, $store) => $store.dispatch('fetchBlocks'),
select: (props, $store) => get($store.state.users.currentUser, 'blockIds', []),
+ destroy: () => {},
childPropName: 'items'
})(SelectableList)
-const MuteList = withSubscription({
+const MuteList = withLoadMore({
fetch: (props, $store) => $store.dispatch('fetchMutes'),
select: (props, $store) => get($store.state.users.currentUser, 'muteIds', []),
+ destroy: () => {},
childPropName: 'items'
})(SelectableList)
diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js
index ff2ccef2..eeacad48 100644
--- a/src/components/settings_modal/tabs/profile_tab.js
+++ b/src/components/settings_modal/tabs/profile_tab.js
@@ -12,6 +12,7 @@ import InterfaceLanguageSwitcher from 'src/components/interface_language_switche
import BooleanSetting from '../helpers/boolean_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import localeService from 'src/services/locale/locale.service.js'
+import { propsToNative } from 'src/services/attributes_helper/attributes_helper.service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
@@ -32,6 +33,8 @@ const ProfileTab = {
newName: this.$store.state.users.currentUser.name_unescaped,
newBio: unescape(this.$store.state.users.currentUser.description),
newLocked: this.$store.state.users.currentUser.locked,
+ newBirthday: this.$store.state.users.currentUser.birthday,
+ showBirthday: this.$store.state.users.currentUser.show_birthday,
newFields: this.$store.state.users.currentUser.fields.map(field => ({ name: field.name, value: field.value })),
showRole: this.$store.state.users.currentUser.show_role,
role: this.$store.state.users.currentUser.role,
@@ -43,7 +46,7 @@ const ProfileTab = {
bannerPreview: null,
background: null,
backgroundPreview: null,
- emailLanguage: this.$store.state.users.currentUser.language || ''
+ emailLanguage: this.$store.state.users.currentUser.language || ['']
}
},
components: {
@@ -125,12 +128,14 @@ const ProfileTab = {
display_name: this.newName,
fields_attributes: this.newFields.filter(el => el != null),
bot: this.bot,
- show_role: this.showRole
+ show_role: this.showRole,
+ birthday: this.newBirthday || '',
+ show_birthday: this.showBirthday
/* eslint-enable camelcase */
}
if (this.emailLanguage) {
- params.language = localeService.internalToBackendLocale(this.emailLanguage)
+ params.language = localeService.internalToBackendLocaleMulti(this.emailLanguage)
}
this.$store.state.api.backendInteractor
@@ -257,6 +262,9 @@ const ProfileTab = {
messageArgs: [error.message],
level: 'error'
})
+ },
+ propsToNative (props) {
+ return propsToNative(props)
}
}
}
diff --git a/src/components/settings_modal/tabs/profile_tab.scss b/src/components/settings_modal/tabs/profile_tab.scss
index 73879192..ee253ffe 100644
--- a/src/components/settings_modal/tabs/profile_tab.scss
+++ b/src/components/settings_modal/tabs/profile_tab.scss
@@ -129,4 +129,9 @@
padding: 0 0.5em;
}
}
+
+ .birthday-input {
+ display: block;
+ margin-bottom: 1em;
+ }
}
diff --git a/src/components/settings_modal/tabs/profile_tab.vue b/src/components/settings_modal/tabs/profile_tab.vue
index 642d54ca..6a5b478a 100644
--- a/src/components/settings_modal/tabs/profile_tab.vue
+++ b/src/components/settings_modal/tabs/profile_tab.vue
@@ -8,11 +8,14 @@
enable-emoji-picker
:suggest="emojiSuggestor"
>
- <input
- id="username"
- v-model="newName"
- class="name-changer"
- >
+ <template #default="inputProps">
+ <input
+ id="username"
+ v-model="newName"
+ class="name-changer"
+ v-bind="propsToNative(inputProps)"
+ >
+ </template>
</EmojiInput>
<p>{{ $t('settings.bio') }}</p>
<EmojiInput
@@ -20,10 +23,13 @@
enable-emoji-picker
:suggest="emojiUserSuggestor"
>
- <textarea
- v-model="newBio"
- class="bio resize-height"
- />
+ <template #default="inputProps">
+ <textarea
+ v-model="newBio"
+ class="bio resize-height"
+ v-bind="propsToNative(inputProps)"
+ />
+ </template>
</EmojiInput>
<p v-if="role === 'admin' || role === 'moderator'">
<Checkbox v-model="showRole">
@@ -35,6 +41,18 @@
</template>
</Checkbox>
</p>
+ <div>
+ <p>{{ $t('settings.birthday.label') }}</p>
+ <input
+ id="birthday"
+ v-model="newBirthday"
+ type="date"
+ class="birthday-input"
+ >
+ <Checkbox v-model="showBirthday">
+ {{ $t('settings.birthday.show_birthday') }}
+ </Checkbox>
+ </div>
<div v-if="maxFields > 0">
<p>{{ $t('settings.profile_fields.label') }}</p>
<div
@@ -48,10 +66,13 @@
hide-emoji-button
:suggest="userSuggestor"
>
- <input
- v-model="newFields[i].name"
- :placeholder="$t('settings.profile_fields.name')"
- >
+ <template #default="inputProps">
+ <input
+ v-model="newFields[i].name"
+ :placeholder="$t('settings.profile_fields.name')"
+ v-bind="propsToNative(inputProps)"
+ >
+ </template>
</EmojiInput>
<EmojiInput
v-model="newFields[i].value"
@@ -59,10 +80,13 @@
hide-emoji-button
:suggest="userSuggestor"
>
- <input
- v-model="newFields[i].value"
- :placeholder="$t('settings.profile_fields.value')"
- >
+ <template #default="inputProps">
+ <input
+ v-model="newFields[i].value"
+ :placeholder="$t('settings.profile_fields.value')"
+ v-bind="propsToNative(inputProps)"
+ >
+ </template>
</EmojiInput>
<button
class="delete-field button-unstyled -hover-highlight"
diff --git a/src/components/shadow_control/shadow_control.vue b/src/components/shadow_control/shadow_control.vue
index 7546535d..1f3c26aa 100644
--- a/src/components/shadow_control/shadow_control.vue
+++ b/src/components/shadow_control/shadow_control.vue
@@ -129,12 +129,13 @@
v-model="selected.inset"
:disabled="!present"
name="inset"
- class="input-inset"
+ class="input-inset visible-for-screenreader-only"
type="checkbox"
>
<label
class="checkbox-label"
for="inset"
+ :aria-hidden="true"
/>
</div>
<div
diff --git a/src/components/tab_switcher/tab_switcher.jsx b/src/components/tab_switcher/tab_switcher.jsx
index c8d390bc..a7ef8560 100644
--- a/src/components/tab_switcher/tab_switcher.jsx
+++ b/src/components/tab_switcher/tab_switcher.jsx
@@ -117,6 +117,7 @@ export default {
onClick={this.clickTab(index)}
class={classesTab.join(' ')}
type="button"
+ role="tab"
>
<img src={props.image} title={props['image-tooltip']}/>
{props.label ? '' : props.label}
@@ -131,6 +132,7 @@ export default {
onClick={this.clickTab(index)}
class={classesTab.join(' ')}
type="button"
+ role="tab"
>
{!props.icon ? '' : (<FAIcon class="tab-icon" size="2x" fixed-width icon={props.icon}/>)}
<span class="text">
@@ -167,11 +169,15 @@ export default {
return (
<div class={'tab-switcher ' + (this.sideTabBar ? 'side-tabs' : 'top-tabs')}>
- <div class="tabs">
+ <div
+ class="tabs"
+ role="tablist"
+ >
{tabs}
</div>
<div
ref="contents"
+ role="tabpanel"
class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')}
v-body-scroll-lock={this.bodyScrollLock}
>
diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js
index 67879307..e17bf8eb 100644
--- a/src/components/user_card/user_card.js
+++ b/src/components/user_card/user_card.js
@@ -1,3 +1,4 @@
+import { unitToSeconds } from 'src/services/date_utils/date_utils.js'
import UserAvatar from '../user_avatar/user_avatar.vue'
import RemoteFollow from '../remote_follow/remote_follow.vue'
import ProgressButton from '../progress_button/progress_button.vue'
@@ -8,6 +9,7 @@ import UserNote from '../user_note/user_note.vue'
import Select from '../select/select.vue'
import UserLink from '../user_link/user_link.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
+import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
@@ -46,7 +48,10 @@ export default {
data () {
return {
followRequestInProgress: false,
- betterShadow: this.$store.state.interface.browserSupport.cssFilter
+ betterShadow: this.$store.state.interface.browserSupport.cssFilter,
+ showingConfirmMute: false,
+ muteExpiryAmount: 0,
+ muteExpiryUnit: 'minutes'
}
},
created () {
@@ -137,6 +142,12 @@ export default {
supportsNote () {
return 'note' in this.relationship
},
+ shouldConfirmMute () {
+ return this.mergedConfig.modalOnMute
+ },
+ muteExpiryUnits () {
+ return ['minutes', 'hours', 'days']
+ },
...mapGetters(['mergedConfig'])
},
components: {
@@ -149,11 +160,29 @@ export default {
Select,
RichContent,
UserLink,
- UserNote
+ UserNote,
+ ConfirmModal
},
methods: {
+ showConfirmMute () {
+ this.showingConfirmMute = true
+ },
+ hideConfirmMute () {
+ this.showingConfirmMute = false
+ },
muteUser () {
- this.$store.dispatch('muteUser', this.user.id)
+ if (!this.shouldConfirmMute) {
+ this.doMuteUser()
+ } else {
+ this.showConfirmMute()
+ }
+ },
+ doMuteUser () {
+ this.$store.dispatch('muteUser', {
+ id: this.user.id,
+ expiresIn: this.shouldConfirmMute ? unitToSeconds(this.muteExpiryUnit, this.muteExpiryAmount) : 0
+ })
+ this.hideConfirmMute()
},
unmuteUser () {
this.$store.dispatch('unmuteUser', this.user.id)
diff --git a/src/components/user_card/user_card.scss b/src/components/user_card/user_card.scss
index d56b6672..4ab93a8a 100644
--- a/src/components/user_card/user_card.scss
+++ b/src/components/user_card/user_card.scss
@@ -355,3 +355,8 @@
text-decoration: none;
}
}
+
+.mute-expiry {
+ display: flex;
+ flex-direction: row;
+}
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index 349c7cb1..2de14063 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -314,6 +314,53 @@
:handle-links="true"
/>
</div>
+ <teleport to="#modal">
+ <confirm-modal
+ v-if="showingConfirmMute"
+ :title="$t('user_card.mute_confirm_title')"
+ :confirm-text="$t('user_card.mute_confirm_accept_button')"
+ :cancel-text="$t('user_card.mute_confirm_cancel_button')"
+ @accepted="doMuteUser"
+ @cancelled="hideConfirmMute"
+ >
+ <i18n-t
+ keypath="user_card.mute_confirm"
+ tag="div"
+ >
+ <template #user>
+ <span
+ v-text="user.screen_name_ui"
+ />
+ </template>
+ </i18n-t>
+ <div
+ class="mute-expiry"
+ >
+ <label>
+ {{ $t('user_card.mute_duration_prompt') }}
+ </label>
+ <input
+ v-model="muteExpiryAmount"
+ type="number"
+ class="expiry-amount hide-number-spinner"
+ :min="0"
+ >
+ <Select
+ v-model="muteExpiryUnit"
+ unstyled="true"
+ class="expiry-unit"
+ >
+ <option
+ v-for="unit in muteExpiryUnits"
+ :key="unit"
+ :value="unit"
+ >
+ {{ $t(`time.${unit}_short`, ['']) }}
+ </option>
+ </Select>
+ </div>
+ </confirm-modal>
+ </teleport>
</div>
</template>
diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index 08adaeab..acb612ed 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -7,13 +7,16 @@ import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import List from '../list/list.vue'
import withLoadMore from '../../hocs/with_load_more/with_load_more'
+import localeService from 'src/services/locale/locale.service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
- faCircleNotch
+ faCircleNotch,
+ faBirthdayCake
} from '@fortawesome/free-solid-svg-icons'
library.add(
- faCircleNotch
+ faCircleNotch,
+ faBirthdayCake
)
const FollowerList = withLoadMore({
@@ -76,6 +79,10 @@ const UserProfile = {
},
followersTabVisible () {
return this.isUs || !this.user.hide_followers
+ },
+ formattedBirthday () {
+ const browserLocale = localeService.internalToBrowserLocale(this.$i18n.locale)
+ return this.user.birthday && new Date(Date.parse(this.user.birthday)).toLocaleDateString(browserLocale, { timeZone: 'UTC', day: 'numeric', month: 'long', year: 'numeric' })
}
},
methods: {
diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index 2720f052..c63a303c 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -12,6 +12,16 @@
rounded="top"
:has-note-editor="true"
/>
+ <span
+ v-if="!!user.birthday"
+ class="user-birthday"
+ >
+ <FAIcon
+ class="fa-old-padding"
+ icon="birthday-cake"
+ />
+ {{ $t('user_card.birthday', { birthday: formattedBirthday }) }}
+ </span>
<div
v-if="user.fields_html && user.fields_html.length > 0"
class="user-profile-fields"
@@ -149,6 +159,10 @@
// No sticky header on user profile
--currentPanelStack: 1;
+ .user-birthday {
+ margin: 0 0.75em 0.5em;
+ }
+
.user-profile-fields {
margin: 0 0.5em;