diff options
Diffstat (limited to 'src')
93 files changed, 2559 insertions, 534 deletions
diff --git a/src/App.scss b/src/App.scss index 1c4c8941..3f352e8d 100644 --- a/src/App.scss +++ b/src/App.scss @@ -580,8 +580,6 @@ textarea, } &[type="checkbox"] { - display: none; - &:checked + label::before { color: $fallback--text; color: var(--inputText, $fallback--text); @@ -887,3 +885,15 @@ option { opacity: 0; } /* stylelint-enable no-descending-specificity */ + +.visible-for-screenreader-only { + display: block; + width: 1px; + height: 1px; + margin: -1px; + overflow: hidden; + visibility: visible; + clip: rect(0 0 0 0); + padding: 0; + position: absolute; +} diff --git a/src/App.vue b/src/App.vue index 23a388a6..fe214ce7 100644 --- a/src/App.vue +++ b/src/App.vue @@ -71,7 +71,6 @@ <StatusHistoryModal v-if="editingAvailable" /> <SettingsModal /> <UpdateNotification /> - <div id="modal" /> <GlobalNoticeList /> </div> </template> diff --git a/src/boot/after_store.js b/src/boot/after_store.js index 1fa9dd2a..9c1f007b 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -60,6 +60,8 @@ const getInstanceConfig = async ({ store }) => { store.dispatch('setInstanceOption', { name: 'textlimit', value: textlimit }) store.dispatch('setInstanceOption', { name: 'accountApprovalRequired', value: data.approval_required }) + store.dispatch('setInstanceOption', { name: 'birthdayRequired', value: !!data.pleroma.metadata.birthday_required }) + store.dispatch('setInstanceOption', { name: 'birthdayMinAge', value: data.pleroma.metadata.birthday_min_age || 0 }) if (vapidPublicKey) { store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey }) @@ -251,6 +253,7 @@ const getNodeInfo = async ({ store }) => { store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') }) store.dispatch('setInstanceOption', { name: 'shoutAvailable', value: features.includes('chat') }) store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') }) + store.dispatch('setInstanceOption', { name: 'pleromaCustomEmojiReactionsAvailable', value: features.includes('pleroma_custom_emoji_reactions') }) store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') }) store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') }) store.dispatch('setInstanceOption', { name: 'editingAvailable', value: features.includes('editing') }) 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; diff --git a/src/hocs/with_load_more/with_load_more.jsx b/src/hocs/with_load_more/with_load_more.jsx index c0ae1856..4e5bb50f 100644 --- a/src/hocs/with_load_more/with_load_more.jsx +++ b/src/hocs/with_load_more/with_load_more.jsx @@ -98,7 +98,7 @@ const withLoadMore = ({ </button> } {!this.error && this.loading && <FAIcon spin icon="circle-notch"/>} - {!this.error && !this.loading && !this.bottomedOut && <a onClick={this.fetchEntries}>{this.$t('general.more')}</a>} + {!this.error && !this.loading && !this.bottomedOut && <a onClick={this.fetchEntries} role="button" tabindex="0">{this.$t('general.more')}</a>} </div> </div> ) diff --git a/src/i18n/en.json b/src/i18n/en.json index 5e653ad8..b051f088 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -137,6 +137,10 @@ "login": "Log in", "description": "Log in with OAuth", "logout": "Log out", + "logout_confirm_title": "Logout confirmation", + "logout_confirm": "Do you really want to logout?", + "logout_confirm_accept_button": "Logout", + "logout_confirm_cancel_button": "Do not logout", "password": "Password", "placeholder": "e.g. lain", "register": "Register", @@ -172,6 +176,7 @@ "bookmarks": "Bookmarks", "user_search": "User Search", "search": "Search", + "search_close": "Close search bar", "who_to_follow": "Who to follow", "preferences": "Preferences", "timelines": "Timelines", @@ -266,6 +271,7 @@ "text/markdown": "Markdown", "text/bbcode": "BBCode" }, + "content_type_selection": "Post format", "content_warning": "Subject (optional)", "default": "Just landed in L.A.", "direct_warning_to_all": "This post will be visible to all the mentioned users.", @@ -283,6 +289,7 @@ "private": "This post will be visible to your followers only", "unlisted": "This post will not be visible in Public Timeline and The Whole Known Network" }, + "scope_notice_dismiss": "Close this notice", "scope": { "direct": "Direct - post to mentioned users only", "private": "Followers-only - post to followers only", @@ -312,9 +319,13 @@ "email_required": "cannot be left blank", "password_required": "cannot be left blank", "password_confirmation_required": "cannot be left blank", - "password_confirmation_match": "should be the same as password" + "password_confirmation_match": "should be the same as password", + "birthday_required": "cannot be left blank", + "birthday_min_age": "must be on or before {date}" }, - "email_language": "In which language do you want to receive emails from the server?" + "email_language": "In which language do you want to receive emails from the server?", + "birthday": "Birthday:", + "birthday_optional": "Birthday (optional):" }, "remote_user_resolver": { "remote_user_resolver": "Remote user resolver", @@ -335,6 +346,10 @@ "select_all": "Select all" }, "settings": { + "add_language": "Add fallback language", + "remove_language": "Remove", + "primary_language": "Primary language:", + "fallback_language": "Fallback language {index}:", "app_name": "App name", "expert_mode": "Show advanced", "save": "Save changes", @@ -416,6 +431,16 @@ "composing": "Composing", "confirm_new_password": "Confirm new password", "current_password": "Current password", + "confirm_dialogs": "Ask for confirmation when", + "confirm_dialogs_repeat": "repeating a status", + "confirm_dialogs_unfollow": "unfollowing a user", + "confirm_dialogs_block": "blocking a user", + "confirm_dialogs_mute": "muting a user", + "confirm_dialogs_delete": "deleting a status", + "confirm_dialogs_logout": "logging out", + "confirm_dialogs_approve_follow": "approving a follower", + "confirm_dialogs_deny_follow": "denying a follower", + "confirm_dialogs_remove_follower": "removing a follower", "mutes_and_blocks": "Mutes and Blocks", "data_import_export_tab": "Data import / export", "default_vis": "Default visibility scope", @@ -440,7 +465,9 @@ "domain_mutes": "Domains", "avatar_size_instruction": "The recommended minimum size for avatar images is 150x150 pixels.", "pad_emoji": "Pad emoji with spaces when adding from picker", + "autocomplete_select_first": "Automatically select the first candidate when autocomplete results are available", "emoji_reactions_on_timeline": "Show emoji reactions on timeline", + "emoji_reactions_scale": "Reactions scale factor", "export_theme": "Save preset", "filtering": "Filtering", "wordfilter": "Wordfilter", @@ -510,6 +537,10 @@ "name": "Label", "value": "Content" }, + "birthday": { + "label": "Birthday", + "show_birthday": "Show my birthday" + }, "account_privacy": "Privacy", "use_contain_fit": "Don't crop the attachment in thumbnails", "name": "Name", @@ -843,6 +874,10 @@ "status": { "favorites": "Favorites", "repeats": "Repeats", + "repeat_confirm": "Do you really want to repeat this status?", + "repeat_confirm_title": "Repeat confirmation", + "repeat_confirm_accept_button": "Repeat", + "repeat_confirm_cancel_button": "Do not repeat", "delete": "Delete status", "delete_error": "Error deleting status: {0}", "edit": "Edit status", @@ -853,6 +888,9 @@ "bookmark": "Bookmark", "unbookmark": "Unbookmark", "delete_confirm": "Do you really want to delete this status?", + "delete_confirm_title": "Delete confirmation", + "delete_confirm_accept_button": "Delete", + "delete_confirm_cancel_button": "Keep", "reply_to": "Reply to", "mentions": "Mentions", "replies_list": "Replies:", @@ -899,10 +937,22 @@ }, "user_card": { "approve": "Approve", + "approve_confirm_title": "Approve confirmation", + "approve_confirm_accept_button": "Approve", + "approve_confirm_cancel_button": "Do not approve", + "approve_confirm": "Do you want to approve {user}'s follow request?", "block": "Block", "blocked": "Blocked!", + "block_confirm_title": "Block confirmation", + "block_confirm": "Do you really want to block {user}?", + "block_confirm_accept_button": "Block", + "block_confirm_cancel_button": "Do not block", "deactivated": "Deactivated", "deny": "Deny", + "deny_confirm_title": "Deny confirmation", + "deny_confirm_accept_button": "Deny", + "deny_confirm_cancel_button": "Do not deny", + "deny_confirm": "Do you want to deny {user}'s follow request?", "edit_profile": "Edit profile", "favorites": "Favorites", "follow": "Follow", @@ -910,6 +960,10 @@ "follow_sent": "Request sent!", "follow_progress": "Requesting…", "follow_unfollow": "Unfollow", + "unfollow_confirm_title": "Unfollow confirmation", + "unfollow_confirm": "Do you really want to unfollow {user}?", + "unfollow_confirm_accept_button": "Unfollow", + "unfollow_confirm_cancel_button": "Do not unfollow", "followees": "Following", "followers": "Followers", "following": "Following!", @@ -921,9 +975,18 @@ "message": "Message", "mute": "Mute", "muted": "Muted", + "mute_confirm_title": "Mute confirmation", + "mute_confirm": "Do you really want to mute {user}?", + "mute_confirm_accept_button": "Mute", + "mute_confirm_cancel_button": "Do not mute", + "mute_duration_prompt": "Mute this user for (0 for indefinite time):", "per_day": "per day", "remote_follow": "Remote follow", "remove_follower": "Remove follower", + "remove_follower_confirm_title": "Remove follower confirmation", + "remove_follower_confirm_accept_button": "Remove", + "remove_follower_confirm_cancel_button": "Keep", + "remove_follower_confirm": "Do you really want to remove {user} from your followers?", "report": "Report", "statuses": "Statuses", "subscribe": "Subscribe", @@ -937,6 +1000,7 @@ "hide_repeats": "Hide repeats", "show_repeats": "Show repeats", "bot": "Bot", + "birthday": "Born {birthday}", "admin_menu": { "moderation": "Moderation", "grant_admin": "Grant Admin", @@ -997,7 +1061,8 @@ "reject_follow_request": "Reject follow request", "bookmark": "Bookmark", "toggle_expand": "Expand or collapse notification to show post in full", - "toggle_mute": "Expand or collapse notification to reveal muted content" + "toggle_mute": "Expand or collapse notification to reveal muted content", + "autocomplete_available": "{number} result is available. Use up and down keys to navigate through them. | {number} results are available. Use up and down keys to navigate through them." }, "upload": { "error": { diff --git a/src/i18n/eo.json b/src/i18n/eo.json index 3596065c..e013edee 100644 --- a/src/i18n/eo.json +++ b/src/i18n/eo.json @@ -603,7 +603,7 @@ "use_websockets": "Uzi teĥnikaron «websockets» (tuja ĝisdatigo)", "mention_link_display_full_for_remote": "plene nur je uzantoj foraj (ekz. {'@'}zozo{'@'}ekzemplo.org)", "expert_mode": "Montri altnivelajn", - "setting_server_side": "Ĉi tiu agordo estas ligita al via profilo, kaj efektiviĝon en ĉiuj viaj salutoj kaj klientoj", + "setting_server_side": "Ĉi tiu agordo estas ligita al via profilo, kaj efektiviĝos en ĉiuj viaj salutoj kaj klientoj", "post_look_feel": "Aspekto de afiŝoj", "mention_links": "Menciaj ligiloj", "email_language": "Lingvo de leteroj ricevotaj de la servilo", diff --git a/src/i18n/ja_easy.json b/src/i18n/ja_easy.json index abca262b..21b27d12 100644 --- a/src/i18n/ja_easy.json +++ b/src/i18n/ja_easy.json @@ -17,7 +17,17 @@ "media_removal": "メディアをのぞく", "media_removal_desc": "このインスタンスは、これらのインスタンスからおくられてきたメディアを、とりのぞきます:", "media_nsfw": "メディアをすべてセンシティブにする", - "media_nsfw_desc": "このインスタンスは、これらのインスタンスからおくられてきたメディアを、すべて、センシティブにマークします:" + "media_nsfw_desc": "このインスタンスは、これらのインスタンスからおくられてきたメディアを、すべて、センシティブにマークします:", + "reason": "りゆう", + "instance": "インスタンス", + "not_applicable": "なし" + }, + "keyword": { + "keyword_policies": "キーワードポリシー", + "reject": "おことわり", + "replace": "おきかえ", + "ftl_removal": "「つながっているすべてのネットワーク」タイムラインからのぞく", + "is_replaced_by": "→" } }, "staff": "スタッフ" @@ -36,7 +46,10 @@ "scope_options": "こうかいはんいせんたく", "text_limit": "もじのかず", "title": "ゆうこうなきのう", - "who_to_follow": "おすすめユーザー" + "who_to_follow": "おすすめユーザー", + "pleroma_chat_messages": "Pleroma チャット", + "upload_limit": "アップロードできるファイルのおおきさ", + "shout": "Shoutbox" }, "finder": { "error_fetching_user": "ユーザーけんさくがエラーになりました", @@ -54,7 +67,34 @@ "disable": "なし", "enable": "あり", "confirm": "たしかめる", - "verify": "たしかめる" + "verify": "たしかめる", + "retry": "もういちど、ためしてください", + "loading": "よみこんでいます…", + "undo": "もとにもどす", + "yes": "はい", + "no": "いいえ", + "unpin": "ピンどめするのをやめる", + "scroll_to_top": "いちばんうえにもどる", + "role": { + "moderator": "モデレーター", + "admin": "かんりするひと" + }, + "flash_security": "Flash コンテンツはどんなコードでもじっこうできるので、あぶないかもしれません。", + "flash_fail": "Flash コンテンツをよみこむことに、しっぱいしました。コンソールで、くわしいないようを、よむことができます。", + "scope_in_timeline": { + "private": "フォロワーげんてい", + "public": "パブリック", + "unlisted": "アンリステッド", + "direct": "ダイレクト" + }, + "pin": "ピンどめする", + "flash_content": "Flash コンテンツを、 Ruffle をつかってひょうじする (うごかないかもしれません)。", + "generic_error_message": "エラーになりました: {0}", + "error_retry": "もういちど、ためしてください", + "never_show_again": "にどとひょうじしない", + "close": "とじる", + "dismiss": "むしする", + "peek": "かくす" }, "image_cropper": { "crop_picture": "がぞうをきりぬく", @@ -83,11 +123,17 @@ "heading": { "totp": "2-ファクターにんしょう", "recovery": "2-ファクターリカバリー" - } + }, + "logout_confirm_title": "ログアウトのかくにん", + "logout_confirm": "ほんとうに、ログアウトしますか?", + "logout_confirm_accept_button": "ログアウトする", + "logout_confirm_cancel_button": "ログアウトしない" }, "media_modal": { "previous": "まえ", - "next": "つぎ" + "next": "つぎ", + "counter": "{current} / {total}", + "hide": "メディアビューアーをとじる" }, "nav": { "about": "これはなに?", @@ -104,7 +150,20 @@ "user_search": "ユーザーをさがす", "search": "さがす", "who_to_follow": "おすすめユーザー", - "preferences": "せってい" + "preferences": "せってい", + "home_timeline": "ホームタイムライン", + "bookmarks": "ブックマーク", + "timelines": "タイムライン", + "chats": "チャット", + "lists": "リスト", + "mobile_notifications": "つうちをひらく (よんでないものがあります)", + "mobile_notifications_close": "つうちをとじる", + "announcements": "おしらせ", + "edit_pinned": "ピンどめをへんしゅう", + "search_close": "けんさくバーをとじる", + "edit_nav_mobile": "ナビゲーションバーのせっていをかえる", + "mobile_sidebar": "モバイルのサイドバーをきりかえる", + "edit_finish": "へんしゅうをおわりにする" }, "notifications": { "broken_favorite": "ステータスがみつかりません。さがしています…", @@ -114,21 +173,29 @@ "notifications": "つうち", "read": "よんだ!", "repeated_you": "あなたのステータスがリピートされました", - "no_more_notifications": "つうちはありません" + "no_more_notifications": "つうちはありません", + "error": "つうちをとりにいくことに、しっぱいしました: {0}", + "follow_request": "あなたをフォローしたいです", + "migrated_to": "インスタンスを、ひっこしました", + "reacted_with": "{0} でリアクションしました", + "poll_ended": "とうひょうが、おわりました", + "submitted_report": "つうほうしました" }, "polls": { - "add_poll": "いれふだをはじめる", + "add_poll": "とうひょうをはじめる", "add_option": "オプションをふやす", "option": "オプション", - "votes": "いれふだ", - "vote": "ふだをいれる", - "type": "いれふだのかた", + "votes": "ひょう", + "vote": "とうひょうする", + "type": "とうひょうのけいしき", "single_choice": "ひとつえらぶ", "multiple_choices": "いくつでもえらべる", - "expiry": "いれふだのながさ", - "expires_in": "いれふだは {0} で、おわります", - "expired": "いれふだは {0} まえに、おわりました", - "not_enough_options": "ユニークなオプションが、たりません" + "expiry": "とうひょうのながさ", + "expires_in": "とうひょうは {0} で、おわります", + "expired": "とうひょうは {0} まえに、おわりました", + "not_enough_options": "ユニークなオプションが、たりません", + "people_voted_count": "{count} にんが、とうひょうしました", + "votes_count": "{count} ひょう" }, "emoji": { "stickers": "ステッカー", @@ -139,7 +206,19 @@ "custom": "カスタムえもじ", "unicode": "ユニコードえもじ", "load_all_hint": "はじめの {saneAmount} このえもじだけがロードされています。すべてのえもじをロードすると、パフォーマンスがわるくなるかもしれません。", - "load_all": "すべてのえもじをロード ({emojiAmount} こあります)" + "load_all": "すべてのえもじをロード ({emojiAmount} こあります)", + "unicode_groups": { + "flags": "はた", + "activities": "かつどう", + "animals-and-nature": "どうぶつ・しぜん", + "food-and-drink": "たべもの・のみもの", + "objects": "もの", + "people-and-body": "ひと・からだ", + "smileys-and-emotion": "えがお・きもち", + "symbols": "きごう", + "travel-and-places": "りょこう・ばしょ" + }, + "regional_indicator": "ばしょをしめすきごう {letter}" }, "stickers": { "add_sticker": "ステッカーをふやす" @@ -147,7 +226,10 @@ "interactions": { "favs_repeats": "リピートとおきにいり", "follows": "あたらしいフォロー", - "load_older": "ふるいやりとりをみる" + "load_older": "ふるいやりとりをみる", + "emoji_reactions": "えもじリアクション", + "moves": "ユーザーのひっこし", + "reports": "つうほう" }, "post_status": { "new_status": "とうこうする", @@ -176,7 +258,18 @@ "private": "フォロワーげんてい: フォロワーのみにとどきます", "public": "パブリック: パブリックタイムラインにとどきます", "unlisted": "アンリステッド: パブリックタイムラインにとどきません" - } + }, + "media_description_error": "メディアのアップロードにしっぱいしました。もういちどためしてください", + "edit_status": "ステータスをへんしゅうする", + "media_description": "メディアのせつめい", + "content_type_selection": "とうこうのけいしき", + "edit_remote_warning": "ほかのリモートインスタンスは、へんしゅうをサポートしていないかもしれません。そして、へんしゅうされたとうこうをうけとることができないかもしれません。", + "post": "とうこう", + "edit_unsupported_warning": "Pleroma は、メンションやとうひょうのへんしゅうを、サポートしていません。", + "preview": "プレビュー", + "preview_empty": "なにもありません", + "empty_status_error": "とうこうないようを、にゅうりょくしてください", + "scope_notice_dismiss": "このつうちをとじる" }, "registration": { "bio": "プロフィール", @@ -196,8 +289,18 @@ "email_required": "なにかかいてください", "password_required": "なにかかいてください", "password_confirmation_required": "なにかかいてください", - "password_confirmation_match": "パスワードがちがいます" - } + "password_confirmation_match": "パスワードがちがいます", + "birthday_required": "なにかかいてください", + "birthday_min_age": "{date} か、それよりまえにしてください" + }, + "reason_placeholder": "このインスタンスでは、ひとがかくにんして、とうろくをうけいれています。\nなぜあなたがとうろくしたいのかを、かんりしているひとに、おしえてください。", + "bio_optional": "プロフィール (かかなくてもよい)", + "reason": "とうろくするりゆう", + "email_optional": "Eメール (かかなくてもよい)", + "register": "とうろくする", + "email_language": "サーバーからのメールは、なにご(どのことば)がいいですか?", + "birthday": "たんじょうび:", + "birthday_optional": "たんじょうび (かかなくてもよい):" }, "remote_user_resolver": { "remote_user_resolver": "リモートユーザーリゾルバー", @@ -393,7 +496,24 @@ "save_load_hint": "「のこす」オプションをONにすると、テーマをえらんだときとロードしたとき、いまのせっていをのこします。また、テーマをエクスポートするとき、これらのオプションをストアします。すべてのチェックボックスをOFFにすると、テーマをエクスポートしたとき、すべてのせっていをセーブします。", "reset": "リセット", "clear_all": "すべてクリア", - "clear_opacity": "とうめいどをクリア" + "clear_opacity": "とうめいどをクリア", + "help": { + "older_version_imported": "ふるいバージョンのフロントエンドでつくられたファイルをインポートしました。", + "snapshot_missing": "ファイルにはテーマのスナップショットがありません。おもっていたみためと、ちがうかもしれません。", + "migration_snapshot_ok": "あんぜんのため、テーマのスナップショットがよみこまれました。テーマのデータをよみこむことができます。", + "snapshot_source_mismatch": "バージョンがただしくないです。フロントエンドのバージョンをもとにもどしたあと、あたらしくしたことが、りゆうかもしれません。ふるいフロントエンドでテーマをへんこうしていたばあい、ふるいバージョンをつかうのがいいです。そうでないばあい、あたらしいバージョンをつかってください。", + "snapshot_present": "テーマのスナップショットをよみこみました。せっていはうわがきされました。かわりに、テーマのじっさいのデータをよみこむことができます。", + "fe_upgraded": "フロントエンドといっしょに、テーマエンジンもあたらしくなりました。", + "fe_downgraded": "フロントエンドが、まえのバージョンにもどりました。", + "migration_napshot_gone": "スナップショットがありません。おぼえているみためと、ちがうかもしれません。", + "upgraded_from_v2": "PleromaFEがあたらしくなったので、いままでのみためとすこしちがうかもしれません。", + "v2_imported": "ふるいフロントエンドのためのファイルをインポートしました。せっていしたのとは、すこしちがうかもしれません。", + "future_version_imported": "あたらしいフロントエンドでつくられたファイルをインポートしました。" + }, + "load_theme": "テーマをよみこむ", + "keep_as_is": "そのままにする", + "use_snapshot": "ふるいバージョン", + "use_source": "あたらしいバージョン" }, "common": { "color": "いろ", @@ -429,7 +549,26 @@ "borders": "さかいめ", "buttons": "ボタン", "inputs": "インプットフィールド", - "faint_text": "うすいテキスト" + "faint_text": "うすいテキスト", + "post": "とうこう / プロフィール", + "wallpaper": "かべがみ", + "icons": "アイコン", + "highlight": "よくみえるようにした、ようそ", + "pressed": "おしたとき", + "chat": { + "border": "さかいめ", + "incoming": "うけとったもの", + "outgoing": "おくったもの" + }, + "underlay": "アンダーレイ", + "alert_neutral": "それいがい", + "popover": "ツールチップ、メニュー、ポップオーバー", + "poll": "とうひょうのグラフ", + "selectedPost": "えらんだとうこう", + "selectedMenu": "えらんだメニューアイテム", + "disabled": "つかえないとき", + "toggled": "きりかえたとき", + "tabs": "タブ" }, "radii": { "_tab_label": "まるさ" @@ -462,7 +601,8 @@ "buttonPressed": "ボタン (おされているとき)", "buttonPressedHover": "ボタン (ホバー、かつ、おされているとき)", "input": "インプットフィールド" - } + }, + "hintV3": "かげのばあいは、 {0} というかきかたをつかうことができます。そうすると、ほかのいろのスロットをつかうことができます。" }, "fonts": { "_tab_label": "フォント", @@ -497,7 +637,167 @@ "title": "バージョン", "backend_version": "バックエンドのバージョン", "frontend_version": "フロントエンドのバージョン" - } + }, + "notification_visibility_polls": "あなたがさんかしたとうひょうが、おわりました", + "setting_server_side": "このせっていは、あなたのプロフィールについてのものです。へんこうすると、すべてのセッションとクライアントにえいきょうします", + "mute_import_error": "ミュートのインポートが、エラーになりました", + "account_backup_description": "あなたのアカウントじょうほうや、とうこうのアーカイブを、ダウンロードすることができます。しかし、 Pleroma アカウントにインポートすることはまだできません。", + "list_backups_error": "バックアップリストをとりにいくことが、エラーになりました: {error}", + "list_aliases_error": "エイリアスをとりにいくときに、エラーになりました: {error}", + "added_alias": "エイリアスをつくりました。", + "move_account_notes": "もしあなたがアカウントをほかのインスタンスにひっこしたいのなら、ひっこすさきのアカウントからここへのエイリアスをつくってください。", + "file_export_import": { + "backup_settings_theme": "せっていとテーマをファイルにバックアップする", + "restore_settings": "ファイルからせっていをもとにもどす", + "errors": { + "file_too_new": "メジャーバージョン({fileMajor})がちがいます。この PleromaFE (せっていのバージョン {feMajor}) はふるいので、つかうことができません", + "file_slightly_new": "ファイルのマイナーバージョンがちがっています。いくつかのせっていは、よみこまれないかもしれません", + "invalid_file": "これは Pleroma のせっていをバックアップしたファイルではありません。", + "file_too_old": "メジャーバージョン({fileMajor})がちがいます。ファイルのバージョンが古いので、使うことができません(バージョン {feMajor} いじょうのせっていバージョンをつかってください)" + }, + "backup_settings": "せっていをファイルにバックアップする", + "backup_restore": "せっていのバックアップ" + }, + "hide_wallpaper": "このインスタンスのバックグラウンドをかくす", + "reply_visibility_following_short": "わたしのフォローしているひとにあてられたリプライをみる", + "reply_visibility_self_short": "じぶんにあてられたリプライだけをみる", + "save": "へんこうをほぞんする", + "reset_banner_confirm": "ほんとうに、バナーをリセットしますか?", + "tree_advanced": "ツリービューで、ナビゲーションをもっとじゅうなんにする", + "third_column_mode": "じゅうぶんなくうかんがあれば、3ばんめのれつをひょうじする", + "conversation_other_replies_button": "「ほかのリプライ」ボタンをひょうじするばしょ", + "user_popover_avatar_action_open": "プロフィールをひらく", + "notification_setting_filters": "フィルター", + "notification_setting_hide_notification_contents": "おくったひとと、ないようを、プッシュつうちにひょうじしない", + "backup_running": "バックアップしています。{number}このデータをしょりしました。", + "word_filter_and_more": "ことばのフィルターと、そのほか…", + "account_privacy": "プライバシー", + "posts": "とうこう", + "move_account": "アカウントをひっこす", + "move_account_target": "ひっこしさきのアカウント (れい: {example})", + "mute_bot_posts": "Bot のとうこうをミュートする", + "hide_bot_indication": "Bot によるとうこうであることを、とうこうにひょうじしない", + "hide_all_muted_posts": "ミュートしたとうこうをかくす", + "hide_shoutbox": "Shoutbox をかくす", + "conversation_display_tree": "ツリーけいしき", + "mention_link_display_full_for_remote": "リモートユーザーだけ、ながいなまえでひょうじする (れい: {'@'}hoge{'@'}example.org)", + "mention_link_bolden_you": "あなたがメンションされたとき、あなたへのメンションを、よくみえるようにする", + "user_popover_avatar_action": "ポップオーバーのアバターをクリックしたとき", + "user_popover_avatar_action_zoom": "アバターをおおきくする", + "user_popover_avatar_action_close": "ポップオーバーをとじる", + "always_show_post_button": "とうこうボタンをいつもひょうじする", + "auto_update": "あたらしいとうこうを、じどうてきにみせる", + "user_mutes": "ユーザー", + "useStreamingApi": "とうこうとつうちを、リアルタイムにうけとる", + "use_websockets": "Websockets をつかう (リアルタイムアップデート)", + "mutes_and_blocks": "ミュートとブロック", + "emoji_reactions_on_timeline": "えもじリアクションをタイムラインにひょうじする", + "accent": "アクセント", + "domain_mutes": "ドメイン", + "import_mutes_from_a_csv_file": "CSVファイルからミュートをインポートする", + "reset_avatar": "アバターをリセットする", + "remove_language": "とりのぞく", + "primary_language": "いちばんわかることば:", + "add_language": "よびとしてつかうことばを、ついかする", + "fallback_language": "よびとしてつかうことば {index}:", + "lists_navigation": "ナビゲーションにリストをひょうじする", + "account_alias": "アカウントのエイリアス", + "mention_link_display_full": "いつも、ながいなまえをひょうじする (れい: {'@'}hoge{'@'}example.org)", + "setting_changed": "せっていは、デフォルトとちがっています", + "email_language": "サーバーからうけとるEメールのことば", + "mute_export": "ミュートのエクスポート", + "mute_export_button": "あなたのミュートを、 CSV ファイルにエクスポートします", + "mute_import": "ミュートのインポート", + "mutes_imported": "ミュートをインポートしました!すこしじかんがかかるかもしれません。", + "account_backup": "アカウントのバックアップ", + "account_backup_table_head": "バックアップ", + "download_backup": "ダウンロード", + "backup_not_ready": "バックアップのじゅんびが、まだできていません。", + "backup_failed": "バックアップにしっぱいしました。", + "remove_backup": "とりのぞく", + "add_backup": "あたらしいバックアップをつくる", + "added_backup": "あたらしいバックアップをつくりました。", + "add_backup_error": "あたらしいバックアップをつくるときに、エラーになりました: {error}", + "bot": "これは bot アカウントです", + "account_alias_table_head": "エイリアス", + "hide_list_aliases_error_action": "とじる", + "remove_alias": "このエイリアスをけす", + "add_alias_error": "エイリアスをつくるときに、エラーになりました: {error}", + "new_alias_target": "あたらしいエイリアスをつくる (れい: {example})", + "moved_account": "アカウントをひっこしました。", + "move_account_error": "アカウントをひっこしているときに、エラーになりました: {error}", + "wordfilter": "ことばのフィルター", + "hide_media_previews": "メディアのプレビューをかくす", + "right_sidebar": "サイドバーをみぎにひょうじする", + "hide_wordfiltered_statuses": "ことばのフィルターでフィルターされたステータスをかくす", + "hide_muted_threads": "ミュートされたスレッドをかくす", + "navbar_column_stretch": "ナビゲーションバーをれつのはばまでのばす", + "birthday": { + "label": "たんじょうび", + "show_birthday": "たんじょうびを、ひょうじする" + }, + "profile_fields": { + "label": "プロフィールのメタデータ", + "add_field": "フィールドをふやす", + "name": "ラベル", + "value": "ないよう" + }, + "user_profiles": "ユーザープロフィール", + "notification_visibility_moves": "ユーザーのひっこし", + "notification_visibility_emoji_reactions": "リアクション", + "hide_favorites_description": "おきにいりのリストをみせない (つうちはおくられます)", + "reset_profile_background": "プロフィールバックグラウンドをリセットする", + "reset_profile_banner": "プロフィールバナーをリセットする", + "reset_avatar_confirm": "ほんとうに、アバターをリセットしますか?", + "reset_background_confirm": "ほんとうに、バックグラウンドをリセットしますか?", + "column_sizes_sidebar": "サイドバー", + "column_sizes_notifs": "つうち", + "columns": "れつ", + "column_sizes": "れつのおおきさ", + "column_sizes_content": "コンテンツ", + "conversation_display": "スレッドのひょうじけいしき", + "conversation_display_linear": "リニアけいしき", + "conversation_display_linear_quick": "リニアビュー", + "show_scrollbars": "よこのれつにスクロールバーをひょうじする", + "third_column_mode_none": "3ばんめのれつをひょうじしない", + "third_column_mode_postform": "とうこうフォームとナビゲーション", + "third_column_mode_notifications": "つうちのれつをひょうじする", + "tree_fade_ancestors": "げんざいのステータスのおやを、うすいいろのもじでひょうじする", + "conversation_other_replies_button_below": "ステータスのした", + "conversation_other_replies_button_inside": "ステータスのなか", + "max_depth_in_thread": "デフォルトでひょうじするスレッドのふかさ", + "sensitive_by_default": "デフォルトで、とうこうをNSFWにする", + "type_domains_to_mute": "ミュートしたいドメインを、ここでけんさくできます", + "mention_link_use_tooltip": "メンションのリンクをクリックしたとき、ユーザーカードをみせる", + "mention_link_show_avatar": "ユーザーのアバターをリンクのよこにひょうじする", + "mention_link_show_avatar_quick": "ユーザーのアバターをメンションのとなりにひょうじする", + "mention_link_fade_domain": "ドメイン(れい: {'@'}hoge{'@'}example.org のなかの {'@'}example.org)を、うすいいろにする", + "user_popover_avatar_overlay": "ユーザーのポップオーバーを、ユーザーのアバターのうえにひょうじする", + "show_yous": "(あなた)をひょうじする", + "notification_setting_block_from_strangers": "フォローしていないユーザーからのつうちをブロックする", + "notification_setting_privacy": "プライバシー", + "more_settings": "そのたのせってい", + "expert_mode": "くわしいせっていを、ひょうじする", + "mention_links": "メンションのリンク", + "post_look_feel": "とうこうのみためとかんかく", + "allow_following_move": "フォローしているアカウントがインスタンスをひっこしたばあい、じどうでフォローしてもよい", + "chatMessageRadius": "チャットメッセージ", + "confirm_dialogs": "つぎのばあいに、かくにんをする", + "confirm_dialogs_repeat": "ステータスをリピートするとき", + "confirm_dialogs_unfollow": "ユーザーのフォローをはずすとき", + "confirm_dialogs_block": "ユーザーをブロックするとき", + "confirm_dialogs_mute": "ユーザーをミュートするとき", + "confirm_dialogs_delete": "ステータスをけすとき", + "confirm_dialogs_logout": "ログアウトするとき", + "confirm_dialogs_approve_follow": "フォローをうけいれるとき", + "confirm_dialogs_deny_follow": "フォローをことわるとき", + "confirm_dialogs_remove_follower": "フォロワーをとりのぞくとき", + "conversation_display_tree_quick": "ツリービュー", + "disable_sticky_headers": "れつのヘッダーを、がめんのいちばんうえにこていしない", + "virtual_scrolling": "タイムラインのレンダリングをよくする", + "use_at_icon": "{'@'} きごうを、もじのかわりに、アイコンでひょうじする", + "mention_link_display_short": "いつも、みじかいなまえにする (れい: {'@'}hoge)", + "mention_link_display": "メンションのリンクをひょうじするけいしき" }, "time": { "day": "{0}日", @@ -531,7 +831,23 @@ "year": "{0}年", "years": "{0}年", "year_short": "{0}年", - "years_short": "{0}年" + "years_short": "{0}年", + "unit": { + "minutes": "{0}ふん", + "seconds_short": "{0}びょう", + "weeks": "{0}しゅうかん", + "weeks_short": "{0}しゅう", + "years": "{0}ねん", + "years_short": "{0}ねん", + "days": "{0}にち", + "days_short": "{0}にち", + "hours": "{0}じかん", + "hours_short": "{0}じかん", + "minutes_short": "{0}ふん", + "months": "{0}かげつ", + "months_short": "{0}かげつ", + "seconds": "{0}びょう" + } }, "timeline": { "collapse": "たたむ", @@ -543,7 +859,11 @@ "show_new": "よみこみ", "up_to_date": "さいしん", "no_more_statuses": "これでおわりです", - "no_statuses": "ありません" + "no_statuses": "ありません", + "socket_broke": "コード{0}により、リアルタイムでつながることがなくなりました", + "socket_reconnected": "リアルタイムでつながることを、つくりました", + "reload": "もういちど、よみこむ", + "error": "タイムラインをとりにいくときに、エラーになりました: {0}" }, "status": { "favorites": "おきにいり", @@ -556,7 +876,57 @@ "reply_to": "へんしん:", "replies_list": "へんしん:", "mute_conversation": "スレッドをミュートする", - "unmute_conversation": "スレッドをミュートするのをやめる" + "unmute_conversation": "スレッドをミュートするのをやめる", + "repeat_confirm_title": "リピートのかくにん", + "mentions": "メンション", + "thread_muted": "ミュートされたスレッド", + "collapse_attachments": "ファイルをかくす", + "remove_attachment": "ファイルをとりのぞく", + "thread_show_full": "このスレッドのすべてのとうこうをみる (ぜんぶで{numStatus}このステータス、ふかさ{depth})", + "show_all_attachments": "すべてのファイルをみる", + "hide_full_subject": "かくす", + "nsfw": "NSFW", + "hide_content": "かくす", + "status_deleted": "このとうこうは、けされました", + "you": "(あなた)", + "expand": "ひろげる", + "repeat_confirm_accept_button": "リピートする", + "repeat_confirm_cancel_button": "リピートしない", + "edited_at": "({time} まえにへんしゅう)", + "delete_confirm_title": "けすことのかくにん", + "delete_confirm_accept_button": "けす", + "delete_confirm_cancel_button": "のこす", + "edit": "ステータスをへんしゅうする", + "bookmark": "ブックマークする", + "unbookmark": "ブックマークをはずす", + "replies_list_with_others": "へんしん (ほかに +{numReplies}こ):", + "status_unavailable": "ステータスがありません", + "copy_link": "リンクをコピー", + "external_source": "そとにあるソース", + "thread_muted_and_words": "つぎのことばをふくむので:", + "show_content": "みる", + "plus_more": "あと {number}こ", + "many_attachments": "とうこうには、{number}このファイルがついています", + "show_attachment_in_modal": "メディアモーダルでみる", + "show_attachment_description": "せつめいのプレビュー (ぜんぶみるには、ファイルをひらいてください)", + "hide_attachment": "ファイルをかくす", + "attachment_stop_flash": "Flash プレーヤーをとめる", + "move_up": "ファイルをひだりにうごかす", + "move_down": "ファイルをみぎにうごかす", + "open_gallery": "ギャラリーをひらく", + "thread_hide": "スレッドをかくす", + "thread_show": "スレッドをみる", + "show_full_subject": "すべてをみる", + "repeat_confirm": "ほんとうに、このステータスをリピートしますか?", + "show_all_conversation": "このスレッドをぜんぶみる (あと {numStatus}このステータス)", + "show_only_conversation_under_this": "このステータスへのへんしんだけをみる", + "status_history": "ステータスのれきし", + "thread_show_full_with_icon": "{icon} {text}", + "thread_follow": "のこりのとうこうをみる (ぜんぶで {numStatus}このステータス)", + "thread_follow_with_icon": "{icon} {text}", + "ancestor_follow": "このステータスよりしたの、{numReplies}このへんしんをみる", + "ancestor_follow_with_icon": "{icon} {text}", + "show_all_conversation_with_icon": "{icon} {text}" }, "user_card": { "approve": "うけいれ", @@ -577,7 +947,7 @@ "media": "メディア", "mention": "メンション", "mute": "ミュート", - "muted": "ミュートしています!", + "muted": "ミュートしています", "per_day": "/日", "remote_follow": "リモートフォロー", "report": "つうほう", @@ -608,8 +978,52 @@ "disable_remote_subscription": "ほかのインスタンスからフォローされないようにする", "disable_any_subscription": "フォローされないようにする", "quarantine": "ほかのインスタンスのユーザーのとうこうをとめる", - "delete_user": "ユーザーをけす" - } + "delete_user": "ユーザーをけす", + "delete_user_data_and_deactivate_confirmation": "これをすると、このアカウントのデータがきえて、にどとつかえなくなります。ほんとうに、していいですか?" + }, + "mute_confirm_accept_button": "ミュートする", + "unfollow_confirm_title": "フォローをやめることのかくにん", + "mute_confirm": "ほんとうに、 {user} をミュートしますか?", + "mute_duration_prompt": "このユーザーをつぎのじかんだけミュートする (0にすると、おわりがありません):", + "edit_note_apply": "てきよう", + "block_confirm": "ほんとうに、 {user} をブロックしますか?", + "deactivated": "つかえない", + "remove_follower": "フォロワーをとりのぞく", + "highlight": { + "solid": "バッググラウンドをひとつのいろにする", + "striped": "しまもようのバックグラウンドにする", + "side": "はじにせんをつける", + "disabled": "めだたせない" + }, + "mute_confirm_cancel_button": "ミュートしない", + "unfollow_confirm_accept_button": "フォローをやめる", + "unfollow_confirm": "ほんとうに、 {user} のフォローをやめますか?", + "unfollow_confirm_cancel_button": "フォローしたままにする", + "mute_confirm_title": "ミュートのかくにん", + "block_confirm_accept_button": "ブロックする", + "block_confirm_cancel_button": "ブロックしない", + "deny_confirm_title": "おことわりのかくにん", + "deny_confirm_accept_button": "ことわる", + "deny_confirm_cancel_button": "ことわらない", + "deny_confirm": "{user} のフォローリクエストをことわりますか?", + "follow_cancel": "リクエストをキャンセル", + "birthday": "{birthday} に、うまれました", + "remove_follower_confirm_title": "フォロワーをとりのぞくことのかくにん", + "remove_follower_confirm_accept_button": "とりのぞく", + "remove_follower_confirm_cancel_button": "のこす", + "remove_follower_confirm": "ほんとうに、 {user} をあなたのフォロワーからとりのぞきますか?", + "edit_note": "メモをへんしゅうする", + "edit_note_cancel": "キャンセル", + "message": "メッセージ", + "bot": "bot", + "approve_confirm_title": "うけいれのかくにん", + "approve_confirm_accept_button": "うけいれる", + "approve_confirm_cancel_button": "うけいれない", + "approve_confirm": "{user} のフォローリクエストをうけいれますか?", + "edit_profile": "プロフィールをへんしゅう", + "block_confirm_title": "ブロックのかくにん", + "note_blank": "(なし)", + "note": "メモ" }, "user_profile": { "timeline_title": "ユーザータイムライン", @@ -634,13 +1048,21 @@ "repeat": "リピート", "reply": "リプライ", "favorite": "おきにいり", - "user_settings": "ユーザーせってい" + "user_settings": "ユーザーせってい", + "accept_follow_request": "フォローのおねがいを、うけいれる", + "toggle_mute": "ミュートされたないようをみるために、つうちをひらくか、とじる", + "autocomplete_available": "{number}このけっかが、あります。うえとしたのキーをつかって、けっかをみることができます。", + "add_reaction": "リアクションをつける", + "reject_follow_request": "フォローのおねがいを、ことわる", + "bookmark": "ブックマーク", + "toggle_expand": "とうこうをすべてみるために、つうちをひらくか、とじる" }, "upload": { "error": { "base": "アップロードにしっぱいしました。", "file_too_big": "ファイルがおおきすぎます [{filesize} {filesizeunit} / {allowedsize} {allowedsizeunit}]", - "default": "しばらくしてから、ためしてください" + "default": "しばらくしてから、ためしてください", + "message": "アップロードにしっぱいしました: {0}" }, "file_size_units": { "B": "B", @@ -655,7 +1077,9 @@ "hashtags": "ハッシュタグ", "person_talking": "{count} にんが、はなしています", "people_talking": "{count} にんが、はなしています", - "no_results": "みつかりませんでした" + "no_results": "みつかりませんでした", + "no_more_results": "これでおわりです", + "load_more": "もっとみる" }, "password_reset": { "forgot_password": "パスワードを、わすれましたか?", @@ -668,5 +1092,103 @@ "password_reset_disabled": "このインスタンスでは、パスワードリセットは、できません。インスタンスのアドミニストレーターに、おといあわせください。", "password_reset_required": "ログインするには、パスワードをリセットしてください。", "password_reset_required_but_mailer_is_disabled": "あなたはパスワードのリセットがひつようです。しかし、まずいことに、このインスタンスでは、パスワードのリセットができなくなっています。このインスタンスのアドミニストレーターに、おといあわせください。" + }, + "announcements": { + "post_placeholder": "おしらせのないようを、にゅうりょくしてください。", + "end_time_prompt": "おわるじかん: ", + "inactive_message": "このおしらせは、つかわれていません", + "page_header": "おしらせ", + "title": "おしらせ", + "post_action": "とうこう", + "post_form_header": "おしらせをとうこう", + "mark_as_read_action": "よんだことにする", + "post_error": "エラー: {error}", + "close_error": "とじる", + "delete_action": "けす", + "start_time_display": "{time}にはじまります", + "end_time_display": "{time}におわります", + "edit_action": "へんしゅう", + "start_time_prompt": "はじまるじかん: ", + "all_day_prompt": "このイベントはいちにちじゅうやります", + "published_time_display": "{time}にこうかいされました", + "submit_edit_action": "そうしん", + "cancel_edit_action": "キャンセル" + }, + "report": { + "reported_statuses": "つうほうされたステータス:", + "reporter": "つうほうしたひと:", + "state_closed": "クローズ", + "state_resolved": "かいけつしました", + "reported_user": "つうほうされたユーザー:", + "notes": "メモ:", + "state": "じょうたい:", + "state_open": "オープン" + }, + "update": { + "update_bugs": "もんだいや、バグがあれば、 {pleromaGitlab} でおしえてください。ちゃんとテストはしているのですが、たくさんのことをかえているので、そしてかいはつバージョンをつかっているので、もんだいやバグに、きづかないことがあります。あなたがきづいたもんだいについての、フィードバックやていあんを、まっています。 Pleroma や Pleroma-FE をよくするやりかたについても、おしえてください。", + "update_changelog_here": "すべてのかわったことのきろく", + "art_by": "{linkToArtist}によるさくひん", + "big_update_title": "すこし、まってください", + "big_update_content": "しばらくリリースがありませんでした。おもっていたみためと、ちがうかもしれません。", + "update_bugs_gitlab": "Pleroma GitLab", + "update_changelog": "かわったことをすべてみるには、{theFullChangelog}をみてください。" + }, + "chats": { + "new": "あたらしいチャット", + "chats": "チャット", + "you": "あなた:", + "message_user": "{nickname} にメッセージ", + "delete": "けす", + "empty_message_error": "なにかかいてください", + "more": "もっとみる", + "delete_confirm": "ほんとうに、このメッセージをけしますか?", + "error_loading_chat": "チャットをよみこむことに、しっぱいしました。", + "error_sending_message": "メッセージをおくることに、しっぱいしました。", + "empty_chat_list_placeholder": "チャットがありません。あたらしいチャットボタンをおして、はじめてください!" + }, + "shoutbox": { + "title": "Shoutbox" + }, + "errors": { + "storage_unavailable": "Pleroma はブラウザーのストレージにアクセスすることができません。あなたがログインしたことと、あなたのローカルのせっていは、ほぞんされません。ほかにももんだいがおきるかもしれません。 Cookie をゆうこうにしてください。" + }, + "lists": { + "lists": "リスト", + "new": "あたらしいリスト", + "search": "ユーザーをさがす", + "title": "リストのなまえ", + "create": "つくる", + "save": "へんこうをほぞんする", + "delete": "リストをけす", + "following_only": "フォローしているひとげんていにする", + "manage_lists": "リストをかんりする", + "manage_members": "リストにふくまれるひとを、かんりする", + "add_members": "もっとユーザーをさがす", + "remove_from_list": "リストからとりのぞく", + "add_to_list": "リストにいれる", + "editing_list": "リスト {listTitle} をへんしゅうしています", + "creating_list": "あたらしいリストをつくっています", + "update_title": "なまえをほぞんする", + "really_delete": "ほんとうに、リストをけしますか?", + "is_in_list": "すでにリストのなかにあります", + "error": "リストをへんしゅうするときに、エラーになりました: {0}" + }, + "file_type": { + "audio": "オーディオ", + "video": "ビデオ", + "image": "がぞう", + "file": "ファイル" + }, + "display_date": { + "today": "きょう" + }, + "unicode_domain_indicator": { + "tooltip": "このドメインは、ASCIIいがいのもじをふくんでいます。" + }, + "domain_mute_card": { + "mute": "ミュート", + "mute_progress": "ミュートしています…", + "unmute": "ミュートをやめる", + "unmute_progress": "ミュートをやめています…" } } diff --git a/src/i18n/messages.js b/src/i18n/messages.js index 74a89ca8..849d98fd 100644 --- a/src/i18n/messages.js +++ b/src/i18n/messages.js @@ -7,8 +7,11 @@ // sed -i -e "s/'//gm" -e 's/"/\\"/gm' -re 's/^( +)(.+?): ((.+?))?(,?)(\{?)$/\1"\2": "\4"/gm' -e 's/\"\{\"/{/g' -e 's/,"$/",/g' file.json // There's only problem that apostrophe character ' gets replaced by \\ so you have to fix it manually, sorry. +import { isEqual } from 'lodash' import { languages, langCodeToJsonName } from './languages.js' +const ULTIMATE_FALLBACK_LOCALE = 'en' + const hasLanguageFile = (code) => languages.includes(code) const loadLanguageFile = (code) => { @@ -25,11 +28,26 @@ const messages = { en: require('./en.json').default }, setLanguage: async (i18n, language) => { - if (hasLanguageFile(language)) { - const messages = await loadLanguageFile(language) - i18n.setLocaleMessage(language, messages.default) + const languages = (Array.isArray(language) ? language : [language]).filter(k => k) + + if (!languages.includes(ULTIMATE_FALLBACK_LOCALE)) { + languages.push(ULTIMATE_FALLBACK_LOCALE) + } + const [first, ...rest] = languages + + if (first === i18n.locale && isEqual(rest, i18n.fallbackLocale)) { + return } - i18n.locale = language + + for (const lang of languages) { + if (hasLanguageFile(lang)) { + const messages = await loadLanguageFile(lang) + i18n.setLocaleMessage(lang, messages.default) + } + } + + i18n.fallbackLocale = rest + i18n.locale = first } } diff --git a/src/i18n/uk.json b/src/i18n/uk.json index c781b1f6..cbb1a21f 100644 --- a/src/i18n/uk.json +++ b/src/i18n/uk.json @@ -118,7 +118,10 @@ "totp": "Двофакторна автентифікація" }, "enter_two_factor_code": "Введіть двофакторний код автентифікації", - "placeholder": "напр. stepan" + "placeholder": "напр. stepan", + "logout_confirm": "Ви дійсно хочете вийти?", + "logout_confirm_accept_button": "Вийти", + "logout_confirm_cancel_button": "Ні, хочу назад!" }, "importer": { "error": "Під час імпортування файлу сталася помилка.", @@ -189,7 +192,8 @@ "mobile_notifications": "Відкрити сповіщення (є непрочитані)", "mobile_notifications_close": "Закрити сповіщення", "edit_nav_mobile": "Редагувати панель навігації", - "announcements": "Анонси" + "announcements": "Анонси", + "search_close": "Закрити панель пошуку" }, "media_modal": { "next": "Наступна", diff --git a/src/i18n/zh.json b/src/i18n/zh.json index cf5f384c..8ea4c1e0 100644 --- a/src/i18n/zh.json +++ b/src/i18n/zh.json @@ -53,7 +53,15 @@ "direct": "私讯", "private": "仅关注者", "unlisted": "列外" - } + }, + "scroll_to_top": "滚动至顶", + "generic_error_message": "发生一个错误:{0}", + "never_show_again": "不再显示", + "undo": "撤销", + "yes": "是", + "no": "否", + "unpin": "取消固定该项", + "pin": "固定该项" }, "image_cropper": { "crop_picture": "裁剪图片", @@ -82,7 +90,11 @@ "heading": { "totp": "双重因素验证", "recovery": "双重因素恢复" - } + }, + "logout_confirm_cancel_button": "不要登出", + "logout_confirm_title": "确认登出", + "logout_confirm_accept_button": "登出", + "logout_confirm": "您确定要登出吗?" }, "media_modal": { "previous": "往前", @@ -109,7 +121,16 @@ "chats": "聊天", "timelines": "时间线", "bookmarks": "书签", - "home_timeline": "主页时间线" + "home_timeline": "主页时间线", + "lists": "列表", + "edit_finish": "完成编辑", + "mobile_notifications": "打开通知(有未读的)", + "mobile_notifications_close": "关闭通知", + "announcements": "公告", + "edit_nav_mobile": "自定义导航栏", + "edit_pinned": "编辑固定的项目", + "mobile_sidebar": "切换移动设备侧栏", + "search_close": "关闭搜索栏" }, "notifications": { "broken_favorite": "未知的状态,正在搜索中…", @@ -124,7 +145,8 @@ "migrated_to": "迁移到了", "follow_request": "想要关注你", "error": "取得通知时发生错误:{0}", - "poll_ended": "投票结束了" + "poll_ended": "投票结束了", + "submitted_report": "提交举报" }, "polls": { "add_poll": "增加投票", @@ -149,7 +171,9 @@ "favs_repeats": "转发和喜欢", "follows": "新的关注者", "load_older": "加载更早的互动", - "moves": "用户迁移" + "moves": "用户迁移", + "reports": "举报", + "emoji_reactions": "表情回应" }, "post_status": { "new_status": "发布新状态", @@ -183,7 +207,12 @@ "media_description": "媒体描述", "media_description_error": "更新媒体失败,请重试", "empty_status_error": "不能发布没有内容、没有附件的发文", - "post": "发送" + "post": "发送", + "edit_remote_warning": "其它远程实例可能不支持编辑并且无法接收您的帖子的最新版本。", + "edit_unsupported_warning": "Pleroma 不支持对提及或投票进行编辑。", + "edit_status": "编辑状态", + "content_type_selection": "发帖格式", + "scope_notice_dismiss": "关闭此提示" }, "registration": { "bio": "简介", @@ -203,12 +232,18 @@ "email_required": "不能留空", "password_required": "不能留空", "password_confirmation_required": "不能留空", - "password_confirmation_match": "密码不一致" + "password_confirmation_match": "密码不一致", + "birthday_required": "不能为空", + "birthday_min_age": "必须在 {date} 或之前" }, "reason_placeholder": "此实例的注册需要手动批准。\n请让管理员知道您为什么想要注册。", "reason": "注册理由", "register": "注册", - "email_language": "你想从服务器收到什么语言的邮件?" + "email_language": "你想从服务器收到什么语言的邮件?", + "bio_optional": "介绍(可选)", + "email_optional": "电子邮件(可选)", + "birthday": "生日:", + "birthday_optional": "生日(可选):" }, "selectable_list": { "select_all": "选择全部" @@ -260,7 +295,7 @@ "change_password_error": "修改密码的时候出了点问题。", "changed_password": "成功修改了密码!", "collapse_subject": "折叠带主题的内容", - "composing": "写作", + "composing": "撰写", "confirm_new_password": "确认新密码", "current_avatar": "当前头像", "current_password": "当前密码", @@ -599,7 +634,7 @@ "backup_settings": "备份设置到文件", "backup_restore": "设置备份" }, - "right_sidebar": "在右侧显示侧边栏", + "right_sidebar": "反转分栏的顺序", "hide_shoutbox": "隐藏实例留言板", "expert_mode": "显示高级", "download_backup": "下载", @@ -631,7 +666,79 @@ "move_account_notes": "如果你想把账号移动到别的地方,你必须去目标账号,然后加一个指向这里的别名。", "wordfilter": "词语过滤器", "user_profiles": "用户资料", - "third_column_mode_notifications": "消息栏" + "third_column_mode_notifications": "通知栏", + "backup_running": "此备份正在进行,已处理 {number} 条记录。 |此备份正在进行,已处理 {number} 条记录。", + "lists_navigation": "在导航中显示列表", + "word_filter_and_more": "词过滤器及其它...", + "backup_failed": "此备份已失败。", + "birthday": { + "label": "生日", + "show_birthday": "展示我的生日" + }, + "hide_favorites_description": "不显示我的喜欢列表(人们仍然会收到通知)", + "third_column_mode": "当有足够的空间时,显示第三栏包含", + "third_column_mode_postform": "主要的发文形式和导航", + "columns": "分栏", + "user_popover_avatar_overlay": "在用户头像上显示用户弹出窗口", + "navbar_column_stretch": "延伸导航栏至分栏宽度", + "posts": "帖子", + "conversation_display_linear_quick": "线性视图", + "conversation_other_replies_button": "显示 “其它回复” 按钮", + "confirm_dialogs_delete": "删除状态", + "confirm_dialogs_mute": "隐藏用户", + "column_sizes": "分栏大小", + "column_sizes_sidebar": "侧栏", + "column_sizes_content": "內容", + "column_sizes_notifs": "通知", + "conversation_other_replies_button_below": "在状态下方", + "conversation_other_replies_button_inside": "在状态中", + "auto_update": "自动显示新的帖子", + "use_websockets": "使用 websockets(实时更新)", + "max_depth_in_thread": "默认显示同主题帖子中的最大层数", + "hide_wordfiltered_statuses": "隐藏经过词语过滤的状态", + "hide_muted_threads": "不显示已隐藏的同主题帖子", + "notification_visibility_polls": "你所投的投票的结束于", + "tree_advanced": "允许在树状视图中进行更灵活的导航", + "tree_fade_ancestors": "以模糊的文字显示当前状态的原型", + "conversation_display_linear": "线性样式", + "mention_link_fade_domain": "淡化域名(例如:{'@'}example.org 中的 {'@'}foo{'@'}example.org)", + "mention_link_bolden_you": "当你被提及时突出显示提及你", + "user_popover_avatar_action": "弹出式头像点击动作", + "user_popover_avatar_action_zoom": "缩放头像", + "user_popover_avatar_action_close": "关闭弹出窗口", + "show_yous": "显示 (You)s", + "add_language": "添加备用语言", + "remove_language": "移除", + "primary_language": "主要语言:", + "fallback_language": "备用语言 {index}:", + "account_privacy": "隐私", + "conversation_display": "对话显示样式", + "conversation_display_tree": "树状样式", + "conversation_display_tree_quick": "树状视图", + "disable_sticky_headers": "不要把分栏的顶栏固定在屏幕的顶部", + "confirm_dialogs": "请求确认于", + "confirm_dialogs_logout": "登出", + "confirm_dialogs_deny_follow": "拒绝关注请求", + "confirm_dialogs_approve_follow": "批准关注请求", + "confirm_dialogs_block": "屏蔽用户", + "confirm_dialogs_unfollow": "取消关注用户", + "confirm_dialogs_repeat": "转发状态", + "confirm_dialogs_remove_follower": "移除关注者", + "mute_bot_posts": "隐藏机器人的帖子", + "hide_bot_indication": "隐藏帖子中的机器人提示", + "always_show_post_button": "始终显示浮动的新帖子按钮", + "show_scrollbars": "显示侧栏的滚动条", + "third_column_mode_none": "完全不显示第三栏", + "use_at_icon": "将 {'@'} 符号显示为图标而不是文本", + "mention_link_display": "显示提及链接", + "mention_link_display_short": "始终以简称的形式出现(例如:{'@'}foo)", + "mention_link_display_full_for_remote": "仅远程实例用户以全名的形式出现(例如:{'@'}foo{'@'}example.org)", + "mention_link_display_full": "始终以全名的形式出现(例如:{'@'}foo{'@'}example.org)", + "mention_link_use_tooltip": "点击提及链接时显示用户卡片", + "mention_link_show_avatar": "在链接旁边显示用户头像", + "mention_link_show_avatar_quick": "在提及内容旁边显示用户头像", + "user_popover_avatar_action_open": "打开个人资料", + "autocomplete_select_first": "当有自动完成的结果时,自动选择第一个候选项" }, "time": { "day": "{0} 天", @@ -697,7 +804,9 @@ "reload": "重新载入", "error": "取得时间轴时发生错误:{0}", "socket_broke": "丢失实时连接:CloseEvent code {0}", - "socket_reconnected": "已建立实时连接" + "socket_reconnected": "已建立实时连接", + "quick_view_settings": "快速视图设置", + "quick_filter_settings": "快速过滤设置" }, "status": { "favorites": "喜欢", @@ -706,7 +815,7 @@ "pin": "在个人资料置顶", "unpin": "取消在个人资料置顶", "pinned": "置顶", - "delete_confirm": "你真的想要删除这条状态吗?", + "delete_confirm": "您确定要删除这条状态吗?", "reply_to": "回复", "replies_list": "回复:", "mute_conversation": "隐藏对话", @@ -715,7 +824,7 @@ "show_content": "显示内容", "hide_full_subject": "隐藏此部分标题", "show_full_subject": "显示全部标题", - "thread_muted": "此系列消息已被隐藏", + "thread_muted": "同主题帖子已被隐藏", "copy_link": "复制状态链接", "status_unavailable": "状态不可取得", "unbookmark": "取消书签", @@ -736,10 +845,10 @@ "attachment_stop_flash": "停止 Flash 播放器", "move_up": "把附件左移", "open_gallery": "打开图库", - "thread_hide": "隐藏这个线索", - "thread_show": "显示这个线索", + "thread_hide": "隐藏这个同主题帖子", + "thread_show": "显示这个同主题帖子", "thread_show_full_with_icon": "{icon} {text}", - "thread_follow": "查看这个线索的剩余部分(一共有 {numStatus} 个状态)", + "thread_follow": "查看这个同主题帖子的剩余部分(一共有 {numStatus} 个状态)", "thread_follow_with_icon": "{icon} {text}", "ancestor_follow": "查看这个状态下的别的 {numReplies} 个回复", "ancestor_follow_with_icon": "{icon} {text}", @@ -748,8 +857,19 @@ "mentions": "提及", "replies_list_with_others": "回复(另外 +{numReplies} 个):", "move_down": "把附件右移", - "thread_show_full": "显示这个线索下的所有东西(一共有 {numStatus} 个状态,最大深度 {depth})", - "show_only_conversation_under_this": "只显示这个状态的回复" + "thread_show_full": "显示这个同主题帖子下的所有东西(一共有 {numStatus} 个状态,最大深度 {depth})", + "show_only_conversation_under_this": "只显示这个状态的回复", + "repeat_confirm": "您确定要转发这条状态吗?", + "repeat_confirm_title": "确认转发", + "repeat_confirm_accept_button": "转发", + "repeat_confirm_cancel_button": "不要转发", + "edit": "编辑状态", + "edited_at": "(最后编辑于 {time})", + "delete_confirm_title": "确认删除", + "delete_confirm_accept_button": "删除", + "delete_confirm_cancel_button": "保留", + "show_attachment_in_modal": "在媒体模式中显示", + "status_history": "状态历史" }, "user_card": { "approve": "核准", @@ -797,7 +917,8 @@ "disable_remote_subscription": "禁止从远程实例关注用户", "disable_any_subscription": "完全禁止关注用户", "quarantine": "从联合实例中禁止用户帖子", - "delete_user": "删除用户" + "delete_user": "删除用户", + "delete_user_data_and_deactivate_confirmation": "这将永久删除该账户的数据并停用该账户。你完全确定吗?" }, "hidden": "已隐藏", "show_repeats": "显示转发", @@ -811,7 +932,41 @@ "solid": "单一颜色背景", "disabled": "不突出显示" }, - "edit_profile": "编辑个人资料" + "edit_profile": "编辑个人资料", + "approve_confirm_title": "确认批准", + "approve_confirm_accept_button": "批准", + "block_confirm_accept_button": "屏蔽", + "block_confirm_cancel_button": "不要屏蔽", + "deactivated": "已停用", + "deny_confirm_title": "确认拒绝", + "deny_confirm_accept_button": "拒绝", + "deny_confirm_cancel_button": "不要拒绝", + "deny_confirm": "您是否要拒绝 {user} 的关注请求?", + "follow_cancel": "取消请求", + "unfollow_confirm_title": "确认取消关注", + "unfollow_confirm": "您确定要取消关注 {user} 吗?", + "unfollow_confirm_accept_button": "取消关注", + "unfollow_confirm_cancel_button": "不要取消关注", + "mute_confirm_title": "确认隐藏", + "mute_confirm_accept_button": "隐藏", + "mute_confirm_cancel_button": "不要隐藏", + "mute_duration_prompt": "让这个用户隐藏(0表示无限期):", + "remove_follower": "移除关注者", + "remove_follower_confirm_title": "确认移除关注者", + "remove_follower_confirm_cancel_button": "保留", + "remove_follower_confirm": "您确定要将 {user} 从您的关注者里移除吗?", + "birthday": "生于 {birthday}", + "note": "备注", + "approve_confirm_cancel_button": "不要批准", + "approve_confirm": "您是否要批准 {user} 的关注请求?", + "block_confirm_title": "确认屏蔽", + "block_confirm": "您确定要屏蔽 {user} 吗?", + "mute_confirm": "您确定要隐藏 {user} 吗?", + "remove_follower_confirm_accept_button": "移除", + "note_blank": "(空)", + "edit_note": "编辑备注", + "edit_note_apply": "应用", + "edit_note_cancel": "取消" }, "user_profile": { "timeline_title": "用户时间线", @@ -840,7 +995,10 @@ "reject_follow_request": "拒绝关注请求", "add_reaction": "添加互动", "bookmark": "书签", - "accept_follow_request": "接受关注请求" + "accept_follow_request": "接受关注请求", + "toggle_expand": "展开或折叠通知以显示帖子全文", + "toggle_mute": "展开或折叠通知以显示已隐藏的内容", + "autocomplete_available": "共有 {number} 个结果可用。使用向上和向下键浏览它们。" }, "upload": { "error": { @@ -862,7 +1020,9 @@ "hashtags": "话题标签", "person_talking": "{count} 人正在讨论", "people_talking": "{count} 人正在讨论", - "no_results": "没有搜索结果" + "no_results": "没有搜索结果", + "no_more_results": "没有更多结果", + "load_more": "加载更多结果" }, "password_reset": { "forgot_password": "忘记密码了?", @@ -890,7 +1050,20 @@ "search_emoji": "搜索表情符号", "emoji": "表情符号", "load_all": "加载所有表情符号(共 {emojiAmount} 个)", - "load_all_hint": "最先加载的 {saneAmount} 表情符号,加载全部表情符号可能会带来性能问题。" + "load_all_hint": "最先加载的 {saneAmount} 表情符号,加载全部表情符号可能会带来性能问题。", + "unicode_groups": { + "flags": "旗帜", + "food-and-drink": "饮食", + "objects": "物件", + "people-and-body": "人和身体", + "symbols": "符号", + "travel-and-places": "旅行和地点", + "activities": "活动", + "animals-and-nature": "动物和自然", + "smileys-and-emotion": "表情与情感" + }, + "regional_indicator": "地区指示符 {letter}", + "unpacked": "拆分的表情符号" }, "about": { "mrf": { @@ -950,7 +1123,7 @@ "empty_chat_list_placeholder": "您还没有任何聊天记录。开始聊天吧!", "error_sending_message": "发送消息时出了点问题。", "error_loading_chat": "加载聊天时出了点问题。", - "delete_confirm": "您确实要删除此消息吗?", + "delete_confirm": "您确定要删除此消息吗?", "more": "更多", "empty_message_error": "无法发布空消息", "new": "新聊天", @@ -958,5 +1131,69 @@ "delete": "删除", "message_user": "发消息给 {nickname}", "you": "你:" + }, + "announcements": { + "page_header": "公告", + "title": "公告", + "mark_as_read_action": "标为已读", + "post_form_header": "发布公告", + "post_placeholder": "在这里输入公告内容...", + "post_action": "发布", + "post_error": "错误:{error}", + "close_error": "关闭", + "delete_action": "删除", + "start_time_prompt": "起始时间: ", + "end_time_prompt": "终止时间: ", + "all_day_prompt": "这是全天的事件", + "published_time_display": "发表于 {time}", + "start_time_display": "开始于 {time}", + "end_time_display": "结束于 {time}", + "edit_action": "编辑", + "submit_edit_action": "提交", + "cancel_edit_action": "取消", + "inactive_message": "这个公告不活跃" + }, + "report": { + "reported_user": "被举报者:", + "state_closed": "已关闭", + "state_resolved": "已解决", + "reporter": "举报者:", + "state_open": "开启", + "reported_statuses": "已举报的状态:", + "notes": "备注:", + "state": "状态:" + }, + "unicode_domain_indicator": { + "tooltip": "此域名包含非 ascii 字符。" + }, + "update": { + "update_bugs_gitlab": "Pleroma GitLab", + "update_changelog": "关于变化的更多细节,请参见 {theFullChangelog} 。", + "update_changelog_here": "完整的更新日志", + "big_update_title": "请忍耐一下", + "big_update_content": "我们已经有一段时间没有发布发行版,所以事情的外观和感觉可能与你习惯的不一样。", + "update_bugs": "请在 {pleromaGitlab} 上报告任何问题和bug,因为我们已经改变了很多,虽然我们进行了彻底的测试,并且自己使用了开发版本,但我们可能错过了一些东西。我们欢迎你对你可能遇到的问题或如何改进Pleroma和Pleroma-FE提出反馈和建议。", + "art_by": "Art by {linkToArtist}" + }, + "lists": { + "search": "搜索用户", + "create": "创建", + "save": "保存更改", + "delete": "删除列表", + "following_only": "限制于正在关注", + "manage_lists": "管理列表", + "manage_members": "管理列表成员", + "add_members": "搜索更多用户", + "remove_from_list": "从列表中移除", + "add_to_list": "添加到列表", + "is_in_list": "已在列表中", + "editing_list": "正在编辑列表 {listTitle}", + "creating_list": "正在创建新的列表", + "update_title": "保存标题", + "really_delete": "真的要删除列表吗?", + "error": "操作列表时出错:{0}", + "lists": "列表", + "new": "新的列表", + "title": "列表标题" } } diff --git a/src/i18n/zh_Hant.json b/src/i18n/zh_Hant.json index 6f0f63b5..96c75215 100644 --- a/src/i18n/zh_Hant.json +++ b/src/i18n/zh_Hant.json @@ -113,12 +113,23 @@ "submit": "提交", "apply": "應用", "role": { - "moderator": "主持人", + "moderator": "審查者", "admin": "管理員" }, "flash_content": "點擊以使用 Ruffle 顯示 Flash 內容(實驗性,可能無效)。", "flash_security": "請注意,這可能有潜在的危險,因為Flash內容仍然是武斷的程式碼。", - "flash_fail": "無法加載flash內容,請參閱控制台瞭解詳細資訊。" + "flash_fail": "無法加載flash內容,請參閱控制台瞭解詳細資訊。", + "no": "否", + "generic_error_message": "發生了一個錯誤: {0}", + "never_show_again": "不再顯示", + "yes": "是", + "undo": "復原", + "scroll_to_top": "滾動至頂部", + "pin": "置頂", + "scope_in_timeline": { + "private": "僅關注者" + }, + "unpin": "停止置頂" }, "finder": { "find_user": "尋找用戶", @@ -133,7 +144,8 @@ "pleroma_chat_messages": "Pleroma 聊天", "chat": "聊天", "gopher": "Gopher", - "upload_limit": "上傳限制" + "upload_limit": "上傳限制", + "shout": "留言板" }, "exporter": { "processing": "正在處理,稍後會提示您下載文件", @@ -164,11 +176,14 @@ "reject": "拒絕", "accept_desc": "本實例只接收來自下列實例的消息:", "simple_policies": "站規", - "accept": "接受" + "accept": "接受", + "instance": "實例", + "reason": "原因", + "not_applicable": "N/A" }, "mrf_policies_desc": "MRF 策略會影響本實例的互通行為。以下策略已啟用:", "keyword": { - "ftl_removal": "從“全部已知網絡”時間線上移除", + "ftl_removal": "從「全部已知網絡」時間線上移除", "replace": "取代", "reject": "拒絕", "is_replaced_by": "→", @@ -865,5 +880,26 @@ "password_reset_disabled": "密碼重置已經被禁用。請聯繫你的實例管理員。", "password_reset_required": "您必須重置密碼才能登陸。", "password_reset_required_but_mailer_is_disabled": "您必須重置密碼,但是密碼重置被禁用了。請聯繫您所在實例的管理員。" + }, + "announcements": { + "post_error": "錯誤: {error}", + "close_error": "關閉", + "delete_action": "刪除", + "start_time_prompt": "開始時間: ", + "end_time_prompt": "結束時間: ", + "all_day_prompt": "這是全日活動", + "start_time_display": "{time} 開始", + "end_time_display": "{time} 結束", + "published_time_display": "{time} 發布", + "edit_action": "編輯", + "submit_edit_action": "送出", + "cancel_edit_action": "取消", + "inactive_message": "此公告無效", + "page_header": "公告", + "title": "公告", + "mark_as_read_action": "標示為以閱讀", + "post_placeholder": "在此輸入您的公告內容……", + "post_form_header": "發布公告", + "post_action": "發布" } } diff --git a/src/modules/config.js b/src/modules/config.js index 3cd6888f..7597886e 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -78,6 +78,15 @@ export const defaultState = { minimalScopesMode: undefined, // instance default // This hides statuses filtered via a word filter hideFilteredStatuses: undefined, // instance default + modalOnRepeat: undefined, // instance default + modalOnUnfollow: undefined, // instance default + modalOnBlock: undefined, // instance default + modalOnMute: undefined, // instance default + modalOnDelete: undefined, // instance default + modalOnLogout: undefined, // instance default + modalOnApproveFollow: undefined, // instance default + modalOnDenyFollow: undefined, // instance default + modalOnRemoveUserFromFollowers: undefined, // instance default playVideosInModal: false, useOneClickNsfw: false, useContainFit: true, @@ -88,6 +97,7 @@ export const defaultState = { sidebarColumnWidth: '25rem', contentColumnWidth: '45rem', notifsColumnWidth: '25rem', + emojiReactionsScale: 1.0, navbarColumnStretch: false, greentext: undefined, // instance default useAtIcon: undefined, // instance default @@ -106,7 +116,8 @@ export const defaultState = { conversationTreeAdvanced: undefined, // instance default conversationOtherRepliesButton: undefined, // instance default conversationTreeFadeAncestors: undefined, // instance default - maxDepthInThread: undefined // instance default + maxDepthInThread: undefined, // instance default + autocompleteSelect: undefined // instance default } // caching the instance default properties @@ -175,6 +186,7 @@ const config = { case 'sidebarColumnWidth': case 'contentColumnWidth': case 'notifsColumnWidth': + case 'emojiReactionsScale': applyConfig(state) break case 'customTheme': @@ -184,7 +196,10 @@ const config = { case 'interfaceLanguage': messages.setLanguage(this.getters.i18n, value) dispatch('loadUnicodeEmojiData', value) - Cookies.set(BACKEND_LANGUAGE_COOKIE_NAME, localeService.internalToBackendLocale(value)) + Cookies.set( + BACKEND_LANGUAGE_COOKIE_NAME, + localeService.internalToBackendLocaleMulti(value) + ) break case 'thirdColumnMode': dispatch('setLayoutWidth', undefined) diff --git a/src/modules/instance.js b/src/modules/instance.js index 8e8d13d3..bb0292da 100644 --- a/src/modules/instance.js +++ b/src/modules/instance.js @@ -71,6 +71,15 @@ const defaultState = { hideSitename: false, hideUserStats: false, muteBotStatuses: false, + modalOnRepeat: false, + modalOnUnfollow: false, + modalOnBlock: true, + modalOnMute: false, + modalOnDelete: true, + modalOnLogout: true, + modalOnApproveFollow: false, + modalOnDenyFollow: false, + modalOnRemoveUserFromFollowers: false, loginMethod: 'password', logo: '/static/logo.svg', logoMargin: '.2em', @@ -95,6 +104,7 @@ const defaultState = { conversationOtherRepliesButton: 'below', conversationTreeFadeAncestors: false, maxDepthInThread: 6, + autocompleteSelect: false, // Nasty stuff customEmoji: [], @@ -107,10 +117,13 @@ const defaultState = { restrictedNicknames: [], safeDM: true, knownDomains: [], + birthdayRequired: false, + birthdayMinAge: 0, // Feature-set, apparently, not everything here is reported... shoutAvailable: false, pleromaChatMessagesAvailable: false, + pleromaCustomEmojiReactionsAvailable: false, gopherAvailable: false, mediaProxyAvailable: false, suggestionsEnabled: false, @@ -286,8 +299,13 @@ const instance = { langList .map(async lang => { if (!state.unicodeEmojiAnnotations[lang]) { - const annotations = await loadAnnotations(lang) - commit('setUnicodeEmojiAnnotations', { lang, annotations }) + try { + const annotations = await loadAnnotations(lang) + commit('setUnicodeEmojiAnnotations', { lang, annotations }) + } catch (e) { + console.warn(`Error loading unicode emoji annotations for ${lang}: `, e) + // ignore + } } })) }, diff --git a/src/modules/users.js b/src/modules/users.js index 053e44b6..7b41fab6 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -61,13 +61,16 @@ const editUserNote = (store, { id, comment }) => { .then((relationship) => store.commit('updateUserRelationship', [relationship])) } -const muteUser = (store, id) => { +const muteUser = (store, args) => { + const id = typeof args === 'object' ? args.id : args + const expiresIn = typeof args === 'object' ? args.expiresIn : 0 + const predictedRelationship = store.state.relationships[id] || { id } predictedRelationship.muting = true store.commit('updateUserRelationship', [predictedRelationship]) store.commit('addMuteId', id) - return store.rootState.api.backendInteractor.muteUser({ id }) + return store.rootState.api.backendInteractor.muteUser({ id, expiresIn }) .then((relationship) => { store.commit('updateUserRelationship', [relationship]) store.commit('addMuteId', id) @@ -192,9 +195,15 @@ export const mutations = { state.currentUser.blockIds.push(blockId) } }, + setBlockIdsMaxId (state, blockIdsMaxId) { + state.currentUser.blockIdsMaxId = blockIdsMaxId + }, saveMuteIds (state, muteIds) { state.currentUser.muteIds = muteIds }, + setMuteIdsMaxId (state, muteIdsMaxId) { + state.currentUser.muteIdsMaxId = muteIdsMaxId + }, addMuteId (state, muteId) { if (state.currentUser.muteIds.indexOf(muteId) === -1) { state.currentUser.muteIds.push(muteId) @@ -317,10 +326,20 @@ const users = { .then((inLists) => store.commit('updateUserInLists', { id, inLists })) } }, - fetchBlocks (store) { - return store.rootState.api.backendInteractor.fetchBlocks() + fetchBlocks (store, args) { + const { reset } = args || {} + + const maxId = store.state.currentUser.blockIdsMaxId + return store.rootState.api.backendInteractor.fetchBlocks({ maxId }) .then((blocks) => { - store.commit('saveBlockIds', map(blocks, 'id')) + if (reset) { + store.commit('saveBlockIds', map(blocks, 'id')) + } else { + map(blocks, 'id').map(id => store.commit('addBlockId', id)) + } + if (blocks.length) { + store.commit('setBlockIdsMaxId', last(blocks).id) + } store.commit('addNewUsers', blocks) return blocks }) @@ -343,10 +362,20 @@ const users = { editUserNote (store, args) { return editUserNote(store, args) }, - fetchMutes (store) { - return store.rootState.api.backendInteractor.fetchMutes() + fetchMutes (store, args) { + const { reset } = args || {} + + const maxId = store.state.currentUser.muteIdsMaxId + return store.rootState.api.backendInteractor.fetchMutes({ maxId }) .then((mutes) => { - store.commit('saveMuteIds', map(mutes, 'id')) + if (reset) { + store.commit('saveMuteIds', map(mutes, 'id')) + } else { + map(mutes, 'id').map(id => store.commit('addMuteId', id)) + } + if (mutes.length) { + store.commit('setMuteIdsMaxId', last(mutes).id) + } store.commit('addNewUsers', mutes) return mutes }) diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 609f6790..e90723a1 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -840,7 +840,7 @@ const postStatus = ({ }) if (pollOptions.some(option => option !== '')) { const normalizedPoll = { - expires_in: poll.expiresIn, + expires_in: parseInt(poll.expiresIn, 10), multiple: poll.multiple } Object.keys(normalizedPoll).forEach(key => { @@ -897,7 +897,7 @@ const editStatus = ({ if (pollOptions.some(option => option !== '')) { const normalizedPoll = { - expires_in: poll.expiresIn, + expires_in: parseInt(poll.expiresIn, 10), multiple: poll.multiple } Object.keys(normalizedPoll).forEach(key => { @@ -1114,13 +1114,21 @@ const generateMfaBackupCodes = ({ credentials }) => { }).then((data) => data.json()) } -const fetchMutes = ({ credentials }) => { - return promisedRequest({ url: MASTODON_USER_MUTES_URL, credentials }) +const fetchMutes = ({ maxId, credentials }) => { + const query = new URLSearchParams({ with_relationships: true }) + if (maxId) { + query.append('max_id', maxId) + } + return promisedRequest({ url: `${MASTODON_USER_MUTES_URL}?${query.toString()}`, credentials }) .then((users) => users.map(parseUser)) } -const muteUser = ({ id, credentials }) => { - return promisedRequest({ url: MASTODON_MUTE_USER_URL(id), credentials, method: 'POST' }) +const muteUser = ({ id, expiresIn, credentials }) => { + const payload = {} + if (expiresIn) { + payload.expires_in = expiresIn + } + return promisedRequest({ url: MASTODON_MUTE_USER_URL(id), credentials, method: 'POST', payload }) } const unmuteUser = ({ id, credentials }) => { @@ -1135,8 +1143,12 @@ const unsubscribeUser = ({ id, credentials }) => { return promisedRequest({ url: MASTODON_UNSUBSCRIBE_USER(id), credentials, method: 'POST' }) } -const fetchBlocks = ({ credentials }) => { - return promisedRequest({ url: MASTODON_USER_BLOCKS_URL, credentials }) +const fetchBlocks = ({ maxId, credentials }) => { + const query = new URLSearchParams({ with_relationships: true }) + if (maxId) { + query.append('max_id', maxId) + } + return promisedRequest({ url: `${MASTODON_USER_BLOCKS_URL}?${query.toString()}`, credentials }) .then((users) => users.map(parseUser)) } diff --git a/src/services/attributes_helper/attributes_helper.service.js b/src/services/attributes_helper/attributes_helper.service.js new file mode 100644 index 00000000..74d3323c --- /dev/null +++ b/src/services/attributes_helper/attributes_helper.service.js @@ -0,0 +1,8 @@ +import { kebabCase } from 'lodash' + +const propsToNative = props => Object.keys(props).reduce((acc, cur) => { + acc[kebabCase(cur)] = props[cur] + return acc +}, {}) + +export { propsToNative } diff --git a/src/services/date_utils/date_utils.js b/src/services/date_utils/date_utils.js index c93d2176..ed8e1417 100644 --- a/src/services/date_utils/date_utils.js +++ b/src/services/date_utils/date_utils.js @@ -41,3 +41,19 @@ export const relativeTimeShort = (date, nowThreshold = 1) => { r.key += '_short' return r } + +export const unitToSeconds = (unit, amount) => { + switch (unit) { + case 'minutes': return 0.001 * amount * MINUTE + case 'hours': return 0.001 * amount * HOUR + case 'days': return 0.001 * amount * DAY + } +} + +export const secondsToUnit = (unit, amount) => { + switch (unit) { + case 'minutes': return (1000 * amount) / MINUTE + case 'hours': return (1000 * amount) / HOUR + case 'days': return (1000 * amount) / DAY + } +} diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index ea138177..adefc5a5 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -125,6 +125,8 @@ export const parseUser = (data) => { output.role = 'member' } + output.birthday = data.pleroma.birthday + if (data.pleroma.privileges) { output.privileges = data.pleroma.privileges } else if (data.pleroma.is_admin) { @@ -162,6 +164,7 @@ export const parseUser = (data) => { output.no_rich_text = data.source.pleroma.no_rich_text output.show_role = data.source.pleroma.show_role output.discoverable = data.source.pleroma.discoverable + output.show_birthday = data.pleroma.show_birthday } } @@ -438,6 +441,7 @@ export const parseNotification = (data) => { : parseUser(data.target) output.from_profile = parseUser(data.account) output.emoji = data.emoji + output.emoji_url = data.emoji_url if (data.report) { output.report = data.report output.report.content = data.report.content diff --git a/src/services/locale/locale.service.js b/src/services/locale/locale.service.js index d3389785..a4af8b90 100644 --- a/src/services/locale/locale.service.js +++ b/src/services/locale/locale.service.js @@ -11,6 +11,10 @@ const specialLanguageCodes = { const internalToBrowserLocale = code => specialLanguageCodes[code] || code const internalToBackendLocale = code => internalToBrowserLocale(code).replace('_', '-') +const internalToBackendLocaleMulti = codes => { + const langs = Array.isArray(codes) ? codes : [codes] + return langs.map(internalToBackendLocale).join(',') +} const getLanguageName = (code) => { const specialLanguageNames = { @@ -28,6 +32,7 @@ const languages = _.map(languagesObject.languages, (code) => ({ code, name: getL const localeService = { internalToBrowserLocale, internalToBackendLocale, + internalToBackendLocaleMulti, languages, getLanguageName } diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js index d6e973a1..43fe3c73 100644 --- a/src/services/style_setter/style_setter.js +++ b/src/services/style_setter/style_setter.js @@ -21,8 +21,8 @@ export const applyTheme = (input) => { body.classList.remove('hidden') } -const configColumns = ({ sidebarColumnWidth, contentColumnWidth, notifsColumnWidth }) => - ({ sidebarColumnWidth, contentColumnWidth, notifsColumnWidth }) +const configColumns = ({ sidebarColumnWidth, contentColumnWidth, notifsColumnWidth, emojiReactionsScale }) => + ({ sidebarColumnWidth, contentColumnWidth, notifsColumnWidth, emojiReactionsScale }) const defaultConfigColumns = configColumns(defaultState) |
