diff options
Diffstat (limited to 'src/components')
102 files changed, 3812 insertions, 486 deletions
diff --git a/src/components/checkbox/checkbox.vue b/src/components/checkbox/checkbox.vue index 42f89be9..b8b77e7c 100644 --- a/src/components/checkbox/checkbox.vue +++ b/src/components/checkbox/checkbox.vue @@ -1,7 +1,7 @@ <template> <label class="checkbox" - :class="{ disabled, indeterminate }" + :class="{ disabled, indeterminate, 'indeterminate-fix': indeterminateTransitionFix }" > <input type="checkbox" @@ -14,6 +14,7 @@ <i class="checkbox-indicator" :aria-hidden="true" + @transitionend.capture="onTransitionEnd" /> <span v-if="!!$slots.default" @@ -31,7 +32,24 @@ export default { 'indeterminate', 'disabled' ], - emits: ['update:modelValue'] + emits: ['update:modelValue'], + data: (vm) => ({ + indeterminateTransitionFix: vm.indeterminate + }), + watch: { + indeterminate (e) { + if (e) { + this.indeterminateTransitionFix = true + } + } + }, + methods: { + onTransitionEnd (e) { + if (!this.indeterminate) { + this.indeterminateTransitionFix = false + } + } + } } </script> @@ -98,6 +116,12 @@ export default { } } + &.indeterminate-fix { + input[type="checkbox"] + .checkbox-indicator::before { + content: "–"; + } + } + & > span { margin-left: 0.5em; } diff --git a/src/components/desktop_nav/desktop_nav.js b/src/components/desktop_nav/desktop_nav.js index 745b1a81..f6a2e294 100644 --- a/src/components/desktop_nav/desktop_nav.js +++ b/src/components/desktop_nav/desktop_nav.js @@ -107,7 +107,10 @@ export default { this.searchBarHidden = hidden }, openSettingsModal () { - this.$store.dispatch('openSettingsModal') + this.$store.dispatch('openSettingsModal', 'user') + }, + openAdminModal () { + this.$store.dispatch('openSettingsModal', 'admin') } } } diff --git a/src/components/desktop_nav/desktop_nav.vue b/src/components/desktop_nav/desktop_nav.vue index dc8bbfd3..49382f8e 100644 --- a/src/components/desktop_nav/desktop_nav.vue +++ b/src/components/desktop_nav/desktop_nav.vue @@ -48,20 +48,19 @@ icon="cog" /> </button> - <a + <button v-if="currentUser && currentUser.role === 'admin'" - href="/pleroma/admin/#/login-pleroma" - class="nav-icon" + class="button-unstyled nav-icon" target="_blank" :title="$t('nav.administration')" - @click.stop + @click.stop="openAdminModal" > <FAIcon fixed-width class="fa-scale-110 fa-old-padding" icon="tachometer-alt" /> - </a> + </button> <span class="spacer" /> <button v-if="currentUser" diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js index 68654f69..9baf63f2 100644 --- a/src/components/emoji_input/emoji_input.js +++ b/src/components/emoji_input/emoji_input.js @@ -1,4 +1,5 @@ import Completion from '../../services/completion/completion.js' +import genRandomSeed from '../../services/random_seed/random_seed.service.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' @@ -110,7 +111,7 @@ const EmojiInput = { }, data () { return { - randomSeed: `${Math.random()}`.replace('.', '-'), + randomSeed: genRandomSeed(), input: undefined, caretEl: undefined, highlighted: -1, diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js index 349b043d..eb665c40 100644 --- a/src/components/emoji_picker/emoji_picker.js +++ b/src/components/emoji_picker/emoji_picker.js @@ -105,6 +105,7 @@ const EmojiPicker = { default: false } }, + inject: ['popoversZLayer'], data () { return { keyword: '', @@ -113,6 +114,7 @@ const EmojiPicker = { groupsScrolledClass: 'scrolled-top', keepOpen: false, customEmojiTimeout: null, + hideCustomEmojiInPicker: false, // Lazy-load only after the first time `showing` becomes true. contentLoaded: false, groupRefs: {}, @@ -285,7 +287,7 @@ const EmojiPicker = { return 0 }, allCustomGroups () { - if (this.hideCustomEmoji) { + if (this.hideCustomEmoji || this.hideCustomEmojiInPicker) { return {} } const emojis = this.$store.getters.groupedCustomEmojis @@ -350,6 +352,9 @@ const EmojiPicker = { return emoji.displayText } + }, + isInModal () { + return this.popoversZLayer === 'modals' } } } diff --git a/src/components/emoji_picker/emoji_picker.scss b/src/components/emoji_picker/emoji_picker.scss index 5bcff33b..aab9251d 100644 --- a/src/components/emoji_picker/emoji_picker.scss +++ b/src/components/emoji_picker/emoji_picker.scss @@ -39,11 +39,16 @@ $emoji-picker-emoji-size: 32px; } .keep-open, - .too-many-emoji { + .too-many-emoji, + .hide-custom-emoji { padding: 7px; line-height: normal; } + .hide-custom-emoji { + padding-top: 0; + } + .too-many-emoji { display: flex; flex-direction: column; diff --git a/src/components/emoji_picker/emoji_picker.vue b/src/components/emoji_picker/emoji_picker.vue index 6972164b..1231ce2b 100644 --- a/src/components/emoji_picker/emoji_picker.vue +++ b/src/components/emoji_picker/emoji_picker.vue @@ -3,14 +3,20 @@ ref="popover" trigger="click" popover-class="emoji-picker popover-default" - :trigger-attrs="{ 'aria-hidden': true }" + :trigger-attrs="{ 'aria-hidden': true, tabindex: -1 }" @show="onPopoverShown" @close="onPopoverClosed" > <template #content> <div class="heading"> + <!-- + Body scroll lock needs to be on every scrollable element on safari iOS. + Here we tell it to enable scrolling for this element. + See https://github.com/willmcpo/body-scroll-lock#vanilla-js + --> <span ref="header" + v-body-scroll-lock="isInModal" class="emoji-tabs" > <span @@ -22,6 +28,7 @@ active: activeGroupView === group.id }" :title="group.text" + role="button" @click.prevent="highlight(group.id)" > <span @@ -75,8 +82,10 @@ @input="$event.target.composing = false" > </div> + <!-- Enables scrolling for this element on safari iOS. See comments for header. --> <DynamicScroller ref="emoji-groups" + v-body-scroll-lock="isInModal" class="emoji-groups" :class="groupsScrolledClass" :min-item-size="minItemSize" @@ -108,6 +117,7 @@ :key="group.id + emoji.displayText" :title="maybeLocalizedEmojiName(emoji)" class="emoji-item" + role="button" @click.stop.prevent="onEmoji(emoji)" > <span @@ -118,6 +128,7 @@ v-else class="emoji-picker-emoji -custom" loading="lazy" + :alt="maybeLocalizedEmojiName(emoji)" :src="emoji.imageUrl" :data-emoji-name="group.id + emoji.displayText" /> @@ -131,6 +142,17 @@ {{ $t('emoji.keep_open') }} </Checkbox> </div> + <div + v-if="!hideCustomEmoji" + class="hide-custom-emoji" + > + <Checkbox + v-model="hideCustomEmojiInPicker" + @change="onShowing" + > + {{ $t('emoji.hide_custom_emoji') }} + </Checkbox> + </div> </div> <div v-if="showingStickers" diff --git a/src/components/emoji_reactions/emoji_reactions.js b/src/components/emoji_reactions/emoji_reactions.js index bb11b840..4d5c6c5a 100644 --- a/src/components/emoji_reactions/emoji_reactions.js +++ b/src/components/emoji_reactions/emoji_reactions.js @@ -1,5 +1,17 @@ import UserAvatar from '../user_avatar/user_avatar.vue' import UserListPopover from '../user_list_popover/user_list_popover.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faPlus, + faMinus, + faCheck +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faPlus, + faMinus, + faCheck +) const EMOJI_REACTION_COUNT_CUTOFF = 12 @@ -33,6 +45,9 @@ const EmojiReactions = { }, loggedIn () { return !!this.$store.state.users.currentUser + }, + remoteInteractionLink () { + return this.$store.getters.remoteInteractionLink({ statusId: this.status.id }) } }, methods: { @@ -42,10 +57,10 @@ const EmojiReactions = { reactedWith (emoji) { return this.status.emoji_reactions.find(r => r.name === emoji).me }, - fetchEmojiReactionsByIfMissing () { + async fetchEmojiReactionsByIfMissing () { const hasNoAccounts = this.status.emoji_reactions.find(r => !r.accounts) if (hasNoAccounts) { - this.$store.dispatch('fetchEmojiReactionsBy', this.status.id) + return await this.$store.dispatch('fetchEmojiReactionsBy', this.status.id) } }, reactWith (emoji) { @@ -54,14 +69,26 @@ const EmojiReactions = { unreact (emoji) { this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji }) }, - emojiOnClick (emoji, event) { + async emojiOnClick (emoji, event) { if (!this.loggedIn) return + await this.fetchEmojiReactionsByIfMissing() if (this.reactedWith(emoji)) { this.unreact(emoji) } else { this.reactWith(emoji) } + }, + counterTriggerAttrs (reaction) { + return { + class: [ + 'btn', + 'button-default', + 'emoji-reaction-count-button', + { '-picked-reaction': this.reactedWith(reaction.name) } + ], + 'aria-label': this.$tc('status.reaction_count_label', reaction.count, { num: reaction.count }) + } } } } diff --git a/src/components/emoji_reactions/emoji_reactions.vue b/src/components/emoji_reactions/emoji_reactions.vue index eb46018e..c11b338e 100644 --- a/src/components/emoji_reactions/emoji_reactions.vue +++ b/src/components/emoji_reactions/emoji_reactions.vue @@ -1,15 +1,19 @@ <template> <div class="EmojiReactions"> - <UserListPopover + <span v-for="(reaction) in emojiReactions" :key="reaction.url || reaction.name" - :users="accountsForEmoji[reaction.name]" + class="emoji-reaction-container btn-group" > - <button + <component + :is="loggedIn ? 'button' : 'a'" + v-bind="!loggedIn ? { href: remoteInteractionLink } : {}" + role="button" class="emoji-reaction btn button-default" - :class="{ '-picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }" + :class="{ '-picked-reaction': reactedWith(reaction.name) }" + :title="reaction.url ? reaction.name : undefined" + :aria-pressed="reactedWith(reaction.name)" @click="emojiOnClick(reaction.name, $event)" - @mouseenter="fetchEmojiReactionsByIfMissing()" > <span class="reaction-emoji" @@ -17,7 +21,6 @@ <img v-if="reaction.url" :src="reaction.url" - :title="reaction.name" class="reaction-emoji-content" width="1em" > @@ -26,9 +29,36 @@ class="reaction-emoji reaction-emoji-content" >{{ reaction.name }}</span> </span> - <span>{{ reaction.count }}</span> - </button> - </UserListPopover> + <FALayers> + <FAIcon + v-if="reactedWith(reaction.name)" + class="active-marker" + transform="shrink-6 up-9" + icon="check" + /> + <FAIcon + v-if="!reactedWith(reaction.name)" + class="focus-marker" + transform="shrink-6 up-9" + icon="plus" + /> + <FAIcon + v-else + class="focus-marker" + transform="shrink-6 up-9" + icon="minus" + /> + </FALayers> + </component> + <UserListPopover + :users="accountsForEmoji[reaction.name]" + class="emoji-reaction-popover" + :trigger-attrs="counterTriggerAttrs(reaction)" + @show="fetchEmojiReactionsByIfMissing()" + > + <span class="emoji-reaction-counts">{{ reaction.count }}</span> + </UserListPopover> + </span> <a v-if="tooManyReactions" class="emoji-reaction-expand faint" @@ -43,6 +73,7 @@ <script src="./emoji_reactions.js"></script> <style lang="scss"> @import "../../variables"; +@import "../../mixins"; .EmojiReactions { display: flex; @@ -51,14 +82,46 @@ --emoji-size: calc(1.25em * var(--emojiReactionsScale, 1)); - .emoji-reaction { - padding: 0 0.5em; - margin-right: 0.5em; + .emoji-reaction-container { + display: flex; + align-items: stretch; margin-top: 0.5em; + margin-right: 0.5em; + + .emoji-reaction-popover { + padding: 0; + + .emoji-reaction-count-button { + background-color: var(--btn); + margin: 0; + height: 100%; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + box-sizing: border-box; + min-width: 2em; + display: inline-flex; + justify-content: center; + align-items: center; + color: $fallback--text; + color: var(--btnText, $fallback--text); + + &.-picked-reaction { + border: 1px solid var(--accent, $fallback--link); + margin-right: -1px; + } + } + } + } + + .emoji-reaction { + padding-left: 0.5em; display: flex; align-items: center; justify-content: center; box-sizing: border-box; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + margin: 0; .reaction-emoji { width: var(--emoji-size); @@ -85,19 +148,45 @@ outline: none; } - &.not-clickable { - cursor: default; - - &:hover { - box-shadow: $fallback--buttonShadow; - box-shadow: var(--buttonShadow); - } + .svg-inline--fa { + color: $fallback--text; + color: var(--btnText, $fallback--text); } &.-picked-reaction { border: 1px solid var(--accent, $fallback--link); margin-left: -1px; // offset the border, can't use inset shadows either - margin-right: calc(0.5em - 1px); + margin-right: -1px; + + .svg-inline--fa { + color: $fallback--link; + color: var(--accent, $fallback--link); + } + } + + @include unfocused-style { + .focus-marker { + visibility: hidden; + } + + .active-marker { + visibility: visible; + } + } + + @include focused-style { + .svg-inline--fa { + color: $fallback--link; + color: var(--accent, $fallback--link); + } + + .focus-marker { + visibility: visible; + } + + .active-marker { + visibility: hidden; + } } } diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js index 48b960b2..e2c88ceb 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 genRandomSeed from '../../services/random_seed/random_seed.service.js' import ConfirmModal from '../confirm_modal/confirm_modal.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { @@ -40,7 +41,8 @@ const ExtraButtons = { data () { return { expanded: false, - showingDeleteDialog: false + showingDeleteDialog: false, + randomSeed: genRandomSeed() } }, methods: { @@ -152,6 +154,15 @@ const ExtraButtons = { editingAvailable () { return this.$store.state.instance.editingAvailable }, shouldConfirmDelete () { return this.$store.getters.mergedConfig.modalOnDelete + }, + triggerAttrs () { + return { + title: this.$t('status.more_actions'), + id: `popup-trigger-${this.randomSeed}`, + 'aria-controls': `popup-menu-${this.randomSeed}`, + 'aria-expanded': this.expanded, + 'aria-haspopup': 'menu' + } } } } diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue index c1c15c0f..b7d3b1d3 100644 --- a/src/components/extra_buttons/extra_buttons.vue +++ b/src/components/extra_buttons/extra_buttons.vue @@ -2,6 +2,7 @@ <Popover class="ExtraButtons" trigger="click" + :trigger-attrs="triggerAttrs" placement="top" :offset="{ y: 5 }" :bound-to="{ x: 'container' }" @@ -10,10 +11,15 @@ @close="onClose" > <template #content="{close}"> - <div class="dropdown-menu"> + <div + class="dropdown-menu" + role="menu" + :id="`popup-menu-${randomSeed}`" + > <button v-if="canMute && !status.thread_muted" class="button-default dropdown-item dropdown-item-icon" + role="menuitem" @click.prevent="muteConversation" > <FAIcon @@ -24,6 +30,7 @@ <button v-if="canMute && status.thread_muted" class="button-default dropdown-item dropdown-item-icon" + role="menuitem" @click.prevent="unmuteConversation" > <FAIcon @@ -34,6 +41,7 @@ <button v-if="!status.pinned && canPin" class="button-default dropdown-item dropdown-item-icon" + role="menuitem" @click.prevent="pinStatus" @click="close" > @@ -45,6 +53,7 @@ <button v-if="status.pinned && canPin" class="button-default dropdown-item dropdown-item-icon" + role="menuitem" @click.prevent="unpinStatus" @click="close" > @@ -57,6 +66,7 @@ <button v-if="!status.bookmarked" class="button-default dropdown-item dropdown-item-icon" + role="menuitem" @click.prevent="bookmarkStatus" @click="close" > @@ -68,6 +78,7 @@ <button v-if="status.bookmarked" class="button-default dropdown-item dropdown-item-icon" + role="menuitem" @click.prevent="unbookmarkStatus" @click="close" > @@ -80,6 +91,7 @@ <button v-if="ownStatus && editingAvailable" class="button-default dropdown-item dropdown-item-icon" + role="menuitem" @click.prevent="editStatus" @click="close" > @@ -91,6 +103,7 @@ <button v-if="isEdited && editingAvailable" class="button-default dropdown-item dropdown-item-icon" + role="menuitem" @click.prevent="showStatusHistory" @click="close" > @@ -102,6 +115,7 @@ <button v-if="canDelete" class="button-default dropdown-item dropdown-item-icon" + role="menuitem" @click.prevent="deleteStatus" @click="close" > @@ -112,6 +126,7 @@ </button> <button class="button-default dropdown-item dropdown-item-icon" + role="menuitem" @click.prevent="copyLink" @click="close" > @@ -123,6 +138,7 @@ <a v-if="!status.is_local" class="button-default dropdown-item dropdown-item-icon" + role="menuitem" title="Source" :href="status.external_url" target="_blank" @@ -134,6 +150,7 @@ </a> <button class="button-default dropdown-item dropdown-item-icon" + role="menuitem" @click.prevent="reportStatus" @click="close" > diff --git a/src/components/extra_notifications/extra_notifications.js b/src/components/extra_notifications/extra_notifications.js new file mode 100644 index 00000000..1bb0f837 --- /dev/null +++ b/src/components/extra_notifications/extra_notifications.js @@ -0,0 +1,48 @@ +import { mapGetters } from 'vuex' + +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faUserPlus, + faComments, + faBullhorn +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faUserPlus, + faComments, + faBullhorn +) + +const ExtraNotifications = { + computed: { + shouldShowChats () { + return this.mergedConfig.showExtraNotifications && this.mergedConfig.showChatsInExtraNotifications && this.unreadChatCount + }, + shouldShowAnnouncements () { + return this.mergedConfig.showExtraNotifications && this.mergedConfig.showAnnouncementsInExtraNotifications && this.unreadAnnouncementCount + }, + shouldShowFollowRequests () { + return this.mergedConfig.showExtraNotifications && this.mergedConfig.showFollowRequestsInExtraNotifications && this.followRequestCount + }, + hasAnythingToShow () { + return this.shouldShowChats || this.shouldShowAnnouncements || this.shouldShowFollowRequests + }, + shouldShowCustomizationTip () { + return this.mergedConfig.showExtraNotificationsTip && this.hasAnythingToShow + }, + currentUser () { + return this.$store.state.users.currentUser + }, + ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount', 'followRequestCount', 'mergedConfig']) + }, + methods: { + openNotificationSettings () { + return this.$store.dispatch('openSettingsModalTab', 'notifications') + }, + dismissConfigurationTip () { + return this.$store.dispatch('setOption', { name: 'showExtraNotificationsTip', value: false }) + } + } +} + +export default ExtraNotifications diff --git a/src/components/extra_notifications/extra_notifications.vue b/src/components/extra_notifications/extra_notifications.vue new file mode 100644 index 00000000..6e0456a5 --- /dev/null +++ b/src/components/extra_notifications/extra_notifications.vue @@ -0,0 +1,113 @@ +<template> + <div class="ExtraNotifications"> + <div + v-if="shouldShowChats" + class="notification unseen" + > + <div class="notification-overlay" /> + <router-link + class="button-unstyled -link extra-notification" + :to="{ name: 'chats', params: { username: currentUser.screen_name } }" + > + <FAIcon + fixed-width + class="fa-scale-110 icon" + icon="comments" + /> + {{ $tc('notifications.unread_chats', unreadChatCount, { num: unreadChatCount }) }} + </router-link> + </div> + <div + v-if="shouldShowAnnouncements" + class="notification unseen" + > + <div class="notification-overlay" /> + <router-link + class="button-unstyled -link extra-notification" + :to="{ name: 'announcements' }" + > + <FAIcon + fixed-width + class="fa-scale-110 icon" + icon="bullhorn" + /> + {{ $tc('notifications.unread_announcements', unreadAnnouncementCount, { num: unreadAnnouncementCount }) }} + </router-link> + </div> + <div + v-if="shouldShowFollowRequests" + class="notification unseen" + > + <div class="notification-overlay" /> + <router-link + class="button-unstyled -link extra-notification" + :to="{ name: 'friend-requests' }" + > + <FAIcon + fixed-width + class="fa-scale-110 icon" + icon="user-plus" + /> + {{ $tc('notifications.unread_follow_requests', followRequestCount, { num: followRequestCount }) }} + </router-link> + </div> + <i18n-t + v-if="shouldShowCustomizationTip" + tag="span" + class="notification tip extra-notification" + keypath="notifications.configuration_tip" + > + <template #theSettings> + <button + class="button-unstyled -link" + @click="openNotificationSettings" + > + {{ $t('notifications.configuration_tip_settings') }} + </button> + </template> + <template #dismiss> + <button + class="button-unstyled -link" + @click="dismissConfigurationTip" + > + {{ $t('notifications.configuration_tip_dismiss') }} + </button> + </template> + </i18n-t> + </div> +</template> + +<script src="./extra_notifications.js" /> + +<style lang="scss"> +@import "../../variables"; + +.ExtraNotifications { + width: 100%; + display: flex; + flex-direction: column; + align-items: stretch; + + .notification { + width: 100%; + border-bottom: 1px solid; + border-color: $fallback--border; + border-color: var(--border, $fallback--border); + display: flex; + flex-direction: column; + align-items: stretch; + } + + .extra-notification { + padding: 1em; + } + + .icon { + margin-right: 0.5em; + } + + .tip { + display: inline; + } +} +</style> diff --git a/src/components/global_notice_list/global_notice_list.vue b/src/components/global_notice_list/global_notice_list.vue index 0e58476f..9e9ec7aa 100644 --- a/src/components/global_notice_list/global_notice_list.vue +++ b/src/components/global_notice_list/global_notice_list.vue @@ -32,7 +32,7 @@ top: calc(var(--navbar-height) + 0.5em); width: 100%; pointer-events: none; - z-index: var(--ZI_navbar_popovers); + z-index: var(--ZI_modals_popovers); display: flex; flex-direction: column; align-items: center; diff --git a/src/components/interactions/interactions.vue b/src/components/interactions/interactions.vue index b7291c02..35d03562 100644 --- a/src/components/interactions/interactions.vue +++ b/src/components/interactions/interactions.vue @@ -39,6 +39,7 @@ <Notifications ref="notifications" :no-heading="true" + :no-extra="true" :minimal-mode="true" :filter-mode="filterMode" /> diff --git a/src/components/interface_language_switcher/interface_language_switcher.vue b/src/components/interface_language_switcher/interface_language_switcher.vue index a57e8761..364791a1 100644 --- a/src/components/interface_language_switcher/interface_language_switcher.vue +++ b/src/components/interface_language_switcher/interface_language_switcher.vue @@ -36,7 +36,9 @@ <button class="button-default btn" @click="addLanguage" - >{{ $t('settings.add_language') }}</button> + > + {{ $t('settings.add_language') }} + </button> </li> </ul> </div> diff --git a/src/components/media_upload/media_upload.js b/src/components/media_upload/media_upload.js index cfd42d4c..8c9e5f71 100644 --- a/src/components/media_upload/media_upload.js +++ b/src/components/media_upload/media_upload.js @@ -23,6 +23,11 @@ const mediaUpload = { } }, methods: { + onClick () { + if (this.uploadReady) { + this.$refs.input.click() + } + }, uploadFile (file) { const self = this const store = this.$store @@ -69,10 +74,15 @@ const mediaUpload = { this.multiUpload(target.files) } }, - props: [ - 'dropFiles', - 'disabled' - ], + props: { + dropFiles: Object, + disabled: Boolean, + normalButton: Boolean, + acceptTypes: { + type: String, + default: '*/*' + } + }, watch: { dropFiles: function (fileInfos) { if (!this.uploading) { diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue index 2799495b..2ea5567a 100644 --- a/src/components/media_upload/media_upload.vue +++ b/src/components/media_upload/media_upload.vue @@ -1,8 +1,9 @@ <template> - <label + <button class="media-upload" - :class="{ disabled: disabled }" + :class="[normalButton ? 'button-default btn' : 'button-unstyled', { disabled }]" :title="$t('tool_tip.media_upload')" + @click="onClick" > <FAIcon v-if="uploading" @@ -15,15 +16,21 @@ class="new-icon" icon="upload" /> + <template v-if="normalButton"> + {{ ' ' }} + {{ uploading ? $t('general.loading') : $t('tool_tip.media_upload') }} + </template> <input v-if="uploadReady" + ref="input" class="hidden-input-file" :disabled="disabled" type="file" multiple="true" + :accept="acceptTypes" @change="change" > - </label> + </button> </template> <script src="./media_upload.js"></script> @@ -32,10 +39,12 @@ @import "../../variables"; .media-upload { - cursor: pointer; // We use <label> for interactivity... i wonder if it's fine - .hidden-input-file { display: none; } } - </style> + +label.media-upload { + cursor: pointer; // We use <label> for interactivity... i wonder if it's fine +} +</style> diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js index dad1f6aa..8c9261b0 100644 --- a/src/components/mobile_nav/mobile_nav.js +++ b/src/components/mobile_nav/mobile_nav.js @@ -1,7 +1,10 @@ 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 { + unseenNotificationsFromStore, + countExtraNotifications +} from '../../services/notification_utils/notification_utils' import GestureService from '../../services/gesture_service/gesture_service' import NavigationPins from 'src/components/navigation/navigation_pins.vue' import { mapGetters } from 'vuex' @@ -11,7 +14,8 @@ import { faBell, faBars, faArrowUp, - faMinus + faMinus, + faCheckDouble } from '@fortawesome/free-solid-svg-icons' library.add( @@ -19,7 +23,8 @@ library.add( faBell, faBars, faArrowUp, - faMinus + faMinus, + faCheckDouble ) const MobileNav = { @@ -50,8 +55,14 @@ const MobileNav = { return unseenNotificationsFromStore(this.$store) }, unseenNotificationsCount () { + return this.unseenNotifications.length + countExtraNotifications(this.$store) + }, + unseenCount () { return this.unseenNotifications.length }, + unseenCountBadgeText () { + return `${this.unseenCount ? this.unseenCount : ''}` + }, hideSitename () { return this.$store.state.instance.hideSitename }, sitename () { return this.$store.state.instance.name }, isChat () { @@ -64,6 +75,9 @@ const MobileNav = { shouldConfirmLogout () { return this.$store.getters.mergedConfig.modalOnLogout }, + closingDrawerMarksAsSeen () { + return this.$store.getters.mergedConfig.closingDrawerMarksAsSeen + }, ...mapGetters(['unreadChatCount']) }, methods: { @@ -78,7 +92,7 @@ const MobileNav = { // make sure to mark notifs seen only when the notifs were open and not // from close-calls. this.notificationsOpen = false - if (markRead) { + if (markRead && this.closingDrawerMarksAsSeen) { this.markNotificationsAsSeen() } } @@ -114,7 +128,6 @@ const MobileNav = { this.hideConfirmLogout() }, markNotificationsAsSeen () { - // this.$refs.notifications.markAsSeen() this.$store.dispatch('markNotificationsAsSeen') }, onScroll ({ target: { scrollTop, clientHeight, scrollHeight } }) { diff --git a/src/components/mobile_nav/mobile_nav.vue b/src/components/mobile_nav/mobile_nav.vue index c2746abe..f20a509d 100644 --- a/src/components/mobile_nav/mobile_nav.vue +++ b/src/components/mobile_nav/mobile_nav.vue @@ -50,7 +50,13 @@ @touchmove.stop="notificationsTouchMove" > <div class="mobile-notifications-header"> - <span class="title">{{ $t('notifications.notifications') }}</span> + <span class="title"> + {{ $t('notifications.notifications') }} + <span + v-if="unseenCountBadgeText" + class="badge badge-notification unseen-count" + >{{ unseenCountBadgeText }}</span> + </span> <span class="spacer" /> <button v-if="notificationsAtTop" @@ -67,6 +73,17 @@ </FALayers> </button> <button + v-if="!closingDrawerMarksAsSeen" + class="button-unstyled mobile-nav-button" + :title="$t('nav.mobile_notifications_mark_as_seen')" + @click.stop.prevent="markNotificationsAsSeen()" + > + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="check-double" + /> + </button> + <button class="button-unstyled mobile-nav-button" :title="$t('nav.mobile_notifications_close')" @click.stop.prevent="closeMobileNotifications(true)" diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js index 420db4f0..0e938c42 100644 --- a/src/components/notification/notification.js +++ b/src/components/notification/notification.js @@ -50,6 +50,7 @@ const Notification = { } }, props: ['notification'], + emits: ['interacted'], components: { StatusContent, UserAvatar, @@ -72,6 +73,9 @@ const Notification = { getUser (notification) { return this.$store.state.users.usersObject[notification.from_profile.id] }, + interacted () { + this.$emit('interacted') + }, toggleMute () { this.unmuted = !this.unmuted }, @@ -95,6 +99,7 @@ const Notification = { } }, doApprove () { + this.$emit('interacted') this.$store.state.api.backendInteractor.approveUser({ id: this.user.id }) this.$store.dispatch('removeFollowRequest', this.user) this.$store.dispatch('markSingleNotificationAsSeen', { id: this.notification.id }) @@ -114,6 +119,7 @@ const Notification = { } }, doDeny () { + this.$emit('interacted') this.$store.state.api.backendInteractor.denyUser({ id: this.user.id }) .then(() => { this.$store.dispatch('dismissNotificationLocal', { id: this.notification.id }) diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index 4d801c5e..a8eceab0 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -6,6 +6,7 @@ class="Notification" :compact="true" :statusoid="notification.status" + @interacted="interacted" /> </article> <article v-else> @@ -125,7 +126,8 @@ v-if="notification.emoji_url" class="emoji-reaction-emoji emoji-reaction-emoji-image" :src="notification.emoji_url" - :name="notification.emoji" + :alt="notification.emoji" + :title="notification.emoji" > <span v-else @@ -162,8 +164,8 @@ </router-link> <button class="button-unstyled expand-icon" - :aria-expanded="statusExpanded" :title="$t('tool_tip.toggle_expand')" + :aria-expanded="statusExpanded" @click.prevent="toggleStatusExpanded" > <FAIcon @@ -247,7 +249,7 @@ <StatusContent :class="{ faint: !statusExpanded }" :compact="!statusExpanded" - :status="notification.action" + :status="notification.status" /> </template> </div> diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js index d499d3d6..a9fa8455 100644 --- a/src/components/notifications/notifications.js +++ b/src/components/notifications/notifications.js @@ -1,12 +1,15 @@ import { computed } from 'vue' import { mapGetters } from 'vuex' import Notification from '../notification/notification.vue' +import ExtraNotifications from '../extra_notifications/extra_notifications.vue' import NotificationFilters from './notification_filters.vue' import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js' import { notificationsFromStore, filteredNotificationsFromStore, - unseenNotificationsFromStore + unseenNotificationsFromStore, + countExtraNotifications, + ACTIONABLE_NOTIFICATION_TYPES } from '../../services/notification_utils/notification_utils.js' import FaviconService from '../../services/favicon_service/favicon_service.js' import { library } from '@fortawesome/fontawesome-svg-core' @@ -23,7 +26,8 @@ const DEFAULT_SEEN_TO_DISPLAY_COUNT = 30 const Notifications = { components: { Notification, - NotificationFilters + NotificationFilters, + ExtraNotifications }, props: { // Disables panel styles, unread mark, potentially other notification-related actions @@ -31,6 +35,11 @@ const Notifications = { minimalMode: Boolean, // Custom filter mode, an array of strings, possible values 'mention', 'repeat', 'like', 'follow', used to override global filter for use in "Interactions" timeline filterMode: Array, + // Do not show extra notifications + noExtra: { + type: Boolean, + default: false + }, // Disable teleporting (i.e. for /users/user/notifications) disableTeleport: Boolean }, @@ -57,22 +66,36 @@ const Notifications = { return notificationsFromStore(this.$store) }, error () { - return this.$store.state.statuses.notifications.error + return this.$store.state.notifications.error }, unseenNotifications () { return unseenNotificationsFromStore(this.$store) }, filteredNotifications () { - return filteredNotificationsFromStore(this.$store, this.filterMode) + if (this.unseenAtTop) { + return [ + ...filteredNotificationsFromStore(this.$store).filter(n => this.shouldShowUnseen(n)), + ...filteredNotificationsFromStore(this.$store).filter(n => !this.shouldShowUnseen(n)) + ] + } else { + return filteredNotificationsFromStore(this.$store, this.filterMode) + } + }, + unseenCountBadgeText () { + return `${this.unseenCount ? this.unseenCount : ''}${this.extraNotificationsCount ? '*' : ''}` }, unseenCount () { return this.unseenNotifications.length }, + ignoreInactionableSeen () { return this.$store.getters.mergedConfig.ignoreInactionableSeen }, + extraNotificationsCount () { + return countExtraNotifications(this.$store) + }, unseenCountTitle () { - return this.unseenCount + (this.unreadChatCount) + this.unreadAnnouncementCount + return this.unseenNotifications.length + (this.unreadChatCount) + this.unreadAnnouncementCount }, loading () { - return this.$store.state.statuses.notifications.loading + return this.$store.state.notifications.loading }, noHeading () { const { layoutType } = this.$store.state.interface @@ -94,6 +117,10 @@ const Notifications = { return this.filteredNotifications.slice(0, this.unseenCount + this.seenToDisplayCount) }, noSticky () { return this.$store.getters.mergedConfig.disableStickyHeaders }, + unseenAtTop () { return this.$store.getters.mergedConfig.unseenAtTop }, + showExtraNotifications () { + return !this.noExtra + }, ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount']) }, mounted () { @@ -137,11 +164,28 @@ const Notifications = { scrollToTop () { const scrollable = this.scrollerRef scrollable.scrollTo({ top: this.$refs.root.offsetTop }) - // this.$refs.root.scrollIntoView({ behavior: 'smooth', block: 'start' }) }, updateScrollPosition () { this.showScrollTop = this.$refs.root.offsetTop < this.scrollerRef.scrollTop }, + shouldShowUnseen (notification) { + if (notification.seen) return false + + const actionable = ACTIONABLE_NOTIFICATION_TYPES.has(notification.type) + return this.ignoreInactionableSeen ? actionable : true + }, + /* "Interacted" really refers to "actionable" notifications that require user input, + * everything else (likes/repeats/reacts) cannot be acted and therefore we just clear + * the "seen" status upon any clicks on them + */ + notificationClicked (notification) { + const { id } = notification + this.$store.dispatch('notificationClicked', { id }) + }, + notificationInteracted (notification) { + const { id } = notification + this.$store.dispatch('markSingleNotificationAsSeen', { id }) + }, markAsSeen () { this.$store.dispatch('markNotificationsAsSeen') this.seenToDisplayCount = DEFAULT_SEEN_TO_DISPLAY_COUNT diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss index 61f7317e..5749e430 100644 --- a/src/components/notifications/notifications.scss +++ b/src/components/notifications/notifications.scss @@ -136,6 +136,7 @@ .emoji-reaction-emoji-image { vertical-align: middle; + object-fit: contain; } .notification-details { diff --git a/src/components/notifications/notifications.vue b/src/components/notifications/notifications.vue index 633efca6..a0025182 100644 --- a/src/components/notifications/notifications.vue +++ b/src/components/notifications/notifications.vue @@ -17,9 +17,9 @@ <div class="title"> {{ $t('notifications.notifications') }} <span - v-if="unseenCount" + v-if="unseenCountBadgeText" class="badge badge-notification unseen-count" - >{{ unseenCount }}</span> + >{{ unseenCountBadgeText }}</span> </div> <div v-if="showScrollTop" @@ -55,14 +55,25 @@ role="feed" > <div + v-if="showExtraNotifications" + role="listitem" + class="notification" + > + <extra-notifications /> + </div> + <div v-for="notification in notificationsToDisplay" :key="notification.id" role="listitem" class="notification" - :class="{unseen: !minimalMode && !notification.seen}" + :class="{unseen: !minimalMode && shouldShowUnseen(notification)}" + @click="e => notificationClicked(notification)" > <div class="notification-overlay" /> - <notification :notification="notification" /> + <notification + :notification="notification" + @interacted="e => notificationInteracted(notification)" + /> </div> </div> <div class="panel-footer"> diff --git a/src/components/poll/poll.js b/src/components/poll/poll.js index e4d6869a..f6001f56 100644 --- a/src/components/poll/poll.js +++ b/src/components/poll/poll.js @@ -1,4 +1,5 @@ import Timeago from 'components/timeago/timeago.vue' +import genRandomSeed from '../../services/random_seed/random_seed.service.js' import RichContent from 'components/rich_content/rich_content.jsx' import { forEach, map } from 'lodash' @@ -13,7 +14,7 @@ export default { return { loading: false, choices: [], - randomSeed: `${Math.random()}`.replace('.', '-') + randomSeed: genRandomSeed() } }, created () { diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js index d44b266b..bc078533 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -45,6 +45,9 @@ const Popover = { // Lets hover popover stay when clicking inside of it stayOnClick: Boolean, + // Use styled button (to avoid nested buttons) + normalButton: Boolean, + triggerAttrs: { type: Object, default: {} diff --git a/src/components/popover/popover.vue b/src/components/popover/popover.vue index fd0fd821..1a4bffd9 100644 --- a/src/components/popover/popover.vue +++ b/src/components/popover/popover.vue @@ -5,7 +5,8 @@ > <button ref="trigger" - class="button-unstyled popover-trigger-button" + class="popover-trigger-button" + :class="normalButton ? 'button-default btn' : 'button-unstyled'" type="button" v-bind="triggerAttrs" @click="onClick" diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index b75fee69..5564b118 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -1,4 +1,5 @@ import statusPoster from '../../services/status_poster/status_poster.service.js' +import genRandomSeed from '../../services/random_seed/random_seed.service.js' import MediaUpload from '../media_upload/media_upload.vue' import ScopeSelector from '../scope_selector/scope_selector.vue' import EmojiInput from '../emoji_input/emoji_input.vue' @@ -156,11 +157,13 @@ const PostStatusForm = { poll: this.statusPoll || {}, mediaDescriptions: this.statusMediaDescriptions || {}, visibility: this.statusScope || scope, - contentType: statusContentType + contentType: statusContentType, + quoting: false } } return { + randomSeed: genRandomSeed(), dropFiles: [], uploadingFiles: false, error: null, @@ -265,6 +268,30 @@ const PostStatusForm = { isEdit () { return typeof this.statusId !== 'undefined' && this.statusId.trim() !== '' }, + quotable () { + if (!this.$store.state.instance.quotingAvailable) { + return false + } + + if (!this.replyTo) { + return false + } + + const repliedStatus = this.$store.state.statuses.allStatusesObject[this.replyTo] + if (!repliedStatus) { + return false + } + + if (repliedStatus.visibility === 'public' || + repliedStatus.visibility === 'unlisted' || + repliedStatus.visibility === 'local') { + return true + } else if (repliedStatus.visibility === 'private') { + return repliedStatus.user.id === this.$store.state.users.currentUser.id + } + + return false + }, ...mapGetters(['mergedConfig']), ...mapState({ mobileLayout: state => state.interface.mobileLayout @@ -292,7 +319,8 @@ const PostStatusForm = { visibility: newStatus.visibility, contentType: newStatus.contentType, poll: {}, - mediaDescriptions: {} + mediaDescriptions: {}, + quoting: false } this.pollFormVisible = false this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile() @@ -340,6 +368,8 @@ const PostStatusForm = { return } + const replyOrQuoteAttr = newStatus.quoting ? 'quoteId' : 'inReplyToStatusId' + const postingOptions = { status: newStatus.status, spoilerText: newStatus.spoilerText || null, @@ -347,7 +377,7 @@ const PostStatusForm = { sensitive: newStatus.nsfw, media: newStatus.files, store: this.$store, - inReplyToStatusId: this.replyTo, + [replyOrQuoteAttr]: this.replyTo, contentType: newStatus.contentType, poll, idempotencyKey: this.idempotencyKey @@ -373,6 +403,7 @@ const PostStatusForm = { } const newStatus = this.newStatus this.previewLoading = true + const replyOrQuoteAttr = newStatus.quoting ? 'quoteId' : 'inReplyToStatusId' statusPoster.postStatus({ status: newStatus.status, spoilerText: newStatus.spoilerText || null, @@ -380,7 +411,7 @@ const PostStatusForm = { sensitive: newStatus.nsfw, media: [], store: this.$store, - inReplyToStatusId: this.replyTo, + [replyOrQuoteAttr]: this.replyTo, contentType: newStatus.contentType, poll: {}, preview: true diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index 86c1f907..9b108a5a 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -126,6 +126,36 @@ class="preview-status" /> </div> + <div + v-if="quotable" + role="radiogroup" + class="btn-group reply-or-quote-selector" + > + <button + :id="`reply-or-quote-option-${randomSeed}-reply`" + class="btn button-default reply-or-quote-option" + :class="{ toggled: !newStatus.quoting }" + tabindex="0" + role="radio" + :aria-labelledby="`reply-or-quote-option-${randomSeed}-reply`" + :aria-checked="!newStatus.quoting" + @click="newStatus.quoting = false" + > + {{ $t('post_status.reply_option') }} + </button> + <button + :id="`reply-or-quote-option-${randomSeed}-quote`" + class="btn button-default reply-or-quote-option" + :class="{ toggled: newStatus.quoting }" + tabindex="0" + role="radio" + :aria-labelledby="`reply-or-quote-option-${randomSeed}-quote`" + :aria-checked="newStatus.quoting" + @click="newStatus.quoting = true" + > + {{ $t('post_status.quote_option') }} + </button> + </div> <EmojiInput v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)" v-model="newStatus.spoilerText" @@ -420,6 +450,10 @@ margin: 0; } + .reply-or-quote-selector { + margin-bottom: 0.5em; + } + .text-format { .only-format { color: $fallback--faint; diff --git a/src/components/post_status_modal/post_status_modal.js b/src/components/post_status_modal/post_status_modal.js index b44354db..8970dd9b 100644 --- a/src/components/post_status_modal/post_status_modal.js +++ b/src/components/post_status_modal/post_status_modal.js @@ -44,6 +44,10 @@ const PostStatusModal = { methods: { closeModal () { this.$store.dispatch('closePostStatusModal') + }, + resetAndClose () { + this.$store.dispatch('resetPostStatusModal') + this.$store.dispatch('closePostStatusModal') } } } diff --git a/src/components/post_status_modal/post_status_modal.vue b/src/components/post_status_modal/post_status_modal.vue index dbcd321e..bc2cad4a 100644 --- a/src/components/post_status_modal/post_status_modal.vue +++ b/src/components/post_status_modal/post_status_modal.vue @@ -12,7 +12,7 @@ <PostStatusForm class="panel-body" v-bind="params" - @posted="closeModal" + @posted="resetAndClose" /> </div> </Modal> diff --git a/src/components/quick_view_settings/quick_view_settings.js b/src/components/quick_view_settings/quick_view_settings.js index 2798f37a..67aa6713 100644 --- a/src/components/quick_view_settings/quick_view_settings.js +++ b/src/components/quick_view_settings/quick_view_settings.js @@ -52,7 +52,6 @@ const QuickViewSettings = { get () { return this.mergedConfig.mentionLinkShowAvatar }, set () { const value = !this.showUserAvatars - console.log(value) this.$store.dispatch('setOption', { name: 'mentionLinkShowAvatar', value }) } }, diff --git a/src/components/react_button/react_button.js b/src/components/react_button/react_button.js index 8eed4b60..0d252155 100644 --- a/src/components/react_button/react_button.js +++ b/src/components/react_button/react_button.js @@ -46,7 +46,7 @@ const ReactButton = { }, computed: { hideCustomEmoji () { - return !this.$store.state.instance.pleromaChatMessagesAvailable + return !this.$store.state.instance.pleromaCustomEmojiReactionsAvailable } } } diff --git a/src/components/react_button/react_button.vue b/src/components/react_button/react_button.vue index 947536a1..1b0674e6 100644 --- a/src/components/react_button/react_button.vue +++ b/src/components/react_button/react_button.vue @@ -11,6 +11,8 @@ /> <span class="button-unstyled popover-trigger" + role="button" + :tabindex="0" :title="$t('tool_tip.add_reaction')" @click.stop.prevent="show" > diff --git a/src/components/registration/registration.js b/src/components/registration/registration.js index b88bdeec..78d31980 100644 --- a/src/components/registration/registration.js +++ b/src/components/registration/registration.js @@ -83,6 +83,8 @@ const registration = { signedIn: (state) => !!state.users.currentUser, isPending: (state) => state.users.signUpPending, serverValidationErrors: (state) => state.users.signUpErrors, + signUpNotice: (state) => state.users.signUpNotice, + hasSignUpNotice: (state) => !!state.users.signUpNotice.message, termsOfService: (state) => state.instance.tos, accountActivationRequired: (state) => state.instance.accountActivationRequired, accountApprovalRequired: (state) => state.instance.accountApprovalRequired, @@ -107,8 +109,12 @@ const registration = { if (!this.v$.$invalid) { try { - await this.signUp(this.user) - this.$router.push({ name: 'friends' }) + const status = await this.signUp(this.user) + if (status === 'ok') { + this.$router.push({ name: 'friends' }) + } + // If status is not 'ok' (i.e. it needs further actions to be done + // before you can login), display sign up notice, do not switch anywhere } catch (error) { console.warn('Registration failed: ', error) this.setCaptcha() diff --git a/src/components/registration/registration.vue b/src/components/registration/registration.vue index 7438a5f4..5c913f94 100644 --- a/src/components/registration/registration.vue +++ b/src/components/registration/registration.vue @@ -3,7 +3,10 @@ <div class="panel-heading"> {{ $t('registration.registration') }} </div> - <div class="panel-body"> + <div + v-if="!hasSignUpNotice" + class="panel-body" + > <form class="registration-form" @submit.prevent="submit(user)" @@ -307,6 +310,11 @@ </div> </form> </div> + <div v-else> + <p class="registration-notice"> + {{ signUpNotice.message }} + </p> + </div> </div> </template> @@ -404,6 +412,10 @@ $validations-cRed: #f04124; } } +.registration-notice { + margin: 0.6em; +} + @media all and (max-width: 800px) { .registration-form .container { flex-direction: column-reverse; diff --git a/src/components/report/report.js b/src/components/report/report.js index 76055764..f8675c0f 100644 --- a/src/components/report/report.js +++ b/src/components/report/report.js @@ -1,6 +1,7 @@ import Select from '../select/select.vue' import StatusContent from '../status_content/status_content.vue' import Timeago from '../timeago/timeago.vue' +import RichContent from 'src/components/rich_content/rich_content.jsx' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' const Report = { @@ -10,7 +11,8 @@ const Report = { components: { Select, StatusContent, - Timeago + Timeago, + RichContent }, computed: { report () { diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx index 7881e365..ff14a58a 100644 --- a/src/components/rich_content/rich_content.jsx +++ b/src/components/rich_content/rich_content.jsx @@ -8,6 +8,27 @@ import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue' import './rich_content.scss' +const MAYBE_LINE_BREAKING_ELEMENTS = [ + 'blockquote', + 'br', + 'hr', + 'ul', + 'ol', + 'li', + 'p', + 'table', + 'tbody', + 'td', + 'th', + 'thead', + 'tr', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5' +] + /** * RichContent, The Über-powered component for rendering Post HTML. * @@ -149,7 +170,9 @@ export default { // Handle tag nodes if (Array.isArray(item)) { const [opener, children, closer] = item - const Tag = getTagName(opener) + let Tag = getTagName(opener) + if (Tag.toLowerCase() === 'script') Tag = 'js-exploit' + if (Tag.toLowerCase() === 'style') Tag = 'css-exploit' const fullAttrs = getAttrs(opener, () => true) const attrs = getAttrs(opener) const previouslyMentions = currentMentions !== null @@ -164,25 +187,22 @@ export default { !(children && typeof children[0] === 'string' && children[0].match(/^\s/)) ? lastSpacing : '' - switch (Tag) { - case 'br': + if (MAYBE_LINE_BREAKING_ELEMENTS.includes(Tag)) { + // all the elements that can cause a line change + currentMentions = null + } else if (Tag === 'img') { // replace images with StillImage + return ['', [mentionsLinePadding, renderImage(opener)], ''] + } else if (Tag === 'a' && this.handleLinks) { // replace mentions with MentionLink + if (fullAttrs.class && fullAttrs.class.includes('mention')) { + // Handling mentions here + return renderMention(attrs, children) + } else { currentMentions = null - break - case 'img': // replace images with StillImage - return ['', [mentionsLinePadding, renderImage(opener)], ''] - case 'a': // replace mentions with MentionLink - if (!this.handleLinks) break - if (fullAttrs.class && fullAttrs.class.includes('mention')) { - // Handling mentions here - return renderMention(attrs, children) - } else { - currentMentions = null - break - } - case 'span': - if (this.handleLinks && fullAttrs.class && fullAttrs.class.includes('h-card')) { - return ['', children.map(processItem), ''] - } + } + } else if (Tag === 'span') { + if (this.handleLinks && fullAttrs.class && fullAttrs.class.includes('h-card')) { + return ['', children.map(processItem), ''] + } } if (children !== undefined) { diff --git a/src/components/settings_modal/admin_tabs/emoji_tab.js b/src/components/settings_modal/admin_tabs/emoji_tab.js new file mode 100644 index 00000000..58e1468f --- /dev/null +++ b/src/components/settings_modal/admin_tabs/emoji_tab.js @@ -0,0 +1,257 @@ +import { clone, assign } from 'lodash' +import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' +import StringSetting from '../helpers/string_setting.vue' +import Checkbox from 'components/checkbox/checkbox.vue' +import StillImage from 'components/still-image/still-image.vue' +import Select from 'components/select/select.vue' +import Popover from 'components/popover/popover.vue' +import ConfirmModal from 'components/confirm_modal/confirm_modal.vue' +import ModifiedIndicator from '../helpers/modified_indicator.vue' +import EmojiEditingPopover from '../helpers/emoji_editing_popover.vue' + +const EmojiTab = { + components: { + TabSwitcher, + StringSetting, + Checkbox, + StillImage, + Select, + Popover, + ConfirmModal, + ModifiedIndicator, + EmojiEditingPopover + }, + + data () { + return { + knownLocalPacks: { }, + knownRemotePacks: { }, + editedMetadata: { }, + packName: '', + newPackName: '', + deleteModalVisible: false, + remotePackInstance: '', + remotePackDownloadAs: '' + } + }, + + provide () { + return { emojiAddr: this.emojiAddr } + }, + + computed: { + pack () { + return this.packName !== '' ? this.knownPacks[this.packName] : undefined + }, + packMeta () { + if (this.editedMetadata[this.packName] === undefined) { + this.editedMetadata[this.packName] = clone(this.pack.pack) + } + + return this.editedMetadata[this.packName] + }, + knownPacks () { + // Copy the object itself but not the children, so they are still passed by reference and modified + const result = clone(this.knownLocalPacks) + for (const instName in this.knownRemotePacks) { + for (const instPack in this.knownRemotePacks[instName]) { + result[`${instPack}@${instName}`] = this.knownRemotePacks[instName][instPack] + } + } + + return result + }, + downloadWillReplaceLocal () { + return (this.remotePackDownloadAs.trim() === '' && this.pack.remote && this.pack.remote.baseName in this.knownLocalPacks) || + (this.remotePackDownloadAs in this.knownLocalPacks) + } + }, + + methods: { + reloadEmoji () { + this.$store.state.api.backendInteractor.reloadEmoji() + }, + importFromFS () { + this.$store.state.api.backendInteractor.importEmojiFromFS() + }, + emojiAddr (name) { + if (this.pack.remote !== undefined) { + // Remote pack + return `${this.pack.remote.instance}/emoji/${encodeURIComponent(this.pack.remote.baseName)}/${name}` + } else { + return `${this.$store.state.instance.server}/emoji/${encodeURIComponent(this.packName)}/${name}` + } + }, + + createEmojiPack () { + this.$store.state.api.backendInteractor.createEmojiPack( + { name: this.newPackName } + ).then(resp => resp.json()).then(resp => { + if (resp === 'ok') { + return this.refreshPackList() + } else { + this.displayError(resp.error) + return Promise.reject(resp) + } + }).then(done => { + this.$refs.createPackPopover.hidePopover() + + this.packName = this.newPackName + this.newPackName = '' + }) + }, + deleteEmojiPack () { + this.$store.state.api.backendInteractor.deleteEmojiPack( + { name: this.packName } + ).then(resp => resp.json()).then(resp => { + if (resp === 'ok') { + return this.refreshPackList() + } else { + this.displayError(resp.error) + return Promise.reject(resp) + } + }).then(done => { + delete this.editedMetadata[this.packName] + + this.deleteModalVisible = false + this.packName = '' + }) + }, + + metaEdited (prop) { + if (!this.pack) return + + const def = this.pack.pack[prop] || '' + const edited = this.packMeta[prop] || '' + return edited !== def + }, + savePackMetadata () { + this.$store.state.api.backendInteractor.saveEmojiPackMetadata({ name: this.packName, newData: this.packMeta }).then( + resp => resp.json() + ).then(resp => { + if (resp.error !== undefined) { + this.displayError(resp.error) + return + } + + // Update actual pack data + this.pack.pack = resp + // Delete edited pack data, should auto-update itself + delete this.editedMetadata[this.packName] + }) + }, + + updatePackFiles (newFiles) { + this.pack.files = newFiles + this.sortPackFiles(this.packName) + }, + + loadPacksPaginated (listFunction) { + const pageSize = 25 + const allPacks = {} + + return listFunction({ instance: this.remotePackInstance, page: 1, pageSize: 0 }) + .then(data => data.json()) + .then(data => { + if (data.error !== undefined) { return Promise.reject(data.error) } + + let resultingPromise = Promise.resolve({}) + for (let i = 0; i < Math.ceil(data.count / pageSize); i++) { + resultingPromise = resultingPromise.then(() => listFunction({ instance: this.remotePackInstance, page: i, pageSize }) + ).then(data => data.json()).then(pageData => { + if (pageData.error !== undefined) { return Promise.reject(pageData.error) } + + assign(allPacks, pageData.packs) + }) + } + + return resultingPromise + }) + .then(finished => allPacks) + .catch(data => { + this.displayError(data) + }) + }, + + refreshPackList () { + this.loadPacksPaginated(this.$store.state.api.backendInteractor.listEmojiPacks) + .then(allPacks => { + this.knownLocalPacks = allPacks + for (const name of Object.keys(this.knownLocalPacks)) { + this.sortPackFiles(name) + } + }) + }, + listRemotePacks () { + this.loadPacksPaginated(this.$store.state.api.backendInteractor.listRemoteEmojiPacks) + .then(allPacks => { + let inst = this.remotePackInstance + if (!inst.startsWith('http')) { inst = 'https://' + inst } + const instUrl = new URL(inst) + inst = instUrl.host + + for (const packName in allPacks) { + allPacks[packName].remote = { + baseName: packName, + instance: instUrl.origin + } + } + + this.knownRemotePacks[inst] = allPacks + for (const pack in this.knownRemotePacks[inst]) { + this.sortPackFiles(`${pack}@${inst}`) + } + + this.$refs.remotePackPopover.hidePopover() + }) + .catch(data => { + this.displayError(data) + }) + }, + downloadRemotePack () { + if (this.remotePackDownloadAs.trim() === '') { + this.remotePackDownloadAs = this.pack.remote.baseName + } + + this.$store.state.api.backendInteractor.downloadRemoteEmojiPack({ + instance: this.pack.remote.instance, packName: this.pack.remote.baseName, as: this.remotePackDownloadAs + }) + .then(data => data.json()) + .then(resp => { + if (resp === 'ok') { + this.$refs.dlPackPopover.hidePopover() + + return this.refreshPackList() + } else { + this.displayError(resp.error) + return Promise.reject(resp) + } + }).then(done => { + this.packName = this.remotePackDownloadAs + this.remotePackDownloadAs = '' + }) + }, + displayError (msg) { + this.$store.dispatch('pushGlobalNotice', { + messageKey: 'admin_dash.emoji.error', + messageArgs: [msg], + level: 'error' + }) + }, + sortPackFiles (nameOfPack) { + // Sort by key + const sorted = Object.keys(this.knownPacks[nameOfPack].files).sort().reduce((acc, key) => { + if (key.length === 0) return acc + acc[key] = this.knownPacks[nameOfPack].files[key] + return acc + }, {}) + this.knownPacks[nameOfPack].files = sorted + } + }, + + mounted () { + this.refreshPackList() + } +} + +export default EmojiTab diff --git a/src/components/settings_modal/admin_tabs/emoji_tab.scss b/src/components/settings_modal/admin_tabs/emoji_tab.scss new file mode 100644 index 00000000..cc918870 --- /dev/null +++ b/src/components/settings_modal/admin_tabs/emoji_tab.scss @@ -0,0 +1,61 @@ +@import "src/variables"; + +.emoji-tab { + .btn-group .btn:not(:first-child) { + margin-left: 0.5em; + } + + .pack-info-wrapper { + margin-top: 1em; + } + + .emoji-info-input { + width: 100%; + } + + .emoji-data-input { + width: 40%; + margin-left: 0.5em; + margin-right: 0.5em; + } + + .emoji { + width: 32px; + height: 32px; + } + + .emoji-unsaved { + box-shadow: 0 3px 5px var(--cBlue, $fallback--cBlue); + } + + .emoji-list { + display: flex; + flex-wrap: wrap; + gap: 1em 1em; + } +} + +.emoji-tab-popover-button:not(:first-child) { + margin-left: 0.5em; +} + +.emoji-tab-popover-input { + margin-bottom: 0.5em; + + label { + display: block; + margin-bottom: 0.5em; + } + + input { + width: 20em; + } + + .emoji-tab-popover-file { + padding-top: 3px; + } + + .warning { + color: var(--cOrange, $fallback--cOrange); + } +} diff --git a/src/components/settings_modal/admin_tabs/emoji_tab.vue b/src/components/settings_modal/admin_tabs/emoji_tab.vue new file mode 100644 index 00000000..5231f860 --- /dev/null +++ b/src/components/settings_modal/admin_tabs/emoji_tab.vue @@ -0,0 +1,278 @@ +<template> + <div + class="emoji-tab" + :label="$t('admin_dash.tabs.emoji')" + > + <div class="setting-item"> + <h2>{{ $t('admin_dash.tabs.emoji') }}</h2> + + <ul class="setting-list"> + <h3>{{ $t('admin_dash.emoji.global_actions') }}</h3> + + <li class="btn-group setting-item"> + <button + class="button button-default btn" + type="button" + @click="reloadEmoji"> + {{ $t('admin_dash.emoji.reload') }} + </button> + <button + class="button button-default btn" + type="button" + @click="importFromFS"> + {{ $t('admin_dash.emoji.importFS') }} + </button> + </li> + + <li class="btn-group setting-item"> + <button + class="button button-default btn" + type="button" + @click="$refs.remotePackPopover.showPopover"> + {{ $t('admin_dash.emoji.remote_packs') }} + + <Popover + ref="remotePackPopover" + popover-class="emoji-tab-edit-popover popover-default" + trigger="click" + placement="bottom" + bound-to-selector=".emoji-tab" + :bound-to="{ x: 'container' }" + :offset="{ y: 5 }" + > + <template #content> + <div class="emoji-tab-popover-input"> + <h3>{{ $t('admin_dash.emoji.remote_pack_instance') }}</h3> + <input v-model="remotePackInstance" :placeholder="$t('admin_dash.emoji.remote_pack_instance')"> + <button + class="button button-default btn emoji-tab-popover-button" + type="button" + @click="listRemotePacks"> + {{ $t('admin_dash.emoji.do_list') }} + </button> + </div> + </template> + </Popover> + </button> + </li> + + <h3>{{ $t('admin_dash.emoji.emoji_packs') }}</h3> + + <li> + <h4>{{ $t('admin_dash.emoji.edit_pack') }}</h4> + + <Select class="form-control" v-model="packName"> + <option value="" disabled hidden>{{ $t('admin_dash.emoji.emoji_pack') }}</option> + <option v-for="(pack, listPackName) in knownPacks" :label="listPackName" :key="listPackName"> + {{ listPackName }} + </option> + </Select> + + <button + class="button button-default btn emoji-tab-popover-button" + type="button" + @click="$refs.createPackPopover.showPopover"> + {{ $t('admin_dash.emoji.create_pack') }} + </button> + <Popover + ref="createPackPopover" + popover-class="emoji-tab-edit-popover popover-default" + trigger="click" + placement="bottom" + bound-to-selector=".emoji-tab" + :bound-to="{ x: 'container' }" + :offset="{ y: 5 }" + > + <template #content> + <div class="emoji-tab-popover-input"> + <h3>{{ $t('admin_dash.emoji.new_pack_name') }}</h3> + <input v-model="newPackName" :placeholder="$t('admin_dash.emoji.new_pack_name')"> + <button + class="button button-default btn emoji-tab-popover-button" + type="button" + @click="createEmojiPack"> + {{ $t('admin_dash.emoji.create') }} + </button> + </div> + </template> + </Popover> + </li> + </ul> + + <div v-if="pack"> + <div class="pack-info-wrapper"> + <ul class="setting-list"> + <li> + <label> + {{ $t('admin_dash.emoji.description') }} + <ModifiedIndicator :changed="metaEdited('description')" message-key="admin_dash.emoji.metadata_changed" /> + + <textarea + v-model="packMeta.description" + :disabled="pack.remote !== undefined" + class="bio resize-height" /> + </label> + </li> + <li> + <label> + {{ $t('admin_dash.emoji.homepage') }} + <ModifiedIndicator :changed="metaEdited('homepage')" message-key="admin_dash.emoji.metadata_changed" /> + + <input + class="emoji-info-input" v-model="packMeta.homepage" + :disabled="pack.remote !== undefined"> + </label> + </li> + <li> + <label> + {{ $t('admin_dash.emoji.fallback_src') }} + <ModifiedIndicator :changed="metaEdited('fallback-src')" message-key="admin_dash.emoji.metadata_changed" /> + + <input class="emoji-info-input" v-model="packMeta['fallback-src']" :disabled="pack.remote !== undefined"> + </label> + </li> + <li> + <label> + {{ $t('admin_dash.emoji.fallback_sha256') }} + + <input :disabled="true" class="emoji-info-input" v-model="packMeta['fallback-src-sha256']"> + </label> + </li> + <li> + <Checkbox :disabled="pack.remote !== undefined" v-model="packMeta['share-files']"> + {{ $t('admin_dash.emoji.share') }} + </Checkbox> + + <ModifiedIndicator :changed="metaEdited('share-files')" message-key="admin_dash.emoji.metadata_changed" /> + </li> + <li class="btn-group"> + <button + class="button button-default btn" + type="button" + v-if="pack.remote === undefined" + @click="savePackMetadata"> + {{ $t('admin_dash.emoji.save_meta') }} + </button> + <button + class="button button-default btn" + type="button" + v-if="pack.remote === undefined" + @click="savePackMetadata"> + {{ $t('admin_dash.emoji.revert_meta') }} + </button> + + <button + class="button button-default btn" + v-if="pack.remote === undefined" + type="button" + @click="deleteModalVisible = true"> + {{ $t('admin_dash.emoji.delete_pack') }} + + <ConfirmModal + v-if="deleteModalVisible" + :title="$t('admin_dash.emoji.delete_title')" + :cancel-text="$t('status.delete_confirm_cancel_button')" + :confirm-text="$t('status.delete_confirm_accept_button')" + @cancelled="deleteModalVisible = false" + @accepted="deleteEmojiPack" > + {{ $t('admin_dash.emoji.delete_confirm', [packName]) }} + </ConfirmModal> + </button> + + <button + class="button button-default btn" + type="button" + v-if="pack.remote !== undefined" + @click="$refs.dlPackPopover.showPopover"> + {{ $t('admin_dash.emoji.download_pack') }} + + <Popover + ref="dlPackPopover" + trigger="click" + placement="bottom" + bound-to-selector=".emoji-tab" + popover-class="emoji-tab-edit-popover popover-default" + :bound-to="{ x: 'container' }" + :offset="{ y: 5 }" + > + <template #content> + <h3>{{ $t('admin_dash.emoji.downloading_pack', [packName]) }}</h3> + <div> + <div> + <div class="emoji-tab-popover-input"> + <label> + {{ $t('admin_dash.emoji.download_as_name') }} + <input class="emoji-data-input" + v-model="remotePackDownloadAs" + :placeholder="$t('admin_dash.emoji.download_as_name_full')"> + </label> + + <div v-if="downloadWillReplaceLocal" class="warning"> + <em>{{ $t('admin_dash.emoji.replace_warning') }}</em> + </div> + </div> + + <button + class="button button-default btn" + type="button" + @click="downloadRemotePack"> + {{ $t('admin_dash.emoji.download') }} + </button> + </div> + </div> + </template> + </Popover> + </button> + </li> + </ul> + </div> + + <ul class="setting-list"> + <h4> + {{ $t('admin_dash.emoji.files') }} + + <ModifiedIndicator v-if="pack" + :changed="$refs.emojiPopovers && $refs.emojiPopovers.some(p => p.isEdited)" + message-key="admin_dash.emoji.emoji_changed"/> + </h4> + + <div class="emoji-list" v-if="pack"> + <EmojiEditingPopover + v-if="pack.remote === undefined" + placement="bottom" new-upload + :title="$t('admin_dash.emoji.adding_new')" + :packName="packName" + @updatePackFiles="updatePackFiles" @displayError="displayError" + > + <template #trigger> + <FAIcon icon="plus" size="2x" :title="$t('admin_dash.emoji.add_file')" /> + </template> + </EmojiEditingPopover> + + <EmojiEditingPopover + placement="top" ref="emojiPopovers" + v-for="(file, shortcode) in pack.files" :key="shortcode" + :title="$t('admin_dash.emoji.editing', [shortcode])" + :disabled="pack.remote !== undefined" + :shortcode="shortcode" :file="file" :packName="packName" + @updatePackFiles="updatePackFiles" @displayError="displayError" + > + <template #trigger> + <StillImage + class="emoji" + :src="emojiAddr(file)" + :title="`:${shortcode}:`" + :alt="`:${shortcode}:`" + /> + </template> + </EmojiEditingPopover> + </div> + </ul> + </div> + </div> + </div> +</template> + +<script src="./emoji_tab.js"></script> + +<style lang="scss" src="./emoji_tab.scss"></style> diff --git a/src/components/settings_modal/admin_tabs/frontends_tab.js b/src/components/settings_modal/admin_tabs/frontends_tab.js new file mode 100644 index 00000000..f57310ee --- /dev/null +++ b/src/components/settings_modal/admin_tabs/frontends_tab.js @@ -0,0 +1,113 @@ +import BooleanSetting from '../helpers/boolean_setting.vue' +import ChoiceSetting from '../helpers/choice_setting.vue' +import IntegerSetting from '../helpers/integer_setting.vue' +import StringSetting from '../helpers/string_setting.vue' +import GroupSetting from '../helpers/group_setting.vue' +import Popover from 'src/components/popover/popover.vue' +import PanelLoading from 'src/components/panel_loading/panel_loading.vue' + +import SharedComputedObject from '../helpers/shared_computed_object.js' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faGlobe +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faGlobe +) + +const FrontendsTab = { + provide () { + return { + defaultDraftMode: true, + defaultSource: 'admin' + } + }, + data () { + return { + working: false + } + }, + components: { + BooleanSetting, + ChoiceSetting, + IntegerSetting, + StringSetting, + GroupSetting, + PanelLoading, + Popover + }, + created () { + if (this.user.rights.admin) { + this.$store.dispatch('loadFrontendsStuff') + } + }, + computed: { + frontends () { + return this.$store.state.adminSettings.frontends + }, + ...SharedComputedObject() + }, + methods: { + canInstall (frontend) { + const fe = this.frontends.find(f => f.name === frontend.name) + if (!fe) return false + return fe.refs.includes(frontend.ref) + }, + getSuggestedRef (frontend) { + if (this.adminDraft) { + const defaultFe = this.adminDraft[':pleroma'][':frontends'][':primary'] + if (defaultFe?.name === frontend.name && this.canInstall(defaultFe)) { + return defaultFe.ref + } else { + return frontend.refs[0] + } + } else { + return frontend.refs[0] + } + }, + update (frontend, suggestRef) { + const ref = suggestRef || this.getSuggestedRef(frontend) + const { name } = frontend + const payload = { name, ref } + + this.working = true + this.$store.state.api.backendInteractor.installFrontend({ payload }) + .finally(() => { + this.working = false + }) + .then(async (response) => { + this.$store.dispatch('loadFrontendsStuff') + if (response.error) { + const reason = await response.error.json() + this.$store.dispatch('pushGlobalNotice', { + level: 'error', + messageKey: 'admin_dash.frontend.failure_installing_frontend', + messageArgs: { + version: name + '/' + ref, + reason: reason.error + }, + timeout: 5000 + }) + } else { + this.$store.dispatch('pushGlobalNotice', { + level: 'success', + messageKey: 'admin_dash.frontend.success_installing_frontend', + messageArgs: { + version: name + '/' + ref + }, + timeout: 2000 + }) + } + }) + }, + setDefault (frontend, suggestRef) { + const ref = suggestRef || this.getSuggestedRef(frontend) + const { name } = frontend + + this.$store.commit('updateAdminDraft', { path: [':pleroma', ':frontends', ':primary'], value: { name, ref } }) + } + } +} + +export default FrontendsTab diff --git a/src/components/settings_modal/admin_tabs/frontends_tab.scss b/src/components/settings_modal/admin_tabs/frontends_tab.scss new file mode 100644 index 00000000..420d20b3 --- /dev/null +++ b/src/components/settings_modal/admin_tabs/frontends_tab.scss @@ -0,0 +1,29 @@ +.frontends-tab { + .cards-list { + padding: 0; + } + + .relative { + position: relative; + } + + .overlay { + position: absolute; + background: var(--bg); + // fix buttons showing through + z-index: 2; + opacity: 0.9; + top: 0; + bottom: 0; + left: 0; + right: 0; + } + + dd { + text-overflow: ellipsis; + word-wrap: nowrap; + white-space: nowrap; + overflow-x: hidden; + max-width: 10em; + } +} diff --git a/src/components/settings_modal/admin_tabs/frontends_tab.vue b/src/components/settings_modal/admin_tabs/frontends_tab.vue new file mode 100644 index 00000000..097877bc --- /dev/null +++ b/src/components/settings_modal/admin_tabs/frontends_tab.vue @@ -0,0 +1,200 @@ +<template> + <div + class="frontends-tab" + :label="$t('admin_dash.tabs.frontends')" + > + <div class="setting-item"> + <h2>{{ $t('admin_dash.tabs.frontends') }}</h2> + <p>{{ $t('admin_dash.frontend.wip_notice') }}</p> + <ul class="setting-list" v-if="adminDraft"> + <li> + <h3>{{ $t('admin_dash.frontend.default_frontend') }}</h3> + <p>{{ $t('admin_dash.frontend.default_frontend_tip') }}</p> + <ul class="setting-list"> + <li> + <StringSetting path=":pleroma.:frontends.:primary.name" /> + </li> + <li> + <StringSetting path=":pleroma.:frontends.:primary.ref" /> + </li> + <li> + <GroupSetting path=":pleroma.:frontends.:primary" /> + </li> + </ul> + </li> + </ul> + <div v-else class="setting-list"> + {{ $t('admin_dash.frontend.default_frontend_unavail') }} + </div> + + <div class="setting-list relative"> + <PanelLoading class="overlay" v-if="working"/> + <h3>{{ $t('admin_dash.frontend.available_frontends') }}</h3> + <ul class="cards-list"> + <li + v-for="frontend in frontends" + :key="frontend.name" + > + <strong>{{ frontend.name }}</strong> + {{ ' ' }} + <span v-if="adminDraft && adminDraft[':pleroma'][':frontends'][':primary']?.name === frontend.name"> + <i18n-t + v-if="adminDraft && adminDraft[':pleroma'][':frontends'][':primary']?.ref === frontend.refs[0]" + keypath="admin_dash.frontend.is_default" + /> + <i18n-t + v-else + keypath="admin_dash.frontend.is_default_custom" + > + <template #version> + <code>{{ adminDraft && adminDraft[':pleroma'][':frontends'][':primary'].ref }}</code> + </template> + </i18n-t> + </span> + <dl> + <dt>{{ $t('admin_dash.frontend.repository') }}</dt> + <dd> + <a + :href="frontend.git" + target="_blank" + >{{ frontend.git }}</a> + </dd> + <template v-if="expertLevel"> + <dt>{{ $t('admin_dash.frontend.versions') }}</dt> + <dd + v-for="ref in frontend.refs" + :key="ref" + > + <code>{{ ref }}</code> + </dd> + </template> + <dt v-if="expertLevel"> + {{ $t('admin_dash.frontend.build_url') }} + </dt> + <dd v-if="expertLevel"> + <a + :href="frontend.build_url" + target="_blank" + >{{ frontend.build_url }}</a> + </dd> + </dl> + <div> + <span class="btn-group"> + <button + class="button button-default btn" + type="button" + @click="update(frontend)" + > + {{ + frontend.installed + ? $t('admin_dash.frontend.reinstall') + : $t('admin_dash.frontend.install') + }} + <code> + {{ + getSuggestedRef(frontend) + }} + </code> + </button> + <Popover + v-if="frontend.refs.length > 1" + trigger="click" + class="button-dropdown" + placement="bottom" + > + <template #content="{close}"> + <div class="dropdown-menu"> + <button + v-for="ref in frontend.refs" + :key="ref" + class="button-default dropdown-item" + @click.prevent="update(frontend, ref)" + @click="close" + > + <i18n-t keypath="admin_dash.frontend.install_version"> + <template #version> + <code>{{ ref }}</code> + </template> + </i18n-t> + </button> + </div> + </template> + <template #trigger> + <button + class="button button-default btn dropdown-button" + type="button" + :title="$t('admin_dash.frontend.more_install_options')" + > + <FAIcon icon="chevron-down" /> + </button> + </template> + </Popover> + </span> + <span + v-if="frontend.installed && frontend.name !== 'admin-fe'" + class="btn-group" + > + <button + class="button button-default btn" + type="button" + :disabled=" + !adminDraft || adminDraft[':pleroma'][':frontends'][':primary']?.name === frontend.name && + adminDraft[':pleroma'][':frontends'][':primary']?.ref === frontend.refs[0] + " + @click="setDefault(frontend)" + > + {{ + $t('admin_dash.frontend.set_default') + }} + <code> + {{ + getSuggestedRef(frontend) + }} + </code> + </button> + {{ ' ' }} + <Popover + v-if="frontend.refs.length > 1" + trigger="click" + class="button-dropdown" + placement="bottom" + > + <template #content="{close}"> + <div class="dropdown-menu"> + <button + v-for="ref in frontend.installedRefs || frontend.refs" + :key="ref" + class="button-default dropdown-item" + @click.prevent="setDefault(frontend, ref)" + @click="close" + > + <i18n-t keypath="admin_dash.frontend.set_default_version"> + <template #version> + <code>{{ ref }}</code> + </template> + </i18n-t> + </button> + </div> + </template> + <template #trigger> + <button + class="button button-default btn dropdown-button" + type="button" + :title="$t('admin_dash.frontend.more_default_options')" + > + <FAIcon icon="chevron-down" /> + </button> + </template> + </Popover> + </span> + </div> + </li> + </ul> + </div> + </div> + </div> +</template> + +<script src="./frontends_tab.js"></script> + +<style lang="scss" src="./frontends_tab.scss"></style> diff --git a/src/components/settings_modal/admin_tabs/instance_tab.js b/src/components/settings_modal/admin_tabs/instance_tab.js new file mode 100644 index 00000000..b07bafe8 --- /dev/null +++ b/src/components/settings_modal/admin_tabs/instance_tab.js @@ -0,0 +1,38 @@ +import BooleanSetting from '../helpers/boolean_setting.vue' +import ChoiceSetting from '../helpers/choice_setting.vue' +import IntegerSetting from '../helpers/integer_setting.vue' +import StringSetting from '../helpers/string_setting.vue' +import GroupSetting from '../helpers/group_setting.vue' +import AttachmentSetting from '../helpers/attachment_setting.vue' + +import SharedComputedObject from '../helpers/shared_computed_object.js' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faGlobe +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faGlobe +) + +const InstanceTab = { + provide () { + return { + defaultDraftMode: true, + defaultSource: 'admin' + } + }, + components: { + BooleanSetting, + ChoiceSetting, + IntegerSetting, + StringSetting, + AttachmentSetting, + GroupSetting + }, + computed: { + ...SharedComputedObject() + } +} + +export default InstanceTab diff --git a/src/components/settings_modal/admin_tabs/instance_tab.vue b/src/components/settings_modal/admin_tabs/instance_tab.vue new file mode 100644 index 00000000..a0e3351e --- /dev/null +++ b/src/components/settings_modal/admin_tabs/instance_tab.vue @@ -0,0 +1,200 @@ +<template> + <div :label="$t('admin_dash.tabs.instance')"> + <div class="setting-item"> + <h2>{{ $t('admin_dash.instance.instance') }}</h2> + <ul class="setting-list"> + <li> + <StringSetting path=":pleroma.:instance.:name" /> + </li> + <!-- See https://git.pleroma.social/pleroma/pleroma/-/merge_requests/3963 --> + <li v-if="adminDraft[':pleroma'][':instance'][':favicon'] !== undefined"> + <AttachmentSetting compact path=":pleroma.:instance.:favicon" /> + </li> + <li> + <StringSetting path=":pleroma.:instance.:email" /> + </li> + <li> + <StringSetting path=":pleroma.:instance.:description" /> + </li> + <li> + <StringSetting path=":pleroma.:instance.:short_description" /> + </li> + <li> + <AttachmentSetting compact path=":pleroma.:instance.:instance_thumbnail" /> + </li> + <li> + <AttachmentSetting path=":pleroma.:instance.:background_image" /> + </li> + </ul> + </div> + <div class="setting-item"> + <h2>{{ $t('admin_dash.instance.registrations') }}</h2> + <ul class="setting-list"> + <li> + <BooleanSetting path=":pleroma.:instance.:registrations_open" /> + <ul class="setting-list suboptions"> + <li> + <BooleanSetting + path=":pleroma.:instance.:invites_enabled" + parent-path=":pleroma.:instance.:registrations_open" + parent-invert + /> + </li> + </ul> + </li> + <li> + <BooleanSetting path=":pleroma.:instance.:birthday_required" /> + <ul class="setting-list suboptions"> + <li> + <IntegerSetting + path=":pleroma.:instance.:birthday_min_age" + parent-path=":pleroma.:instance.:birthday_required" + /> + </li> + </ul> + </li> + <li> + <BooleanSetting path=":pleroma.:instance.:account_activation_required" /> + </li> + <li> + <BooleanSetting path=":pleroma.:instance.:account_approval_required" /> + </li> + <li> + <h3>{{ $t('admin_dash.instance.captcha_header') }}</h3> + <ul class="setting-list"> + <li> + <BooleanSetting :path="[':pleroma', 'Pleroma.Captcha', ':enabled']" /> + <ul class="setting-list suboptions"> + <li> + <ChoiceSetting + :path="[':pleroma', 'Pleroma.Captcha', ':method']" + :parent-path="[':pleroma', 'Pleroma.Captcha', ':enabled']" + :option-label-map="{ + 'Pleroma.Captcha.Native': $t('admin_dash.captcha.native'), + 'Pleroma.Captcha.Kocaptcha': $t('admin_dash.captcha.kocaptcha') + }" + /> + <IntegerSetting + :path="[':pleroma', 'Pleroma.Captcha', ':seconds_valid']" + :parent-path="[':pleroma', 'Pleroma.Captcha', ':enabled']" + /> + </li> + <li + v-if="adminDraft[':pleroma']['Pleroma.Captcha'][':enabled'] && adminDraft[':pleroma']['Pleroma.Captcha'][':method'] === 'Pleroma.Captcha.Kocaptcha'" + > + <h4>{{ $t('admin_dash.instance.kocaptcha') }}</h4> + <ul class="setting-list"> + <li> + <StringSetting :path="[':pleroma', 'Pleroma.Captcha.Kocaptcha', ':endpoint']" /> + </li> + </ul> + </li> + </ul> + </li> + </ul> + </li> + </ul> + </div> + <div class="setting-item"> + <h2>{{ $t('admin_dash.instance.access') }}</h2> + <ul class="setting-list"> + <li> + <BooleanSetting + override-backend-description + override-backend-description-label + path=":pleroma.:instance.:public" + /> + </li> + <li> + <ChoiceSetting + override-backend-description + override-backend-description-label + path=":pleroma.:instance.:limit_to_local_content" + /> + </li> + <li v-if="expertLevel"> + <h3>{{ $t('admin_dash.instance.restrict.header') }}</h3> + <p> + {{ $t('admin_dash.instance.restrict.description') }} + </p> + <ul class="setting-list"> + <li> + <h4>{{ $t('admin_dash.instance.restrict.timelines') }}</h4> + <ul class="setting-list"> + <li> + <BooleanSetting + path=":pleroma.:restrict_unauthenticated.:timelines.:local" + indeterminate-state=":if_instance_is_private" + swap-description-and-label + hide-description + /> + </li> + <li> + <BooleanSetting + path=":pleroma.:restrict_unauthenticated.:timelines.:federated" + indeterminate-state=":if_instance_is_private" + swap-description-and-label + hide-description + /> + </li> + <li> + <GroupSetting path=":pleroma.:restrict_unauthenticated.:timelines" /> + </li> + </ul> + </li> + <li> + <h4>{{ $t('admin_dash.instance.restrict.profiles') }}</h4> + <ul class="setting-list"> + <li> + <BooleanSetting + path=":pleroma.:restrict_unauthenticated.:profiles.:local" + indeterminate-state=":if_instance_is_private" + swap-description-and-label + hide-description + /> + </li> + <li> + <BooleanSetting + path=":pleroma.:restrict_unauthenticated.:profiles.:remote" + indeterminate-state=":if_instance_is_private" + swap-description-and-label + hide-description + /> + </li> + <li> + <GroupSetting path=":pleroma.:restrict_unauthenticated.:profiles" /> + </li> + </ul> + </li> + <li> + <h4>{{ $t('admin_dash.instance.restrict.activities') }}</h4> + <ul class="setting-list"> + <li> + <BooleanSetting + path=":pleroma.:restrict_unauthenticated.:activities.:local" + indeterminate-state=":if_instance_is_private" + swap-description-and-label + hide-description + /> + </li> + <li> + <BooleanSetting + path=":pleroma.:restrict_unauthenticated.:activities.:remote" + indeterminate-state=":if_instance_is_private" + swap-description-and-label + hide-description + /> + </li> + <li> + <GroupSetting path=":pleroma.:restrict_unauthenticated.:activities" /> + </li> + </ul> + </li> + </ul> + </li> + </ul> + </div> + </div> +</template> + +<script src="./instance_tab.js"></script> diff --git a/src/components/settings_modal/admin_tabs/limits_tab.js b/src/components/settings_modal/admin_tabs/limits_tab.js new file mode 100644 index 00000000..684739c3 --- /dev/null +++ b/src/components/settings_modal/admin_tabs/limits_tab.js @@ -0,0 +1,29 @@ +import BooleanSetting from '../helpers/boolean_setting.vue' +import ChoiceSetting from '../helpers/choice_setting.vue' +import IntegerSetting from '../helpers/integer_setting.vue' +import StringSetting from '../helpers/string_setting.vue' + +import SharedComputedObject from '../helpers/shared_computed_object.js' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faGlobe +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faGlobe +) + +const LimitsTab = { + data () {}, + components: { + BooleanSetting, + ChoiceSetting, + IntegerSetting, + StringSetting + }, + computed: { + ...SharedComputedObject() + } +} + +export default LimitsTab diff --git a/src/components/settings_modal/admin_tabs/limits_tab.vue b/src/components/settings_modal/admin_tabs/limits_tab.vue new file mode 100644 index 00000000..ef4b9271 --- /dev/null +++ b/src/components/settings_modal/admin_tabs/limits_tab.vue @@ -0,0 +1,136 @@ +<template> + <div :label="$t('admin_dash.tabs.limits')"> + <div class="setting-item"> + <h2>{{ $t('admin_dash.limits.arbitrary_limits') }}</h2> + <ul class="setting-list"> + <li> + <h3>{{ $t('admin_dash.limits.posts') }}</h3> + <ul class="setting-list"> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:limit" + draft-mode + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:remote_limit" + expert="1" + draft-mode + /> + </li> + </ul> + </li> + <li> + <h3>{{ $t('admin_dash.limits.uploads') }}</h3> + <ul class="setting-list"> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:description_limit" + draft-mode + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:upload_limit" + draft-mode + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:max_media_attachments" + draft-mode + /> + </li> + </ul> + </li> + <li> + <h3>{{ $t('admin_dash.limits.users') }}</h3> + <ul class="setting-list"> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:max_pinned_statuses" + draft-mode + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:user_bio_length" + draft-mode + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:user_name_length" + draft-mode + /> + </li> + <li> + <h4>{{ $t('admin_dash.limits.profile_fields') }}</h4> + <ul class="setting-list"> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:max_account_fields" + draft-mode + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:max_remote_account_fields" + draft-mode + expert="1" + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:account_field_name_length" + draft-mode + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:account_field_value_length" + draft-mode + /> + </li> + </ul> + </li> + <li> + <h4>{{ $t('admin_dash.limits.user_uploads') }}</h4> + <ul class="setting-list"> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:avatar_upload_limit" + draft-mode + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:banner_upload_limit" + draft-mode + /> + </li> + </ul> + </li> + </ul> + </li> + </ul> + </div> + </div> +</template> + +<script src="./limits_tab.js"></script> diff --git a/src/components/settings_modal/helpers/attachment_setting.js b/src/components/settings_modal/helpers/attachment_setting.js new file mode 100644 index 00000000..c4c04b2b --- /dev/null +++ b/src/components/settings_modal/helpers/attachment_setting.js @@ -0,0 +1,44 @@ +import Setting from './setting.js' +import { fileTypeExt } from 'src/services/file_type/file_type.service.js' +import MediaUpload from 'src/components/media_upload/media_upload.vue' +import Attachment from 'src/components/attachment/attachment.vue' + +export default { + ...Setting, + props: { + ...Setting.props, + compact: Boolean, + acceptTypes: { + type: String, + required: false, + default: 'image/*' + } + }, + components: { + ...Setting.components, + MediaUpload, + Attachment + }, + computed: { + ...Setting.computed, + attachment () { + const path = this.realDraftMode ? this.draft : this.state + // The "server" part is primarily for local dev, but could be useful for alt-domain or multiuser usage. + const url = path.includes('://') ? path : this.$store.state.instance.server + path + return { + mimetype: fileTypeExt(url), + url + } + } + }, + methods: { + ...Setting.methods, + setMediaFile (fileInfo) { + if (this.realDraftMode) { + this.draft = fileInfo.url + } else { + this.configSink(this.path, fileInfo.url) + } + } + } +} diff --git a/src/components/settings_modal/helpers/attachment_setting.vue b/src/components/settings_modal/helpers/attachment_setting.vue new file mode 100644 index 00000000..b50231f2 --- /dev/null +++ b/src/components/settings_modal/helpers/attachment_setting.vue @@ -0,0 +1,126 @@ +<template> + <span + v-if="matchesExpertLevel" + class="AttachmentSetting" + :class="{ '-compact': compact }" + > + <label + :for="path" + :class="{ 'faint': shouldBeDisabled }" + > + <template v-if="backendDescriptionLabel"> + {{ backendDescriptionLabel + ' ' }} + </template> + <template v-else-if="source === 'admin'"> + MISSING LABEL FOR {{ path }} + </template> + <slot v-else /> + + </label> + <p + v-if="backendDescriptionDescription" + class="setting-description" + :class="{ 'faint': shouldBeDisabled }" + > + {{ backendDescriptionDescription + ' ' }} + </p> + <div class="attachment-input"> + <div class="controls control-field"> + <label for="path">{{ $t('settings.url') }}</label> + <input + :id="path" + class="string-input" + :disabled="shouldBeDisabled" + :value="realDraftMode ? draft : state" + @change="update" + > + {{ ' ' }} + <ModifiedIndicator + :changed="isChanged" + :onclick="reset" + /> + <ProfileSettingIndicator :is-profile="isProfileSetting" /> + </div> + <div v-if="!compact">{{ $t('settings.preview') }}</div> + <Attachment + class="attachment" + :compact="compact" + :attachment="attachment" + size="small" + hide-description + @setMedia="onMedia" + @naturalSizeLoad="onNaturalSizeLoad" + /> + <div class="controls control-upload"> + <MediaUpload + ref="mediaUpload" + class="media-upload-icon" + :drop-files="dropFiles" + normal-button + :accept-types="acceptTypes" + @uploaded="setMediaFile" + @upload-failed="uploadFailed" + /> + </div> + </div> + <DraftButtons /> + </span> +</template> + +<script src="./attachment_setting.js"></script> + +<style lang="scss"> +.AttachmentSetting { + .attachment { + display: block; + width: 100%; + height: 15em; + margin-bottom: 0.5em; + } + + .attachment-input { + margin-left: 1em; + display: flex; + flex-direction: column; + width: 20em; + } + + &.-compact { + .attachment-input { + flex-direction: row; + align-items: flex-end; + } + + .attachment { + flex: 0; + order: 0; + display: block; + min-width: 4em; + height: 4em; + align-self: center; + margin-bottom: 0; + } + + .control-field { + order: 1; + min-width: 12em; + margin-left: 0.5em; + } + + .control-upload { + order: 2; + min-width: 12em; + padding: 0 0.5em; + } + } + + .controls { + margin-bottom: 0.5em; + + input, + button { + width: 100%; + } + } +} +</style> diff --git a/src/components/settings_modal/helpers/boolean_setting.js b/src/components/settings_modal/helpers/boolean_setting.js index 2e6992cb..199d3d0f 100644 --- a/src/components/settings_modal/helpers/boolean_setting.js +++ b/src/components/settings_modal/helpers/boolean_setting.js @@ -1,56 +1,31 @@ -import { get, set } from 'lodash' import Checkbox from 'src/components/checkbox/checkbox.vue' -import ModifiedIndicator from './modified_indicator.vue' -import ServerSideIndicator from './server_side_indicator.vue' +import Setting from './setting.js' + export default { + ...Setting, + props: { + ...Setting.props, + indeterminateState: [String, Object] + }, components: { - Checkbox, - ModifiedIndicator, - ServerSideIndicator + ...Setting.components, + Checkbox }, - props: [ - 'path', - 'disabled', - 'expert' - ], computed: { - pathDefault () { - const [firstSegment, ...rest] = this.path.split('.') - return [firstSegment + 'DefaultValue', ...rest].join('.') - }, - state () { - const value = get(this.$parent, this.path) - if (value === undefined) { - return this.defaultState - } else { - return value - } - }, - defaultState () { - return get(this.$parent, this.pathDefault) - }, - isServerSide () { - return this.path.startsWith('serverSide_') - }, - isChanged () { - return !this.path.startsWith('serverSide_') && this.state !== this.defaultState - }, - matchesExpertLevel () { - return (this.expert || 0) <= this.$parent.expertLevel + ...Setting.computed, + isIndeterminate () { + return this.visibleState === this.indeterminateState } }, methods: { - update (e) { - const [firstSegment, ...rest] = this.path.split('.') - set(this.$parent, this.path, e) - // Updating nested properties does not trigger update on its parent. - // probably still not as reliable, but works for depth=1 at least - if (rest.length > 0) { - set(this.$parent, firstSegment, { ...get(this.$parent, firstSegment) }) + ...Setting.methods, + getValue (e) { + // Basic tri-state toggle implementation + if (!!this.indeterminateState && !e && this.visibleState === true) { + // If we have indeterminate state, switching from true to false first goes through indeterminate + return this.indeterminateState } - }, - reset () { - set(this.$parent, this.path, this.defaultState) + return e } } } diff --git a/src/components/settings_modal/helpers/boolean_setting.vue b/src/components/settings_modal/helpers/boolean_setting.vue index 41142966..5a9eab34 100644 --- a/src/components/settings_modal/helpers/boolean_setting.vue +++ b/src/components/settings_modal/helpers/boolean_setting.vue @@ -4,23 +4,37 @@ class="BooleanSetting" > <Checkbox - :model-value="state" - :disabled="disabled" + :model-value="visibleState" + :disabled="shouldBeDisabled" + :indeterminate="isIndeterminate" @update:modelValue="update" > <span - v-if="!!$slots.default" class="label" + :class="{ 'faint': shouldBeDisabled }" > - <slot /> + <template v-if="backendDescriptionLabel"> + {{ backendDescriptionLabel }} + </template> + <template v-else-if="source === 'admin'"> + MISSING LABEL FOR {{ path }} + </template> + <slot v-else /> </span> - {{ ' ' }} - <ModifiedIndicator - :changed="isChanged" - :onclick="reset" - /> - <ServerSideIndicator :server-side="isServerSide" /> </Checkbox> + <ModifiedIndicator + :changed="isChanged" + :onclick="reset" + /> + <ProfileSettingIndicator :is-profile="isProfileSetting" /> + <DraftButtons /> + <p + v-if="backendDescriptionDescription" + class="setting-description" + :class="{ 'faint': shouldBeDisabled }" + > + {{ backendDescriptionDescription + ' ' }} + </p> </label> </template> diff --git a/src/components/settings_modal/helpers/choice_setting.js b/src/components/settings_modal/helpers/choice_setting.js index 3da559fe..bdeece76 100644 --- a/src/components/settings_modal/helpers/choice_setting.js +++ b/src/components/settings_modal/helpers/choice_setting.js @@ -1,51 +1,41 @@ -import { get, set } from 'lodash' import Select from 'src/components/select/select.vue' -import ModifiedIndicator from './modified_indicator.vue' -import ServerSideIndicator from './server_side_indicator.vue' +import Setting from './setting.js' + export default { + ...Setting, components: { - Select, - ModifiedIndicator, - ServerSideIndicator + ...Setting.components, + Select }, - props: [ - 'path', - 'disabled', - 'options', - 'expert' - ], - computed: { - pathDefault () { - const [firstSegment, ...rest] = this.path.split('.') - return [firstSegment + 'DefaultValue', ...rest].join('.') + props: { + ...Setting.props, + options: { + type: Array, + required: false }, - state () { - const value = get(this.$parent, this.path) - if (value === undefined) { - return this.defaultState - } else { - return value + optionLabelMap: { + type: Object, + required: false, + default: {} + } + }, + computed: { + ...Setting.computed, + realOptions () { + if (this.realSource === 'admin') { + return this.backendDescriptionSuggestions.map(x => ({ + key: x, + value: x, + label: this.optionLabelMap[x] || x + })) } - }, - defaultState () { - return get(this.$parent, this.pathDefault) - }, - isServerSide () { - return this.path.startsWith('serverSide_') - }, - isChanged () { - return !this.path.startsWith('serverSide_') && this.state !== this.defaultState - }, - matchesExpertLevel () { - return (this.expert || 0) <= this.$parent.expertLevel + return this.options } }, methods: { - update (e) { - set(this.$parent, this.path, e) - }, - reset () { - set(this.$parent, this.path, this.defaultState) + ...Setting.methods, + getValue (e) { + return e } } } diff --git a/src/components/settings_modal/helpers/choice_setting.vue b/src/components/settings_modal/helpers/choice_setting.vue index 8fdbb5d3..114e9b7d 100644 --- a/src/components/settings_modal/helpers/choice_setting.vue +++ b/src/components/settings_modal/helpers/choice_setting.vue @@ -3,15 +3,20 @@ v-if="matchesExpertLevel" class="ChoiceSetting" > - <slot /> + <template v-if="backendDescriptionLabel"> + {{ backendDescriptionLabel }} + </template> + <template v-else> + <slot /> + </template> {{ ' ' }} <Select - :model-value="state" + :model-value="realDraftMode ? draft :state" :disabled="disabled" @update:modelValue="update" > <option - v-for="option in options" + v-for="option in realOptions" :key="option.key" :value="option.value" > @@ -23,7 +28,14 @@ :changed="isChanged" :onclick="reset" /> - <ServerSideIndicator :server-side="isServerSide" /> + <ProfileSettingIndicator :is-profile="isProfileSetting" /> + <DraftButtons /> + <p + v-if="backendDescriptionDescription" + class="setting-description" + > + {{ backendDescriptionDescription + ' ' }} + </p> </label> </template> diff --git a/src/components/settings_modal/helpers/draft_buttons.vue b/src/components/settings_modal/helpers/draft_buttons.vue new file mode 100644 index 00000000..46a70e86 --- /dev/null +++ b/src/components/settings_modal/helpers/draft_buttons.vue @@ -0,0 +1,88 @@ +<!-- this is a helper exclusive to Setting components --> +<!-- TODO make it reusable --> +<template> + <span + class="DraftButtons" + > + <Popover + v-if="$parent.isDirty" + trigger="hover" + normal-button + :trigger-attrs="{ 'aria-label': $t('settings.commit_value_tooltip') }" + @click="$parent.commitDraft" + > + <template #trigger> + {{ $t('settings.commit_value') }} + </template> + <template #content> + <div class="modified-tooltip"> + {{ $t('settings.commit_value_tooltip') }} + </div> + </template> + </Popover> + <Popover + v-if="$parent.isDirty" + trigger="hover" + normal-button + :trigger-attrs="{ 'aria-label': $t('settings.reset_value_tooltip') }" + @click="$parent.reset" + > + <template #trigger> + {{ $t('settings.reset_value') }} + </template> + <template #content> + <div class="modified-tooltip"> + {{ $t('settings.reset_value_tooltip') }} + </div> + </template> + </Popover> + <Popover + v-if="$parent.canHardReset" + trigger="hover" + normal-button + :trigger-attrs="{ 'aria-label': $t('settings.hard_reset_value_tooltip') }" + @click="$parent.hardReset" + > + <template #trigger> + {{ $t('settings.hard_reset_value') }} + </template> + <template #content> + <div class="modified-tooltip"> + {{ $t('settings.hard_reset_value_tooltip') }} + </div> + </template> + </Popover> + </span> +</template> + +<script> +import Popover from 'src/components/popover/popover.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import { faWrench } from '@fortawesome/free-solid-svg-icons' + +library.add( + faWrench +) + +export default { + components: { Popover }, + props: ['changed'] +} +</script> + +<style lang="scss"> +.DraftButtons { + display: inline-block; + position: relative; + + .button-default { + margin-left: 0.5em; + } +} + +.draft-tooltip { + margin: 0.5em 1em; + min-width: 10em; + text-align: center; +} +</style> diff --git a/src/components/settings_modal/helpers/emoji_editing_popover.vue b/src/components/settings_modal/helpers/emoji_editing_popover.vue new file mode 100644 index 00000000..cdd3e403 --- /dev/null +++ b/src/components/settings_modal/helpers/emoji_editing_popover.vue @@ -0,0 +1,208 @@ +<template> + <Popover + trigger="click" + :placement="placement" + bound-to-selector=".emoji-list" + popover-class="emoji-tab-edit-popover popover-default" + ref="emojiPopover" + :bound-to="{ x: 'container' }" + :offset="{ y: 5 }" + :disabled="disabled" + :class="{'emoji-unsaved': isEdited}" + > + <template #trigger> + <slot name="trigger" /> + </template> + <template #content> + <h3> + {{ title }} + </h3> + + <StillImage class="emoji" v-if="emojiPreview" :src="emojiPreview" /> + <div v-else class="emoji"></div> + + <div class="emoji-tab-popover-input" v-if="newUpload"> + <input + type="file" + accept="image/*" + class="emoji-tab-popover-file" + @change="uploadFile = $event.target.files"> + </div> + <div> + <div class="emoji-tab-popover-input"> + <label> + {{ $t('admin_dash.emoji.shortcode') }} + <input class="emoji-data-input" + v-model="editedShortcode" + :placeholder="$t('admin_dash.emoji.new_shortcode')"> + </label> + </div> + + <div class="emoji-tab-popover-input"> + <label> + {{ $t('admin_dash.emoji.filename') }} + + <input class="emoji-data-input" + v-model="editedFile" + :placeholder="$t('admin_dash.emoji.new_filename')"> + </label> + </div> + + <button + class="button button-default btn" + type="button" + :disabled="newUpload ? uploadFile.length == 0 : !isEdited" + @click="newUpload ? uploadEmoji() : saveEditedEmoji()"> + {{ $t('admin_dash.emoji.save') }} + </button> + + <template v-if="!newUpload"> + <button + class="button button-default btn emoji-tab-popover-button" + type="button" + @click="deleteModalVisible = true"> + {{ $t('admin_dash.emoji.delete') }} + </button> + <button + class="button button-default btn emoji-tab-popover-button" + type="button" + @click="revertEmoji"> + {{ $t('admin_dash.emoji.revert') }} + </button> + <ConfirmModal + v-if="deleteModalVisible" + :title="$t('admin_dash.emoji.delete_title')" + :cancel-text="$t('status.delete_confirm_cancel_button')" + :confirm-text="$t('status.delete_confirm_accept_button')" + @cancelled="deleteModalVisible = false" + @accepted="deleteEmoji" > + {{ $t('admin_dash.emoji.delete_confirm', [shortcode]) }} + </ConfirmModal> + </template> + </div> + </template> + </Popover> +</template> + +<script> +import Popover from 'components/popover/popover.vue' +import ConfirmModal from 'components/confirm_modal/confirm_modal.vue' +import StillImage from 'components/still-image/still-image.vue' + +export default { + components: { Popover, ConfirmModal, StillImage }, + data () { + return { + uploadFile: [], + editedShortcode: this.shortcode, + editedFile: this.file, + deleteModalVisible: false + } + }, + computed: { + emojiPreview () { + if (this.newUpload && this.uploadFile.length > 0) { + return URL.createObjectURL(this.uploadFile[0]) + } else if (!this.newUpload) { + return this.emojiAddr(this.file) + } + + return null + }, + isEdited () { + return !this.newUpload && (this.editedShortcode !== this.shortcode || this.editedFile !== this.file) + } + }, + inject: ['emojiAddr'], + methods: { + saveEditedEmoji () { + if (!this.isEdited) return + + this.$store.state.api.backendInteractor.updateEmojiFile( + { packName: this.packName, shortcode: this.shortcode, newShortcode: this.editedShortcode, newFilename: this.editedFile, force: false } + ).then(resp => { + if (resp.error !== undefined) { + this.$emit('displayError', resp.error) + return Promise.reject(resp.error) + } + + return resp.json() + }).then(resp => this.$emit('updatePackFiles', resp)) + }, + uploadEmoji () { + this.$store.state.api.backendInteractor.addNewEmojiFile({ + packName: this.packName, + file: this.uploadFile[0], + shortcode: this.editedShortcode, + filename: this.editedFile + }).then(resp => resp.json()).then(resp => { + if (resp.error !== undefined) { + this.$emit('displayError', resp.error) + return + } + + this.$emit('updatePackFiles', resp) + this.$refs.emojiPopover.hidePopover() + + this.editedFile = '' + this.editedShortcode = '' + this.uploadFile = [] + }) + }, + revertEmoji () { + this.editedFile = this.file + this.editedShortcode = this.shortcode + }, + deleteEmoji () { + this.deleteModalVisible = false + + this.$store.state.api.backendInteractor.deleteEmojiFile( + { packName: this.packName, shortcode: this.shortcode } + ).then(resp => resp.json()).then(resp => { + if (resp.error !== undefined) { + this.$emit('displayError', resp.error) + return + } + + this.$emit('updatePackFiles', resp) + }) + } + }, + emits: ['updatePackFiles', 'displayError'], + props: { + placement: String, + disabled: { + type: Boolean, + default: false + }, + + newUpload: Boolean, + + title: String, + packName: String, + shortcode: { + type: String, + // Only exists when this is not a new upload + default: '' + }, + file: { + type: String, + // Only exists when this is not a new upload + default: '' + } + } +} +</script> + +<style lang="scss"> + .emoji-tab-edit-popover { + padding-left: 0.5em; + padding-right: 0.5em; + padding-bottom: 0.5em; + + .emoji { + width: 32px; + height: 32px; + } + } +</style> diff --git a/src/components/settings_modal/helpers/group_setting.js b/src/components/settings_modal/helpers/group_setting.js new file mode 100644 index 00000000..23a2a202 --- /dev/null +++ b/src/components/settings_modal/helpers/group_setting.js @@ -0,0 +1,13 @@ +import { isEqual } from 'lodash' + +import Setting from './setting.js' + +export default { + ...Setting, + computed: { + ...Setting.computed, + isDirty () { + return !isEqual(this.state, this.draft) + } + } +} diff --git a/src/components/settings_modal/helpers/group_setting.vue b/src/components/settings_modal/helpers/group_setting.vue new file mode 100644 index 00000000..a4df4bf3 --- /dev/null +++ b/src/components/settings_modal/helpers/group_setting.vue @@ -0,0 +1,15 @@ +<template> + <span + v-if="matchesExpertLevel" + class="GroupSetting" + > + <ModifiedIndicator + :changed="isChanged" + :onclick="reset" + /> + <ProfileSettingIndicator :is-profile="isProfileSetting" /> + <DraftButtons /> + </span> +</template> + +<script src="./group_setting.js"></script> diff --git a/src/components/settings_modal/helpers/modified_indicator.vue b/src/components/settings_modal/helpers/modified_indicator.vue index 45db3fc2..a747cebd 100644 --- a/src/components/settings_modal/helpers/modified_indicator.vue +++ b/src/components/settings_modal/helpers/modified_indicator.vue @@ -15,7 +15,7 @@ </template> <template #content> <div class="modified-tooltip"> - {{ $t('settings.setting_changed') }} + {{ $t(messageKey) }} </div> </template> </Popover> @@ -33,7 +33,13 @@ library.add( export default { components: { Popover }, - props: ['changed'] + props: { + changed: Boolean, + messageKey: { + type: String, + default: 'settings.setting_changed' + } + } } </script> diff --git a/src/components/settings_modal/helpers/number_setting.js b/src/components/settings_modal/helpers/number_setting.js index 73c39948..676a0d22 100644 --- a/src/components/settings_modal/helpers/number_setting.js +++ b/src/components/settings_modal/helpers/number_setting.js @@ -1,56 +1,24 @@ -import { get, set } from 'lodash' -import ModifiedIndicator from './modified_indicator.vue' +import Setting from './setting.js' + export default { - components: { - ModifiedIndicator - }, + ...Setting, props: { - path: String, - disabled: Boolean, - min: Number, - step: Number, - truncate: Number, - expert: [Number, String] - }, - computed: { - pathDefault () { - const [firstSegment, ...rest] = this.path.split('.') - return [firstSegment + 'DefaultValue', ...rest].join('.') - }, - parent () { - return this.$parent.$parent - }, - state () { - const value = get(this.parent, this.path) - if (value === undefined) { - return this.defaultState - } else { - return value - } - }, - defaultState () { - return get(this.parent, this.pathDefault) - }, - isChanged () { - return this.state !== this.defaultState - }, - matchesExpertLevel () { - return (this.expert || 0) <= this.parent.expertLevel + ...Setting.props, + truncate: { + type: Number, + required: false, + default: 1 } }, methods: { - truncateValue (value) { - if (!this.truncate) { - return value + ...Setting.methods, + getValue (e) { + if (!this.truncate === 1) { + return parseInt(e.target.value) + } else if (this.truncate > 1) { + return Math.trunc(e.target.value / this.truncate) * this.truncate } - - return Math.trunc(value / this.truncate) * this.truncate - }, - update (e) { - set(this.parent, this.path, this.truncateValue(parseFloat(e.target.value))) - }, - reset () { - set(this.parent, this.path, this.defaultState) + return parseFloat(e.target.value) } } } diff --git a/src/components/settings_modal/helpers/number_setting.vue b/src/components/settings_modal/helpers/number_setting.vue index 3eab5178..93f11331 100644 --- a/src/components/settings_modal/helpers/number_setting.vue +++ b/src/components/settings_modal/helpers/number_setting.vue @@ -3,17 +3,26 @@ v-if="matchesExpertLevel" class="NumberSetting" > - <label :for="path"> - <slot /> + <label + :for="path" + :class="{ 'faint': shouldBeDisabled }" + > + <template v-if="backendDescriptionLabel"> + {{ backendDescriptionLabel + ' ' }} + </template> + <template v-else-if="source === 'admin'"> + MISSING LABEL FOR {{ path }} + </template> + <slot v-else /> </label> <input :id="path" class="number-input" type="number" :step="step || 1" - :disabled="disabled" + :disabled="shouldBeDisabled" :min="min || 0" - :value="state" + :value="realDraftMode ? draft :state" @change="update" > {{ ' ' }} @@ -21,6 +30,15 @@ :changed="isChanged" :onclick="reset" /> + <ProfileSettingIndicator :is-profile="isProfileSetting" /> + <DraftButtons /> + <p + v-if="backendDescriptionDescription" + class="setting-description" + :class="{ 'faint': shouldBeDisabled }" + > + {{ backendDescriptionDescription + ' ' }} + </p> </span> </template> diff --git a/src/components/settings_modal/helpers/server_side_indicator.vue b/src/components/settings_modal/helpers/profile_setting_indicator.vue index bf181959..d160781b 100644 --- a/src/components/settings_modal/helpers/server_side_indicator.vue +++ b/src/components/settings_modal/helpers/profile_setting_indicator.vue @@ -1,7 +1,7 @@ <template> <span - v-if="serverSide" - class="ServerSideIndicator" + v-if="isProfile" + class="ProfileSettingIndicator" > <Popover trigger="hover" @@ -14,7 +14,7 @@ /> </template> <template #content> - <div class="serverside-tooltip"> + <div class="profilesetting-tooltip"> {{ $t('settings.setting_server_side') }} </div> </template> @@ -33,17 +33,17 @@ library.add( export default { components: { Popover }, - props: ['serverSide'] + props: ['isProfile'] } </script> <style lang="scss"> -.ServerSideIndicator { +.ProfileSettingIndicator { display: inline-block; position: relative; } -.serverside-tooltip { +.profilesetting-tooltip { margin: 0.5em 1em; min-width: 10em; text-align: center; diff --git a/src/components/settings_modal/helpers/setting.js b/src/components/settings_modal/helpers/setting.js new file mode 100644 index 00000000..abf9cfdf --- /dev/null +++ b/src/components/settings_modal/helpers/setting.js @@ -0,0 +1,238 @@ +import ModifiedIndicator from './modified_indicator.vue' +import ProfileSettingIndicator from './profile_setting_indicator.vue' +import DraftButtons from './draft_buttons.vue' +import { get, set, cloneDeep } from 'lodash' + +export default { + components: { + ModifiedIndicator, + DraftButtons, + ProfileSettingIndicator + }, + props: { + path: { + type: [String, Array], + required: true + }, + disabled: { + type: Boolean, + default: false + }, + parentPath: { + type: [String, Array] + }, + parentInvert: { + type: Boolean, + default: false + }, + expert: { + type: [Number, String], + default: 0 + }, + source: { + type: String, + default: undefined + }, + hideDescription: { + type: Boolean + }, + swapDescriptionAndLabel: { + type: Boolean + }, + overrideBackendDescription: { + type: Boolean + }, + overrideBackendDescriptionLabel: { + type: Boolean + }, + draftMode: { + type: Boolean, + default: undefined + } + }, + inject: { + defaultSource: { + default: 'default' + }, + defaultDraftMode: { + default: false + } + }, + data () { + return { + localDraft: null + } + }, + created () { + if (this.realDraftMode && this.realSource !== 'admin') { + this.draft = this.state + } + }, + computed: { + draft: { + // TODO allow passing shared draft object? + get () { + if (this.realSource === 'admin') { + return get(this.$store.state.adminSettings.draft, this.canonPath) + } else { + return this.localDraft + } + }, + set (value) { + if (this.realSource === 'admin') { + this.$store.commit('updateAdminDraft', { path: this.canonPath, value }) + } else { + this.localDraft = value + } + } + }, + state () { + const value = get(this.configSource, this.canonPath) + if (value === undefined) { + return this.defaultState + } else { + return value + } + }, + visibleState () { + return this.realDraftMode ? this.draft : this.state + }, + realSource () { + return this.source || this.defaultSource + }, + realDraftMode () { + return typeof this.draftMode === 'undefined' ? this.defaultDraftMode : this.draftMode + }, + backendDescription () { + return get(this.$store.state.adminSettings.descriptions, this.path) + }, + backendDescriptionLabel () { + if (this.realSource !== 'admin') return '' + if (!this.backendDescription || this.overrideBackendDescriptionLabel) { + return this.$t([ + 'admin_dash', + 'temp_overrides', + ...this.canonPath.map(p => p.replace(/\./g, '_DOT_')), + 'label' + ].join('.')) + } else { + return this.swapDescriptionAndLabel + ? this.backendDescription?.description + : this.backendDescription?.label + } + }, + backendDescriptionDescription () { + if (this.realSource !== 'admin') return '' + if (this.hideDescription) return null + if (!this.backendDescription || this.overrideBackendDescription) { + return this.$t([ + 'admin_dash', + 'temp_overrides', + ...this.canonPath.map(p => p.replace(/\./g, '_DOT_')), + 'description' + ].join('.')) + } else { + return this.swapDescriptionAndLabel + ? this.backendDescription?.label + : this.backendDescription?.description + } + }, + backendDescriptionSuggestions () { + return this.backendDescription?.suggestions + }, + shouldBeDisabled () { + const parentValue = this.parentPath !== undefined ? get(this.configSource, this.parentPath) : null + return this.disabled || (parentValue !== null ? (this.parentInvert ? parentValue : !parentValue) : false) + }, + configSource () { + switch (this.realSource) { + case 'profile': + return this.$store.state.profileConfig + case 'admin': + return this.$store.state.adminSettings.config + default: + return this.$store.getters.mergedConfig + } + }, + configSink () { + switch (this.realSource) { + case 'profile': + return (k, v) => this.$store.dispatch('setProfileOption', { name: k, value: v }) + case 'admin': + return (k, v) => this.$store.dispatch('pushAdminSetting', { path: k, value: v }) + default: + return (k, v) => this.$store.dispatch('setOption', { name: k, value: v }) + } + }, + defaultState () { + switch (this.realSource) { + case 'profile': + return {} + default: + return get(this.$store.getters.defaultConfig, this.path) + } + }, + isProfileSetting () { + return this.realSource === 'profile' + }, + isChanged () { + switch (this.realSource) { + case 'profile': + case 'admin': + return false + default: + return this.state !== this.defaultState + } + }, + canonPath () { + return Array.isArray(this.path) ? this.path : this.path.split('.') + }, + isDirty () { + if (this.realSource === 'admin' && this.canonPath.length > 3) { + return false // should not show draft buttons for "grouped" values + } else { + return this.realDraftMode && this.draft !== this.state + } + }, + canHardReset () { + return this.realSource === 'admin' && this.$store.state.adminSettings.modifiedPaths && + this.$store.state.adminSettings.modifiedPaths.has(this.canonPath.join(' -> ')) + }, + matchesExpertLevel () { + return (this.expert || 0) <= this.$store.state.config.expertLevel > 0 + } + }, + methods: { + getValue (e) { + return e.target.value + }, + update (e) { + if (this.realDraftMode) { + this.draft = this.getValue(e) + } else { + this.configSink(this.path, this.getValue(e)) + } + }, + commitDraft () { + if (this.realDraftMode) { + this.configSink(this.path, this.draft) + } + }, + reset () { + if (this.realDraftMode) { + this.draft = cloneDeep(this.state) + } else { + set(this.$store.getters.mergedConfig, this.path, cloneDeep(this.defaultState)) + } + }, + hardReset () { + switch (this.realSource) { + case 'admin': + return this.$store.dispatch('resetAdminSetting', { path: this.path }) + .then(() => { this.draft = this.state }) + default: + console.warn('Hard reset not implemented yet!') + } + } + } +} diff --git a/src/components/settings_modal/helpers/shared_computed_object.js b/src/components/settings_modal/helpers/shared_computed_object.js index 12431dca..bb3d36ac 100644 --- a/src/components/settings_modal/helpers/shared_computed_object.js +++ b/src/components/settings_modal/helpers/shared_computed_object.js @@ -1,52 +1,18 @@ -import { defaultState as configDefaultState } from 'src/modules/config.js' -import { defaultState as serverSideConfigDefaultState } from 'src/modules/serverSideConfig.js' - const SharedComputedObject = () => ({ user () { return this.$store.state.users.currentUser }, - // Getting values for default properties - ...Object.keys(configDefaultState) - .map(key => [ - key + 'DefaultValue', - function () { - return this.$store.getters.defaultConfig[key] - } - ]) - .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), - // Generating computed values for vuex properties - ...Object.keys(configDefaultState) - .map(key => [key, { - get () { return this.$store.getters.mergedConfig[key] }, - set (value) { - this.$store.dispatch('setOption', { name: key, value }) - } - }]) - .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), - ...Object.keys(serverSideConfigDefaultState) - .map(key => ['serverSide_' + key, { - get () { return this.$store.state.serverSideConfig[key] }, - set (value) { - this.$store.dispatch('setServerSideOption', { name: key, value }) - } - }]) - .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), - // Special cases (need to transform values or perform actions first) - useStreamingApi: { - get () { return this.$store.getters.mergedConfig.useStreamingApi }, - set (value) { - const promise = value - ? this.$store.dispatch('enableMastoSockets') - : this.$store.dispatch('disableMastoSockets') - - promise.then(() => { - this.$store.dispatch('setOption', { name: 'useStreamingApi', value }) - }).catch((e) => { - console.error('Failed starting MastoAPI Streaming socket', e) - this.$store.dispatch('disableMastoSockets') - this.$store.dispatch('setOption', { name: 'useStreamingApi', value: false }) - }) - } + expertLevel () { + return this.$store.getters.mergedConfig.expertLevel > 0 + }, + mergedConfig () { + return this.$store.getters.mergedConfig + }, + adminConfig () { + return this.$store.state.adminSettings.config + }, + adminDraft () { + return this.$store.state.adminSettings.draft } }) diff --git a/src/components/settings_modal/helpers/size_setting.js b/src/components/settings_modal/helpers/size_setting.js index 58697412..12cef705 100644 --- a/src/components/settings_modal/helpers/size_setting.js +++ b/src/components/settings_modal/helpers/size_setting.js @@ -1,67 +1,40 @@ -import { get, set } from 'lodash' -import ModifiedIndicator from './modified_indicator.vue' import Select from 'src/components/select/select.vue' +import Setting from './setting.js' export const allCssUnits = ['cm', 'mm', 'in', 'px', 'pt', 'pc', 'em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax', '%'] export const defaultHorizontalUnits = ['px', 'rem', 'vw'] export const defaultVerticalUnits = ['px', 'rem', 'vh'] export default { + ...Setting, components: { - ModifiedIndicator, + ...Setting.components, Select }, props: { - path: String, - disabled: Boolean, + ...Setting.props, min: Number, units: { - type: [String], + type: Array, default: () => allCssUnits - }, - expert: [Number, String] + } }, computed: { - pathDefault () { - const [firstSegment, ...rest] = this.path.split('.') - return [firstSegment + 'DefaultValue', ...rest].join('.') - }, + ...Setting.computed, stateUnit () { - return (this.state || '').replace(/\d+/, '') + return this.state.replace(/\d+/, '') }, stateValue () { - return (this.state || '').replace(/\D+/, '') - }, - state () { - const value = get(this.$parent, this.path) - if (value === undefined) { - return this.defaultState - } else { - return value - } - }, - defaultState () { - return get(this.$parent, this.pathDefault) - }, - isChanged () { - return this.state !== this.defaultState - }, - matchesExpertLevel () { - return (this.expert || 0) <= this.$parent.expertLevel + return this.state.replace(/\D+/, '') } }, methods: { - update (e) { - set(this.$parent, this.path, e) - }, - reset () { - set(this.$parent, this.path, this.defaultState) - }, + ...Setting.methods, updateValue (e) { - set(this.$parent, this.path, parseInt(e.target.value) + this.stateUnit) + this.configSink(this.path, parseInt(e.target.value) + this.stateUnit) }, updateUnit (e) { - set(this.$parent, this.path, this.stateValue + e.target.value) + this.configSink(this.path, this.stateValue + e.target.value) } } } diff --git a/src/components/settings_modal/helpers/size_setting.vue b/src/components/settings_modal/helpers/size_setting.vue index 5a78f100..6c3fbaeb 100644 --- a/src/components/settings_modal/helpers/size_setting.vue +++ b/src/components/settings_modal/helpers/size_setting.vue @@ -45,11 +45,18 @@ <script src="./size_setting.js"></script> <style lang="scss"> -.css-unit-input, -.css-unit-input select { - margin-left: 0.5em; - width: 4em; - max-width: 4em; - min-width: 4em; +.SizeSetting { + .number-input { + max-width: 6.5em; + } + + .css-unit-input, + .css-unit-input select { + margin-left: 0.5em; + width: 4em; + max-width: 4em; + min-width: 4em; + } } + </style> diff --git a/src/components/settings_modal/helpers/string_setting.js b/src/components/settings_modal/helpers/string_setting.js new file mode 100644 index 00000000..b368cfc8 --- /dev/null +++ b/src/components/settings_modal/helpers/string_setting.js @@ -0,0 +1,5 @@ +import Setting from './setting.js' + +export default { + ...Setting +} diff --git a/src/components/settings_modal/helpers/string_setting.vue b/src/components/settings_modal/helpers/string_setting.vue new file mode 100644 index 00000000..0cfa61ce --- /dev/null +++ b/src/components/settings_modal/helpers/string_setting.vue @@ -0,0 +1,42 @@ +<template> + <label + v-if="matchesExpertLevel" + class="StringSetting" + > + <label + :for="path" + :class="{ 'faint': shouldBeDisabled }" + > + <template v-if="backendDescriptionLabel"> + {{ backendDescriptionLabel + ' ' }} + </template> + <template v-else-if="source === 'admin'"> + MISSING LABEL FOR {{ path }} + </template> + <slot v-else /> + </label> + <input + :id="path" + class="string-input" + :disabled="shouldBeDisabled" + :value="realDraftMode ? draft : state" + @change="update" + > + {{ ' ' }} + <ModifiedIndicator + :changed="isChanged" + :onclick="reset" + /> + <ProfileSettingIndicator :is-profile="isProfileSetting" /> + <DraftButtons /> + <p + v-if="backendDescriptionDescription" + class="setting-description" + :class="{ 'faint': shouldBeDisabled }" + > + {{ backendDescriptionDescription + ' ' }} + </p> + </label> +</template> + +<script src="./string_setting.js"></script> diff --git a/src/components/settings_modal/settings_modal.js b/src/components/settings_modal/settings_modal.js index 0a72dca1..ff58f2c3 100644 --- a/src/components/settings_modal/settings_modal.js +++ b/src/components/settings_modal/settings_modal.js @@ -5,7 +5,7 @@ import getResettableAsyncComponent from 'src/services/resettable_async_component import Popover from '../popover/popover.vue' import Checkbox from 'src/components/checkbox/checkbox.vue' import { library } from '@fortawesome/fontawesome-svg-core' -import { cloneDeep } from 'lodash' +import { cloneDeep, isEqual } from 'lodash' import { newImporter, newExporter @@ -53,8 +53,16 @@ const SettingsModal = { Modal, Popover, Checkbox, - SettingsModalContent: getResettableAsyncComponent( - () => import('./settings_modal_content.vue'), + SettingsModalUserContent: getResettableAsyncComponent( + () => import('./settings_modal_user_content.vue'), + { + loadingComponent: PanelLoading, + errorComponent: AsyncComponentError, + delay: 0 + } + ), + SettingsModalAdminContent: getResettableAsyncComponent( + () => import('./settings_modal_admin_content.vue'), { loadingComponent: PanelLoading, errorComponent: AsyncComponentError, @@ -147,6 +155,12 @@ const SettingsModal = { PLEROMAFE_SETTINGS_MINOR_VERSION ] return clone + }, + resetAdminDraft () { + this.$store.commit('resetAdminDraft') + }, + pushAdminDraft () { + this.$store.dispatch('pushAdminDraft') } }, computed: { @@ -156,8 +170,14 @@ const SettingsModal = { modalActivated () { return this.$store.state.interface.settingsModalState !== 'hidden' }, - modalOpenedOnce () { - return this.$store.state.interface.settingsModalLoaded + modalMode () { + return this.$store.state.interface.settingsModalMode + }, + modalOpenedOnceUser () { + return this.$store.state.interface.settingsModalLoadedUser + }, + modalOpenedOnceAdmin () { + return this.$store.state.interface.settingsModalLoadedAdmin }, modalPeeked () { return this.$store.state.interface.settingsModalState === 'minimized' @@ -167,9 +187,14 @@ const SettingsModal = { return this.$store.state.config.expertLevel > 0 }, set (value) { - console.log(value) this.$store.dispatch('setOption', { name: 'expertLevel', value: value ? 1 : 0 }) } + }, + adminDraftAny () { + return !isEqual( + this.$store.state.adminSettings.config, + this.$store.state.adminSettings.draft + ) } } } diff --git a/src/components/settings_modal/settings_modal.scss b/src/components/settings_modal/settings_modal.scss index f5861229..6bc9459b 100644 --- a/src/components/settings_modal/settings_modal.scss +++ b/src/components/settings_modal/settings_modal.scss @@ -3,6 +3,10 @@ .settings-modal { overflow: hidden; + h4 { + margin-bottom: 0.5em; + } + .setting-list, .option-list { list-style-type: none; @@ -15,6 +19,20 @@ .suboptions { margin-top: 0.3em; } + + &.two-column { + column-count: 2; + + > li { + break-inside: avoid; + } + } + } + + .setting-description { + margin-top: 0.2em; + margin-bottom: 2em; + font-size: 70%; } .settings-modal-panel { @@ -37,7 +55,9 @@ .btn { min-height: 2em; - min-width: 10em; + } + + .btn:not(.dropdown-button) { padding: 0 2em; } } @@ -45,6 +65,8 @@ .settings-footer { display: flex; + flex-wrap: wrap; + line-height: 2; >* { margin-right: 0.5em; diff --git a/src/components/settings_modal/settings_modal.vue b/src/components/settings_modal/settings_modal.vue index 7b457371..4e7fd931 100644 --- a/src/components/settings_modal/settings_modal.vue +++ b/src/components/settings_modal/settings_modal.vue @@ -8,7 +8,7 @@ <div class="settings-modal-panel panel"> <div class="panel-heading"> <span class="title"> - {{ $t('settings.settings') }} + {{ modalMode === 'user' ? $t('settings.settings') : $t('admin_dash.window_title') }} </span> <transition name="fade"> <div @@ -42,10 +42,12 @@ </button> </div> <div class="panel-body"> - <SettingsModalContent v-if="modalOpenedOnce" /> + <SettingsModalUserContent v-if="modalMode === 'user' && modalOpenedOnceUser" /> + <SettingsModalAdminContent v-if="modalMode === 'admin' && modalOpenedOnceAdmin" /> </div> - <div class="panel-footer settings-footer"> + <div class="panel-footer settings-footer -flexible-height"> <Popover + v-if="modalMode === 'user'" class="export" trigger="click" placement="top" @@ -107,10 +109,42 @@ > {{ $t("settings.expert_mode") }} </Checkbox> + <span v-if="modalMode === 'admin'"> + <i18n-t keypath="admin_dash.wip_notice"> + <template #adminFeLink> + <a + href="/pleroma/admin/#/login-pleroma" + target="_blank" + > + {{ $t("admin_dash.old_ui_link") }} + </a> + </template> + </i18n-t> + </span> <span id="unscrolled-content" class="extra-content" /> + <span + v-if="modalMode === 'admin'" + class="admin-buttons" + > + <button + class="button-default btn" + :disabled="!adminDraftAny" + @click="resetAdminDraft" + > + {{ $t("admin_dash.reset_all") }} + </button> + {{ ' ' }} + <button + class="button-default btn" + :disabled="!adminDraftAny" + @click="pushAdminDraft" + > + {{ $t("admin_dash.commit_all") }} + </button> + </span> </div> </div> </Modal> diff --git a/src/components/settings_modal/settings_modal_admin_content.js b/src/components/settings_modal/settings_modal_admin_content.js new file mode 100644 index 00000000..ce835bf2 --- /dev/null +++ b/src/components/settings_modal/settings_modal_admin_content.js @@ -0,0 +1,95 @@ +import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' + +import InstanceTab from './admin_tabs/instance_tab.vue' +import LimitsTab from './admin_tabs/limits_tab.vue' +import FrontendsTab from './admin_tabs/frontends_tab.vue' +import EmojiTab from './admin_tabs/emoji_tab.vue' + +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faWrench, + faHand, + faLaptopCode, + faPaintBrush, + faBell, + faDownload, + faEyeSlash, + faInfo +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faWrench, + faHand, + faLaptopCode, + faPaintBrush, + faBell, + faDownload, + faEyeSlash, + faInfo +) + +const SettingsModalAdminContent = { + components: { + TabSwitcher, + + InstanceTab, + LimitsTab, + FrontendsTab, + EmojiTab + }, + computed: { + user () { + return this.$store.state.users.currentUser + }, + isLoggedIn () { + return !!this.$store.state.users.currentUser + }, + open () { + return this.$store.state.interface.settingsModalState !== 'hidden' + }, + bodyLock () { + return this.$store.state.interface.settingsModalState === 'visible' + }, + adminDbLoaded () { + return this.$store.state.adminSettings.loaded + }, + adminDescriptionsLoaded () { + return this.$store.state.adminSettings.descriptions !== null + }, + noDb () { + return this.$store.state.adminSettings.dbConfigEnabled === false + } + }, + created () { + if (this.user.rights.admin) { + this.$store.dispatch('loadAdminStuff') + } + }, + methods: { + onOpen () { + const targetTab = this.$store.state.interface.settingsModalTargetTab + // We're being told to open in specific tab + if (targetTab) { + const tabIndex = this.$refs.tabSwitcher.$slots.default().findIndex(elm => { + return elm.props && elm.props['data-tab-name'] === targetTab + }) + if (tabIndex >= 0) { + this.$refs.tabSwitcher.setTab(tabIndex) + } + } + // Clear the state of target tab, so that next time settings is opened + // it doesn't force it. + this.$store.dispatch('clearSettingsModalTargetTab') + } + }, + mounted () { + this.onOpen() + }, + watch: { + open: function (value) { + if (value) this.onOpen() + } + } +} + +export default SettingsModalAdminContent diff --git a/src/components/settings_modal/settings_modal_content.scss b/src/components/settings_modal/settings_modal_admin_content.scss index 87df7982..c984d703 100644 --- a/src/components/settings_modal/settings_modal_content.scss +++ b/src/components/settings_modal/settings_modal_admin_content.scss @@ -48,9 +48,5 @@ color: var(--cRed, $fallback--cRed); color: $fallback--cRed; } - - .number-input { - max-width: 6em; - } } } diff --git a/src/components/settings_modal/settings_modal_admin_content.vue b/src/components/settings_modal/settings_modal_admin_content.vue new file mode 100644 index 00000000..65e23b7e --- /dev/null +++ b/src/components/settings_modal/settings_modal_admin_content.vue @@ -0,0 +1,76 @@ +<template> + <tab-switcher + v-if="adminDescriptionsLoaded && (noDb || adminDbLoaded)" + ref="tabSwitcher" + class="settings_tab-switcher" + :side-tab-bar="true" + :scrollable-tabs="true" + :render-only-focused="true" + :body-scroll-lock="bodyLock" + > + <div + v-if="noDb" + :label="$t('admin_dash.tabs.nodb')" + icon="exclamation-triangle" + data-tab-name="nodb-notice" + > + <div :label="$t('admin_dash.tabs.nodb')"> + <div class="setting-item"> + <h2>{{ $t('admin_dash.nodb.heading') }}</h2> + <i18n-t keypath="admin_dash.nodb.text"> + <template #documentation> + <a + href="https://docs-develop.pleroma.social/backend/configuration/howto_database_config/" + target="_blank" + > + {{ $t("admin_dash.nodb.documentation") }} + </a> + </template> + <template #property> + <code>config :pleroma, configurable_from_database</code> + </template> + <template #value> + <code>true</code> + </template> + </i18n-t> + <p>{{ $t('admin_dash.nodb.text2') }}</p> + </div> + </div> + </div> + <div + v-if="adminDbLoaded" + :label="$t('admin_dash.tabs.instance')" + icon="wrench" + data-tab-name="general" + > + <InstanceTab /> + </div> + <div + v-if="adminDbLoaded" + :label="$t('admin_dash.tabs.limits')" + icon="hand" + data-tab-name="limits" + > + <LimitsTab /> + </div> + <div + :label="$t('admin_dash.tabs.frontends')" + icon="laptop-code" + data-tab-name="frontends" + > + <FrontendsTab /> + </div> + + <div + :label="$t('admin_dash.tabs.emoji')" + icon="face-smile-beam" + data-tab-name="emoji" + > + <EmojiTab /> + </div> + </tab-switcher> +</template> + +<script src="./settings_modal_admin_content.js"></script> + +<style src="./settings_modal_admin_content.scss" lang="scss"></style> diff --git a/src/components/settings_modal/settings_modal_content.js b/src/components/settings_modal/settings_modal_user_content.js index 9ac0301f..9ac0301f 100644 --- a/src/components/settings_modal/settings_modal_content.js +++ b/src/components/settings_modal/settings_modal_user_content.js diff --git a/src/components/settings_modal/settings_modal_user_content.scss b/src/components/settings_modal/settings_modal_user_content.scss new file mode 100644 index 00000000..c984d703 --- /dev/null +++ b/src/components/settings_modal/settings_modal_user_content.scss @@ -0,0 +1,52 @@ +@import "src/variables"; + +.settings_tab-switcher { + height: 100%; + + .setting-item { + border-bottom: 2px solid var(--fg, $fallback--fg); + margin: 1em 1em 1.4em; + padding-bottom: 1.4em; + + > div, + > label { + display: block; + margin-bottom: 0.5em; + + &:last-child { + margin-bottom: 0; + } + } + + .select-multiple { + display: flex; + + .option-list { + margin: 0; + padding-left: 0.5em; + } + } + + &:last-child { + border-bottom: none; + padding-bottom: 0; + margin-bottom: 1em; + } + + select { + min-width: 10em; + } + + textarea { + width: 100%; + max-width: 100%; + height: 100px; + } + + .unavailable, + .unavailable svg { + color: var(--cRed, $fallback--cRed); + color: $fallback--cRed; + } + } +} diff --git a/src/components/settings_modal/settings_modal_content.vue b/src/components/settings_modal/settings_modal_user_content.vue index 0be76d22..0221cccb 100644 --- a/src/components/settings_modal/settings_modal_content.vue +++ b/src/components/settings_modal/settings_modal_user_content.vue @@ -78,6 +78,6 @@ </tab-switcher> </template> -<script src="./settings_modal_content.js"></script> +<script src="./settings_modal_user_content.js"></script> -<style src="./settings_modal_content.scss" lang="scss"></style> +<style src="./settings_modal_user_content.scss" lang="scss"></style> diff --git a/src/components/settings_modal/tabs/filtering_tab.vue b/src/components/settings_modal/tabs/filtering_tab.vue index 97046ff0..9e82fcfd 100644 --- a/src/components/settings_modal/tabs/filtering_tab.vue +++ b/src/components/settings_modal/tabs/filtering_tab.vue @@ -7,13 +7,11 @@ <BooleanSetting path="hideFilteredStatuses"> {{ $t('settings.hide_filtered_statuses') }} </BooleanSetting> - <ul - class="setting-list suboptions" - :class="[{disabled: !streaming}]" - > + <ul class="setting-list suboptions"> <li> <BooleanSetting - :disabled="hideFilteredStatuses" + parent-path="hideFilteredStatuses" + :parent-invert="true" path="hideWordFilteredPosts" > {{ $t('settings.hide_wordfiltered_statuses') }} @@ -22,7 +20,8 @@ <li> <BooleanSetting v-if="user" - :disabled="hideFilteredStatuses" + parent-path="hideFilteredStatuses" + :parent-invert="true" path="hideMutedThreads" > {{ $t('settings.hide_muted_threads') }} @@ -31,7 +30,8 @@ <li> <BooleanSetting v-if="user" - :disabled="hideFilteredStatuses" + parent-path="hideFilteredStatuses" + :parent-invert="true" path="hideMutedPosts" > {{ $t('settings.hide_muted_posts') }} @@ -51,7 +51,7 @@ </li> <li> <BooleanSetting path="hideBotIndication"> - {{ $t('settings.hide_bot_indication') }} + {{ $t('settings.hide_actor_type_indication') }} </BooleanSetting> </li> <ChoiceSetting @@ -91,6 +91,11 @@ {{ $t('settings.hide_attachments_in_convo') }} </BooleanSetting> </li> + <li> + <BooleanSetting path="hideScrobbles"> + {{ $t('settings.hide_scrobbles') }} + </BooleanSetting> + </li> </ul> </div> <div diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js index be97710f..3f2bcb13 100644 --- a/src/components/settings_modal/tabs/general_tab.js +++ b/src/components/settings_modal/tabs/general_tab.js @@ -7,7 +7,7 @@ import SizeSetting, { defaultHorizontalUnits } from '../helpers/size_setting.vue import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue' import SharedComputedObject from '../helpers/shared_computed_object.js' -import ServerSideIndicator from '../helpers/server_side_indicator.vue' +import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { faGlobe @@ -67,7 +67,7 @@ const GeneralTab = { SizeSetting, InterfaceLanguageSwitcher, ScopeSelector, - ServerSideIndicator + ProfileSettingIndicator }, computed: { horizontalUnits () { @@ -110,7 +110,7 @@ const GeneralTab = { }, methods: { changeDefaultScope (value) { - this.$store.dispatch('setServerSideOption', { name: 'defaultScope', value }) + this.$store.dispatch('setProfileOption', { name: 'defaultScope', value }) } } } diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue index 21e2d855..f56fa8e0 100644 --- a/src/components/settings_modal/tabs/general_tab.vue +++ b/src/components/settings_modal/tabs/general_tab.vue @@ -29,14 +29,11 @@ <BooleanSetting path="streaming"> {{ $t('settings.streaming') }} </BooleanSetting> - <ul - class="setting-list suboptions" - :class="[{disabled: !streaming}]" - > + <ul class="setting-list suboptions"> <li> <BooleanSetting path="pauseOnUnfocused" - :disabled="!streaming" + parent-path="streaming" > {{ $t('settings.pause_on_unfocused') }} </BooleanSetting> @@ -213,7 +210,7 @@ </ChoiceSetting> </li> <ul - v-if="conversationDisplay !== 'linear'" + v-if="mergedConfig.conversationDisplay !== 'linear'" class="setting-list suboptions" > <li> @@ -265,7 +262,8 @@ <li> <BooleanSetting v-if="user" - path="serverSide_stripRichContent" + source="profile" + path="stripRichContent" expert="1" > {{ $t('settings.no_rich_text_description') }} @@ -299,7 +297,7 @@ <BooleanSetting path="preloadImage" expert="1" - :disabled="!hideNsfw" + parent-path="hideNsfw" > {{ $t('settings.preload_images') }} </BooleanSetting> @@ -308,7 +306,7 @@ <BooleanSetting path="useOneClickNsfw" expert="1" - :disabled="!hideNsfw" + parent-path="hideNsfw" > {{ $t('settings.use_one_click_nsfw') }} </BooleanSetting> @@ -321,15 +319,13 @@ > {{ $t('settings.loop_video') }} </BooleanSetting> - <ul - class="setting-list suboptions" - :class="[{disabled: !streaming}]" - > + <ul class="setting-list suboptions"> <li> <BooleanSetting path="loopVideoSilentOnly" expert="1" - :disabled="!loopVideo || !loopSilentAvailable" + parent-path="loopVideo" + :disabled="!loopSilentAvailable" > {{ $t('settings.loop_video_silent_only') }} </BooleanSetting> @@ -427,18 +423,18 @@ <ul class="setting-list"> <li> <label for="default-vis"> - {{ $t('settings.default_vis') }} <ServerSideIndicator :server-side="true" /> + {{ $t('settings.default_vis') }} <ProfileSettingIndicator :is-profile="true" /> <ScopeSelector class="scope-selector" :show-all="true" - :user-default="serverSide_defaultScope" - :initial-scope="serverSide_defaultScope" + :user-default="$store.state.profileConfig.defaultScope" + :initial-scope="$store.state.profileConfig.defaultScope" :on-scope-change="changeDefaultScope" /> </label> </li> <li> - <!-- <BooleanSetting path="serverSide_defaultNSFW"> --> + <!-- <BooleanSetting source="profile" path="defaultNSFW"> --> <BooleanSetting path="sensitiveByDefault"> {{ $t('settings.sensitive_by_default') }} </BooleanSetting> diff --git a/src/components/settings_modal/tabs/notifications_tab.js b/src/components/settings_modal/tabs/notifications_tab.js index 3c6ab87f..c53b5889 100644 --- a/src/components/settings_modal/tabs/notifications_tab.js +++ b/src/components/settings_modal/tabs/notifications_tab.js @@ -16,6 +16,10 @@ const NotificationsTab = { user () { return this.$store.state.users.currentUser }, + canReceiveReports () { + if (!this.user) { return false } + return this.user.privileges.includes('reports_manage_reports') + }, ...SharedComputedObject() }, methods: { diff --git a/src/components/settings_modal/tabs/notifications_tab.vue b/src/components/settings_modal/tabs/notifications_tab.vue index dd3806ed..9ace4c36 100644 --- a/src/components/settings_modal/tabs/notifications_tab.vue +++ b/src/components/settings_modal/tabs/notifications_tab.vue @@ -1,49 +1,219 @@ <template> <div :label="$t('settings.notifications')"> <div class="setting-item"> + <h2>{{ $t('settings.notification_setting_annoyance') }}</h2> + <ul class="setting-list"> + <li> + <BooleanSetting path="closingDrawerMarksAsSeen"> + {{ $t('settings.notification_setting_drawer_marks_as_seen') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="ignoreInactionableSeen"> + {{ $t('settings.notification_setting_ignore_inactionable_seen') }} + </BooleanSetting> + <div> + <small> + {{ $t('settings.notification_setting_ignore_inactionable_seen_tip') }} + </small> + </div> + </li> + <li> + <BooleanSetting path="unseenAtTop" expert="1"> + {{ $t('settings.notification_setting_unseen_at_top') }} + </BooleanSetting> + </li> + </ul> + </div> + <div class="setting-item"> <h2>{{ $t('settings.notification_setting_filters') }}</h2> <ul class="setting-list"> <li> - <BooleanSetting path="serverSide_blockNotificationsFromStrangers"> + <BooleanSetting + source="profile" + path="blockNotificationsFromStrangers" + > {{ $t('settings.notification_setting_block_from_strangers') }} </BooleanSetting> </li> - <li class="select-multiple"> - <span class="label">{{ $t('settings.notification_visibility') }}</span> - <ul class="option-list"> + <li> + <h3> {{ $t('settings.notification_visibility') }}</h3> + <p v-if="expertLevel > 0">{{ $t('settings.notification_setting_filters_chrome_push') }}</p> + <ul class="setting-list two-column"> <li> - <BooleanSetting path="notificationVisibility.likes"> - {{ $t('settings.notification_visibility_likes') }} - </BooleanSetting> + <h4> {{ $t('settings.notification_visibility_mentions') }}</h4> + <ul class="setting-list"> + <li> + <BooleanSetting path="notificationVisibility.mentions"> + {{ $t('settings.notification_visibility_in_column') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="notificationNative.mentions"> + {{ $t('settings.notification_visibility_native_notifications') }} + </BooleanSetting> + </li> + </ul> </li> <li> - <BooleanSetting path="notificationVisibility.repeats"> - {{ $t('settings.notification_visibility_repeats') }} - </BooleanSetting> + <h4> {{ $t('settings.notification_visibility_likes') }}</h4> + <ul class="setting-list"> + <li> + <BooleanSetting path="notificationVisibility.likes"> + {{ $t('settings.notification_visibility_in_column') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="notificationNative.likes"> + {{ $t('settings.notification_visibility_native_notifications') }} + </BooleanSetting> + </li> + </ul> </li> <li> - <BooleanSetting path="notificationVisibility.follows"> - {{ $t('settings.notification_visibility_follows') }} - </BooleanSetting> + <h4> {{ $t('settings.notification_visibility_repeats') }}</h4> + <ul class="setting-list"> + <li> + <BooleanSetting path="notificationVisibility.repeats"> + {{ $t('settings.notification_visibility_in_column') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="notificationNative.repeats"> + {{ $t('settings.notification_visibility_native_notifications') }} + </BooleanSetting> + </li> + </ul> + </li> + <li> + <h4> {{ $t('settings.notification_visibility_emoji_reactions') }}</h4> + <ul class="setting-list"> + <li> + <BooleanSetting path="notificationVisibility.emojiReactions"> + {{ $t('settings.notification_visibility_in_column') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="notificationNative.emojiReactions"> + {{ $t('settings.notification_visibility_native_notifications') }} + </BooleanSetting> + </li> + </ul> + </li> + <li> + <h4> {{ $t('settings.notification_visibility_follows') }}</h4> + <ul class="setting-list"> + <li> + <BooleanSetting path="notificationVisibility.follows"> + {{ $t('settings.notification_visibility_in_column') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="notificationNative.follows"> + {{ $t('settings.notification_visibility_native_notifications') }} + </BooleanSetting> + </li> + </ul> + </li> + <li> + <h4> {{ $t('settings.notification_visibility_follow_requests') }}</h4> + <ul class="setting-list"> + <li> + <BooleanSetting path="notificationVisibility.followRequest"> + {{ $t('settings.notification_visibility_in_column') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="notificationNative.followRequest"> + {{ $t('settings.notification_visibility_native_notifications') }} + </BooleanSetting> + </li> + </ul> + </li> + <li> + <h4> {{ $t('settings.notification_visibility_moves') }}</h4> + <ul class="setting-list"> + <li> + <BooleanSetting path="notificationVisibility.moves"> + {{ $t('settings.notification_visibility_in_column') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="notificationNative.moves"> + {{ $t('settings.notification_visibility_native_notifications') }} + </BooleanSetting> + </li> + </ul> + </li> + <li> + <h4> {{ $t('settings.notification_visibility_polls') }}</h4> + <ul class="setting-list"> + <li> + <BooleanSetting path="notificationVisibility.polls"> + {{ $t('settings.notification_visibility_in_column') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="notificationNative.polls"> + {{ $t('settings.notification_visibility_native_notifications') }} + </BooleanSetting> + </li> + </ul> + </li> + <li v-if="canReceiveReports"> + <h4> {{ $t('settings.notification_visibility_reports') }}</h4> + <ul class="setting-list"> + <li> + <BooleanSetting path="notificationVisibility.reports"> + {{ $t('settings.notification_visibility_in_column') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="notificationNative.reports"> + {{ $t('settings.notification_visibility_native_notifications') }} + </BooleanSetting> + </li> + </ul> </li> + </ul> + </li> + <li> + <BooleanSetting path="showExtraNotifications"> + {{ $t('settings.notification_show_extra') }} + </BooleanSetting> + </li> + <li> + <ul class="setting-list suboptions"> <li> - <BooleanSetting path="notificationVisibility.mentions"> - {{ $t('settings.notification_visibility_mentions') }} + <BooleanSetting + path="showChatsInExtraNotifications" + :disabled="!mergedConfig.showExtraNotifications" + > + {{ $t('settings.notification_extra_chats') }} </BooleanSetting> </li> <li> - <BooleanSetting path="notificationVisibility.moves"> - {{ $t('settings.notification_visibility_moves') }} + <BooleanSetting + path="showAnnouncementsInExtraNotifications" + :disabled="!mergedConfig.showExtraNotifications" + > + {{ $t('settings.notification_extra_announcements') }} </BooleanSetting> </li> <li> - <BooleanSetting path="notificationVisibility.emojiReactions"> - {{ $t('settings.notification_visibility_emoji_reactions') }} + <BooleanSetting + path="showFollowRequestsInExtraNotifications" + :disabled="!mergedConfig.showExtraNotifications" + > + {{ $t('settings.notification_extra_follow_requests') }} </BooleanSetting> </li> <li> - <BooleanSetting path="notificationVisibility.polls"> - {{ $t('settings.notification_visibility_polls') }} + <BooleanSetting + path="showExtraNotificationsTip" + :disabled="!mergedConfig.showExtraNotifications" + > + {{ $t('settings.notification_extra_tip') }} </BooleanSetting> </li> </ul> @@ -64,10 +234,26 @@ > {{ $t('settings.enable_web_push_notifications') }} </BooleanSetting> + <ul class="setting-list suboptions"> + <li> + <BooleanSetting + path="webPushAlwaysShowNotifications" + :disabled="!mergedConfig.webPushNotifications" + > + {{ $t('settings.enable_web_push_always_show') }} + </BooleanSetting> + <div :class="{ faint: !mergedConfig.webPushNotifications }"> + <small> + {{ $t('settings.enable_web_push_always_show_tip') }} + </small> + </div> + </li> + </ul> </li> <li> <BooleanSetting - path="serverSide_webPushHideContents" + source="profile" + path="webPushHideContents" expert="1" > {{ $t('settings.notification_setting_hide_notification_contents') }} diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js index eeacad48..dee17450 100644 --- a/src/components/settings_modal/tabs/profile_tab.js +++ b/src/components/settings_modal/tabs/profile_tab.js @@ -9,6 +9,7 @@ import suggestor from 'src/components/emoji_input/suggestor.js' import Autosuggest from 'src/components/autosuggest/autosuggest.vue' import Checkbox from 'src/components/checkbox/checkbox.vue' import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue' +import Select from 'src/components/select/select.vue' import BooleanSetting from '../helpers/boolean_setting.vue' import SharedComputedObject from '../helpers/shared_computed_object.js' import localeService from 'src/services/locale/locale.service.js' @@ -39,6 +40,7 @@ const ProfileTab = { showRole: this.$store.state.users.currentUser.show_role, role: this.$store.state.users.currentUser.role, bot: this.$store.state.users.currentUser.bot, + actorType: this.$store.state.users.currentUser.actor_type, pickAvatarBtnVisible: true, bannerUploading: false, backgroundUploading: false, @@ -57,7 +59,8 @@ const ProfileTab = { ProgressButton, Checkbox, BooleanSetting, - InterfaceLanguageSwitcher + InterfaceLanguageSwitcher, + Select }, computed: { user () { @@ -116,6 +119,12 @@ const ProfileTab = { bannerImgSrc () { const src = this.$store.state.users.currentUser.cover_photo return (!src) ? this.defaultBanner : src + }, + groupActorAvailable () { + return this.$store.state.instance.groupActorAvailable + }, + availableActorTypes () { + return this.groupActorAvailable ? ['Person', 'Service', 'Group'] : ['Person', 'Service'] } }, methods: { @@ -127,7 +136,7 @@ const ProfileTab = { /* eslint-disable camelcase */ display_name: this.newName, fields_attributes: this.newFields.filter(el => el != null), - bot: this.bot, + actor_type: this.actorType, show_role: this.showRole, birthday: this.newBirthday || '', show_birthday: this.showBirthday diff --git a/src/components/settings_modal/tabs/profile_tab.vue b/src/components/settings_modal/tabs/profile_tab.vue index 6a5b478a..de5219a7 100644 --- a/src/components/settings_modal/tabs/profile_tab.vue +++ b/src/components/settings_modal/tabs/profile_tab.vue @@ -109,10 +109,24 @@ </button> </div> <p> - <Checkbox v-model="bot"> - {{ $t('settings.bot') }} - </Checkbox> + <label> + {{ $t('settings.actor_type') }} + <Select v-model="actorType"> + <option + v-for="option in availableActorTypes" + :key="option" + :value="option" + > + {{ $t('settings.actor_type_' + option) }} + </option> + </Select> + </label> </p> + <div v-if="groupActorAvailable"> + <small> + {{ $t('settings.actor_type_description') }} + </small> + </div> <p> <interface-language-switcher :prompt-text="$t('settings.email_language')" @@ -254,37 +268,50 @@ <h2>{{ $t('settings.account_privacy') }}</h2> <ul class="setting-list"> <li> - <BooleanSetting path="serverSide_locked"> + <BooleanSetting + source="profile" + path="locked" + > {{ $t('settings.lock_account_description') }} </BooleanSetting> </li> <li> - <BooleanSetting path="serverSide_discoverable"> + <BooleanSetting + source="profile" + path="discoverable" + > {{ $t('settings.discoverable') }} </BooleanSetting> </li> <li> - <BooleanSetting path="serverSide_allowFollowingMove"> + <BooleanSetting + source="profile" + path="allowFollowingMove" + > {{ $t('settings.allow_following_move') }} </BooleanSetting> </li> <li> - <BooleanSetting path="serverSide_hideFavorites"> + <BooleanSetting + source="profile" + path="hideFavorites" + > {{ $t('settings.hide_favorites_description') }} </BooleanSetting> </li> <li> - <BooleanSetting path="serverSide_hideFollowers"> + <BooleanSetting + source="profile" + path="hideFollowers" + > {{ $t('settings.hide_followers_description') }} </BooleanSetting> - <ul - class="setting-list suboptions" - :class="[{disabled: !serverSide_hideFollowers}]" - > + <ul class="setting-list suboptions"> <li> <BooleanSetting - path="serverSide_hideFollowersCount" - :disabled="!serverSide_hideFollowers" + source="profile" + path="hideFollowersCount" + parent-path="hideFollowers" > {{ $t('settings.hide_followers_count_description') }} </BooleanSetting> @@ -292,17 +319,18 @@ </ul> </li> <li> - <BooleanSetting path="serverSide_hideFollows"> + <BooleanSetting + source="profile" + path="hideFollows" + > {{ $t('settings.hide_follows_description') }} </BooleanSetting> - <ul - class="setting-list suboptions" - :class="[{disabled: !serverSide_hideFollows}]" - > + <ul class="setting-list suboptions"> <li> <BooleanSetting - path="serverSide_hideFollowsCount" - :disabled="!serverSide_hideFollows" + source="profile" + path="hideFollowsCount" + parent-path="hideFollows" > {{ $t('settings.hide_follows_count_description') }} </BooleanSetting> diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.vue b/src/components/settings_modal/tabs/security_tab/security_tab.vue index 6e03bef4..d36d478f 100644 --- a/src/components/settings_modal/tabs/security_tab/security_tab.vue +++ b/src/components/settings_modal/tabs/security_tab/security_tab.vue @@ -143,8 +143,8 @@ /> </div> <div> - <i18n - path="settings.new_alias_target" + <i18n-t + keypath="settings.new_alias_target" tag="p" > <code @@ -152,7 +152,7 @@ > foo@example.org </code> - </i18n> + </i18n-t> <input v-model="addAliasTarget" > @@ -175,16 +175,16 @@ <h2>{{ $t('settings.move_account') }}</h2> <p>{{ $t('settings.move_account_notes') }}</p> <div> - <i18n - path="settings.move_account_target" + <i18n-t + keypath="settings.move_account_target" tag="p" > - <code - place="example" - > - foo@example.org - </code> - </i18n> + <template #example> + <code> + foo@example.org + </code> + </template> + </i18n-t> <input v-model="moveAccountTarget" > diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js index 4a739f73..58f8d44a 100644 --- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js +++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js @@ -755,7 +755,6 @@ export default { selected () { this.selectedTheme = Object.entries(this.availableStyles).find(([k, s]) => { if (Array.isArray(s)) { - console.log(s[0] === this.selected, this.selected) return s[0] === this.selected } else { return s.name === this.selected diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js index 27019577..81c5a612 100644 --- a/src/components/side_drawer/side_drawer.js +++ b/src/components/side_drawer/side_drawer.js @@ -115,7 +115,10 @@ const SideDrawer = { GestureService.updateSwipe(e, this.closeGesture) }, openSettingsModal () { - this.$store.dispatch('openSettingsModal') + this.$store.dispatch('openSettingsModal', 'user') + }, + openAdminModal () { + this.$store.dispatch('openSettingsModal', 'admin') } } } diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue index 994ac953..09588767 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -180,16 +180,16 @@ v-if="currentUser && currentUser.role === 'admin'" @click="toggleDrawer" > - <a - href="/pleroma/admin/#/login-pleroma" - target="_blank" + <button + class="button-unstyled -link -fullwidth" + @click.stop="openAdminModal" > <FAIcon fixed-width class="fa-scale-110 fa-old-padding" icon="tachometer-alt" /> {{ $t("nav.administration") }} - </a> + </button> </li> <li v-if="currentUser && supportsAnnouncements" diff --git a/src/components/status/status.js b/src/components/status/status.js index 9a9bca7a..8f22b708 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -39,7 +39,8 @@ import { faThumbtack, faChevronUp, faChevronDown, - faAngleDoubleRight + faAngleDoubleRight, + faPlay } from '@fortawesome/free-solid-svg-icons' library.add( @@ -59,7 +60,8 @@ library.add( faThumbtack, faChevronUp, faChevronDown, - faAngleDoubleRight + faAngleDoubleRight, + faPlay ) const camelCase = name => name.charAt(0).toUpperCase() + name.slice(1) @@ -133,6 +135,7 @@ const Status = { 'showPinned', 'inProfile', 'profileUserId', + 'inQuote', 'simpleTree', 'controlledThreadDisplayStatus', @@ -151,6 +154,7 @@ const Status = { 'controlledSetMediaPlaying', 'dive' ], + emits: ['interacted'], data () { return { uncontrolledReplying: false, @@ -159,7 +163,8 @@ const Status = { uncontrolledMediaPlaying: [], suspendable: true, error: null, - headTailLinks: null + headTailLinks: null, + displayQuote: !this.inQuote } }, computed: { @@ -227,17 +232,11 @@ const Status = { muteWordHits () { return muteWordHits(this.status, this.muteWords) }, - rtBotStatus () { - return this.statusoid.user.bot - }, botStatus () { - return this.status.user.bot - }, - botIndicator () { - return this.botStatus && !this.hideBotIndication + return this.status.user.actor_type === 'Service' }, - rtBotIndicator () { - return this.rtBotStatus && !this.hideBotIndication + showActorTypeIndicator () { + return !this.hideBotIndication }, mentionsLine () { if (!this.headTailLinks) return [] @@ -401,6 +400,24 @@ const Status = { }, editingAvailable () { return this.$store.state.instance.editingAvailable + }, + hasVisibleQuote () { + return this.status.quote_url && this.status.quote_visible + }, + hasInvisibleQuote () { + return this.status.quote_url && !this.status.quote_visible + }, + quotedStatus () { + return this.status.quote_id ? this.$store.state.statuses.allStatusesObject[this.status.quote_id] : undefined + }, + shouldDisplayQuote () { + return this.quotedStatus && this.displayQuote + }, + scrobblePresent () { + return !this.mergedConfig.hideScrobbles && this.status.user.latestScrobble && this.status.user.latestScrobble.artist + }, + scrobble () { + return this.status.user.latestScrobble } }, methods: { @@ -420,9 +437,11 @@ const Status = { this.error = error }, clearError () { + this.$emit('interacted') this.error = undefined }, toggleReplying () { + this.$emit('interacted') controlledOrUncontrolledToggle(this, 'replying') }, gotoOriginal (id) { @@ -469,6 +488,18 @@ const Status = { window.scrollBy(0, rect.bottom - window.innerHeight + 50) } } + }, + toggleDisplayQuote () { + if (this.shouldDisplayQuote) { + this.displayQuote = false + } else if (!this.quotedStatus) { + this.$store.dispatch('fetchStatus', this.status.quote_id) + .then(() => { + this.displayQuote = true + }) + } else { + this.displayQuote = true + } } }, watch: { diff --git a/src/components/status/status.scss b/src/components/status/status.scss index 44812867..760c6ac1 100644 --- a/src/components/status/status.scss +++ b/src/components/status/status.scss @@ -422,4 +422,22 @@ } } } + + .quoted-status { + margin-top: 0.5em; + border: 1px solid var(--border, $fallback--border); + border-radius: var(--attachmentRadius, $fallback--attachmentRadius); + + &.-unavailable-prompt { + padding: 0.5em; + } + } + + .display-quoted-status-button { + margin: 0.5em; + + &-icon { + color: inherit; + } + } } diff --git a/src/components/status/status.vue b/src/components/status/status.vue index 35b15362..1c91c36c 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -79,7 +79,7 @@ <UserAvatar v-if="retweet" class="left-side repeater-avatar" - :bot="rtBotIndicator" + :show-actor-type-indicator="showActorTypeIndicator" :better-shadow="betterShadow" :user="statusoid.user" /> @@ -133,7 +133,7 @@ > <UserAvatar class="post-avatar" - :bot="botIndicator" + :show-actor-type-indicator="showActorTypeIndicator" :compact="compact" :better-shadow="betterShadow" :user="status.user" @@ -250,6 +250,47 @@ </span> </div> <div + v-if="scrobblePresent" + class="status-rich-presence" + > + <a + v-if="scrobble.externalLink" + :href="scrobble.externalLink" + target="_blank" + > + {{ scrobble.artist }} — {{ scrobble.title }} + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="play" + /> + <span class="status-rich-presence-time"> + <Timeago + template-key="time.in_past" + :time="scrobble.created_at" + :auto-update="60" + /> + </span> + </a> + <span v-if="!scrobble.externalLink"> + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="music" + /> + {{ scrobble.artist }} — {{ scrobble.title }} + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="play" + /> + <span class="status-rich-presence-time"> + <Timeago + template-key="time.in_past" + :time="scrobble.created_at" + :auto-update="60" + /> + </span> + </span> + </div> + <div v-if="isReply || hasMentionsLine" class="heading-reply-row" > @@ -364,6 +405,45 @@ @parseReady="setHeadTailLinks" /> + <article + v-if="hasVisibleQuote" + class="quoted-status" + > + <button + class="button-unstyled -link display-quoted-status-button" + :aria-expanded="shouldDisplayQuote" + @click="toggleDisplayQuote" + > + {{ shouldDisplayQuote ? $t('status.hide_quote') : $t('status.display_quote') }} + <FAIcon + class="display-quoted-status-button-icon" + :icon="shouldDisplayQuote ? 'chevron-up' : 'chevron-down'" + /> + </button> + <Status + v-if="shouldDisplayQuote" + :statusoid="quotedStatus" + :in-quote="true" + /> + </article> + <p + v-else-if="hasInvisibleQuote" + class="quoted-status -unavailable-prompt" + > + <i18n-t keypath="status.invisible_quote"> + <template #link> + <bdi> + <a + :href="status.quote_url" + target="_blank" + > + {{ status.quote_url }} + </a> + </bdi> + </template> + </i18n-t> + </p> + <div v-if="inConversation && !isPreview && replies && replies.length" class="replies" @@ -451,14 +531,17 @@ :visibility="status.visibility" :logged-in="loggedIn" :status="status" + @click="$emit('interacted')" /> <favorite-button :logged-in="loggedIn" :status="status" + @click="$emit('interacted')" /> <ReactButton v-if="loggedIn" :status="status" + @click="$emit('interacted')" /> <extra-buttons :status="status" @@ -476,7 +559,7 @@ <UserAvatar class="post-avatar" :compact="compact" - :bot="botIndicator" + :show-actor-type-indicator="showActorTypeIndicator" /> </div> <div class="right-side"> diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js index 89f0aa51..8d8a91dc 100644 --- a/src/components/status_content/status_content.js +++ b/src/components/status_content/status_content.js @@ -73,6 +73,10 @@ const StatusContent = { }, computed: { ...controlledOrUncontrolledGetters(['showingTall', 'expandingSubject', 'showingLongSubject']), + statusCard () { + if (!this.status.card) return null + return this.status.card.url === this.status.quote_url ? null : this.status.card + }, hideAttachments () { return (this.mergedConfig.hideAttachments && !this.inConversation) || (this.mergedConfig.hideAttachmentsInConv && this.inConversation) diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue index c0e5c0b9..e977d489 100644 --- a/src/components/status_content/status_content.vue +++ b/src/components/status_content/status_content.vue @@ -43,7 +43,7 @@ /> <div - v-if="status.card && !noHeading && !compact" + v-if="statusCard && !noHeading && !compact" class="link-preview media-body" > <link-preview diff --git a/src/components/tab_switcher/tab_switcher.jsx b/src/components/tab_switcher/tab_switcher.jsx index a7ef8560..b444da43 100644 --- a/src/components/tab_switcher/tab_switcher.jsx +++ b/src/components/tab_switcher/tab_switcher.jsx @@ -60,13 +60,7 @@ export default { const isWanted = slot => slot.props && slot.props['data-tab-name'] === tabName return this.$slots.default().findIndex(isWanted) === this.activeIndex } - }, - settingsModalVisible () { - return this.settingsModalState === 'visible' - }, - ...mapState({ - settingsModalState: state => state.interface.settingsModalState - }) + } }, beforeUpdate () { const currentSlot = this.slots()[this.active] diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index b7414610..1050b87a 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -160,6 +160,9 @@ const Timeline = { if (this.timeline.flushMarker !== 0) { this.$store.commit('clearTimeline', { timeline: this.timelineName, excludeUserId: true }) this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 }) + if (this.timelineName === 'user') { + this.$store.dispatch('fetchPinnedStatuses', this.userId) + } this.fetchOlderStatuses() } else { this.blockClicksTemporarily() diff --git a/src/components/user_avatar/user_avatar.js b/src/components/user_avatar/user_avatar.js index 33d9a258..ffd81f87 100644 --- a/src/components/user_avatar/user_avatar.js +++ b/src/components/user_avatar/user_avatar.js @@ -3,11 +3,13 @@ import StillImage from '../still-image/still-image.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { - faRobot + faRobot, + faPeopleGroup } from '@fortawesome/free-solid-svg-icons' library.add( - faRobot + faRobot, + faPeopleGroup ) const UserAvatar = { @@ -15,7 +17,7 @@ const UserAvatar = { 'user', 'betterShadow', 'compact', - 'bot' + 'showActorTypeIndicator' ], data () { return { diff --git a/src/components/user_avatar/user_avatar.vue b/src/components/user_avatar/user_avatar.vue index 91c17611..3cbccec3 100644 --- a/src/components/user_avatar/user_avatar.vue +++ b/src/components/user_avatar/user_avatar.vue @@ -18,9 +18,14 @@ :class="{ '-compact': compact }" /> <FAIcon - v-if="bot" + v-if="showActorTypeIndicator && user?.actor_type === 'Service'" icon="robot" - class="bot-indicator" + class="actor-type-indicator" + /> + <FAIcon + v-if="showActorTypeIndicator && user?.actor_type === 'Group'" + icon="people-group" + class="actor-type-indicator" /> </span> </template> @@ -79,7 +84,7 @@ height: 100%; } - .bot-indicator { + .actor-type-indicator { position: absolute; bottom: 0; right: 0; diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue index 2de14063..2c76a220 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -124,11 +124,17 @@ {{ $t(`general.role.${visibleRole}`) }} </span> <span - v-if="user.bot" + v-if="user.actor_type === 'Service'" class="alert user-role" > {{ $t('user_card.bot') }} </span> + <span + v-if="user.actor_type === 'Group'" + class="alert user-role" + > + {{ $t('user_card.group') }} + </span> </template> <span v-if="user.locked"> <FAIcon diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index acb612ed..751bfd5a 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -80,6 +80,9 @@ const UserProfile = { followersTabVisible () { return this.isUs || !this.user.hide_followers }, + favoritesTabVisible () { + return this.isUs || !this.user.hide_favorites + }, 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' }) @@ -103,6 +106,8 @@ const UserProfile = { startFetchingTimeline('user', userId) startFetchingTimeline('media', userId) if (this.isUs) { + startFetchingTimeline('favorites') + } else if (!this.user.hide_favorites) { startFetchingTimeline('favorites', userId) } // Fetch all pinned statuses immediately diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index c63a303c..d0618dbb 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -109,7 +109,7 @@ :footer-slipgate="footerRef" /> <Timeline - v-if="isUs" + v-if="favoritesTabVisible" key="favorites" :label="$t('user_card.favorites')" :disabled="!favorites.visibleStatuses.length" @@ -117,6 +117,7 @@ :title="$t('user_card.favorites')" timeline-name="favorites" :timeline="favorites" + :user-id="userId" :in-profile="true" :footer-slipgate="footerRef" /> diff --git a/src/components/video_attachment/video_attachment.vue b/src/components/video_attachment/video_attachment.vue index 8a3ea1e3..df763143 100644 --- a/src/components/video_attachment/video_attachment.vue +++ b/src/components/video_attachment/video_attachment.vue @@ -2,7 +2,7 @@ <video class="video" preload="metadata" - :src="attachment.url" + :src="attachment.url + '#t=0.00000000000001'" :loop="loopVideo" :controls="controls" :alt="attachment.description" |
