diff options
Diffstat (limited to 'src/components')
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> <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; |
