diff options
Diffstat (limited to 'src/components')
89 files changed, 3461 insertions, 2384 deletions
diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js index 5d7ecf7e..0826c275 100644 --- a/src/components/account_actions/account_actions.js +++ b/src/components/account_actions/account_actions.js @@ -3,7 +3,7 @@ import Popover from '../popover/popover.vue' const AccountActions = { props: [ - 'user' + 'user', 'relationship' ], data () { return { } diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue index 483783cf..029e7096 100644 --- a/src/components/account_actions/account_actions.vue +++ b/src/components/account_actions/account_actions.vue @@ -3,22 +3,23 @@ <Popover trigger="click" placement="bottom" + :bound-to="{ x: 'container' }" > <div slot="content" class="account-tools-popover" > <div class="dropdown-menu"> - <template v-if="user.following"> + <template v-if="relationship.following"> <button - v-if="user.showing_reblogs" + v-if="relationship.showing_reblogs" class="btn btn-default dropdown-item" @click="hideRepeats" > {{ $t('user_card.hide_repeats') }} </button> <button - v-if="!user.showing_reblogs" + v-if="!relationship.showing_reblogs" class="btn btn-default dropdown-item" @click="showRepeats" > @@ -30,7 +31,7 @@ /> </template> <button - v-if="user.statusnet_blocking" + v-if="relationship.blocking" class="btn btn-default btn-block dropdown-item" @click="unblockUser" > diff --git a/src/components/async_component_error/async_component_error.vue b/src/components/async_component_error/async_component_error.vue new file mode 100644 index 00000000..b68b98f9 --- /dev/null +++ b/src/components/async_component_error/async_component_error.vue @@ -0,0 +1,41 @@ +<template> + <div class="async-component-error"> + <div> + <h4> + {{ $t('general.generic_error') }} + </h4> + <p> + {{ $t('general.error_retry') }} + </p> + <button + class="btn" + @click="retry" + > + {{ $t('general.retry') }} + </button> + </div> + </div> +</template> + +<script> +export default { + methods: { + retry () { + this.$emit('resetAsyncComponent') + } + } +} +</script> + +<style lang="scss"> +.async-component-error { + display: flex; + height: 100%; + align-items: center; + justify-content: center; + .btn { + margin: .5em; + padding: .5em 2em; + } +} +</style> diff --git a/src/components/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue index 8a02174e..9e410610 100644 --- a/src/components/basic_user_card/basic_user_card.vue +++ b/src/components/basic_user_card/basic_user_card.vue @@ -12,7 +12,7 @@ class="basic-user-card-expanded-content" > <UserCard - :user="user" + :user-id="user.id" :rounded="true" :bordered="true" /> diff --git a/src/components/block_card/block_card.js b/src/components/block_card/block_card.js index c459ff1b..0bf4e37b 100644 --- a/src/components/block_card/block_card.js +++ b/src/components/block_card/block_card.js @@ -11,8 +11,11 @@ const BlockCard = { user () { return this.$store.getters.findUser(this.userId) }, + relationship () { + return this.$store.getters.relationship(this.userId) + }, blocked () { - return this.user.statusnet_blocking + return this.relationship.blocking } }, components: { diff --git a/src/components/domain_mute_card/domain_mute_card.js b/src/components/domain_mute_card/domain_mute_card.js index c8e838ba..f234dcb0 100644 --- a/src/components/domain_mute_card/domain_mute_card.js +++ b/src/components/domain_mute_card/domain_mute_card.js @@ -5,9 +5,20 @@ const DomainMuteCard = { components: { ProgressButton }, + computed: { + user () { + return this.$store.state.users.currentUser + }, + muted () { + return this.user.domainMutes.includes(this.domain) + } + }, methods: { unmuteDomain () { return this.$store.dispatch('unmuteDomain', this.domain) + }, + muteDomain () { + return this.$store.dispatch('muteDomain', this.domain) } } } diff --git a/src/components/domain_mute_card/domain_mute_card.vue b/src/components/domain_mute_card/domain_mute_card.vue index 567d81c5..97aee243 100644 --- a/src/components/domain_mute_card/domain_mute_card.vue +++ b/src/components/domain_mute_card/domain_mute_card.vue @@ -4,6 +4,7 @@ {{ domain }} </div> <ProgressButton + v-if="muted" :click="unmuteDomain" class="btn btn-default" > @@ -12,6 +13,16 @@ {{ $t('domain_mute_card.unmute_progress') }} </template> </ProgressButton> + <ProgressButton + v-else + :click="muteDomain" + class="btn btn-default" + > + {{ $t('domain_mute_card.mute') }} + <template slot="progress"> + {{ $t('domain_mute_card.mute_progress') }} + </template> + </ProgressButton> </div> </template> @@ -34,5 +45,9 @@ button { width: 10em; } + + .autosuggest-results & { + padding-left: 1em; + } } </style> diff --git a/src/components/emoji_input/suggestor.js b/src/components/emoji_input/suggestor.js index aec5c39d..15a71eff 100644 --- a/src/components/emoji_input/suggestor.js +++ b/src/components/emoji_input/suggestor.js @@ -29,17 +29,29 @@ export default data => input => { export const suggestEmoji = emojis => input => { const noPrefix = input.toLowerCase().substr(1) return emojis - .filter(({ displayText }) => displayText.toLowerCase().startsWith(noPrefix)) + .filter(({ displayText }) => displayText.toLowerCase().match(noPrefix)) .sort((a, b) => { let aScore = 0 let bScore = 0 - // Make custom emojis a priority - aScore += a.imageUrl ? 10 : 0 - bScore += b.imageUrl ? 10 : 0 + // An exact match always wins + aScore += a.displayText.toLowerCase() === noPrefix ? 200 : 0 + bScore += b.displayText.toLowerCase() === noPrefix ? 200 : 0 - // Sort alphabetically - const alphabetically = a.displayText > b.displayText ? 1 : -1 + // Prioritize custom emoji a lot + aScore += a.imageUrl ? 100 : 0 + bScore += b.imageUrl ? 100 : 0 + + // Prioritize prefix matches somewhat + aScore += a.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0 + bScore += b.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0 + + // Sort by length + aScore -= a.displayText.length + bScore -= b.displayText.length + + // Break ties alphabetically + const alphabetically = a.displayText > b.displayText ? 0.5 : -0.5 return bScore - aScore + alphabetically }) diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js index 37485383..e4b19d01 100644 --- a/src/components/extra_buttons/extra_buttons.js +++ b/src/components/extra_buttons/extra_buttons.js @@ -29,6 +29,11 @@ const ExtraButtons = { this.$store.dispatch('unmuteConversation', this.status.id) .then(() => this.$emit('onSuccess')) .catch(err => this.$emit('onError', err.error.error)) + }, + copyLink () { + navigator.clipboard.writeText(this.statusLink) + .then(() => this.$emit('onSuccess')) + .catch(err => this.$emit('onError', err.error.error)) } }, computed: { @@ -46,6 +51,9 @@ const ExtraButtons = { }, canMute () { return !!this.currentUser + }, + statusLink () { + return `${this.$store.state.instance.server}${this.$router.resolve({ name: 'conversation', params: { id: this.status.id } }).href}` } } } diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue index 3a7f1283..68db6fd8 100644 --- a/src/components/extra_buttons/extra_buttons.vue +++ b/src/components/extra_buttons/extra_buttons.vue @@ -1,11 +1,14 @@ <template> <Popover - v-if="canDelete || canMute || canPin" trigger="click" placement="top" class="extra-button-popover" + :bound-to="{ x: 'container' }" > - <div slot="content"> + <div + slot="content" + slot-scope="{close}" + > <div class="dropdown-menu"> <button v-if="canMute && !status.thread_muted" @@ -23,28 +26,35 @@ </button> <button v-if="!status.pinned && canPin" - v-close-popover class="dropdown-item dropdown-item-icon" @click.prevent="pinStatus" + @click="close" > <i class="icon-pin" /><span>{{ $t("status.pin") }}</span> </button> <button v-if="status.pinned && canPin" - v-close-popover class="dropdown-item dropdown-item-icon" @click.prevent="unpinStatus" + @click="close" > <i class="icon-pin" /><span>{{ $t("status.unpin") }}</span> </button> <button v-if="canDelete" - v-close-popover class="dropdown-item dropdown-item-icon" @click.prevent="deleteStatus" + @click="close" > <i class="icon-cancel" /><span>{{ $t("status.delete") }}</span> </button> + <button + class="dropdown-item dropdown-item-icon" + @click.prevent="copyLink" + @click="close" + > + <i class="icon-share" /><span>{{ $t("status.copy_link") }}</span> + </button> </div> </div> <i diff --git a/src/components/follow_button/follow_button.js b/src/components/follow_button/follow_button.js index 12da2645..95e7cb6b 100644 --- a/src/components/follow_button/follow_button.js +++ b/src/components/follow_button/follow_button.js @@ -1,6 +1,6 @@ import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate' export default { - props: ['user', 'labelFollowing', 'buttonClass'], + props: ['relationship', 'labelFollowing', 'buttonClass'], data () { return { inProgress: false @@ -8,12 +8,12 @@ export default { }, computed: { isPressed () { - return this.inProgress || this.user.following + return this.inProgress || this.relationship.following }, title () { - if (this.inProgress || this.user.following) { + if (this.inProgress || this.relationship.following) { return this.$t('user_card.follow_unfollow') - } else if (this.user.requested) { + } else if (this.relationship.requested) { return this.$t('user_card.follow_again') } else { return this.$t('user_card.follow') @@ -22,9 +22,9 @@ export default { label () { if (this.inProgress) { return this.$t('user_card.follow_progress') - } else if (this.user.following) { + } else if (this.relationship.following) { return this.labelFollowing || this.$t('user_card.following') - } else if (this.user.requested) { + } else if (this.relationship.requested) { return this.$t('user_card.follow_sent') } else { return this.$t('user_card.follow') @@ -33,20 +33,20 @@ export default { }, methods: { onClick () { - this.user.following ? this.unfollow() : this.follow() + this.relationship.following ? this.unfollow() : this.follow() }, follow () { this.inProgress = true - requestFollow(this.user, this.$store).then(() => { + requestFollow(this.relationship.id, this.$store).then(() => { this.inProgress = false }) }, unfollow () { const store = this.$store this.inProgress = true - requestUnfollow(this.user, store).then(() => { + requestUnfollow(this.relationship.id, store).then(() => { this.inProgress = false - store.commit('removeStatus', { timeline: 'friends', userId: this.user.id }) + store.commit('removeStatus', { timeline: 'friends', userId: this.relationship.id }) }) } } diff --git a/src/components/follow_card/follow_card.js b/src/components/follow_card/follow_card.js index aefd609e..6dcb6d47 100644 --- a/src/components/follow_card/follow_card.js +++ b/src/components/follow_card/follow_card.js @@ -18,6 +18,9 @@ const FollowCard = { }, loggedIn () { return this.$store.state.users.currentUser + }, + relationship () { + return this.$store.getters.relationship(this.user.id) } } } diff --git a/src/components/follow_card/follow_card.vue b/src/components/follow_card/follow_card.vue index 81e6e6dc..b503783f 100644 --- a/src/components/follow_card/follow_card.vue +++ b/src/components/follow_card/follow_card.vue @@ -2,24 +2,24 @@ <basic-user-card :user="user"> <div class="follow-card-content-container"> <span - v-if="!noFollowsYou && user.follows_you" + v-if="isMe || (!noFollowsYou && relationship.followed_by)" class="faint" > {{ isMe ? $t('user_card.its_you') : $t('user_card.follows_you') }} </span> <template v-if="!loggedIn"> <div - v-if="!user.following" + v-if="!relationship.following" class="follow-card-follow-button" > <RemoteFollow :user="user" /> </div> </template> - <template v-else> + <template v-else-if="!isMe"> <FollowButton - :user="user" - class="follow-card-follow-button" + :relationship="relationship" :label-following="$t('user_card.follow_unfollow')" + class="follow-card-follow-button" /> </template> </div> diff --git a/src/components/follow_request_card/follow_request_card.js b/src/components/follow_request_card/follow_request_card.js index a8931787..cbd75311 100644 --- a/src/components/follow_request_card/follow_request_card.js +++ b/src/components/follow_request_card/follow_request_card.js @@ -1,4 +1,5 @@ import BasicUserCard from '../basic_user_card/basic_user_card.vue' +import { notificationsFromStore } from '../../services/notification_utils/notification_utils.js' const FollowRequestCard = { props: ['user'], @@ -6,13 +7,32 @@ const FollowRequestCard = { BasicUserCard }, methods: { + findFollowRequestNotificationId () { + const notif = notificationsFromStore(this.$store).find( + (notif) => notif.from_profile.id === this.user.id && notif.type === 'follow_request' + ) + return notif && notif.id + }, approveUser () { this.$store.state.api.backendInteractor.approveUser({ id: this.user.id }) this.$store.dispatch('removeFollowRequest', this.user) + + const notifId = this.findFollowRequestNotificationId() + this.$store.dispatch('markSingleNotificationAsSeen', { id: notifId }) + this.$store.dispatch('updateNotification', { + id: notifId, + updater: notification => { + notification.type = 'follow' + } + }) }, denyUser () { + const notifId = this.findFollowRequestNotificationId() this.$store.state.api.backendInteractor.denyUser({ id: this.user.id }) - this.$store.dispatch('removeFollowRequest', this.user) + .then(() => { + this.$store.dispatch('dismissNotificationLocal', { id: notifId }) + this.$store.dispatch('removeFollowRequest', this.user) + }) } } } diff --git a/src/components/gallery/gallery.vue b/src/components/gallery/gallery.vue index 7abc2161..1ffa7b3c 100644 --- a/src/components/gallery/gallery.vue +++ b/src/components/gallery/gallery.vue @@ -78,6 +78,7 @@ video, canvas { object-fit: contain; + height: 100%; } } diff --git a/src/components/interface_language_switcher/interface_language_switcher.vue b/src/components/interface_language_switcher/interface_language_switcher.vue index f5ace0cc..dd6800a3 100644 --- a/src/components/interface_language_switcher/interface_language_switcher.vue +++ b/src/components/interface_language_switcher/interface_language_switcher.vue @@ -32,7 +32,7 @@ import _ from 'lodash' export default { computed: { languageCodes () { - return Object.keys(languagesObject) + return languagesObject.languages }, languageNames () { @@ -43,7 +43,6 @@ export default { get: function () { return this.$store.getters.mergedConfig.interfaceLanguage }, set: function (val) { this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val }) - this.$i18n.locale = val } } }, diff --git a/src/components/media_modal/media_modal.js b/src/components/media_modal/media_modal.js index abb18c7d..24764e80 100644 --- a/src/components/media_modal/media_modal.js +++ b/src/components/media_modal/media_modal.js @@ -84,10 +84,12 @@ const MediaModal = { } }, mounted () { + window.addEventListener('popstate', this.hide) document.addEventListener('keyup', this.handleKeyupEvent) document.addEventListener('keydown', this.handleKeydownEvent) }, destroyed () { + window.removeEventListener('popstate', this.hide) document.removeEventListener('keyup', this.handleKeyupEvent) document.removeEventListener('keydown', this.handleKeydownEvent) } diff --git a/src/components/media_upload/media_upload.js b/src/components/media_upload/media_upload.js index f457d022..fbb2d03d 100644 --- a/src/components/media_upload/media_upload.js +++ b/src/components/media_upload/media_upload.js @@ -5,10 +5,15 @@ import fileSizeFormatService from '../../services/file_size_format/file_size_for const mediaUpload = { data () { return { - uploading: false, + uploadCount: 0, uploadReady: true } }, + computed: { + uploading () { + return this.uploadCount > 0 + } + }, methods: { uploadFile (file) { const self = this @@ -23,29 +28,21 @@ const mediaUpload = { formData.append('file', file) self.$emit('uploading') - self.uploading = true + self.uploadCount++ statusPosterService.uploadMedia({ store, formData }) .then((fileData) => { self.$emit('uploaded', fileData) - self.uploading = false + self.decreaseUploadCount() }, (error) => { // eslint-disable-line handle-callback-err self.$emit('upload-failed', 'default') - self.uploading = false + self.decreaseUploadCount() }) }, - fileDrop (e) { - if (e.dataTransfer.files.length > 0) { - e.preventDefault() // allow dropping text like before - this.uploadFile(e.dataTransfer.files[0]) - } - }, - fileDrag (e) { - let types = e.dataTransfer.types - if (types.contains('Files')) { - e.dataTransfer.dropEffect = 'copy' - } else { - e.dataTransfer.dropEffect = 'none' + decreaseUploadCount () { + this.uploadCount-- + if (this.uploadCount === 0) { + this.$emit('all-uploaded') } }, clearFile () { @@ -54,11 +51,13 @@ const mediaUpload = { this.uploadReady = true }) }, - change ({ target }) { - for (var i = 0; i < target.files.length; i++) { - let file = target.files[i] + multiUpload (files) { + for (const file of files) { this.uploadFile(file) } + }, + change ({ target }) { + this.multiUpload(target.files) } }, props: [ @@ -67,7 +66,7 @@ const mediaUpload = { watch: { 'dropFiles': function (fileInfos) { if (!this.uploading) { - this.uploadFile(fileInfos[0]) + this.multiUpload(fileInfos) } } } diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue index 0fc305ac..5e31730b 100644 --- a/src/components/media_upload/media_upload.vue +++ b/src/components/media_upload/media_upload.vue @@ -1,10 +1,5 @@ <template> - <div - class="media-upload" - @drop.prevent - @dragover.prevent="fileDrag" - @drop="fileDrop" - > + <div class="media-upload"> <label class="label" :title="$t('tool_tip.media_upload')" diff --git a/src/components/modal/modal.vue b/src/components/modal/modal.vue index cee24241..2b58913f 100644 --- a/src/components/modal/modal.vue +++ b/src/components/modal/modal.vue @@ -1,8 +1,9 @@ <template> <div v-show="isOpen" - v-body-scroll-lock="isOpen" + v-body-scroll-lock="isOpen && !noBackground" class="modal-view" + :class="classes" @click.self="$emit('backdropClicked')" > <slot /> @@ -15,6 +16,18 @@ export default { isOpen: { type: Boolean, default: true + }, + noBackground: { + type: Boolean, + default: false + } + }, + computed: { + classes () { + return { + 'modal-background': !this.noBackground, + 'open': this.isOpen + } } } } @@ -32,12 +45,22 @@ export default { justify-content: center; align-items: center; overflow: auto; + pointer-events: none; animation-duration: 0.2s; - background-color: rgba(0, 0, 0, 0.5); animation-name: modal-background-fadein; + opacity: 0; + + > * { + pointer-events: initial; + } + + &.modal-background { + pointer-events: initial; + background-color: rgba(0, 0, 0, 0.5); + } - body:not(.scroll-locked) & { - opacity: 0; + &.open { + opacity: 1; } } diff --git a/src/components/mute_card/mute_card.js b/src/components/mute_card/mute_card.js index 65c9cfb5..cbec0e9b 100644 --- a/src/components/mute_card/mute_card.js +++ b/src/components/mute_card/mute_card.js @@ -11,8 +11,11 @@ const MuteCard = { user () { return this.$store.getters.findUser(this.userId) }, + relationship () { + return this.$store.getters.relationship(this.userId) + }, muted () { - return this.user.muted + return this.relationship.muting } }, components: { @@ -21,13 +24,13 @@ const MuteCard = { methods: { unmuteUser () { this.progress = true - this.$store.dispatch('unmuteUser', this.user.id).then(() => { + this.$store.dispatch('unmuteUser', this.userId).then(() => { this.progress = false }) }, muteUser () { this.progress = true - this.$store.dispatch('muteUser', this.user.id).then(() => { + this.$store.dispatch('muteUser', this.userId).then(() => { this.progress = false }) } diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js index e7bd769e..5aa40e98 100644 --- a/src/components/notification/notification.js +++ b/src/components/notification/notification.js @@ -1,7 +1,9 @@ +import StatusContent from '../status_content/status_content.vue' import Status from '../status/status.vue' import UserAvatar from '../user_avatar/user_avatar.vue' import UserCard from '../user_card/user_card.vue' import Timeago from '../timeago/timeago.vue' +import { isStatusNotification } from '../../services/notification_utils/notification_utils.js' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' @@ -15,10 +17,11 @@ const Notification = { }, props: [ 'notification' ], components: { - Status, + StatusContent, UserAvatar, UserCard, - Timeago + Timeago, + Status }, methods: { toggleUserExpanded () { @@ -32,6 +35,24 @@ const Notification = { }, toggleMute () { this.unmuted = !this.unmuted + }, + approveUser () { + this.$store.state.api.backendInteractor.approveUser({ id: this.user.id }) + this.$store.dispatch('removeFollowRequest', this.user) + this.$store.dispatch('markSingleNotificationAsSeen', { id: this.notification.id }) + this.$store.dispatch('updateNotification', { + id: this.notification.id, + updater: notification => { + notification.type = 'follow' + } + }) + }, + denyUser () { + this.$store.state.api.backendInteractor.denyUser({ id: this.user.id }) + .then(() => { + this.$store.dispatch('dismissNotificationLocal', { id: this.notification.id }) + this.$store.dispatch('removeFollowRequest', this.user) + }) } }, computed: { @@ -56,7 +77,10 @@ const Notification = { return this.generateUserProfileLink(this.targetUser) }, needMute () { - return this.user.muted + return this.$store.getters.relationship(this.user.id).muting + }, + isStatusNotification () { + return isStatusNotification(this.notification.type) } } } diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index 411c0271..044ac871 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -40,14 +40,14 @@ <div class="notification-right"> <UserCard v-if="userExpanded" - :user="getUser(notification)" + :user-id="getUser(notification).id" :rounded="true" :bordered="true" /> <span class="notification-details"> <div class="name-and-action"> <!-- eslint-disable vue/no-v-html --> - <span + <bdi v-if="!!notification.from_profile.name_html" class="username" :title="'@'+notification.from_profile.screen_name" @@ -74,6 +74,10 @@ <i class="fa icon-user-plus lit" /> <small>{{ $t('notifications.followed_you') }}</small> </span> + <span v-if="notification.type === 'follow_request'"> + <i class="fa icon-user lit" /> + <small>{{ $t('notifications.follow_request') }}</small> + </span> <span v-if="notification.type === 'move'"> <i class="fa icon-arrow-curved lit" /> <small>{{ $t('notifications.migrated_to') }}</small> @@ -87,30 +91,30 @@ </span> </div> <div - v-if="notification.type === 'follow' || notification.type === 'move'" + v-if="isStatusNotification" class="timeago" > - <span class="faint"> + <router-link + v-if="notification.status" + :to="{ name: 'conversation', params: { id: notification.status.id } }" + class="faint-link" + > <Timeago :time="notification.created_at" :auto-update="240" /> - </span> + </router-link> </div> <div v-else class="timeago" > - <router-link - v-if="notification.status" - :to="{ name: 'conversation', params: { id: notification.status.id } }" - class="faint-link" - > + <span class="faint"> <Timeago :time="notification.created_at" :auto-update="240" /> - </router-link> + </span> </div> <a v-if="needMute" @@ -119,12 +123,30 @@ ><i class="button-icon icon-eye-off" /></a> </span> <div - v-if="notification.type === 'follow'" + v-if="notification.type === 'follow' || notification.type === 'follow_request'" class="follow-text" > - <router-link :to="userProfileLink"> + <router-link + :to="userProfileLink" + class="follow-name" + > @{{ notification.from_profile.screen_name }} </router-link> + <div + v-if="notification.type === 'follow_request'" + style="white-space: nowrap;" + > + <i + class="icon-ok button-icon follow-request-accept" + :title="$t('tool_tip.accept_follow_request')" + @click="approveUser()" + /> + <i + class="icon-cancel button-icon follow-request-reject" + :title="$t('tool_tip.reject_follow_request')" + @click="denyUser()" + /> + </div> </div> <div v-else-if="notification.type === 'move'" @@ -135,11 +157,9 @@ </router-link> </div> <template v-else> - <status + <status-content class="faint" - :compact="true" - :statusoid="notification.action" - :no-heading="true" + :status="notification.action" /> </template> </div> diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss index f5b13322..20797cf9 100644 --- a/src/components/notifications/notifications.scss +++ b/src/components/notifications/notifications.scss @@ -36,6 +36,8 @@ border-bottom: 1px solid; border-color: $fallback--border; border-color: var(--border, $fallback--border); + word-wrap: break-word; + word-break: break-word; &:hover .animated.avatar { canvas { @@ -46,41 +48,62 @@ } } - .muted { - padding: .25em .6em; - } - .non-mention { display: flex; flex: 1; flex-wrap: nowrap; padding: 0.6em; min-width: 0; + .avatar-container { width: 32px; height: 32px; } - .status-el { - .status { - padding: 0.25em 0; - color: $fallback--faint; - color: var(--faint, $fallback--faint); - a { - color: var(--faintLink); - } - .status-content a { - color: var(--postFaintLink); - } + + .status-body { + color: $fallback--faint; + color: var(--faint, $fallback--faint); + a { + color: var(--faintLink); } - padding: 0; - .media-body { - margin: 0; + .status-content a { + color: var(--postFaintLink); } } } + .follow-request-accept { + cursor: pointer; + + &:hover { + color: $fallback--text; + color: var(--text, $fallback--text); + } + } + + .follow-request-reject { + cursor: pointer; + + &:hover { + color: $fallback--cRed; + color: var(--cRed, $fallback--cRed); + } + } + + .follow-text, .move-text { padding: 0.5em 0; + overflow-wrap: break-word; + display: flex; + justify-content: space-between; + + .follow-name { + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } } .status-el { @@ -142,6 +165,11 @@ color: var(--cGreen, $fallback--cGreen); } + .icon-user.lit { + color: $fallback--cBlue; + color: var(--cBlue, $fallback--cBlue); + } + .icon-user-plus.lit { color: $fallback--cBlue; color: var(--cBlue, $fallback--cBlue); diff --git a/src/components/panel_loading/panel_loading.vue b/src/components/panel_loading/panel_loading.vue new file mode 100644 index 00000000..4efebb3c --- /dev/null +++ b/src/components/panel_loading/panel_loading.vue @@ -0,0 +1,29 @@ +<template> + <div class="panel-loading"> + <span class="loading-text"> + <i class="icon-spin4 animate-spin" /> + {{ $t('general.loading') }} + </span> + </div> +</template> + +<style lang="scss"> +@import 'src/_variables.scss'; + +.panel-loading { + display: flex; + height: 100%; + align-items: center; + justify-content: center; + font-size: 2em; + color: $fallback--text; + color: var(--text, $fallback--text); + .loading-text i { + font-size: 3em; + line-height: 0; + vertical-align: middle; + color: $fallback--text; + color: var(--text, $fallback--text); + } +} +</style> diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js index 5881d266..a40a9195 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -1,4 +1,3 @@ - const Popover = { name: 'Popover', props: { @@ -10,6 +9,9 @@ const Popover = { // 'container' for using offsetParent as boundaries for either axis // or 'viewport' boundTo: Object, + // Takes a selector to use as a replacement for the parent container + // for getting boundaries for x an y axis + boundToSelector: String, // Takes a top/bottom/left/right object, how much space to leave // between boundary and popover element margin: Object, @@ -27,6 +29,10 @@ const Popover = { } }, methods: { + containerBoundingClientRect () { + const container = this.boundToSelector ? this.$el.closest(this.boundToSelector) : this.$el.offsetParent + return container.getBoundingClientRect() + }, updateStyles () { if (this.hidden) { this.styles = { @@ -45,7 +51,8 @@ const Popover = { // Minor optimization, don't call a slow reflow call if we don't have to const parentBounds = this.boundTo && (this.boundTo.x === 'container' || this.boundTo.y === 'container') && - this.$el.offsetParent.getBoundingClientRect() + this.containerBoundingClientRect() + const margin = this.margin || {} // What are the screen bounds for the popover? Viewport vs container diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 74067fef..9027566f 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -82,7 +82,9 @@ const PostStatusForm = { contentType }, caret: 0, - pollFormVisible: false + pollFormVisible: false, + showDropIcon: 'hide', + dropStopTimeout: null } }, computed: { @@ -102,7 +104,7 @@ const PostStatusForm = { ...this.$store.state.instance.customEmoji ], users: this.$store.state.users.users, - updateUsersList: (input) => this.$store.dispatch('searchUsers', input) + updateUsersList: (query) => this.$store.dispatch('searchUsers', { query }) }) }, emojiSuggestor () { @@ -218,7 +220,6 @@ const PostStatusForm = { }, addMediaFile (fileInfo) { this.newStatus.files.push(fileInfo) - this.enableSubmit() }, removeMediaFile (fileInfo) { let index = this.newStatus.files.indexOf(fileInfo) @@ -227,7 +228,6 @@ const PostStatusForm = { uploadFailed (errString, templateArgs) { templateArgs = templateArgs || {} this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs) - this.enableSubmit() }, disableSubmit () { this.submitDisabled = true @@ -250,13 +250,27 @@ const PostStatusForm = { } }, fileDrop (e) { - if (e.dataTransfer.files.length > 0) { + if (e.dataTransfer && e.dataTransfer.types.includes('Files')) { e.preventDefault() // allow dropping text like before this.dropFiles = e.dataTransfer.files + clearTimeout(this.dropStopTimeout) + this.showDropIcon = 'hide' } }, + fileDragStop (e) { + // The false-setting is done with delay because just using leave-events + // directly caused unwanted flickering, this is not perfect either but + // much less noticable. + clearTimeout(this.dropStopTimeout) + this.showDropIcon = 'fade' + this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500) + }, fileDrag (e) { e.dataTransfer.dropEffect = 'copy' + if (e.dataTransfer && e.dataTransfer.types.includes('Files')) { + clearTimeout(this.dropStopTimeout) + this.showDropIcon = 'show' + } }, onEmojiInputInput (e) { this.$nextTick(() => { diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index 9789a481..e3d8d087 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -6,7 +6,15 @@ <form autocomplete="off" @submit.prevent="postStatus(newStatus)" + @dragover.prevent="fileDrag" > + <div + v-show="showDropIcon !== 'hide'" + :style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }" + class="drop-indicator icon-upload" + @dragleave="fileDragStop" + @drop.stop="fileDrop" + /> <div class="form-group"> <i18n v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'" @@ -73,6 +81,7 @@ v-model="newStatus.spoilerText" type="text" :placeholder="$t('post_status.content_warning')" + :disabled="posting" class="form-post-subject" > </EmojiInput> @@ -96,9 +105,7 @@ :disabled="posting" class="form-post-body" @keydown.meta.enter="postStatus(newStatus)" - @keyup.ctrl.enter="postStatus(newStatus)" - @drop="fileDrop" - @dragover.prevent="fileDrag" + @keydown.ctrl.enter="postStatus(newStatus)" @input="resize" @compositionupdate="resize" @paste="paste" @@ -172,6 +179,7 @@ @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" + @all-uploaded="enableSubmit" /> <div class="emoji-icon" @@ -446,7 +454,8 @@ form { display: flex; flex-direction: column; - padding: 0.6em; + margin: 0.6em; + position: relative; } .form-group { @@ -504,5 +513,35 @@ cursor: pointer; z-index: 4; } + + @keyframes fade-in { + from { opacity: 0; } + to { opacity: 0.6; } + } + + @keyframes fade-out { + from { opacity: 0.6; } + to { opacity: 0; } + } + + .drop-indicator { + position: absolute; + z-index: 1; + width: 100%; + height: 100%; + font-size: 5em; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.6; + color: $fallback--text; + color: var(--text, $fallback--text); + background-color: $fallback--bg; + background-color: var(--bg, $fallback--bg); + border-radius: $fallback--tooltipRadius; + border-radius: var(--tooltipRadius, $fallback--tooltipRadius); + border: 2px dashed $fallback--text; + border: 2px dashed var(--text, $fallback--text); + } } </style> diff --git a/src/components/react_button/react_button.js b/src/components/react_button/react_button.js index 19949563..f0931446 100644 --- a/src/components/react_button/react_button.js +++ b/src/components/react_button/react_button.js @@ -2,7 +2,7 @@ import Popover from '../popover/popover.vue' import { mapGetters } from 'vuex' const ReactButton = { - props: ['status', 'loggedIn'], + props: ['status'], data () { return { filterWord: '' @@ -24,7 +24,7 @@ const ReactButton = { }, computed: { commonEmojis () { - return ['❤️', '😠', '👀', '😂', '🔥'] + return ['👍', '😠', '👀', '😂', '🔥'] }, emojis () { if (this.filterWord !== '') { diff --git a/src/components/react_button/react_button.vue b/src/components/react_button/react_button.vue index ab4b4fcd..0b34add1 100644 --- a/src/components/react_button/react_button.vue +++ b/src/components/react_button/react_button.vue @@ -37,7 +37,6 @@ </div> </div> <i - v-if="loggedIn" slot="trigger" class="icon-smile button-icon add-reaction-button" :title="$t('tool_tip.add_reaction')" diff --git a/src/components/registration/registration.js b/src/components/registration/registration.js index ace8cc7c..dab06e1e 100644 --- a/src/components/registration/registration.js +++ b/src/components/registration/registration.js @@ -1,5 +1,5 @@ import { validationMixin } from 'vuelidate' -import { required, sameAs } from 'vuelidate/lib/validators' +import { required, requiredIf, sameAs } from 'vuelidate/lib/validators' import { mapActions, mapState } from 'vuex' const registration = { @@ -14,15 +14,17 @@ const registration = { }, captcha: {} }), - validations: { - user: { - email: { required }, - username: { required }, - fullname: { required }, - password: { required }, - confirm: { - required, - sameAsPassword: sameAs('password') + validations () { + return { + user: { + email: { required: requiredIf(() => this.accountActivationRequired) }, + username: { required }, + fullname: { required }, + password: { required }, + confirm: { + required, + sameAsPassword: sameAs('password') + } } } }, @@ -43,7 +45,8 @@ const registration = { signedIn: (state) => !!state.users.currentUser, isPending: (state) => state.users.signUpPending, serverValidationErrors: (state) => state.users.signUpErrors, - termsOfService: (state) => state.instance.tos + termsOfService: (state) => state.instance.tos, + accountActivationRequired: (state) => state.instance.accountActivationRequired }) }, methods: { diff --git a/src/components/registration/registration.vue b/src/components/registration/registration.vue index fdbda007..a83ca1e5 100644 --- a/src/components/registration/registration.vue +++ b/src/components/registration/registration.vue @@ -187,6 +187,9 @@ class="form-control" type="text" autocomplete="off" + autocorrect="off" + autocapitalize="off" + spellcheck="false" > </template> </div> diff --git a/src/components/settings/settings.js b/src/components/settings/settings.js deleted file mode 100644 index 31a9e9be..00000000 --- a/src/components/settings/settings.js +++ /dev/null @@ -1,128 +0,0 @@ -/* eslint-env browser */ -import { filter, trim } from 'lodash' - -import TabSwitcher from '../tab_switcher/tab_switcher.js' -import StyleSwitcher from '../style_switcher/style_switcher.vue' -import InterfaceLanguageSwitcher from '../interface_language_switcher/interface_language_switcher.vue' -import { extractCommit } from '../../services/version/version.service' -import { instanceDefaultProperties, defaultState as configDefaultState } from '../../modules/config.js' -import Checkbox from '../checkbox/checkbox.vue' - -const pleromaFeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma-fe/commit/' -const pleromaBeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma/commit/' - -const multiChoiceProperties = [ - 'postContentType', - 'subjectLineBehavior' -] - -const settings = { - data () { - const instance = this.$store.state.instance - - return { - loopSilentAvailable: - // Firefox - Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') || - // Chrome-likes - Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'webkitAudioDecodedByteCount') || - // Future spec, still not supported in Nightly 63 as of 08/2018 - Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'audioTracks'), - - backendVersion: instance.backendVersion, - frontendVersion: instance.frontendVersion - } - }, - components: { - TabSwitcher, - StyleSwitcher, - InterfaceLanguageSwitcher, - Checkbox - }, - computed: { - user () { - return this.$store.state.users.currentUser - }, - currentSaveStateNotice () { - return this.$store.state.interface.settings.currentSaveStateNotice - }, - postFormats () { - return this.$store.state.instance.postFormats || [] - }, - instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel }, - frontendVersionLink () { - return pleromaFeCommitUrl + this.frontendVersion - }, - backendVersionLink () { - return pleromaBeCommitUrl + extractCommit(this.backendVersion) - }, - // Getting localized values for instance-default properties - ...instanceDefaultProperties - .filter(key => multiChoiceProperties.includes(key)) - .map(key => [ - key + 'DefaultValue', - function () { - return this.$store.getters.instanceDefaultConfig[key] - } - ]) - .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), - ...instanceDefaultProperties - .filter(key => !multiChoiceProperties.includes(key)) - .map(key => [ - key + 'LocalizedValue', - function () { - return this.$t('settings.values.' + this.$store.getters.instanceDefaultConfig[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 }), {}), - // Special cases (need to transform values or perform actions first) - muteWordsString: { - get () { return this.$store.getters.mergedConfig.muteWords.join('\n') }, - set (value) { - this.$store.dispatch('setOption', { - name: 'muteWords', - value: filter(value.split('\n'), (word) => trim(word).length > 0) - }) - } - }, - 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 }) - }) - } - } - }, - // Updating nested properties - watch: { - notificationVisibility: { - handler (value) { - this.$store.dispatch('setOption', { - name: 'notificationVisibility', - value: this.$store.getters.mergedConfig.notificationVisibility - }) - }, - deep: true - } - } -} - -export default settings diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue deleted file mode 100644 index 9e14b449..00000000 --- a/src/components/settings/settings.vue +++ /dev/null @@ -1,424 +0,0 @@ -<template> - <div class="settings panel panel-default"> - <div class="panel-heading"> - <div class="title"> - {{ $t('settings.settings') }} - </div> - - <transition name="fade"> - <template v-if="currentSaveStateNotice"> - <div - v-if="currentSaveStateNotice.error" - class="alert error" - @click.prevent - > - {{ $t('settings.saving_err') }} - </div> - - <div - v-if="!currentSaveStateNotice.error" - class="alert transparent" - @click.prevent - > - {{ $t('settings.saving_ok') }} - </div> - </template> - </transition> - </div> - <div class="panel-body"> - <keep-alive> - <tab-switcher> - <div :label="$t('settings.general')"> - <div class="setting-item"> - <h2>{{ $t('settings.interface') }}</h2> - <ul class="setting-list"> - <li> - <interface-language-switcher /> - </li> - <li v-if="instanceSpecificPanelPresent"> - <Checkbox v-model="hideISP"> - {{ $t('settings.hide_isp') }} - </Checkbox> - </li> - </ul> - </div> - <div class="setting-item"> - <h2>{{ $t('nav.timeline') }}</h2> - <ul class="setting-list"> - <li> - <Checkbox v-model="hideMutedPosts"> - {{ $t('settings.hide_muted_posts') }} {{ $t('settings.instance_default', { value: hideMutedPostsLocalizedValue }) }} - </Checkbox> - </li> - <li> - <Checkbox v-model="collapseMessageWithSubject"> - {{ $t('settings.collapse_subject') }} {{ $t('settings.instance_default', { value: collapseMessageWithSubjectLocalizedValue }) }} - </Checkbox> - </li> - <li> - <Checkbox v-model="streaming"> - {{ $t('settings.streaming') }} - </Checkbox> - <ul - class="setting-list suboptions" - :class="[{disabled: !streaming}]" - > - <li> - <Checkbox - v-model="pauseOnUnfocused" - :disabled="!streaming" - > - {{ $t('settings.pause_on_unfocused') }} - </Checkbox> - </li> - </ul> - </li> - <li> - <Checkbox v-model="useStreamingApi"> - {{ $t('settings.useStreamingApi') }} - <br> - <small> - {{ $t('settings.useStreamingApiWarning') }} - </small> - </Checkbox> - </li> - <li> - <Checkbox v-model="autoLoad"> - {{ $t('settings.autoload') }} - </Checkbox> - </li> - <li> - <Checkbox v-model="hoverPreview"> - {{ $t('settings.reply_link_preview') }} - </Checkbox> - </li> - <li> - <Checkbox v-model="emojiReactionsOnTimeline"> - {{ $t('settings.emoji_reactions_on_timeline') }} - </Checkbox> - </li> - </ul> - </div> - - <div class="setting-item"> - <h2>{{ $t('settings.composing') }}</h2> - <ul class="setting-list"> - <li> - <Checkbox v-model="scopeCopy"> - {{ $t('settings.scope_copy') }} {{ $t('settings.instance_default', { value: scopeCopyLocalizedValue }) }} - </Checkbox> - </li> - <li> - <Checkbox v-model="alwaysShowSubjectInput"> - {{ $t('settings.subject_input_always_show') }} {{ $t('settings.instance_default', { value: alwaysShowSubjectInputLocalizedValue }) }} - </Checkbox> - </li> - <li> - <div> - {{ $t('settings.subject_line_behavior') }} - <label - for="subjectLineBehavior" - class="select" - > - <select - id="subjectLineBehavior" - v-model="subjectLineBehavior" - > - <option value="email"> - {{ $t('settings.subject_line_email') }} - {{ subjectLineBehaviorDefaultValue == 'email' ? $t('settings.instance_default_simple') : '' }} - </option> - <option value="masto"> - {{ $t('settings.subject_line_mastodon') }} - {{ subjectLineBehaviorDefaultValue == 'mastodon' ? $t('settings.instance_default_simple') : '' }} - </option> - <option value="noop"> - {{ $t('settings.subject_line_noop') }} - {{ subjectLineBehaviorDefaultValue == 'noop' ? $t('settings.instance_default_simple') : '' }} - </option> - </select> - <i class="icon-down-open" /> - </label> - </div> - </li> - <li v-if="postFormats.length > 0"> - <div> - {{ $t('settings.post_status_content_type') }} - <label - for="postContentType" - class="select" - > - <select - id="postContentType" - v-model="postContentType" - > - <option - v-for="postFormat in postFormats" - :key="postFormat" - :value="postFormat" - > - {{ $t(`post_status.content_type["${postFormat}"]`) }} - {{ postContentTypeDefaultValue === postFormat ? $t('settings.instance_default_simple') : '' }} - </option> - </select> - <i class="icon-down-open" /> - </label> - </div> - </li> - <li> - <Checkbox v-model="minimalScopesMode"> - {{ $t('settings.minimal_scopes_mode') }} {{ $t('settings.instance_default', { value: minimalScopesModeLocalizedValue }) }} - </Checkbox> - </li> - <li> - <Checkbox v-model="autohideFloatingPostButton"> - {{ $t('settings.autohide_floating_post_button') }} - </Checkbox> - </li> - <li> - <Checkbox v-model="padEmoji"> - {{ $t('settings.pad_emoji') }} - </Checkbox> - </li> - </ul> - </div> - - <div class="setting-item"> - <h2>{{ $t('settings.attachments') }}</h2> - <ul class="setting-list"> - <li> - <Checkbox v-model="hideAttachments"> - {{ $t('settings.hide_attachments_in_tl') }} - </Checkbox> - </li> - <li> - <Checkbox v-model="hideAttachmentsInConv"> - {{ $t('settings.hide_attachments_in_convo') }} - </Checkbox> - </li> - <li> - <label for="maxThumbnails"> - {{ $t('settings.max_thumbnails') }} - </label> - <input - id="maxThumbnails" - v-model.number="maxThumbnails" - class="number-input" - type="number" - min="0" - step="1" - > - </li> - <li> - <Checkbox v-model="hideNsfw"> - {{ $t('settings.nsfw_clickthrough') }} - </Checkbox> - </li> - <ul class="setting-list suboptions"> - <li> - <Checkbox - v-model="preloadImage" - :disabled="!hideNsfw" - > - {{ $t('settings.preload_images') }} - </Checkbox> - </li> - <li> - <Checkbox - v-model="useOneClickNsfw" - :disabled="!hideNsfw" - > - {{ $t('settings.use_one_click_nsfw') }} - </Checkbox> - </li> - </ul> - <li> - <Checkbox v-model="stopGifs"> - {{ $t('settings.stop_gifs') }} - </Checkbox> - </li> - <li> - <Checkbox v-model="loopVideo"> - {{ $t('settings.loop_video') }} - </Checkbox> - <ul - class="setting-list suboptions" - :class="[{disabled: !streaming}]" - > - <li> - <Checkbox - v-model="loopVideoSilentOnly" - :disabled="!loopVideo || !loopSilentAvailable" - > - {{ $t('settings.loop_video_silent_only') }} - </Checkbox> - <div - v-if="!loopSilentAvailable" - class="unavailable" - > - <i class="icon-globe" />! {{ $t('settings.limited_availability') }} - </div> - </li> - </ul> - </li> - <li> - <Checkbox v-model="playVideosInModal"> - {{ $t('settings.play_videos_in_modal') }} - </Checkbox> - </li> - <li> - <Checkbox v-model="useContainFit"> - {{ $t('settings.use_contain_fit') }} - </Checkbox> - </li> - </ul> - </div> - - <div class="setting-item"> - <h2>{{ $t('settings.notifications') }}</h2> - <ul class="setting-list"> - <li> - <Checkbox v-model="webPushNotifications"> - {{ $t('settings.enable_web_push_notifications') }} - </Checkbox> - </li> - </ul> - </div> - - <div class="setting-item"> - <h2>{{ $t('settings.fun') }}</h2> - <ul class="setting-list"> - <li> - <Checkbox v-model="greentext"> - {{ $t('settings.greentext') }} {{ $t('settings.instance_default', { value: greentextLocalizedValue }) }} - </Checkbox> - </li> - </ul> - </div> - </div> - - <div :label="$t('settings.theme')"> - <div class="setting-item"> - <style-switcher /> - </div> - </div> - - <div :label="$t('settings.filtering')"> - <div class="setting-item"> - <div class="select-multiple"> - <span class="label">{{ $t('settings.notification_visibility') }}</span> - <ul class="option-list"> - <li> - <Checkbox v-model="notificationVisibility.likes"> - {{ $t('settings.notification_visibility_likes') }} - </Checkbox> - </li> - <li> - <Checkbox v-model="notificationVisibility.repeats"> - {{ $t('settings.notification_visibility_repeats') }} - </Checkbox> - </li> - <li> - <Checkbox v-model="notificationVisibility.follows"> - {{ $t('settings.notification_visibility_follows') }} - </Checkbox> - </li> - <li> - <Checkbox v-model="notificationVisibility.mentions"> - {{ $t('settings.notification_visibility_mentions') }} - </Checkbox> - </li> - <li> - <Checkbox v-model="notificationVisibility.moves"> - {{ $t('settings.notification_visibility_moves') }} - </Checkbox> - </li> - <li> - <Checkbox v-model="notificationVisibility.emojiReactions"> - {{ $t('settings.notification_visibility_emoji_reactions') }} - </Checkbox> - </li> - </ul> - </div> - <div> - {{ $t('settings.replies_in_timeline') }} - <label - for="replyVisibility" - class="select" - > - <select - id="replyVisibility" - v-model="replyVisibility" - > - <option - value="all" - selected - >{{ $t('settings.reply_visibility_all') }}</option> - <option value="following">{{ $t('settings.reply_visibility_following') }}</option> - <option value="self">{{ $t('settings.reply_visibility_self') }}</option> - </select> - <i class="icon-down-open" /> - </label> - </div> - <div> - <Checkbox v-model="hidePostStats"> - {{ $t('settings.hide_post_stats') }} {{ $t('settings.instance_default', { value: hidePostStatsLocalizedValue }) }} - </Checkbox> - </div> - <div> - <Checkbox v-model="hideUserStats"> - {{ $t('settings.hide_user_stats') }} {{ $t('settings.instance_default', { value: hideUserStatsLocalizedValue }) }} - </Checkbox> - </div> - </div> - <div class="setting-item"> - <div> - <p>{{ $t('settings.filtering_explanation') }}</p> - <textarea - id="muteWords" - v-model="muteWordsString" - /> - </div> - <div> - <Checkbox v-model="hideFilteredStatuses"> - {{ $t('settings.hide_filtered_statuses') }} {{ $t('settings.instance_default', { value: hideFilteredStatusesLocalizedValue }) }} - </Checkbox> - </div> - </div> - </div> - <div :label="$t('settings.version.title')"> - <div class="setting-item"> - <ul class="setting-list"> - <li> - <p>{{ $t('settings.version.backend_version') }}</p> - <ul class="option-list"> - <li> - <a - :href="backendVersionLink" - target="_blank" - >{{ backendVersion }}</a> - </li> - </ul> - </li> - <li> - <p>{{ $t('settings.version.frontend_version') }}</p> - <ul class="option-list"> - <li> - <a - :href="frontendVersionLink" - target="_blank" - >{{ frontendVersion }}</a> - </li> - </ul> - </li> - </ul> - </div> - </div> - </tab-switcher> - </keep-alive> - </div> - </div> -</template> - -<script src="./settings.js"> -</script> diff --git a/src/components/settings_modal/helpers/shared_computed_object.js b/src/components/settings_modal/helpers/shared_computed_object.js new file mode 100644 index 00000000..86703697 --- /dev/null +++ b/src/components/settings_modal/helpers/shared_computed_object.js @@ -0,0 +1,58 @@ +import { + instanceDefaultProperties, + multiChoiceProperties, + defaultState as configDefaultState +} from 'src/modules/config.js' + +const SharedComputedObject = () => ({ + user () { + return this.$store.state.users.currentUser + }, + // Getting localized values for instance-default properties + ...instanceDefaultProperties + .filter(key => multiChoiceProperties.includes(key)) + .map(key => [ + key + 'DefaultValue', + function () { + return this.$store.getters.instanceDefaultConfig[key] + } + ]) + .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), + ...instanceDefaultProperties + .filter(key => !multiChoiceProperties.includes(key)) + .map(key => [ + key + 'LocalizedValue', + function () { + return this.$t('settings.values.' + this.$store.getters.instanceDefaultConfig[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 }), {}), + // 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 }) + }) + } + } +}) + +export default SharedComputedObject diff --git a/src/components/settings_modal/settings_modal.js b/src/components/settings_modal/settings_modal.js new file mode 100644 index 00000000..f0d49c91 --- /dev/null +++ b/src/components/settings_modal/settings_modal.js @@ -0,0 +1,42 @@ +import Modal from 'src/components/modal/modal.vue' +import PanelLoading from 'src/components/panel_loading/panel_loading.vue' +import AsyncComponentError from 'src/components/async_component_error/async_component_error.vue' +import getResettableAsyncComponent from 'src/services/resettable_async_component.js' + +const SettingsModal = { + components: { + Modal, + SettingsModalContent: getResettableAsyncComponent( + () => import('./settings_modal_content.vue'), + { + loading: PanelLoading, + error: AsyncComponentError, + delay: 0 + } + ) + }, + methods: { + closeModal () { + this.$store.dispatch('closeSettingsModal') + }, + peekModal () { + this.$store.dispatch('togglePeekSettingsModal') + } + }, + computed: { + currentSaveStateNotice () { + return this.$store.state.interface.settings.currentSaveStateNotice + }, + modalActivated () { + return this.$store.state.interface.settingsModalState !== 'hidden' + }, + modalOpenedOnce () { + return this.$store.state.interface.settingsModalLoaded + }, + modalPeeked () { + return this.$store.state.interface.settingsModalState === 'minimized' + } + } +} + +export default SettingsModal diff --git a/src/components/settings_modal/settings_modal.scss b/src/components/settings_modal/settings_modal.scss new file mode 100644 index 00000000..833ff89a --- /dev/null +++ b/src/components/settings_modal/settings_modal.scss @@ -0,0 +1,44 @@ +@import 'src/_variables.scss'; +.settings-modal { + overflow: hidden; + + &.peek { + .settings-modal-panel { + /* Explanation: + * Modal is positioned vertically centered. + * 100vh - 100% = Distance between modal's top+bottom boundaries and screen + * (100vh - 100%) / 2 = Distance between bottom (or top) boundary and screen + * + 100% - we move modal completely off-screen, it's top boundary touches + * bottom of the screen + * - 50px - leaving tiny amount of space so that titlebar + tiny amount of modal is visible + */ + transform: translateY(calc(((100vh - 100%) / 2 + 100%) - 50px)); + } + } + + .settings-modal-panel { + overflow: hidden; + transition: transform; + transition-timing-function: ease-in-out; + transition-duration: 300ms; + width: 1000px; + max-width: 90vw; + height: 90vh; + + @media all and (max-width: 800px) { + max-width: 100vw; + height: 100vh; + } + + .panel-body { + height: 100%; + overflow-y: hidden; + + .btn { + min-height: 28px; + min-width: 10em; + padding: 0 2em; + } + } + } +} diff --git a/src/components/settings_modal/settings_modal.vue b/src/components/settings_modal/settings_modal.vue new file mode 100644 index 00000000..6bc64ed0 --- /dev/null +++ b/src/components/settings_modal/settings_modal.vue @@ -0,0 +1,54 @@ +<template> + <Modal + :is-open="modalActivated" + class="settings-modal" + :class="{ peek: modalPeeked }" + :no-background="modalPeeked" + > + <div class="settings-modal-panel panel"> + <div class="panel-heading"> + <span class="title"> + {{ $t('settings.settings') }} + </span> + <transition name="fade"> + <template v-if="currentSaveStateNotice"> + <div + v-if="currentSaveStateNotice.error" + class="alert error" + @click.prevent + > + {{ $t('settings.saving_err') }} + </div> + + <div + v-if="!currentSaveStateNotice.error" + class="alert transparent" + @click.prevent + > + {{ $t('settings.saving_ok') }} + </div> + </template> + </transition> + <button + class="btn" + @click="peekModal" + > + {{ $t('general.peek') }} + </button> + <button + class="btn" + @click="closeModal" + > + {{ $t('general.close') }} + </button> + </div> + <div class="panel-body"> + <SettingsModalContent v-if="modalOpenedOnce" /> + </div> + </div> + </Modal> +</template> + +<script src="./settings_modal.js"></script> + +<style src="./settings_modal.scss" lang="scss"></style> diff --git a/src/components/settings_modal/settings_modal_content.js b/src/components/settings_modal/settings_modal_content.js new file mode 100644 index 00000000..48101a90 --- /dev/null +++ b/src/components/settings_modal/settings_modal_content.js @@ -0,0 +1,34 @@ +import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js' + +import DataImportExportTab from './tabs/data_import_export_tab.vue' +import MutesAndBlocksTab from './tabs/mutes_and_blocks_tab.vue' +import NotificationsTab from './tabs/notifications_tab.vue' +import FilteringTab from './tabs/filtering_tab.vue' +import SecurityTab from './tabs/security_tab/security_tab.vue' +import ProfileTab from './tabs/profile_tab.vue' +import GeneralTab from './tabs/general_tab.vue' +import VersionTab from './tabs/version_tab.vue' +import ThemeTab from './tabs/theme_tab/theme_tab.vue' + +const SettingsModalContent = { + components: { + TabSwitcher, + + DataImportExportTab, + MutesAndBlocksTab, + NotificationsTab, + FilteringTab, + SecurityTab, + ProfileTab, + GeneralTab, + VersionTab, + ThemeTab + }, + computed: { + isLoggedIn () { + return !!this.$store.state.users.currentUser + } + } +} + +export default SettingsModalContent diff --git a/src/components/settings_modal/settings_modal_content.scss b/src/components/settings_modal/settings_modal_content.scss new file mode 100644 index 00000000..a3fef1cf --- /dev/null +++ b/src/components/settings_modal/settings_modal_content.scss @@ -0,0 +1,43 @@ +@import 'src/_variables.scss'; +.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 { + margin-bottom: .5em; + &:last-child { + margin-bottom: 0; + } + } + + &: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 i { + color: var(--cRed, $fallback--cRed); + color: $fallback--cRed; + } + + .number-input { + max-width: 6em; + } + } +} diff --git a/src/components/settings_modal/settings_modal_content.vue b/src/components/settings_modal/settings_modal_content.vue new file mode 100644 index 00000000..2156844f --- /dev/null +++ b/src/components/settings_modal/settings_modal_content.vue @@ -0,0 +1,73 @@ +<template> + <tab-switcher + ref="tabSwitcher" + class="settings_tab-switcher" + :side-tab-bar="true" + :scrollable-tabs="true" + > + <div + :label="$t('settings.general')" + icon="wrench" + > + <GeneralTab /> + </div> + <div + v-if="isLoggedIn" + :label="$t('settings.profile_tab')" + icon="user" + > + <ProfileTab /> + </div> + <div + v-if="isLoggedIn" + :label="$t('settings.security_tab')" + icon="lock" + > + <SecurityTab /> + </div> + <div + :label="$t('settings.filtering')" + icon="filter" + > + <FilteringTab /> + </div> + <div + :label="$t('settings.theme')" + icon="brush" + > + <ThemeTab /> + </div> + <div + v-if="isLoggedIn" + :label="$t('settings.notifications')" + icon="bell-ringing-o" + > + <NotificationsTab /> + </div> + <div + v-if="isLoggedIn" + :label="$t('settings.data_import_export_tab')" + icon="download" + > + <DataImportExportTab /> + </div> + <div + v-if="isLoggedIn" + :label="$t('settings.mutes_and_blocks')" + :fullHeight="true" + icon="eye-off" + > + <MutesAndBlocksTab /> + </div> + <div + :label="$t('settings.version.title')" + icon="info-circled" + > + <VersionTab /> + </div> + </tab-switcher> +</template> + +<script src="./settings_modal_content.js"></script> + +<style src="./settings_modal_content.scss" lang="scss"></style> diff --git a/src/components/settings_modal/tabs/data_import_export_tab.js b/src/components/settings_modal/tabs/data_import_export_tab.js new file mode 100644 index 00000000..168f89e1 --- /dev/null +++ b/src/components/settings_modal/tabs/data_import_export_tab.js @@ -0,0 +1,65 @@ +import Importer from 'src/components/importer/importer.vue' +import Exporter from 'src/components/exporter/exporter.vue' +import Checkbox from 'src/components/checkbox/checkbox.vue' + +const DataImportExportTab = { + data () { + return { + activeTab: 'profile', + newDomainToMute: '' + } + }, + created () { + this.$store.dispatch('fetchTokens') + }, + components: { + Importer, + Exporter, + Checkbox + }, + computed: { + user () { + return this.$store.state.users.currentUser + } + }, + methods: { + getFollowsContent () { + return this.$store.state.api.backendInteractor.exportFriends({ id: this.$store.state.users.currentUser.id }) + .then(this.generateExportableUsersContent) + }, + getBlocksContent () { + return this.$store.state.api.backendInteractor.fetchBlocks() + .then(this.generateExportableUsersContent) + }, + importFollows (file) { + return this.$store.state.api.backendInteractor.importFollows({ file }) + .then((status) => { + if (!status) { + throw new Error('failed') + } + }) + }, + importBlocks (file) { + return this.$store.state.api.backendInteractor.importBlocks({ file }) + .then((status) => { + if (!status) { + throw new Error('failed') + } + }) + }, + generateExportableUsersContent (users) { + // Get addresses + return users.map((user) => { + // check is it's a local user + if (user && user.is_local) { + // append the instance address + // eslint-disable-next-line no-undef + return user.screen_name + '@' + location.hostname + } + return user.screen_name + }).join('\n') + } + } +} + +export default DataImportExportTab diff --git a/src/components/settings_modal/tabs/data_import_export_tab.vue b/src/components/settings_modal/tabs/data_import_export_tab.vue new file mode 100644 index 00000000..b5d0f5ed --- /dev/null +++ b/src/components/settings_modal/tabs/data_import_export_tab.vue @@ -0,0 +1,43 @@ +<template> + <div + :label="$t('settings.data_import_export_tab')" + > + <div class="setting-item"> + <h2>{{ $t('settings.follow_import') }}</h2> + <p>{{ $t('settings.import_followers_from_a_csv_file') }}</p> + <Importer + :submit-handler="importFollows" + :success-message="$t('settings.follows_imported')" + :error-message="$t('settings.follow_import_error')" + /> + </div> + <div class="setting-item"> + <h2>{{ $t('settings.follow_export') }}</h2> + <Exporter + :get-content="getFollowsContent" + filename="friends.csv" + :export-button-label="$t('settings.follow_export_button')" + /> + </div> + <div class="setting-item"> + <h2>{{ $t('settings.block_import') }}</h2> + <p>{{ $t('settings.import_blocks_from_a_csv_file') }}</p> + <Importer + :submit-handler="importBlocks" + :success-message="$t('settings.blocks_imported')" + :error-message="$t('settings.block_import_error')" + /> + </div> + <div class="setting-item"> + <h2>{{ $t('settings.block_export') }}</h2> + <Exporter + :get-content="getBlocksContent" + filename="blocks.csv" + :export-button-label="$t('settings.block_export_button')" + /> + </div> + </div> +</template> + +<script src="./data_import_export_tab.js"></script> +<!-- <style lang="scss" src="./profile.scss"></style> --> diff --git a/src/components/settings_modal/tabs/filtering_tab.js b/src/components/settings_modal/tabs/filtering_tab.js new file mode 100644 index 00000000..224a7f47 --- /dev/null +++ b/src/components/settings_modal/tabs/filtering_tab.js @@ -0,0 +1,44 @@ +import { filter, trim } from 'lodash' +import Checkbox from 'src/components/checkbox/checkbox.vue' + +import SharedComputedObject from '../helpers/shared_computed_object.js' + +const FilteringTab = { + data () { + return { + muteWordsStringLocal: this.$store.getters.mergedConfig.muteWords.join('\n') + } + }, + components: { + Checkbox + }, + computed: { + ...SharedComputedObject(), + muteWordsString: { + get () { + return this.muteWordsStringLocal + }, + set (value) { + this.muteWordsStringLocal = value + this.$store.dispatch('setOption', { + name: 'muteWords', + value: filter(value.split('\n'), (word) => trim(word).length > 0) + }) + } + } + }, + // Updating nested properties + watch: { + notificationVisibility: { + handler (value) { + this.$store.dispatch('setOption', { + name: 'notificationVisibility', + value: this.$store.getters.mergedConfig.notificationVisibility + }) + }, + deep: true + } + } +} + +export default FilteringTab diff --git a/src/components/settings_modal/tabs/filtering_tab.vue b/src/components/settings_modal/tabs/filtering_tab.vue new file mode 100644 index 00000000..eea41514 --- /dev/null +++ b/src/components/settings_modal/tabs/filtering_tab.vue @@ -0,0 +1,86 @@ +<template> + <div :label="$t('settings.filtering')"> + <div class="setting-item"> + <div class="select-multiple"> + <span class="label">{{ $t('settings.notification_visibility') }}</span> + <ul class="option-list"> + <li> + <Checkbox v-model="notificationVisibility.likes"> + {{ $t('settings.notification_visibility_likes') }} + </Checkbox> + </li> + <li> + <Checkbox v-model="notificationVisibility.repeats"> + {{ $t('settings.notification_visibility_repeats') }} + </Checkbox> + </li> + <li> + <Checkbox v-model="notificationVisibility.follows"> + {{ $t('settings.notification_visibility_follows') }} + </Checkbox> + </li> + <li> + <Checkbox v-model="notificationVisibility.mentions"> + {{ $t('settings.notification_visibility_mentions') }} + </Checkbox> + </li> + <li> + <Checkbox v-model="notificationVisibility.moves"> + {{ $t('settings.notification_visibility_moves') }} + </Checkbox> + </li> + <li> + <Checkbox v-model="notificationVisibility.emojiReactions"> + {{ $t('settings.notification_visibility_emoji_reactions') }} + </Checkbox> + </li> + </ul> + </div> + <div> + {{ $t('settings.replies_in_timeline') }} + <label + for="replyVisibility" + class="select" + > + <select + id="replyVisibility" + v-model="replyVisibility" + > + <option + value="all" + selected + >{{ $t('settings.reply_visibility_all') }}</option> + <option value="following">{{ $t('settings.reply_visibility_following') }}</option> + <option value="self">{{ $t('settings.reply_visibility_self') }}</option> + </select> + <i class="icon-down-open" /> + </label> + </div> + <div> + <Checkbox v-model="hidePostStats"> + {{ $t('settings.hide_post_stats') }} {{ $t('settings.instance_default', { value: hidePostStatsLocalizedValue }) }} + </Checkbox> + </div> + <div> + <Checkbox v-model="hideUserStats"> + {{ $t('settings.hide_user_stats') }} {{ $t('settings.instance_default', { value: hideUserStatsLocalizedValue }) }} + </Checkbox> + </div> + </div> + <div class="setting-item"> + <div> + <p>{{ $t('settings.filtering_explanation') }}</p> + <textarea + id="muteWords" + v-model="muteWordsString" + /> + </div> + <div> + <Checkbox v-model="hideFilteredStatuses"> + {{ $t('settings.hide_filtered_statuses') }} {{ $t('settings.instance_default', { value: hideFilteredStatusesLocalizedValue }) }} + </Checkbox> + </div> + </div> + </div> +</template> +<script src="./filtering_tab.js"></script> diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js new file mode 100644 index 00000000..0eb37e44 --- /dev/null +++ b/src/components/settings_modal/tabs/general_tab.js @@ -0,0 +1,31 @@ +import Checkbox from 'src/components/checkbox/checkbox.vue' +import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue' + +import SharedComputedObject from '../helpers/shared_computed_object.js' + +const GeneralTab = { + data () { + return { + loopSilentAvailable: + // Firefox + Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') || + // Chrome-likes + Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'webkitAudioDecodedByteCount') || + // Future spec, still not supported in Nightly 63 as of 08/2018 + Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'audioTracks') + } + }, + components: { + Checkbox, + InterfaceLanguageSwitcher + }, + computed: { + postFormats () { + return this.$store.state.instance.postFormats || [] + }, + instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel }, + ...SharedComputedObject() + } +} + +export default GeneralTab diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue new file mode 100644 index 00000000..f89c0480 --- /dev/null +++ b/src/components/settings_modal/tabs/general_tab.vue @@ -0,0 +1,272 @@ +<template> + <div :label="$t('settings.general')"> + <div class="setting-item"> + <h2>{{ $t('settings.interface') }}</h2> + <ul class="setting-list"> + <li> + <interface-language-switcher /> + </li> + <li v-if="instanceSpecificPanelPresent"> + <Checkbox v-model="hideISP"> + {{ $t('settings.hide_isp') }} + </Checkbox> + </li> + </ul> + </div> + <div class="setting-item"> + <h2>{{ $t('nav.timeline') }}</h2> + <ul class="setting-list"> + <li> + <Checkbox v-model="hideMutedPosts"> + {{ $t('settings.hide_muted_posts') }} {{ $t('settings.instance_default', { value: hideMutedPostsLocalizedValue }) }} + </Checkbox> + </li> + <li> + <Checkbox v-model="collapseMessageWithSubject"> + {{ $t('settings.collapse_subject') }} {{ $t('settings.instance_default', { value: collapseMessageWithSubjectLocalizedValue }) }} + </Checkbox> + </li> + <li> + <Checkbox v-model="streaming"> + {{ $t('settings.streaming') }} + </Checkbox> + <ul + class="setting-list suboptions" + :class="[{disabled: !streaming}]" + > + <li> + <Checkbox + v-model="pauseOnUnfocused" + :disabled="!streaming" + > + {{ $t('settings.pause_on_unfocused') }} + </Checkbox> + </li> + </ul> + </li> + <li> + <Checkbox v-model="useStreamingApi"> + {{ $t('settings.useStreamingApi') }} + <br> + <small> + {{ $t('settings.useStreamingApiWarning') }} + </small> + </Checkbox> + </li> + <li> + <Checkbox v-model="autoLoad"> + {{ $t('settings.autoload') }} + </Checkbox> + </li> + <li> + <Checkbox v-model="hoverPreview"> + {{ $t('settings.reply_link_preview') }} + </Checkbox> + </li> + <li> + <Checkbox v-model="emojiReactionsOnTimeline"> + {{ $t('settings.emoji_reactions_on_timeline') }} + </Checkbox> + </li> + </ul> + </div> + + <div class="setting-item"> + <h2>{{ $t('settings.composing') }}</h2> + <ul class="setting-list"> + <li> + <Checkbox v-model="scopeCopy"> + {{ $t('settings.scope_copy') }} {{ $t('settings.instance_default', { value: scopeCopyLocalizedValue }) }} + </Checkbox> + </li> + <li> + <Checkbox v-model="alwaysShowSubjectInput"> + {{ $t('settings.subject_input_always_show') }} {{ $t('settings.instance_default', { value: alwaysShowSubjectInputLocalizedValue }) }} + </Checkbox> + </li> + <li> + <div> + {{ $t('settings.subject_line_behavior') }} + <label + for="subjectLineBehavior" + class="select" + > + <select + id="subjectLineBehavior" + v-model="subjectLineBehavior" + > + <option value="email"> + {{ $t('settings.subject_line_email') }} + {{ subjectLineBehaviorDefaultValue == 'email' ? $t('settings.instance_default_simple') : '' }} + </option> + <option value="masto"> + {{ $t('settings.subject_line_mastodon') }} + {{ subjectLineBehaviorDefaultValue == 'mastodon' ? $t('settings.instance_default_simple') : '' }} + </option> + <option value="noop"> + {{ $t('settings.subject_line_noop') }} + {{ subjectLineBehaviorDefaultValue == 'noop' ? $t('settings.instance_default_simple') : '' }} + </option> + </select> + <i class="icon-down-open" /> + </label> + </div> + </li> + <li v-if="postFormats.length > 0"> + <div> + {{ $t('settings.post_status_content_type') }} + <label + for="postContentType" + class="select" + > + <select + id="postContentType" + v-model="postContentType" + > + <option + v-for="postFormat in postFormats" + :key="postFormat" + :value="postFormat" + > + {{ $t(`post_status.content_type["${postFormat}"]`) }} + {{ postContentTypeDefaultValue === postFormat ? $t('settings.instance_default_simple') : '' }} + </option> + </select> + <i class="icon-down-open" /> + </label> + </div> + </li> + <li> + <Checkbox v-model="minimalScopesMode"> + {{ $t('settings.minimal_scopes_mode') }} {{ $t('settings.instance_default', { value: minimalScopesModeLocalizedValue }) }} + </Checkbox> + </li> + <li> + <Checkbox v-model="autohideFloatingPostButton"> + {{ $t('settings.autohide_floating_post_button') }} + </Checkbox> + </li> + <li> + <Checkbox v-model="padEmoji"> + {{ $t('settings.pad_emoji') }} + </Checkbox> + </li> + </ul> + </div> + + <div class="setting-item"> + <h2>{{ $t('settings.attachments') }}</h2> + <ul class="setting-list"> + <li> + <Checkbox v-model="hideAttachments"> + {{ $t('settings.hide_attachments_in_tl') }} + </Checkbox> + </li> + <li> + <Checkbox v-model="hideAttachmentsInConv"> + {{ $t('settings.hide_attachments_in_convo') }} + </Checkbox> + </li> + <li> + <label for="maxThumbnails"> + {{ $t('settings.max_thumbnails') }} + </label> + <input + id="maxThumbnails" + v-model.number="maxThumbnails" + class="number-input" + type="number" + min="0" + step="1" + > + </li> + <li> + <Checkbox v-model="hideNsfw"> + {{ $t('settings.nsfw_clickthrough') }} + </Checkbox> + </li> + <ul class="setting-list suboptions"> + <li> + <Checkbox + v-model="preloadImage" + :disabled="!hideNsfw" + > + {{ $t('settings.preload_images') }} + </Checkbox> + </li> + <li> + <Checkbox + v-model="useOneClickNsfw" + :disabled="!hideNsfw" + > + {{ $t('settings.use_one_click_nsfw') }} + </Checkbox> + </li> + </ul> + <li> + <Checkbox v-model="stopGifs"> + {{ $t('settings.stop_gifs') }} + </Checkbox> + </li> + <li> + <Checkbox v-model="loopVideo"> + {{ $t('settings.loop_video') }} + </Checkbox> + <ul + class="setting-list suboptions" + :class="[{disabled: !streaming}]" + > + <li> + <Checkbox + v-model="loopVideoSilentOnly" + :disabled="!loopVideo || !loopSilentAvailable" + > + {{ $t('settings.loop_video_silent_only') }} + </Checkbox> + <div + v-if="!loopSilentAvailable" + class="unavailable" + > + <i class="icon-globe" />! {{ $t('settings.limited_availability') }} + </div> + </li> + </ul> + </li> + <li> + <Checkbox v-model="playVideosInModal"> + {{ $t('settings.play_videos_in_modal') }} + </Checkbox> + </li> + <li> + <Checkbox v-model="useContainFit"> + {{ $t('settings.use_contain_fit') }} + </Checkbox> + </li> + </ul> + </div> + + <div class="setting-item"> + <h2>{{ $t('settings.notifications') }}</h2> + <ul class="setting-list"> + <li> + <Checkbox v-model="webPushNotifications"> + {{ $t('settings.enable_web_push_notifications') }} + </Checkbox> + </li> + </ul> + </div> + + <div class="setting-item"> + <h2>{{ $t('settings.fun') }}</h2> + <ul class="setting-list"> + <li> + <Checkbox v-model="greentext"> + {{ $t('settings.greentext') }} {{ $t('settings.instance_default', { value: greentextLocalizedValue }) }} + </Checkbox> + </li> + </ul> + </div> + </div> +</template> + +<script src="./general_tab.js"></script> diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.js b/src/components/settings_modal/tabs/mutes_and_blocks_tab.js new file mode 100644 index 00000000..40a87b81 --- /dev/null +++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.js @@ -0,0 +1,136 @@ +import get from 'lodash/get' +import map from 'lodash/map' +import reject from 'lodash/reject' +import Autosuggest from 'src/components/autosuggest/autosuggest.vue' +import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js' +import BlockCard from 'src/components/block_card/block_card.vue' +import MuteCard from 'src/components/mute_card/mute_card.vue' +import DomainMuteCard from 'src/components/domain_mute_card/domain_mute_card.vue' +import SelectableList from 'src/components/selectable_list/selectable_list.vue' +import ProgressButton from 'src/components/progress_button/progress_button.vue' +import withSubscription from 'src/components/../hocs/with_subscription/with_subscription' +import Checkbox from 'src/components/checkbox/checkbox.vue' + +const BlockList = withSubscription({ + fetch: (props, $store) => $store.dispatch('fetchBlocks'), + select: (props, $store) => get($store.state.users.currentUser, 'blockIds', []), + childPropName: 'items' +})(SelectableList) + +const MuteList = withSubscription({ + fetch: (props, $store) => $store.dispatch('fetchMutes'), + select: (props, $store) => get($store.state.users.currentUser, 'muteIds', []), + childPropName: 'items' +})(SelectableList) + +const DomainMuteList = withSubscription({ + fetch: (props, $store) => $store.dispatch('fetchDomainMutes'), + select: (props, $store) => get($store.state.users.currentUser, 'domainMutes', []), + childPropName: 'items' +})(SelectableList) + +const MutesAndBlocks = { + data () { + return { + activeTab: 'profile' + } + }, + created () { + this.$store.dispatch('fetchTokens') + this.$store.dispatch('getKnownDomains') + }, + components: { + TabSwitcher, + BlockList, + MuteList, + DomainMuteList, + BlockCard, + MuteCard, + DomainMuteCard, + ProgressButton, + Autosuggest, + Checkbox + }, + computed: { + knownDomains () { + return this.$store.state.instance.knownDomains + }, + user () { + return this.$store.state.users.currentUser + } + }, + methods: { + importFollows (file) { + return this.$store.state.api.backendInteractor.importFollows({ file }) + .then((status) => { + if (!status) { + throw new Error('failed') + } + }) + }, + importBlocks (file) { + return this.$store.state.api.backendInteractor.importBlocks({ file }) + .then((status) => { + if (!status) { + throw new Error('failed') + } + }) + }, + generateExportableUsersContent (users) { + // Get addresses + return users.map((user) => { + // check is it's a local user + if (user && user.is_local) { + // append the instance address + // eslint-disable-next-line no-undef + return user.screen_name + '@' + location.hostname + } + return user.screen_name + }).join('\n') + }, + activateTab (tabName) { + this.activeTab = tabName + }, + filterUnblockedUsers (userIds) { + return reject(userIds, (userId) => { + const relationship = this.$store.getters.relationship(this.userId) + return relationship.blocking || userId === this.user.id + }) + }, + filterUnMutedUsers (userIds) { + return reject(userIds, (userId) => { + const relationship = this.$store.getters.relationship(this.userId) + return relationship.muting || userId === this.user.id + }) + }, + queryUserIds (query) { + return this.$store.dispatch('searchUsers', { query }) + .then((users) => map(users, 'id')) + }, + blockUsers (ids) { + return this.$store.dispatch('blockUsers', ids) + }, + unblockUsers (ids) { + return this.$store.dispatch('unblockUsers', ids) + }, + muteUsers (ids) { + return this.$store.dispatch('muteUsers', ids) + }, + unmuteUsers (ids) { + return this.$store.dispatch('unmuteUsers', ids) + }, + filterUnMutedDomains (urls) { + return urls.filter(url => !this.user.domainMutes.includes(url)) + }, + queryKnownDomains (query) { + return new Promise((resolve, reject) => { + resolve(this.knownDomains.filter(url => url.toLowerCase().includes(query))) + }) + }, + unmuteDomains (domains) { + return this.$store.dispatch('unmuteDomains', domains) + } + } +} + +export default MutesAndBlocks diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.scss b/src/components/settings_modal/tabs/mutes_and_blocks_tab.scss new file mode 100644 index 00000000..ceb64efb --- /dev/null +++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.scss @@ -0,0 +1,29 @@ +.mutes-and-blocks-tab { + height: 100%; + + .usersearch-wrapper { + padding: 1em; + } + + .bulk-actions { + text-align: right; + padding: 0 1em; + min-height: 28px; + } + + .bulk-action-button { + width: 10em + } + + .domain-mute-form { + padding: 1em; + display: flex; + flex-direction: column + } + + .domain-mute-button { + align-self: flex-end; + margin-top: 1em; + width: 10em + } +} diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue b/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue new file mode 100644 index 00000000..5a1cf2c0 --- /dev/null +++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue @@ -0,0 +1,171 @@ +<template> + <tab-switcher + :scrollable-tabs="true" + class="mutes-and-blocks-tab" + > + <div :label="$t('settings.blocks_tab')"> + <div class="usersearch-wrapper"> + <Autosuggest + :filter="filterUnblockedUsers" + :query="queryUserIds" + :placeholder="$t('settings.search_user_to_block')" + > + <BlockCard + slot-scope="row" + :user-id="row.item" + /> + </Autosuggest> + </div> + <BlockList + :refresh="true" + :get-key="i => i" + > + <template + slot="header" + slot-scope="{selected}" + > + <div class="bulk-actions"> + <ProgressButton + v-if="selected.length > 0" + class="btn btn-default bulk-action-button" + :click="() => blockUsers(selected)" + > + {{ $t('user_card.block') }} + <template slot="progress"> + {{ $t('user_card.block_progress') }} + </template> + </ProgressButton> + <ProgressButton + v-if="selected.length > 0" + class="btn btn-default" + :click="() => unblockUsers(selected)" + > + {{ $t('user_card.unblock') }} + <template slot="progress"> + {{ $t('user_card.unblock_progress') }} + </template> + </ProgressButton> + </div> + </template> + <template + slot="item" + slot-scope="{item}" + > + <BlockCard :user-id="item" /> + </template> + <template slot="empty"> + {{ $t('settings.no_blocks') }} + </template> + </BlockList> + </div> + + <div :label="$t('settings.mutes_tab')"> + <tab-switcher> + <div label="Users"> + <div class="usersearch-wrapper"> + <Autosuggest + :filter="filterUnMutedUsers" + :query="queryUserIds" + :placeholder="$t('settings.search_user_to_mute')" + > + <MuteCard + slot-scope="row" + :user-id="row.item" + /> + </Autosuggest> + </div> + <MuteList + :refresh="true" + :get-key="i => i" + > + <template + slot="header" + slot-scope="{selected}" + > + <div class="bulk-actions"> + <ProgressButton + v-if="selected.length > 0" + class="btn btn-default" + :click="() => muteUsers(selected)" + > + {{ $t('user_card.mute') }} + <template slot="progress"> + {{ $t('user_card.mute_progress') }} + </template> + </ProgressButton> + <ProgressButton + v-if="selected.length > 0" + class="btn btn-default" + :click="() => unmuteUsers(selected)" + > + {{ $t('user_card.unmute') }} + <template slot="progress"> + {{ $t('user_card.unmute_progress') }} + </template> + </ProgressButton> + </div> + </template> + <template + slot="item" + slot-scope="{item}" + > + <MuteCard :user-id="item" /> + </template> + <template slot="empty"> + {{ $t('settings.no_mutes') }} + </template> + </MuteList> + </div> + + <div :label="$t('settings.domain_mutes')"> + <div class="domain-mute-form"> + <Autosuggest + :filter="filterUnMutedDomains" + :query="queryKnownDomains" + :placeholder="$t('settings.type_domains_to_mute')" + > + <DomainMuteCard + slot-scope="row" + :domain="row.item" + /> + </Autosuggest> + </div> + <DomainMuteList + :refresh="true" + :get-key="i => i" + > + <template + slot="header" + slot-scope="{selected}" + > + <div class="bulk-actions"> + <ProgressButton + v-if="selected.length > 0" + class="btn btn-default" + :click="() => unmuteDomains(selected)" + > + {{ $t('domain_mute_card.unmute') }} + <template slot="progress"> + {{ $t('domain_mute_card.unmute_progress') }} + </template> + </ProgressButton> + </div> + </template> + <template + slot="item" + slot-scope="{item}" + > + <DomainMuteCard :domain="item" /> + </template> + <template slot="empty"> + {{ $t('settings.no_mutes') }} + </template> + </DomainMuteList> + </div> + </tab-switcher> + </div> + </tab-switcher> +</template> + +<script src="./mutes_and_blocks_tab.js"></script> +<style lang="scss" src="./mutes_and_blocks_tab.scss"></style> diff --git a/src/components/settings_modal/tabs/notifications_tab.js b/src/components/settings_modal/tabs/notifications_tab.js new file mode 100644 index 00000000..3e44c95d --- /dev/null +++ b/src/components/settings_modal/tabs/notifications_tab.js @@ -0,0 +1,27 @@ +import Checkbox from 'src/components/checkbox/checkbox.vue' + +const NotificationsTab = { + data () { + return { + activeTab: 'profile', + notificationSettings: this.$store.state.users.currentUser.notification_settings, + newDomainToMute: '' + } + }, + components: { + Checkbox + }, + computed: { + user () { + return this.$store.state.users.currentUser + } + }, + methods: { + updateNotificationSettings () { + this.$store.state.api.backendInteractor + .updateNotificationSettings({ settings: this.notificationSettings }) + } + } +} + +export default NotificationsTab diff --git a/src/components/settings_modal/tabs/notifications_tab.vue b/src/components/settings_modal/tabs/notifications_tab.vue new file mode 100644 index 00000000..b7a3cb37 --- /dev/null +++ b/src/components/settings_modal/tabs/notifications_tab.vue @@ -0,0 +1,54 @@ +<template> + <div :label="$t('settings.notifications')"> + <div class="setting-item"> + <h2>{{ $t('settings.notification_setting_filters') }}</h2> + <div class="select-multiple"> + <span class="label">{{ $t('settings.notification_setting') }}</span> + <ul class="option-list"> + <li> + <Checkbox v-model="notificationSettings.follows"> + {{ $t('settings.notification_setting_follows') }} + </Checkbox> + </li> + <li> + <Checkbox v-model="notificationSettings.followers"> + {{ $t('settings.notification_setting_followers') }} + </Checkbox> + </li> + <li> + <Checkbox v-model="notificationSettings.non_follows"> + {{ $t('settings.notification_setting_non_follows') }} + </Checkbox> + </li> + <li> + <Checkbox v-model="notificationSettings.non_followers"> + {{ $t('settings.notification_setting_non_followers') }} + </Checkbox> + </li> + </ul> + </div> + </div> + + <div class="setting-item"> + <h2>{{ $t('settings.notification_setting_privacy') }}</h2> + <p> + <Checkbox v-model="notificationSettings.privacy_option"> + {{ $t('settings.notification_setting_privacy_option') }} + </Checkbox> + </p> + </div> + <div class="setting-item"> + <p>{{ $t('settings.notification_mutes') }}</p> + <p>{{ $t('settings.notification_blocks') }}</p> + <button + class="btn btn-default" + @click="updateNotificationSettings" + > + {{ $t('general.submit') }} + </button> + </div> + </div> +</template> + +<script src="./notifications_tab.js"></script> +<!-- <style lang="scss" src="./profile.scss"></style> --> diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js new file mode 100644 index 00000000..8658b097 --- /dev/null +++ b/src/components/settings_modal/tabs/profile_tab.js @@ -0,0 +1,179 @@ +import unescape from 'lodash/unescape' +import ImageCropper from 'src/components/image_cropper/image_cropper.vue' +import ScopeSelector from 'src/components/scope_selector/scope_selector.vue' +import fileSizeFormatService from 'src/components/../services/file_size_format/file_size_format.js' +import ProgressButton from 'src/components/progress_button/progress_button.vue' +import EmojiInput from 'src/components/emoji_input/emoji_input.vue' +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' + +const ProfileTab = { + data () { + return { + newName: this.$store.state.users.currentUser.name, + newBio: unescape(this.$store.state.users.currentUser.description), + newLocked: this.$store.state.users.currentUser.locked, + newNoRichText: this.$store.state.users.currentUser.no_rich_text, + newDefaultScope: this.$store.state.users.currentUser.default_scope, + hideFollows: this.$store.state.users.currentUser.hide_follows, + hideFollowers: this.$store.state.users.currentUser.hide_followers, + hideFollowsCount: this.$store.state.users.currentUser.hide_follows_count, + hideFollowersCount: this.$store.state.users.currentUser.hide_followers_count, + showRole: this.$store.state.users.currentUser.show_role, + role: this.$store.state.users.currentUser.role, + discoverable: this.$store.state.users.currentUser.discoverable, + allowFollowingMove: this.$store.state.users.currentUser.allow_following_move, + pickAvatarBtnVisible: true, + bannerUploading: false, + backgroundUploading: false, + banner: null, + bannerPreview: null, + background: null, + backgroundPreview: null, + bannerUploadError: null, + backgroundUploadError: null + } + }, + components: { + ScopeSelector, + ImageCropper, + EmojiInput, + Autosuggest, + ProgressButton, + Checkbox + }, + computed: { + user () { + return this.$store.state.users.currentUser + }, + emojiUserSuggestor () { + return suggestor({ + emoji: [ + ...this.$store.state.instance.emoji, + ...this.$store.state.instance.customEmoji + ], + users: this.$store.state.users.users, + updateUsersList: (query) => this.$store.dispatch('searchUsers', { query }) + }) + }, + emojiSuggestor () { + return suggestor({ emoji: [ + ...this.$store.state.instance.emoji, + ...this.$store.state.instance.customEmoji + ] }) + } + }, + methods: { + updateProfile () { + this.$store.state.api.backendInteractor + .updateProfile({ + params: { + note: this.newBio, + locked: this.newLocked, + // Backend notation. + /* eslint-disable camelcase */ + display_name: this.newName, + default_scope: this.newDefaultScope, + no_rich_text: this.newNoRichText, + hide_follows: this.hideFollows, + hide_followers: this.hideFollowers, + discoverable: this.discoverable, + allow_following_move: this.allowFollowingMove, + hide_follows_count: this.hideFollowsCount, + hide_followers_count: this.hideFollowersCount, + show_role: this.showRole + /* eslint-enable camelcase */ + } }).then((user) => { + this.$store.commit('addNewUsers', [user]) + this.$store.commit('setCurrentUser', user) + }) + }, + changeVis (visibility) { + this.newDefaultScope = visibility + }, + uploadFile (slot, e) { + const file = e.target.files[0] + if (!file) { return } + if (file.size > this.$store.state.instance[slot + 'limit']) { + const filesize = fileSizeFormatService.fileSizeFormat(file.size) + const allowedsize = fileSizeFormatService.fileSizeFormat(this.$store.state.instance[slot + 'limit']) + this[slot + 'UploadError'] = [ + this.$t('upload.error.base'), + this.$t( + 'upload.error.file_too_big', + { + filesize: filesize.num, + filesizeunit: filesize.unit, + allowedsize: allowedsize.num, + allowedsizeunit: allowedsize.unit + } + ) + ].join(' ') + return + } + // eslint-disable-next-line no-undef + const reader = new FileReader() + reader.onload = ({ target }) => { + const img = target.result + this[slot + 'Preview'] = img + this[slot] = file + } + reader.readAsDataURL(file) + }, + submitAvatar (cropper, file) { + const that = this + return new Promise((resolve, reject) => { + function updateAvatar (avatar) { + that.$store.state.api.backendInteractor.updateAvatar({ avatar }) + .then((user) => { + that.$store.commit('addNewUsers', [user]) + that.$store.commit('setCurrentUser', user) + resolve() + }) + .catch((err) => { + reject(new Error(that.$t('upload.error.base') + ' ' + err.message)) + }) + } + + if (cropper) { + cropper.getCroppedCanvas().toBlob(updateAvatar, file.type) + } else { + updateAvatar(file) + } + }) + }, + submitBanner () { + if (!this.bannerPreview) { return } + + this.bannerUploading = true + this.$store.state.api.backendInteractor.updateBanner({ banner: this.banner }) + .then((user) => { + this.$store.commit('addNewUsers', [user]) + this.$store.commit('setCurrentUser', user) + this.bannerPreview = null + }) + .catch((err) => { + this.bannerUploadError = this.$t('upload.error.base') + ' ' + err.message + }) + .then(() => { this.bannerUploading = false }) + }, + submitBg () { + if (!this.backgroundPreview) { return } + let background = this.background + this.backgroundUploading = true + this.$store.state.api.backendInteractor.updateBg({ background }).then((data) => { + if (!data.error) { + this.$store.commit('addNewUsers', [data]) + this.$store.commit('setCurrentUser', data) + this.backgroundPreview = null + } else { + this.backgroundUploadError = this.$t('upload.error.base') + data.error + } + this.backgroundUploading = false + }) + } + } +} + +export default ProfileTab diff --git a/src/components/settings_modal/tabs/profile_tab.scss b/src/components/settings_modal/tabs/profile_tab.scss new file mode 100644 index 00000000..4aab81eb --- /dev/null +++ b/src/components/settings_modal/tabs/profile_tab.scss @@ -0,0 +1,82 @@ +@import '../../../_variables.scss'; +.profile-tab { + .bio { + margin: 0; + } + + .visibility-tray { + padding-top: 5px; + } + + input[type=file] { + padding: 5px; + height: auto; + } + + .banner { + max-width: 100%; + } + + .uploading { + font-size: 1.5em; + margin: 0.25em; + } + + .name-changer { + width: 100%; + } + + .bg { + max-width: 100%; + } + + .current-avatar { + display: block; + width: 150px; + height: 150px; + border-radius: $fallback--avatarRadius; + border-radius: var(--avatarRadius, $fallback--avatarRadius); + } + + .oauth-tokens { + width: 100%; + + th { + text-align: left; + } + + .actions { + text-align: right; + } + } + + &-usersearch-wrapper { + padding: 1em; + } + + &-bulk-actions { + text-align: right; + padding: 0 1em; + min-height: 28px; + + button { + width: 10em; + } + } + + &-domain-mute-form { + padding: 1em; + display: flex; + flex-direction: column; + + button { + align-self: flex-end; + margin-top: 1em; + width: 10em; + } + } + + .setting-subitem { + margin-left: 1.75em; + } +} diff --git a/src/components/settings_modal/tabs/profile_tab.vue b/src/components/settings_modal/tabs/profile_tab.vue new file mode 100644 index 00000000..fff4f970 --- /dev/null +++ b/src/components/settings_modal/tabs/profile_tab.vue @@ -0,0 +1,213 @@ +<template> + <div class="profile-tab"> + <div class="setting-item"> + <h2>{{ $t('settings.name_bio') }}</h2> + <p>{{ $t('settings.name') }}</p> + <EmojiInput + v-model="newName" + enable-emoji-picker + :suggest="emojiSuggestor" + > + <input + id="username" + v-model="newName" + classname="name-changer" + > + </EmojiInput> + <p>{{ $t('settings.bio') }}</p> + <EmojiInput + v-model="newBio" + enable-emoji-picker + :suggest="emojiUserSuggestor" + > + <textarea + v-model="newBio" + classname="bio" + /> + </EmojiInput> + <p> + <Checkbox v-model="newLocked"> + {{ $t('settings.lock_account_description') }} + </Checkbox> + </p> + <div> + <label for="default-vis">{{ $t('settings.default_vis') }}</label> + <div + id="default-vis" + class="visibility-tray" + > + <scope-selector + :show-all="true" + :user-default="newDefaultScope" + :initial-scope="newDefaultScope" + :on-scope-change="changeVis" + /> + </div> + </div> + <p> + <Checkbox v-model="newNoRichText"> + {{ $t('settings.no_rich_text_description') }} + </Checkbox> + </p> + <p> + <Checkbox v-model="hideFollows"> + {{ $t('settings.hide_follows_description') }} + </Checkbox> + </p> + <p class="setting-subitem"> + <Checkbox + v-model="hideFollowsCount" + :disabled="!hideFollows" + > + {{ $t('settings.hide_follows_count_description') }} + </Checkbox> + </p> + <p> + <Checkbox v-model="hideFollowers"> + {{ $t('settings.hide_followers_description') }} + </Checkbox> + </p> + <p class="setting-subitem"> + <Checkbox + v-model="hideFollowersCount" + :disabled="!hideFollowers" + > + {{ $t('settings.hide_followers_count_description') }} + </Checkbox> + </p> + <p> + <Checkbox v-model="allowFollowingMove"> + {{ $t('settings.allow_following_move') }} + </Checkbox> + </p> + <p v-if="role === 'admin' || role === 'moderator'"> + <Checkbox v-model="showRole"> + <template v-if="role === 'admin'"> + {{ $t('settings.show_admin_badge') }} + </template> + <template v-if="role === 'moderator'"> + {{ $t('settings.show_moderator_badge') }} + </template> + </Checkbox> + </p> + <p> + <Checkbox v-model="discoverable"> + {{ $t('settings.discoverable') }} + </Checkbox> + </p> + <button + :disabled="newName && newName.length === 0" + class="btn btn-default" + @click="updateProfile" + > + {{ $t('general.submit') }} + </button> + </div> + <div class="setting-item"> + <h2>{{ $t('settings.avatar') }}</h2> + <p class="visibility-notice"> + {{ $t('settings.avatar_size_instruction') }} + </p> + <p>{{ $t('settings.current_avatar') }}</p> + <img + :src="user.profile_image_url_original" + class="current-avatar" + > + <p>{{ $t('settings.set_new_avatar') }}</p> + <button + v-show="pickAvatarBtnVisible" + id="pick-avatar" + class="btn" + type="button" + > + {{ $t('settings.upload_a_photo') }} + </button> + <image-cropper + trigger="#pick-avatar" + :submit-handler="submitAvatar" + @open="pickAvatarBtnVisible=false" + @close="pickAvatarBtnVisible=true" + /> + </div> + <div class="setting-item"> + <h2>{{ $t('settings.profile_banner') }}</h2> + <p>{{ $t('settings.current_profile_banner') }}</p> + <img + :src="user.cover_photo" + class="banner" + > + <p>{{ $t('settings.set_new_profile_banner') }}</p> + <img + v-if="bannerPreview" + class="banner" + :src="bannerPreview" + > + <div> + <input + type="file" + @change="uploadFile('banner', $event)" + > + </div> + <i + v-if="bannerUploading" + class=" icon-spin4 animate-spin uploading" + /> + <button + v-else-if="bannerPreview" + class="btn btn-default" + @click="submitBanner" + > + {{ $t('general.submit') }} + </button> + <div + v-if="bannerUploadError" + class="alert error" + > + Error: {{ bannerUploadError }} + <i + class="button-icon icon-cancel" + @click="clearUploadError('banner')" + /> + </div> + </div> + <div class="setting-item"> + <h2>{{ $t('settings.profile_background') }}</h2> + <p>{{ $t('settings.set_new_profile_background') }}</p> + <img + v-if="backgroundPreview" + class="bg" + :src="backgroundPreview" + > + <div> + <input + type="file" + @change="uploadFile('background', $event)" + > + </div> + <i + v-if="backgroundUploading" + class=" icon-spin4 animate-spin uploading" + /> + <button + v-else-if="backgroundPreview" + class="btn btn-default" + @click="submitBg" + > + {{ $t('general.submit') }} + </button> + <div + v-if="backgroundUploadError" + class="alert error" + > + Error: {{ backgroundUploadError }} + <i + class="button-icon icon-cancel" + @click="clearUploadError('background')" + /> + </div> + </div> + </div> +</template> + +<script src="./profile_tab.js"></script> +<style lang="scss" src="./profile_tab.scss"></style> diff --git a/src/components/user_settings/confirm.js b/src/components/settings_modal/tabs/security_tab/confirm.js index 0f4ddfc9..0f4ddfc9 100644 --- a/src/components/user_settings/confirm.js +++ b/src/components/settings_modal/tabs/security_tab/confirm.js diff --git a/src/components/user_settings/confirm.vue b/src/components/settings_modal/tabs/security_tab/confirm.vue index 69b3811b..69b3811b 100644 --- a/src/components/user_settings/confirm.vue +++ b/src/components/settings_modal/tabs/security_tab/confirm.vue diff --git a/src/components/user_settings/mfa.js b/src/components/settings_modal/tabs/security_tab/mfa.js index abf37062..abf37062 100644 --- a/src/components/user_settings/mfa.js +++ b/src/components/settings_modal/tabs/security_tab/mfa.js diff --git a/src/components/user_settings/mfa.vue b/src/components/settings_modal/tabs/security_tab/mfa.vue index 14ea10a1..7aca3c8d 100644 --- a/src/components/user_settings/mfa.vue +++ b/src/components/settings_modal/tabs/security_tab/mfa.vue @@ -137,20 +137,20 @@ <script src="./mfa.js"></script> <style lang="scss"> -@import '../../_variables.scss'; -.warning { - color: $fallback--cOrange; - color: var(--cOrange, $fallback--cOrange); -} +@import '../../../../_variables.scss'; .mfa-settings { .mfa-heading, .method-item { - overflow: hidden; display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; } + .warning { + color: $fallback--cOrange; + color: var(--cOrange, $fallback--cOrange); + } + .setup-otp { display: flex; justify-content: center; diff --git a/src/components/user_settings/mfa_backup_codes.js b/src/components/settings_modal/tabs/security_tab/mfa_backup_codes.js index f0a984ec..f0a984ec 100644 --- a/src/components/user_settings/mfa_backup_codes.js +++ b/src/components/settings_modal/tabs/security_tab/mfa_backup_codes.js diff --git a/src/components/user_settings/mfa_backup_codes.vue b/src/components/settings_modal/tabs/security_tab/mfa_backup_codes.vue index e6c8ede2..d7e98b3c 100644 --- a/src/components/user_settings/mfa_backup_codes.vue +++ b/src/components/settings_modal/tabs/security_tab/mfa_backup_codes.vue @@ -1,5 +1,5 @@ <template> - <div> + <div class="mfa-backup-codes"> <h4 v-if="displayTitle"> {{ $t('settings.mfa.recovery_codes') }} </h4> @@ -21,13 +21,15 @@ </template> <script src="./mfa_backup_codes.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import '../../../../_variables.scss'; -.warning { - color: $fallback--cOrange; - color: var(--cOrange, $fallback--cOrange); -} -.backup-codes { - font-family: var(--postCodeFont, monospace); +.mfa-backup-codes { + .warning { + color: $fallback--cOrange; + color: var(--cOrange, $fallback--cOrange); + } + .backup-codes { + font-family: var(--postCodeFont, monospace); + } } </style> diff --git a/src/components/user_settings/mfa_totp.js b/src/components/settings_modal/tabs/security_tab/mfa_totp.js index 8408d8e9..8408d8e9 100644 --- a/src/components/user_settings/mfa_totp.js +++ b/src/components/settings_modal/tabs/security_tab/mfa_totp.js diff --git a/src/components/user_settings/mfa_totp.vue b/src/components/settings_modal/tabs/security_tab/mfa_totp.vue index c6f2cc7b..c6f2cc7b 100644 --- a/src/components/user_settings/mfa_totp.vue +++ b/src/components/settings_modal/tabs/security_tab/mfa_totp.vue diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.js b/src/components/settings_modal/tabs/security_tab/security_tab.js new file mode 100644 index 00000000..811161a5 --- /dev/null +++ b/src/components/settings_modal/tabs/security_tab/security_tab.js @@ -0,0 +1,106 @@ +import ProgressButton from 'src/components/progress_button/progress_button.vue' +import Checkbox from 'src/components/checkbox/checkbox.vue' +import Mfa from './mfa.vue' + +const SecurityTab = { + data () { + return { + newEmail: '', + changeEmailError: false, + changeEmailPassword: '', + changedEmail: false, + deletingAccount: false, + deleteAccountConfirmPasswordInput: '', + deleteAccountError: false, + changePasswordInputs: [ '', '', '' ], + changedPassword: false, + changePasswordError: false + } + }, + created () { + this.$store.dispatch('fetchTokens') + }, + components: { + ProgressButton, + Mfa, + Checkbox + }, + computed: { + user () { + return this.$store.state.users.currentUser + }, + pleromaBackend () { + return this.$store.state.instance.pleromaBackend + }, + oauthTokens () { + return this.$store.state.oauthTokens.tokens.map(oauthToken => { + return { + id: oauthToken.id, + appName: oauthToken.app_name, + validUntil: new Date(oauthToken.valid_until).toLocaleDateString() + } + }) + } + }, + methods: { + confirmDelete () { + this.deletingAccount = true + }, + deleteAccount () { + this.$store.state.api.backendInteractor.deleteAccount({ password: this.deleteAccountConfirmPasswordInput }) + .then((res) => { + if (res.status === 'success') { + this.$store.dispatch('logout') + this.$router.push({ name: 'root' }) + } else { + this.deleteAccountError = res.error + } + }) + }, + changePassword () { + const params = { + password: this.changePasswordInputs[0], + newPassword: this.changePasswordInputs[1], + newPasswordConfirmation: this.changePasswordInputs[2] + } + this.$store.state.api.backendInteractor.changePassword(params) + .then((res) => { + if (res.status === 'success') { + this.changedPassword = true + this.changePasswordError = false + this.logout() + } else { + this.changedPassword = false + this.changePasswordError = res.error + } + }) + }, + changeEmail () { + const params = { + email: this.newEmail, + password: this.changeEmailPassword + } + this.$store.state.api.backendInteractor.changeEmail(params) + .then((res) => { + if (res.status === 'success') { + this.changedEmail = true + this.changeEmailError = false + } else { + this.changedEmail = false + this.changeEmailError = res.error + } + }) + }, + logout () { + this.$store.dispatch('logout') + this.$router.replace('/') + }, + revokeToken (id) { + if (window.confirm(`${this.$i18n.t('settings.revoke_token')}?`)) { + this.$store.dispatch('revokeToken', id) + } + } + } +} + +export default SecurityTab diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.vue b/src/components/settings_modal/tabs/security_tab/security_tab.vue new file mode 100644 index 00000000..3d32d73d --- /dev/null +++ b/src/components/settings_modal/tabs/security_tab/security_tab.vue @@ -0,0 +1,143 @@ +<template> + <div :label="$t('settings.security_tab')"> + <div class="setting-item"> + <h2>{{ $t('settings.change_email') }}</h2> + <div> + <p>{{ $t('settings.new_email') }}</p> + <input + v-model="newEmail" + type="email" + autocomplete="email" + > + </div> + <div> + <p>{{ $t('settings.current_password') }}</p> + <input + v-model="changeEmailPassword" + type="password" + autocomplete="current-password" + > + </div> + <button + class="btn btn-default" + @click="changeEmail" + > + {{ $t('general.submit') }} + </button> + <p v-if="changedEmail"> + {{ $t('settings.changed_email') }} + </p> + <template v-if="changeEmailError !== false"> + <p>{{ $t('settings.change_email_error') }}</p> + <p>{{ changeEmailError }}</p> + </template> + </div> + + <div class="setting-item"> + <h2>{{ $t('settings.change_password') }}</h2> + <div> + <p>{{ $t('settings.current_password') }}</p> + <input + v-model="changePasswordInputs[0]" + type="password" + > + </div> + <div> + <p>{{ $t('settings.new_password') }}</p> + <input + v-model="changePasswordInputs[1]" + type="password" + > + </div> + <div> + <p>{{ $t('settings.confirm_new_password') }}</p> + <input + v-model="changePasswordInputs[2]" + type="password" + > + </div> + <button + class="btn btn-default" + @click="changePassword" + > + {{ $t('general.submit') }} + </button> + <p v-if="changedPassword"> + {{ $t('settings.changed_password') }} + </p> + <p v-else-if="changePasswordError !== false"> + {{ $t('settings.change_password_error') }} + </p> + <p v-if="changePasswordError"> + {{ changePasswordError }} + </p> + </div> + + <div class="setting-item"> + <h2>{{ $t('settings.oauth_tokens') }}</h2> + <table class="oauth-tokens"> + <thead> + <tr> + <th>{{ $t('settings.app_name') }}</th> + <th>{{ $t('settings.valid_until') }}</th> + <th /> + </tr> + </thead> + <tbody> + <tr + v-for="oauthToken in oauthTokens" + :key="oauthToken.id" + > + <td>{{ oauthToken.appName }}</td> + <td>{{ oauthToken.validUntil }}</td> + <td class="actions"> + <button + class="btn btn-default" + @click="revokeToken(oauthToken.id)" + > + {{ $t('settings.revoke_token') }} + </button> + </td> + </tr> + </tbody> + </table> + </div> + <mfa /> + <div class="setting-item"> + <h2>{{ $t('settings.delete_account') }}</h2> + <p v-if="!deletingAccount"> + {{ $t('settings.delete_account_description') }} + </p> + <div v-if="deletingAccount"> + <p>{{ $t('settings.delete_account_instructions') }}</p> + <p>{{ $t('login.password') }}</p> + <input + v-model="deleteAccountConfirmPasswordInput" + type="password" + > + <button + class="btn btn-default" + @click="deleteAccount" + > + {{ $t('settings.delete_account') }} + </button> + </div> + <p v-if="deleteAccountError !== false"> + {{ $t('settings.delete_account_error') }} + </p> + <p v-if="deleteAccountError"> + {{ deleteAccountError }} + </p> + <button + v-if="!deletingAccount" + class="btn btn-default" + @click="confirmDelete" + > + {{ $t('general.submit') }} + </button> + </div> + </div> +</template> + +<script src="./security_tab.js"></script> +<!-- <style lang="scss" src="./profile.scss"></style> --> diff --git a/src/components/style_switcher/preview.vue b/src/components/settings_modal/tabs/theme_tab/preview.vue index 9d984659..9d984659 100644 --- a/src/components/style_switcher/preview.vue +++ b/src/components/settings_modal/tabs/theme_tab/preview.vue diff --git a/src/components/style_switcher/style_switcher.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js index a7f586f4..9d61b0c4 100644 --- a/src/components/style_switcher/style_switcher.js +++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js @@ -3,7 +3,7 @@ import { rgb2hex, hex2rgb, getContrastRatioLayers -} from '../../services/color_convert/color_convert.js' +} from 'src/services/color_convert/color_convert.js' import { DEFAULT_SHADOWS, generateColors, @@ -14,26 +14,27 @@ import { getThemes, shadows2to3, colors2to3 -} from '../../services/style_setter/style_setter.js' +} from 'src/services/style_setter/style_setter.js' import { SLOT_INHERITANCE -} from '../../services/theme_data/pleromafe.js' +} from 'src/services/theme_data/pleromafe.js' import { CURRENT_VERSION, OPACITIES, getLayers, getOpacitySlot -} from '../../services/theme_data/theme_data.service.js' -import ColorInput from '../color_input/color_input.vue' -import RangeInput from '../range_input/range_input.vue' -import OpacityInput from '../opacity_input/opacity_input.vue' -import ShadowControl from '../shadow_control/shadow_control.vue' -import FontControl from '../font_control/font_control.vue' -import ContrastRatio from '../contrast_ratio/contrast_ratio.vue' -import TabSwitcher from '../tab_switcher/tab_switcher.js' +} from 'src/services/theme_data/theme_data.service.js' +import ColorInput from 'src/components/color_input/color_input.vue' +import RangeInput from 'src/components/range_input/range_input.vue' +import OpacityInput from 'src/components/opacity_input/opacity_input.vue' +import ShadowControl from 'src/components/shadow_control/shadow_control.vue' +import FontControl from 'src/components/font_control/font_control.vue' +import ContrastRatio from 'src/components/contrast_ratio/contrast_ratio.vue' +import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js' +import ExportImport from 'src/components/export_import/export_import.vue' +import Checkbox from 'src/components/checkbox/checkbox.vue' + import Preview from './preview.vue' -import ExportImport from '../export_import/export_import.vue' -import Checkbox from '../checkbox/checkbox.vue' // List of color values used in v1 const v1OnlyNames = [ diff --git a/src/components/style_switcher/style_switcher.scss b/src/components/settings_modal/tabs/theme_tab/theme_tab.scss index d2a40d13..926eceff 100644 --- a/src/components/style_switcher/style_switcher.scss +++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.scss @@ -1,5 +1,6 @@ -@import '../../_variables.scss'; -.style-switcher { +@import 'src/_variables.scss'; +.theme-tab { + padding-bottom: 2em; .theme-warning { display: flex; align-items: baseline; @@ -54,10 +55,6 @@ } } - .tab-switcher { - margin: 0 -1em; - } - .reset-container { flex-wrap: wrap; } @@ -98,20 +95,25 @@ align-items: baseline; width: 100%; min-height: 30px; - - .btn { - min-width: 1px; - flex: 0 auto; - padding: 0 1em; - } + margin-bottom: 1em; p { flex: 1; margin: 0; margin-right: .5em; } + } - margin-bottom: 1em; + .tab-header-buttons { + display: flex; + flex-direction: column; + + .btn { + min-width: 1px; + flex: 0 auto; + padding: 0 1em; + margin-bottom: .5em; + } } .shadow-selector { @@ -161,7 +163,7 @@ border-bottom: 1px dashed; border-color: $fallback--border; border-color: var(--border, $fallback--border); - margin: 1em -1em 0; + margin: 1em 0; padding: 1em; background: var(--body-background-image); background-size: cover; @@ -328,6 +330,14 @@ padding: 20px; } + .apply-container { + .btn { + min-height: 28px; + min-width: 10em; + padding: 0 2em; + } + } + .btn { margin-left: .25em; margin-right: .25em; diff --git a/src/components/style_switcher/style_switcher.vue b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue index 62c8e634..d14f854c 100644 --- a/src/components/style_switcher/style_switcher.vue +++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue @@ -1,5 +1,5 @@ <template> - <div class="style-switcher"> + <div class="theme-tab"> <div class="presets-container"> <div class="save-load"> <div @@ -126,18 +126,20 @@ > <div class="tab-header"> <p>{{ $t('settings.theme_help') }}</p> - <button - class="btn" - @click="clearOpacity" - > - {{ $t('settings.style.switcher.clear_opacity') }} - </button> - <button - class="btn" - @click="clearV1" - > - {{ $t('settings.style.switcher.clear_all') }} - </button> + <div class="tab-header-buttons"> + <button + class="btn" + @click="clearOpacity" + > + {{ $t('settings.style.switcher.clear_opacity') }} + </button> + <button + class="btn" + @click="clearV1" + > + {{ $t('settings.style.switcher.clear_all') }} + </button> + </div> </div> <p>{{ $t('settings.theme_help_v2_1') }}</p> <h4>{{ $t('settings.style.common_colors.main') }}</h4> @@ -254,6 +256,13 @@ :label="$t('settings.links')" /> <ContrastRatio :contrast="previewContrast.postLink" /> + <ColorInput + v-model="postGreentextColorLocal" + name="postGreentextColor" + :fallback="previewTheme.colors.cGreen" + :label="$t('settings.greentext')" + /> + <ContrastRatio :contrast="previewContrast.postGreentext" /> <h4>{{ $t('settings.style.advanced_colors.alert') }}</h4> <ColorInput v-model="alertErrorColorLocal" @@ -951,6 +960,6 @@ </div> </template> -<script src="./style_switcher.js"></script> +<script src="./theme_tab.js"></script> -<style src="./style_switcher.scss" lang="scss"></style> +<style src="./theme_tab.scss" lang="scss"></style> diff --git a/src/components/settings_modal/tabs/version_tab.js b/src/components/settings_modal/tabs/version_tab.js new file mode 100644 index 00000000..616bdadf --- /dev/null +++ b/src/components/settings_modal/tabs/version_tab.js @@ -0,0 +1,24 @@ +import { extractCommit } from 'src/services/version/version.service' + +const pleromaFeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma-fe/commit/' +const pleromaBeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma/commit/' + +const VersionTab = { + data () { + const instance = this.$store.state.instance + return { + backendVersion: instance.backendVersion, + frontendVersion: instance.frontendVersion + } + }, + computed: { + frontendVersionLink () { + return pleromaFeCommitUrl + this.frontendVersion + }, + backendVersionLink () { + return pleromaBeCommitUrl + extractCommit(this.backendVersion) + } + } +} + +export default VersionTab diff --git a/src/components/settings_modal/tabs/version_tab.vue b/src/components/settings_modal/tabs/version_tab.vue new file mode 100644 index 00000000..d35ff25e --- /dev/null +++ b/src/components/settings_modal/tabs/version_tab.vue @@ -0,0 +1,31 @@ +<template> + <div :label="$t('settings.version.title')"> + <div class="setting-item"> + <ul class="setting-list"> + <li> + <p>{{ $t('settings.version.backend_version') }}</p> + <ul class="option-list"> + <li> + <a + :href="backendVersionLink" + target="_blank" + >{{ backendVersion }}</a> + </li> + </ul> + </li> + <li> + <p>{{ $t('settings.version.frontend_version') }}</p> + <ul class="option-list"> + <li> + <a + :href="frontendVersionLink" + target="_blank" + >{{ frontendVersion }}</a> + </li> + </ul> + </li> + </ul> + </div> + </div> +</template> +<script src="./version_tab.js"> diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js index 2181ecc7..d1f044f6 100644 --- a/src/components/side_drawer/side_drawer.js +++ b/src/components/side_drawer/side_drawer.js @@ -62,6 +62,9 @@ const SideDrawer = { }, touchMove (e) { GestureService.updateSwipe(e, this.closeGesture) + }, + openSettingsModal () { + this.$store.dispatch('openSettingsModal') } } } diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue index df1d22a4..f253742d 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -19,7 +19,7 @@ > <UserCard v-if="currentUser" - :user="currentUser" + :user-id="currentUser.id" :hide-bio="true" /> <div @@ -122,9 +122,12 @@ </router-link> </li> <li @click="toggleDrawer"> - <router-link :to="{ name: 'settings' }"> + <a + href="#" + @click="openSettingsModal" + > <i class="button-icon icon-cog" /> {{ $t("settings.settings") }} - </router-link> + </a> </li> <li @click="toggleDrawer"> <router-link :to="{ name: 'about'}"> diff --git a/src/components/status/status.js b/src/components/status/status.js index fc5956ec..73382521 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -1,24 +1,19 @@ -import Attachment from '../attachment/attachment.vue' import FavoriteButton from '../favorite_button/favorite_button.vue' import ReactButton from '../react_button/react_button.vue' import RetweetButton from '../retweet_button/retweet_button.vue' -import Poll from '../poll/poll.vue' import ExtraButtons from '../extra_buttons/extra_buttons.vue' import PostStatusForm from '../post_status_form/post_status_form.vue' import UserCard from '../user_card/user_card.vue' import UserAvatar from '../user_avatar/user_avatar.vue' -import Gallery from '../gallery/gallery.vue' -import LinkPreview from '../link-preview/link-preview.vue' import AvatarList from '../avatar_list/avatar_list.vue' import Timeago from '../timeago/timeago.vue' +import StatusContent from '../status_content/status_content.vue' import StatusPopover from '../status_popover/status_popover.vue' import EmojiReactions from '../emoji_reactions/emoji_reactions.vue' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' -import fileType from 'src/services/file_type/file_type.service' -import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' -import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js' -import { filter, unescape, uniqBy } from 'lodash' +import { muteWordHits } from '../../services/status_parser/status_parser.js' +import { unescape, uniqBy } from 'lodash' import { mapGetters, mapState } from 'vuex' const Status = { @@ -43,20 +38,19 @@ const Status = { replying: false, unmuted: false, userExpanded: false, - showingTall: this.inConversation && this.focused, - showingLongSubject: false, - error: null, - // not as computed because it sets the initial state which will be changed later - expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject + error: null } }, computed: { - localCollapseSubjectDefault () { - return this.mergedConfig.collapseMessageWithSubject - }, muteWords () { return this.mergedConfig.muteWords }, + showReasonMutedThread () { + return ( + this.status.thread_muted || + (this.status.reblog && this.status.reblog.thread_muted) + ) && !this.inConversation + }, repeaterClass () { const user = this.statusoid.user return highlightClass(user) @@ -79,10 +73,6 @@ const Status = { const highlight = this.mergedConfig.highlight return highlightStyle(highlight[user.screen_name]) }, - hideAttachments () { - return (this.mergedConfig.hideAttachments && !this.inConversation) || - (this.mergedConfig.hideAttachmentsInConv && this.inConversation) - }, userProfileLink () { return this.generateUserProfileLink(this.status.user.id, this.status.user.screen_name) }, @@ -110,15 +100,43 @@ const Status = { return !!this.currentUser }, muteWordHits () { - const statusText = this.status.text.toLowerCase() - const statusSummary = this.status.summary.toLowerCase() - const hits = filter(this.muteWords, (muteWord) => { - return statusText.includes(muteWord.toLowerCase()) || statusSummary.includes(muteWord.toLowerCase()) - }) + return muteWordHits(this.status, this.muteWords) + }, + muted () { + const { status } = this + const { reblog } = status + const relationship = this.$store.getters.relationship(status.user.id) + const relationshipReblog = reblog && this.$store.getters.relationship(reblog.user.id) + const reasonsToMute = ( + // Post is muted according to BE + status.muted || + // Reprööt of a muted post according to BE + (reblog && reblog.muted) || + // Muted user + relationship.muting || + // Muted user of a reprööt + (relationshipReblog && relationshipReblog.muting) || + // Thread is muted + status.thread_muted || + // Wordfiltered + this.muteWordHits.length > 0 + ) + const excusesNotToMute = ( + ( + this.inProfile && ( + // Don't mute user's posts on user timeline (except reblogs) + (!reblog && status.user.id === this.profileUserId) || + // Same as above but also allow self-reblogs + (reblog && reblog.user.id === this.profileUserId) + ) + ) || + // Don't mute statuses in muted conversation when said conversation is opened + (this.inConversation && status.thread_muted) + // No excuses if post has muted words + ) && !this.muteWordHits.length > 0 - return hits + return !this.unmuted && !excusesNotToMute && reasonsToMute }, - muted () { return !this.unmuted && ((!(this.inProfile && this.status.user.id === this.profileUserId) && this.status.user.muted) || (!this.inConversation && this.status.thread_muted) || this.muteWordHits.length > 0) }, hideFilteredStatuses () { return this.mergedConfig.hideFilteredStatuses }, @@ -135,20 +153,6 @@ const Status = { // use conversation highlight only when in conversation return this.status.id === this.highlight }, - // This is a bit hacky, but we want to approximate post height before rendering - // so we count newlines (masto uses <p> for paragraphs, GS uses <br> between them) - // as well as approximate line count by counting characters and approximating ~80 - // per line. - // - // Using max-height + overflow: auto for status components resulted in false positives - // very often with japanese characters, and it was very annoying. - tallStatus () { - const lengthScore = this.status.statusnet_html.split(/<p|<br/).length + this.status.text.length / 80 - return lengthScore > 20 - }, - longSubject () { - return this.status.summary.length > 900 - }, isReply () { return !!(this.status.in_reply_to_status_id && this.status.in_reply_to_user_id) }, @@ -178,8 +182,11 @@ const Status = { if (this.status.user.id === this.status.attentions[i].id) { continue } - const taggedUser = this.$store.getters.findUser(this.status.attentions[i].id) - if (checkFollowing && taggedUser && taggedUser.following) { + // There's zero guarantee of this working. If we happen to have that user and their + // relationship in store then it will work, but there's kinda little chance of having + // them for people you're not following. + const relationship = this.$store.state.users.relationships[this.status.attentions[i].id] + if (checkFollowing && relationship && relationship.following) { return false } if (this.status.attentions[i].id === this.currentUser.id) { @@ -188,33 +195,6 @@ const Status = { } return this.status.attentions.length > 0 }, - hideSubjectStatus () { - if (this.tallStatus && !this.localCollapseSubjectDefault) { - return false - } - return !this.expandingSubject && this.status.summary - }, - hideTallStatus () { - if (this.status.summary && this.localCollapseSubjectDefault) { - return false - } - if (this.showingTall) { - return false - } - return this.tallStatus - }, - showingMore () { - return (this.tallStatus && this.showingTall) || (this.status.summary && this.expandingSubject) - }, - nsfwClickthrough () { - if (!this.status.nsfw) { - return false - } - if (this.status.summary && this.localCollapseSubjectDefault) { - return false - } - return true - }, replySubject () { if (!this.status.summary) return '' const decodedSummary = unescape(this.status.summary) @@ -228,83 +208,6 @@ const Status = { return '' } }, - attachmentSize () { - if ((this.mergedConfig.hideAttachments && !this.inConversation) || - (this.mergedConfig.hideAttachmentsInConv && this.inConversation) || - (this.status.attachments.length > this.maxThumbnails)) { - return 'hide' - } else if (this.compact) { - return 'small' - } - return 'normal' - }, - galleryTypes () { - if (this.attachmentSize === 'hide') { - return [] - } - return this.mergedConfig.playVideosInModal - ? ['image', 'video'] - : ['image'] - }, - galleryAttachments () { - return this.status.attachments.filter( - file => fileType.fileMatchesSomeType(this.galleryTypes, file) - ) - }, - nonGalleryAttachments () { - return this.status.attachments.filter( - file => !fileType.fileMatchesSomeType(this.galleryTypes, file) - ) - }, - hasImageAttachments () { - return this.status.attachments.some( - file => fileType.fileType(file.mimetype) === 'image' - ) - }, - hasVideoAttachments () { - return this.status.attachments.some( - file => fileType.fileType(file.mimetype) === 'video' - ) - }, - maxThumbnails () { - return this.mergedConfig.maxThumbnails - }, - postBodyHtml () { - const html = this.status.statusnet_html - - if (this.mergedConfig.greentext) { - try { - if (html.includes('>')) { - // This checks if post has '>' at the beginning, excluding mentions so that @mention >impying works - return processHtml(html, (string) => { - if (string.includes('>') && - string - .replace(/<[^>]+?>/gi, '') // remove all tags - .replace(/@\w+/gi, '') // remove mentions (even failed ones) - .trim() - .startsWith('>')) { - return `<span class='greentext'>${string}</span>` - } else { - return string - } - }) - } else { - return html - } - } catch (e) { - console.err('Failed to process status html', e) - return html - } - } else { - return html - } - }, - contentHtml () { - if (!this.status.summary_html) { - return this.postBodyHtml - } - return this.status.summary_html + '<br />' + this.postBodyHtml - }, combinedFavsAndRepeatsUsers () { // Use the status from the global status repository since favs and repeats are saved in it const combinedUsers = [].concat( @@ -313,9 +216,6 @@ const Status = { ) return uniqBy(combinedUsers, 'id') }, - ownStatus () { - return this.status.user.id === this.currentUser.id - }, tags () { return this.status.tags.filter(tagObj => tagObj.hasOwnProperty('name')).map(tagObj => tagObj.name).join(' ') }, @@ -329,21 +229,18 @@ const Status = { }) }, components: { - Attachment, FavoriteButton, ReactButton, RetweetButton, ExtraButtons, PostStatusForm, - Poll, UserCard, UserAvatar, - Gallery, - LinkPreview, AvatarList, Timeago, StatusPopover, - EmojiReactions + EmojiReactions, + StatusContent }, methods: { visibilityIcon (visibility) { @@ -364,32 +261,6 @@ const Status = { clearError () { this.error = undefined }, - linkClicked (event) { - const target = event.target.closest('.status-content a') - if (target) { - if (target.className.match(/mention/)) { - const href = target.href - const attn = this.status.attentions.find(attn => mentionMatchesUrl(attn, href)) - if (attn) { - event.stopPropagation() - event.preventDefault() - const link = this.generateUserProfileLink(attn.id, attn.screen_name) - this.$router.push(link) - return - } - } - if (target.rel.match(/(?:^|\s)tag(?:$|\s)/) || target.className.match(/hashtag/)) { - // Extract tag name from link url - const tag = extractTagFromUrl(target.href) - if (tag) { - const link = this.generateTagLink(tag) - this.$router.push(link) - return - } - } - window.open(target.href, '_blank') - } - }, toggleReplying () { this.replying = !this.replying }, @@ -407,26 +278,8 @@ const Status = { toggleUserExpanded () { this.userExpanded = !this.userExpanded }, - toggleShowMore () { - if (this.showingTall) { - this.showingTall = false - } else if (this.expandingSubject && this.status.summary) { - this.expandingSubject = false - } else if (this.hideTallStatus) { - this.showingTall = true - } else if (this.hideSubjectStatus && this.status.summary) { - this.expandingSubject = true - } - }, generateUserProfileLink (id, name) { return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames) - }, - generateTagLink (tag) { - return `/tag/${tag}` - }, - setMedia () { - const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments - return () => this.$store.dispatch('setMedia', attachments) } }, watch: { diff --git a/src/components/status/status.vue b/src/components/status/status.vue index a4d12e3e..0afb67fd 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -17,12 +17,33 @@ </div> <template v-if="muted && !isPreview"> <div class="media status container muted"> - <small> + <small class="username"> + <i + v-if="muted && retweet" + class="button-icon icon-retweet" + /> <router-link :to="userProfileLink"> {{ status.user.screen_name }} </router-link> </small> - <small class="muteWords">{{ muteWordHits.join(', ') }}</small> + <small + v-if="showReasonMutedThread" + class="mute-thread" + > + {{ $t('status.thread_muted') }} + </small> + <small + v-if="showReasonMutedThread && muteWordHits.length > 0" + class="mute-thread" + > + {{ $t('status.thread_muted_and_words') }} + </small> + <small + class="mute-words" + :title="muteWordHits.join(', ')" + > + {{ muteWordHits.join(', ') }} + </small> <a href="#" class="unmute" @@ -94,7 +115,7 @@ <div class="status-body"> <UserCard v-if="userExpanded" - :user="status.user" + :user-id="status.user.id" :rounded="true" :bordered="true" class="status-usercard" @@ -241,118 +262,12 @@ </div> </div> - <div - v-if="longSubject" - class="status-content-wrapper" - :class="{ 'tall-status': !showingLongSubject }" - > - <a - v-if="!showingLongSubject" - class="tall-status-hider" - :class="{ 'tall-status-hider_focused': isFocused }" - href="#" - @click.prevent="showingLongSubject=true" - >{{ $t("general.show_more") }}</a> - <div - class="status-content media-body" - @click.prevent="linkClicked" - v-html="contentHtml" - /> - <a - v-if="showingLongSubject" - href="#" - class="status-unhider" - @click.prevent="showingLongSubject=false" - >{{ $t("general.show_less") }}</a> - </div> - <div - v-else - :class="{'tall-status': hideTallStatus}" - class="status-content-wrapper" - > - <a - v-if="hideTallStatus" - class="tall-status-hider" - :class="{ 'tall-status-hider_focused': isFocused }" - href="#" - @click.prevent="toggleShowMore" - >{{ $t("general.show_more") }}</a> - <div - v-if="!hideSubjectStatus" - class="status-content media-body" - @click.prevent="linkClicked" - v-html="contentHtml" - /> - <div - v-else - class="status-content media-body" - @click.prevent="linkClicked" - v-html="status.summary_html" - /> - <a - v-if="hideSubjectStatus" - href="#" - class="cw-status-hider" - @click.prevent="toggleShowMore" - > - {{ $t("general.show_more") }} - <span - v-if="hasImageAttachments" - class="icon-picture" - /> - <span - v-if="hasVideoAttachments" - class="icon-video" - /> - <span - v-if="status.card" - class="icon-link" - /> - </a> - <a - v-if="showingMore" - href="#" - class="status-unhider" - @click.prevent="toggleShowMore" - >{{ $t("general.show_less") }}</a> - </div> - - <div v-if="status.poll && status.poll.options"> - <poll :base-poll="status.poll" /> - </div> - - <div - v-if="status.attachments && (!hideSubjectStatus || showingLongSubject)" - class="attachments media-body" - > - <attachment - v-for="attachment in nonGalleryAttachments" - :key="attachment.id" - class="non-gallery" - :size="attachmentSize" - :nsfw="nsfwClickthrough" - :attachment="attachment" - :allow-play="true" - :set-media="setMedia()" - /> - <gallery - v-if="galleryAttachments.length > 0" - :nsfw="nsfwClickthrough" - :attachments="galleryAttachments" - :set-media="setMedia()" - /> - </div> - - <div - v-if="status.card && !hideSubjectStatus && !noHeading" - class="link-preview media-body" - > - <link-preview - :card="status.card" - :size="attachmentSize" - :nsfw="nsfwClickthrough" - /> - </div> + <StatusContent + :status="status" + :no-heading="noHeading" + :highlight="highlight" + :focused="isFocused" + /> <transition name="fade"> <div @@ -419,7 +334,7 @@ :status="status" /> <ReactButton - :logged-in="loggedIn" + v-if="loggedIn" :status="status" /> <extra-buttons @@ -645,105 +560,6 @@ $status-margin: 0.75em; } } - .tall-status { - position: relative; - height: 220px; - overflow-x: hidden; - overflow-y: hidden; - z-index: 1; - .status-content { - height: 100%; - mask: linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat, - linear-gradient(to top, white, white); - /* Autoprefixed seem to ignore this one, and also syntax is different */ - -webkit-mask-composite: xor; - mask-composite: exclude; - } - } - - .tall-status-hider { - display: inline-block; - word-break: break-all; - position: absolute; - height: 70px; - margin-top: 150px; - width: 100%; - text-align: center; - line-height: 110px; - z-index: 2; - } - - .status-unhider, .cw-status-hider { - width: 100%; - text-align: center; - display: inline-block; - word-break: break-all; - } - - .status-content { - font-family: var(--postFont, sans-serif); - line-height: 1.4em; - white-space: pre-wrap; - - a { - color: $fallback--link; - color: var(--postLink, $fallback--link); - } - - img, video { - max-width: 100%; - max-height: 400px; - vertical-align: middle; - object-fit: contain; - - &.emoji { - width: 32px; - height: 32px; - } - } - - blockquote { - margin: 0.2em 0 0.2em 2em; - font-style: italic; - } - - pre { - overflow: auto; - } - - code, samp, kbd, var, pre { - font-family: var(--postCodeFont, monospace); - } - - p { - margin: 0 0 1em 0; - } - - p:last-child { - margin: 0 0 0 0; - } - - h1 { - font-size: 1.1em; - line-height: 1.2em; - margin: 1.4em 0; - } - - h2 { - font-size: 1.1em; - margin: 1.0em 0; - } - - h3 { - font-size: 1em; - margin: 1.2em 0; - } - - h4 { - margin: 1.1em 0; - } - } - .retweet-info { padding: 0.4em $status-margin; margin: 0; @@ -805,11 +621,6 @@ $status-margin: 0.75em; } } -.greentext { - color: $fallback--cGreen; - color: var(--cGreen, $fallback--cGreen); -} - .status-conversation { border-left-style: solid; } @@ -862,33 +673,54 @@ $status-margin: 0.75em; } .muted { - padding: 0.25em 0.5em; - button { - margin-left: auto; + padding: .25em .6em; + height: 1.2em; + line-height: 1.2em; + text-overflow: ellipsis; + overflow: hidden; + display: flex; + flex-wrap: nowrap; + + .username, .mute-thread, .mute-words { + word-wrap: normal; + word-break: normal; + white-space: nowrap; } - .muteWords { - margin-left: 10px; + .username, .mute-words { + text-overflow: ellipsis; + overflow: hidden; } -} -a.unmute { - display: block; - margin-left: auto; + .username { + flex: 0 1 auto; + margin-right: .2em; + } + + .mute-thread { + flex: 0 0 auto; + } + + .mute-words { + flex: 1 0 5em; + margin-left: .2em; + &::before { + content: ' ' + } + } + + .unmute { + flex: 0 0 auto; + margin-left: auto; + display: block; + margin-left: auto; + } } .reply-body { flex: 1; } -.timeline :not(.panel-disabled) > { - .status-el:last-child { - border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius; - border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius); - border-bottom: none; - } -} - .favs-repeated-users { margin-top: $status-margin; diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js new file mode 100644 index 00000000..c0a71e8f --- /dev/null +++ b/src/components/status_content/status_content.js @@ -0,0 +1,210 @@ +import Attachment from '../attachment/attachment.vue' +import Poll from '../poll/poll.vue' +import Gallery from '../gallery/gallery.vue' +import LinkPreview from '../link-preview/link-preview.vue' +import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' +import fileType from 'src/services/file_type/file_type.service' +import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js' +import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js' +import { mapGetters, mapState } from 'vuex' + +const StatusContent = { + name: 'StatusContent', + props: [ + 'status', + 'focused', + 'noHeading', + 'fullContent' + ], + data () { + return { + showingTall: this.inConversation && this.focused, + showingLongSubject: false, + // not as computed because it sets the initial state which will be changed later + expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject + } + }, + computed: { + localCollapseSubjectDefault () { + return this.mergedConfig.collapseMessageWithSubject + }, + hideAttachments () { + return (this.mergedConfig.hideAttachments && !this.inConversation) || + (this.mergedConfig.hideAttachmentsInConv && this.inConversation) + }, + // This is a bit hacky, but we want to approximate post height before rendering + // so we count newlines (masto uses <p> for paragraphs, GS uses <br> between them) + // as well as approximate line count by counting characters and approximating ~80 + // per line. + // + // Using max-height + overflow: auto for status components resulted in false positives + // very often with japanese characters, and it was very annoying. + tallStatus () { + const lengthScore = this.status.statusnet_html.split(/<p|<br/).length + this.status.text.length / 80 + return lengthScore > 20 + }, + longSubject () { + return this.status.summary.length > 900 + }, + // When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status. + mightHideBecauseSubject () { + return this.status.summary && (!this.tallStatus || this.localCollapseSubjectDefault) + }, + mightHideBecauseTall () { + return this.tallStatus && (!this.status.summary || !this.localCollapseSubjectDefault) + }, + hideSubjectStatus () { + return this.mightHideBecauseSubject && !this.expandingSubject + }, + hideTallStatus () { + return this.mightHideBecauseTall && !this.showingTall + }, + showingMore () { + return (this.mightHideBecauseTall && this.showingTall) || (this.mightHideBecauseSubject && this.expandingSubject) + }, + nsfwClickthrough () { + if (!this.status.nsfw) { + return false + } + if (this.status.summary && this.localCollapseSubjectDefault) { + return false + } + return true + }, + attachmentSize () { + if ((this.mergedConfig.hideAttachments && !this.inConversation) || + (this.mergedConfig.hideAttachmentsInConv && this.inConversation) || + (this.status.attachments.length > this.maxThumbnails)) { + return 'hide' + } else if (this.compact) { + return 'small' + } + return 'normal' + }, + galleryTypes () { + if (this.attachmentSize === 'hide') { + return [] + } + return this.mergedConfig.playVideosInModal + ? ['image', 'video'] + : ['image'] + }, + galleryAttachments () { + return this.status.attachments.filter( + file => fileType.fileMatchesSomeType(this.galleryTypes, file) + ) + }, + nonGalleryAttachments () { + return this.status.attachments.filter( + file => !fileType.fileMatchesSomeType(this.galleryTypes, file) + ) + }, + hasImageAttachments () { + return this.status.attachments.some( + file => fileType.fileType(file.mimetype) === 'image' + ) + }, + hasVideoAttachments () { + return this.status.attachments.some( + file => fileType.fileType(file.mimetype) === 'video' + ) + }, + maxThumbnails () { + return this.mergedConfig.maxThumbnails + }, + postBodyHtml () { + const html = this.status.statusnet_html + + if (this.mergedConfig.greentext) { + try { + if (html.includes('>')) { + // This checks if post has '>' at the beginning, excluding mentions so that @mention >impying works + return processHtml(html, (string) => { + if (string.includes('>') && + string + .replace(/<[^>]+?>/gi, '') // remove all tags + .replace(/@\w+/gi, '') // remove mentions (even failed ones) + .trim() + .startsWith('>')) { + return `<span class='greentext'>${string}</span>` + } else { + return string + } + }) + } else { + return html + } + } catch (e) { + console.err('Failed to process status html', e) + return html + } + } else { + return html + } + }, + contentHtml () { + if (!this.status.summary_html) { + return this.postBodyHtml + } + return this.status.summary_html + '<br />' + this.postBodyHtml + }, + ...mapGetters(['mergedConfig']), + ...mapState({ + betterShadow: state => state.interface.browserSupport.cssFilter, + currentUser: state => state.users.currentUser + }) + }, + components: { + Attachment, + Poll, + Gallery, + LinkPreview + }, + methods: { + linkClicked (event) { + const target = event.target.closest('.status-content a') + if (target) { + if (target.className.match(/mention/)) { + const href = target.href + const attn = this.status.attentions.find(attn => mentionMatchesUrl(attn, href)) + if (attn) { + event.stopPropagation() + event.preventDefault() + const link = this.generateUserProfileLink(attn.id, attn.screen_name) + this.$router.push(link) + return + } + } + if (target.rel.match(/(?:^|\s)tag(?:$|\s)/) || target.className.match(/hashtag/)) { + // Extract tag name from dataset or link url + const tag = target.dataset.tag || extractTagFromUrl(target.href) + if (tag) { + const link = this.generateTagLink(tag) + this.$router.push(link) + return + } + } + window.open(target.href, '_blank') + } + }, + toggleShowMore () { + if (this.mightHideBecauseTall) { + this.showingTall = !this.showingTall + } else if (this.mightHideBecauseSubject) { + this.expandingSubject = !this.expandingSubject + } + }, + generateUserProfileLink (id, name) { + return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames) + }, + generateTagLink (tag) { + return `/tag/${tag}` + }, + setMedia () { + const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments + return () => this.$store.dispatch('setMedia', attachments) + } + } +} + +export default StatusContent diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue new file mode 100644 index 00000000..7adb67ae --- /dev/null +++ b/src/components/status_content/status_content.vue @@ -0,0 +1,240 @@ +<template> + <!-- eslint-disable vue/no-v-html --> + <div class="status-body"> + <slot name="header" /> + <div + v-if="longSubject" + class="status-content-wrapper" + :class="{ 'tall-status': !showingLongSubject }" + > + <a + v-if="!showingLongSubject" + class="tall-status-hider" + :class="{ 'tall-status-hider_focused': focused }" + href="#" + @click.prevent="showingLongSubject=true" + > + {{ $t("general.show_more") }} + <span + v-if="hasImageAttachments" + class="icon-picture" + /> + <span + v-if="hasVideoAttachments" + class="icon-video" + /> + <span + v-if="status.card" + class="icon-link" + /> + </a> + <div + class="status-content media-body" + @click.prevent="linkClicked" + v-html="contentHtml" + /> + <a + v-if="showingLongSubject" + href="#" + class="status-unhider" + @click.prevent="showingLongSubject=false" + >{{ $t("general.show_less") }}</a> + </div> + <div + v-else + :class="{'tall-status': hideTallStatus}" + class="status-content-wrapper" + > + <a + v-if="hideTallStatus" + class="tall-status-hider" + :class="{ 'tall-status-hider_focused': focused }" + href="#" + @click.prevent="toggleShowMore" + >{{ $t("general.show_more") }}</a> + <div + v-if="!hideSubjectStatus" + class="status-content media-body" + @click.prevent="linkClicked" + v-html="contentHtml" + /> + <div + v-else + class="status-content media-body" + @click.prevent="linkClicked" + v-html="status.summary_html" + /> + <a + v-if="hideSubjectStatus" + href="#" + class="cw-status-hider" + @click.prevent="toggleShowMore" + >{{ $t("general.show_more") }}</a> + <a + v-if="showingMore" + href="#" + class="status-unhider" + @click.prevent="toggleShowMore" + >{{ $t("general.show_less") }}</a> + </div> + + <div v-if="status.poll && status.poll.options"> + <poll :base-poll="status.poll" /> + </div> + + <div + v-if="status.attachments.length !== 0 && (!hideSubjectStatus || showingLongSubject)" + class="attachments media-body" + > + <attachment + v-for="attachment in nonGalleryAttachments" + :key="attachment.id" + class="non-gallery" + :size="attachmentSize" + :nsfw="nsfwClickthrough" + :attachment="attachment" + :allow-play="true" + :set-media="setMedia()" + /> + <gallery + v-if="galleryAttachments.length > 0" + :nsfw="nsfwClickthrough" + :attachments="galleryAttachments" + :set-media="setMedia()" + /> + </div> + + <div + v-if="status.card && !hideSubjectStatus && !noHeading" + class="link-preview media-body" + > + <link-preview + :card="status.card" + :size="attachmentSize" + :nsfw="nsfwClickthrough" + /> + </div> + <slot name="footer" /> + </div> + <!-- eslint-enable vue/no-v-html --> +</template> + +<script src="./status_content.js" ></script> +<style lang="scss"> +@import '../../_variables.scss'; + +$status-margin: 0.75em; + +.status-body { + flex: 1; + min-width: 0; + + .tall-status { + position: relative; + height: 220px; + overflow-x: hidden; + overflow-y: hidden; + z-index: 1; + .status-content { + height: 100%; + mask: linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat, + linear-gradient(to top, white, white); + /* Autoprefixed seem to ignore this one, and also syntax is different */ + -webkit-mask-composite: xor; + mask-composite: exclude; + } + } + + .tall-status-hider { + display: inline-block; + word-break: break-all; + position: absolute; + height: 70px; + margin-top: 150px; + width: 100%; + text-align: center; + line-height: 110px; + z-index: 2; + } + + .status-unhider, .cw-status-hider { + width: 100%; + text-align: center; + display: inline-block; + word-break: break-all; + } + + .status-content { + font-family: var(--postFont, sans-serif); + line-height: 1.4em; + white-space: pre-wrap; + + img, video { + max-width: 100%; + max-height: 400px; + vertical-align: middle; + object-fit: contain; + + &.emoji { + width: 32px; + height: 32px; + } + } + + blockquote { + margin: 0.2em 0 0.2em 2em; + font-style: italic; + } + + pre { + overflow: auto; + } + + code, samp, kbd, var, pre { + font-family: var(--postCodeFont, monospace); + } + + p { + margin: 0 0 1em 0; + } + + p:last-child { + margin: 0 0 0 0; + } + + h1 { + font-size: 1.1em; + line-height: 1.2em; + margin: 1.4em 0; + } + + h2 { + font-size: 1.1em; + margin: 1.0em 0; + } + + h3 { + font-size: 1em; + margin: 1.2em 0; + } + + h4 { + margin: 1.1em 0; + } + } +} + +.greentext { + color: $fallback--cGreen; + color: var(--postGreentext, $fallback--cGreen); +} + +.timeline :not(.panel-disabled) > { + .status-el:last-child { + border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius; + border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius); + border-bottom: none; + } +} + +</style> diff --git a/src/components/status_popover/status_popover.js b/src/components/status_popover/status_popover.js index cb55f67e..159132a9 100644 --- a/src/components/status_popover/status_popover.js +++ b/src/components/status_popover/status_popover.js @@ -5,6 +5,11 @@ const StatusPopover = { props: [ 'statusId' ], + data () { + return { + error: false + } + }, computed: { status () { return find(this.$store.state.statuses.allStatuses, { id: this.statusId }) @@ -18,6 +23,8 @@ const StatusPopover = { enter () { if (!this.status) { this.$store.dispatch('fetchStatus', this.statusId) + .then(data => (this.error = false)) + .catch(e => (this.error = true)) } } } diff --git a/src/components/status_popover/status_popover.vue b/src/components/status_popover/status_popover.vue index 11f6cb5a..f5948207 100644 --- a/src/components/status_popover/status_popover.vue +++ b/src/components/status_popover/status_popover.vue @@ -18,8 +18,14 @@ :compact="true" /> <div + v-else-if="error" + class="status-preview-no-content faint" + > + {{ $t('status.status_unavailable') }} + </div> + <div v-else - class="status-preview-loading" + class="status-preview-no-content" > <i class="icon-spin4 animate-spin" /> </div> @@ -50,7 +56,7 @@ border: none; } - .status-preview-loading { + .status-preview-no-content { padding: 1em; text-align: center; diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue index 4137bd59..f2ddeb7b 100644 --- a/src/components/still-image/still-image.vue +++ b/src/components/still-image/still-image.vue @@ -23,12 +23,15 @@ <style lang="scss"> @import '../../_variables.scss'; + .still-image { position: relative; line-height: 0; overflow: hidden; width: 100%; height: 100%; + display: flex; + align-items: center; &:hover canvas { display: none; @@ -36,7 +39,7 @@ img { width: 100%; - height: 100%; + min-height: 100%; object-fit: contain; } diff --git a/src/components/tab_switcher/tab_switcher.js b/src/components/tab_switcher/tab_switcher.js index 008e1e95..7891cb78 100644 --- a/src/components/tab_switcher/tab_switcher.js +++ b/src/components/tab_switcher/tab_switcher.js @@ -24,6 +24,11 @@ export default Vue.component('tab-switcher', { required: false, type: Boolean, default: false + }, + sideTabBar: { + required: false, + type: Boolean, + default: false } }, data () { @@ -55,6 +60,9 @@ export default Vue.component('tab-switcher', { this.onSwitch.call(null, this.$slots.default[index].key) } this.active = index + if (this.scrollableTabs) { + this.$refs.contents.scrollTop = 0 + } } } }, @@ -64,7 +72,6 @@ export default Vue.component('tab-switcher', { if (!slot.tag) return const classesTab = ['tab'] const classesWrapper = ['tab-wrapper'] - if (this.activeIndex === index) { classesTab.push('active') classesWrapper.push('active') @@ -87,8 +94,14 @@ export default Vue.component('tab-switcher', { <button disabled={slot.data.attrs.disabled} onClick={this.activateTab(index)} - class={classesTab.join(' ')}> - {slot.data.attrs.label}</button> + class={classesTab.join(' ')} + type="button" + > + {!slot.data.attrs.icon ? '' : (<i class={'tab-icon icon-' + slot.data.attrs.icon}/>)} + <span class="text"> + {slot.data.attrs.label} + </span> + </button> </div> ) }) @@ -96,20 +109,32 @@ export default Vue.component('tab-switcher', { const contents = this.$slots.default.map((slot, index) => { if (!slot.tag) return const active = this.activeIndex === index - if (this.renderOnlyFocused) { - return active - ? <div class="active">{slot}</div> - : <div class="hidden"></div> + const classes = [ active ? 'active' : 'hidden' ] + if (slot.data.attrs.fullHeight) { + classes.push('full-height') } - return <div class={active ? 'active' : 'hidden' }>{slot}</div> + const renderSlot = (!this.renderOnlyFocused || active) + ? slot + : '' + + return ( + <div class={classes}> + { + this.sideTabBar + ? <h1 class="mobile-label">{slot.data.attrs.label}</h1> + : '' + } + {renderSlot} + </div> + ) }) return ( - <div class="tab-switcher"> + <div class={'tab-switcher ' + (this.sideTabBar ? 'side-tabs' : 'top-tabs')}> <div class="tabs"> {tabs} </div> - <div class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')}> + <div ref="contents" class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')}> {contents} </div> </div> diff --git a/src/components/tab_switcher/tab_switcher.scss b/src/components/tab_switcher/tab_switcher.scss index df585faa..d2ef4857 100644 --- a/src/components/tab_switcher/tab_switcher.scss +++ b/src/components/tab_switcher/tab_switcher.scss @@ -2,7 +2,144 @@ .tab-switcher { display: flex; - flex-direction: column; + + .tab-icon { + font-size: 2em; + display: block; + } + + &.top-tabs { + flex-direction: column; + + > .tabs { + width: 100%; + overflow-y: hidden; + overflow-x: auto; + padding-top: 5px; + flex-direction: row; + + &::after, &::before { + content: ''; + flex: 1 1 auto; + border-bottom: 1px solid; + border-bottom-color: $fallback--border; + border-bottom-color: var(--border, $fallback--border); + } + .tab-wrapper { + height: 28px; + + &:not(.active)::after { + left: 0; + right: 0; + bottom: 0; + border-bottom: 1px solid; + border-bottom-color: $fallback--border; + border-bottom-color: var(--border, $fallback--border); + } + } + .tab { + width: 100%; + min-width: 1px; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + padding-bottom: 99px; + margin-bottom: 6px - 99px; + } + } + .contents.scrollable-tabs { + flex-basis: 0; + } + } + + &.side-tabs { + flex-direction: row; + + @media all and (max-width: 800px) { + overflow-x: auto; + } + + > .contents { + flex: 1 1 auto; + } + + > .tabs { + flex: 0 0 auto; + overflow-y: auto; + overflow-x: hidden; + flex-direction: column; + + &::after, &::before { + flex-shrink: 0; + flex-basis: .5em; + content: ''; + border-right: 1px solid; + border-right-color: $fallback--border; + border-right-color: var(--border, $fallback--border); + } + + &::after { + flex-grow: 1; + } + + &::before { + flex-grow: 0; + } + + .tab-wrapper { + min-width: 10em; + display: flex; + flex-direction: column; + + @media all and (max-width: 800px) { + min-width: 1em; + } + + &:not(.active)::after { + top: 0; + right: 0; + bottom: 0; + border-right: 1px solid; + border-right-color: $fallback--border; + border-right-color: var(--border, $fallback--border); + } + + &::before { + flex: 0 0 6px; + content: ''; + border-right: 1px solid; + border-right-color: $fallback--border; + border-right-color: var(--border, $fallback--border); + } + + &:last-child .tab { + margin-bottom: 0; + } + } + + .tab { + flex: 1; + box-sizing: content-box; + min-width: 10em; + min-width: 1px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + padding-left: 1em; + padding-right: calc(1em + 200px); + margin-right: -200px; + margin-left: 1em; + + @media all and (max-width: 800px) { + padding-left: .25em; + padding-right: calc(.25em + 200px); + margin-right: calc(.25em - 200px); + margin-left: .25em; + .text { + display: none + } + } + } + } + } .contents { flex: 1 0 auto; @@ -11,88 +148,89 @@ .hidden { display: none; } + .full-height:not(.hidden) { + height: 100%; + display: flex; + flex-direction: column; + > *:not(.mobile-label) { + flex: 1; + } + } &.scrollable-tabs { - flex-basis: 0; overflow-y: auto; } } - .tabs { - display: flex; + + .tab { position: relative; - width: 100%; - overflow-y: hidden; - overflow-x: auto; - padding-top: 5px; - box-sizing: border-box; + white-space: nowrap; + padding: 6px 1em; + background-color: $fallback--fg; + background-color: var(--tab, $fallback--fg); - &::after, &::before { - display: block; - content: ''; - flex: 1 1 auto; - border-bottom: 1px solid; - border-bottom-color: $fallback--border; - border-bottom-color: var(--border, $fallback--border); + &, &:active .tab-icon { + color: $fallback--text; + color: var(--tabText, $fallback--text); } - .tab-wrapper { - height: 28px; - position: relative; - display: flex; - flex: 0 0 auto; + &:not(.active) { + z-index: 4; - .tab { - width: 100%; - min-width: 1px; - position: relative; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - padding: 6px 1em; - padding-bottom: 99px; - margin-bottom: 6px - 99px; - white-space: nowrap; + &:hover { + z-index: 6; + } + } - color: $fallback--text; - color: var(--tabText, $fallback--text); - background-color: $fallback--fg; - background-color: var(--tab, $fallback--fg); + &.active { + background: transparent; + z-index: 5; + color: $fallback--text; + color: var(--tabActiveText, $fallback--text); + } - &:not(.active) { - z-index: 4; + img { + max-height: 26px; + vertical-align: top; + margin-top: -5px; + } + } - &:hover { - z-index: 6; - } - } + .tabs { + display: flex; + position: relative; + box-sizing: border-box; - &.active { - background: transparent; - z-index: 5; - color: $fallback--text; - color: var(--tabActiveText, $fallback--text); - } + &::after, &::before { + display: block; + flex: 1 1 auto; + } + } - img { - max-height: 26px; - vertical-align: top; - margin-top: -5px; - } - } + .tab-wrapper { + position: relative; + display: flex; + flex: 0 0 auto; - &:not(.active) { - &::after { - content: ''; - position: absolute; - left: 0; - right: 0; - bottom: 0; - z-index: 7; - border-bottom: 1px solid; - border-bottom-color: $fallback--border; - border-bottom-color: var(--border, $fallback--border); - } + &:not(.active) { + &::after { + content: ''; + position: absolute; + z-index: 7; } } + } + .mobile-label { + padding-left: .3em; + padding-bottom: .25em; + margin-top: .5em; + margin-left: .2em; + margin-bottom: .25em; + border-bottom: 1px solid var(--border, $fallback--border); + + @media all and (min-width: 800px) { + display: none; + } } } diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js index 1cdbd3fa..8e6b9d7f 100644 --- a/src/components/user_card/user_card.js +++ b/src/components/user_card/user_card.js @@ -9,7 +9,7 @@ import { mapGetters } from 'vuex' export default { props: [ - 'user', 'switcher', 'selected', 'hideBio', 'rounded', 'bordered', 'allowZoomingAvatar' + 'userId', 'switcher', 'selected', 'hideBio', 'rounded', 'bordered', 'allowZoomingAvatar' ], data () { return { @@ -21,6 +21,12 @@ export default { this.$store.dispatch('fetchUserRelationship', this.user.id) }, computed: { + user () { + return this.$store.getters.findUser(this.userId) + }, + relationship () { + return this.$store.getters.relationship(this.userId) + }, classes () { return [{ 'user-card-rounded-t': this.rounded === 'top', // set border-top-left-radius and border-top-right-radius diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue index 4ee040e8..c4a5ce9d 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -50,15 +50,6 @@ > {{ user.name }} </div> - <router-link - v-if="!isOtherUser" - :to="{ name: 'user-settings' }" - > - <i - class="button-icon icon-wrench usersettings" - :title="$t('tool_tip.user_settings')" - /> - </router-link> <a v-if="isOtherUser && !user.is_local" :href="user.statusnet_profile_url" @@ -69,6 +60,7 @@ <AccountActions v-if="isOtherUser && loggedIn" :user="user" + :relationship="relationship" /> </div> <div class="bottom-line"> @@ -92,7 +84,7 @@ </div> <div class="user-meta"> <div - v-if="user.follows_you && loggedIn && isOtherUser" + v-if="relationship.followed_by && loggedIn && isOtherUser" class="following" > {{ $t('user_card.follows_you') }} @@ -117,7 +109,7 @@ type="color" > <label - for="style-switcher" + for="theme_tab" class="userHighlightSel select" > <select @@ -139,10 +131,10 @@ class="user-interactions" > <div class="btn-group"> - <FollowButton :user="user" /> - <template v-if="user.following"> + <FollowButton :relationship="relationship" /> + <template v-if="relationship.following"> <ProgressButton - v-if="!user.subscribed" + v-if="!relationship.subscribing" class="btn btn-default" :click="subscribeUser" :title="$t('user_card.subscribe')" @@ -161,7 +153,7 @@ </div> <div> <button - v-if="user.muted" + v-if="relationship.muting" class="btn btn-default btn-block toggled" @click="unmuteUser" > diff --git a/src/components/user_panel/user_panel.vue b/src/components/user_panel/user_panel.vue index e9f08015..1db4f648 100644 --- a/src/components/user_panel/user_panel.vue +++ b/src/components/user_panel/user_panel.vue @@ -6,7 +6,7 @@ class="panel panel-default signed-in" > <UserCard - :user="user" + :user-id="user.id" :hide-bio="true" rounded="top" /> diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index 9558a0bd..95760bf8 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -3,6 +3,7 @@ import UserCard from '../user_card/user_card.vue' import FollowCard from '../follow_card/follow_card.vue' import Timeline from '../timeline/timeline.vue' import Conversation from '../conversation/conversation.vue' +import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js' import List from '../list/list.vue' import withLoadMore from '../../hocs/with_load_more/with_load_more' @@ -146,6 +147,7 @@ const UserProfile = { FollowerList, FriendList, FollowCard, + TabSwitcher, Conversation } } diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index 14082e83..1871d46c 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -5,7 +5,7 @@ class="user-profile panel panel-default" > <UserCard - :user="user" + :user-id="userId" :switcher="true" :selected="timeline.viewing" :allow-zooming-avatar="true" diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js deleted file mode 100644 index eca6f9b1..00000000 --- a/src/components/user_settings/user_settings.js +++ /dev/null @@ -1,393 +0,0 @@ -import unescape from 'lodash/unescape' -import get from 'lodash/get' -import map from 'lodash/map' -import reject from 'lodash/reject' -import TabSwitcher from '../tab_switcher/tab_switcher.js' -import ImageCropper from '../image_cropper/image_cropper.vue' -import StyleSwitcher from '../style_switcher/style_switcher.vue' -import ScopeSelector from '../scope_selector/scope_selector.vue' -import fileSizeFormatService from '../../services/file_size_format/file_size_format.js' -import BlockCard from '../block_card/block_card.vue' -import MuteCard from '../mute_card/mute_card.vue' -import DomainMuteCard from '../domain_mute_card/domain_mute_card.vue' -import SelectableList from '../selectable_list/selectable_list.vue' -import ProgressButton from '../progress_button/progress_button.vue' -import EmojiInput from '../emoji_input/emoji_input.vue' -import suggestor from '../emoji_input/suggestor.js' -import Autosuggest from '../autosuggest/autosuggest.vue' -import Importer from '../importer/importer.vue' -import Exporter from '../exporter/exporter.vue' -import withSubscription from '../../hocs/with_subscription/with_subscription' -import Checkbox from '../checkbox/checkbox.vue' -import Mfa from './mfa.vue' - -const BlockList = withSubscription({ - fetch: (props, $store) => $store.dispatch('fetchBlocks'), - select: (props, $store) => get($store.state.users.currentUser, 'blockIds', []), - childPropName: 'items' -})(SelectableList) - -const MuteList = withSubscription({ - fetch: (props, $store) => $store.dispatch('fetchMutes'), - select: (props, $store) => get($store.state.users.currentUser, 'muteIds', []), - childPropName: 'items' -})(SelectableList) - -const DomainMuteList = withSubscription({ - fetch: (props, $store) => $store.dispatch('fetchDomainMutes'), - select: (props, $store) => get($store.state.users.currentUser, 'domainMutes', []), - childPropName: 'items' -})(SelectableList) - -const UserSettings = { - data () { - return { - newEmail: '', - newName: this.$store.state.users.currentUser.name, - newBio: unescape(this.$store.state.users.currentUser.description), - newLocked: this.$store.state.users.currentUser.locked, - newNoRichText: this.$store.state.users.currentUser.no_rich_text, - newDefaultScope: this.$store.state.users.currentUser.default_scope, - hideFollows: this.$store.state.users.currentUser.hide_follows, - hideFollowers: this.$store.state.users.currentUser.hide_followers, - hideFollowsCount: this.$store.state.users.currentUser.hide_follows_count, - hideFollowersCount: this.$store.state.users.currentUser.hide_followers_count, - showRole: this.$store.state.users.currentUser.show_role, - role: this.$store.state.users.currentUser.role, - discoverable: this.$store.state.users.currentUser.discoverable, - allowFollowingMove: this.$store.state.users.currentUser.allow_following_move, - pickAvatarBtnVisible: true, - bannerUploading: false, - backgroundUploading: false, - banner: null, - bannerPreview: null, - background: null, - backgroundPreview: null, - bannerUploadError: null, - backgroundUploadError: null, - changeEmailError: false, - changeEmailPassword: '', - changedEmail: false, - deletingAccount: false, - deleteAccountConfirmPasswordInput: '', - deleteAccountError: false, - changePasswordInputs: [ '', '', '' ], - changedPassword: false, - changePasswordError: false, - activeTab: 'profile', - notificationSettings: this.$store.state.users.currentUser.notification_settings, - newDomainToMute: '' - } - }, - created () { - this.$store.dispatch('fetchTokens') - }, - components: { - StyleSwitcher, - ScopeSelector, - TabSwitcher, - ImageCropper, - BlockList, - MuteList, - DomainMuteList, - EmojiInput, - Autosuggest, - BlockCard, - MuteCard, - DomainMuteCard, - ProgressButton, - Importer, - Exporter, - Mfa, - Checkbox - }, - computed: { - user () { - return this.$store.state.users.currentUser - }, - emojiUserSuggestor () { - return suggestor({ - emoji: [ - ...this.$store.state.instance.emoji, - ...this.$store.state.instance.customEmoji - ], - users: this.$store.state.users.users, - updateUsersList: (input) => this.$store.dispatch('searchUsers', input) - }) - }, - emojiSuggestor () { - return suggestor({ emoji: [ - ...this.$store.state.instance.emoji, - ...this.$store.state.instance.customEmoji - ] }) - }, - pleromaBackend () { - return this.$store.state.instance.pleromaBackend - }, - minimalScopesMode () { - return this.$store.state.instance.minimalScopesMode - }, - vis () { - return { - public: { selected: this.newDefaultScope === 'public' }, - unlisted: { selected: this.newDefaultScope === 'unlisted' }, - private: { selected: this.newDefaultScope === 'private' }, - direct: { selected: this.newDefaultScope === 'direct' } - } - }, - currentSaveStateNotice () { - return this.$store.state.interface.settings.currentSaveStateNotice - }, - oauthTokens () { - return this.$store.state.oauthTokens.tokens.map(oauthToken => { - return { - id: oauthToken.id, - appName: oauthToken.app_name, - validUntil: new Date(oauthToken.valid_until).toLocaleDateString() - } - }) - } - }, - methods: { - updateProfile () { - this.$store.state.api.backendInteractor - .updateProfile({ - params: { - note: this.newBio, - locked: this.newLocked, - // Backend notation. - /* eslint-disable camelcase */ - display_name: this.newName, - default_scope: this.newDefaultScope, - no_rich_text: this.newNoRichText, - hide_follows: this.hideFollows, - hide_followers: this.hideFollowers, - discoverable: this.discoverable, - allow_following_move: this.allowFollowingMove, - hide_follows_count: this.hideFollowsCount, - hide_followers_count: this.hideFollowersCount, - show_role: this.showRole - /* eslint-enable camelcase */ - } }).then((user) => { - this.$store.commit('addNewUsers', [user]) - this.$store.commit('setCurrentUser', user) - }) - }, - updateNotificationSettings () { - this.$store.state.api.backendInteractor - .updateNotificationSettings({ settings: this.notificationSettings }) - }, - changeVis (visibility) { - this.newDefaultScope = visibility - }, - uploadFile (slot, e) { - const file = e.target.files[0] - if (!file) { return } - if (file.size > this.$store.state.instance[slot + 'limit']) { - const filesize = fileSizeFormatService.fileSizeFormat(file.size) - const allowedsize = fileSizeFormatService.fileSizeFormat(this.$store.state.instance[slot + 'limit']) - this[slot + 'UploadError'] = this.$t('upload.error.base') + ' ' + this.$t('upload.error.file_too_big', { filesize: filesize.num, filesizeunit: filesize.unit, allowedsize: allowedsize.num, allowedsizeunit: allowedsize.unit }) - return - } - // eslint-disable-next-line no-undef - const reader = new FileReader() - reader.onload = ({ target }) => { - const img = target.result - this[slot + 'Preview'] = img - this[slot] = file - } - reader.readAsDataURL(file) - }, - submitAvatar (cropper, file) { - const that = this - return new Promise((resolve, reject) => { - function updateAvatar (avatar) { - that.$store.state.api.backendInteractor.updateAvatar({ avatar }) - .then((user) => { - that.$store.commit('addNewUsers', [user]) - that.$store.commit('setCurrentUser', user) - resolve() - }) - .catch((err) => { - reject(new Error(that.$t('upload.error.base') + ' ' + err.message)) - }) - } - - if (cropper) { - cropper.getCroppedCanvas().toBlob(updateAvatar, file.type) - } else { - updateAvatar(file) - } - }) - }, - clearUploadError (slot) { - this[slot + 'UploadError'] = null - }, - submitBanner () { - if (!this.bannerPreview) { return } - - this.bannerUploading = true - this.$store.state.api.backendInteractor.updateBanner({ banner: this.banner }) - .then((user) => { - this.$store.commit('addNewUsers', [user]) - this.$store.commit('setCurrentUser', user) - this.bannerPreview = null - }) - .catch((err) => { - this.bannerUploadError = this.$t('upload.error.base') + ' ' + err.message - }) - .then(() => { this.bannerUploading = false }) - }, - submitBg () { - if (!this.backgroundPreview) { return } - let background = this.background - this.backgroundUploading = true - this.$store.state.api.backendInteractor.updateBg({ background }).then((data) => { - if (!data.error) { - this.$store.commit('addNewUsers', [data]) - this.$store.commit('setCurrentUser', data) - this.backgroundPreview = null - } else { - this.backgroundUploadError = this.$t('upload.error.base') + data.error - } - this.backgroundUploading = false - }) - }, - importFollows (file) { - return this.$store.state.api.backendInteractor.importFollows({ file }) - .then((status) => { - if (!status) { - throw new Error('failed') - } - }) - }, - importBlocks (file) { - return this.$store.state.api.backendInteractor.importBlocks({ file }) - .then((status) => { - if (!status) { - throw new Error('failed') - } - }) - }, - generateExportableUsersContent (users) { - // Get addresses - return users.map((user) => { - // check is it's a local user - if (user && user.is_local) { - // append the instance address - // eslint-disable-next-line no-undef - return user.screen_name + '@' + location.hostname - } - return user.screen_name - }).join('\n') - }, - getFollowsContent () { - return this.$store.state.api.backendInteractor.exportFriends({ id: this.$store.state.users.currentUser.id }) - .then(this.generateExportableUsersContent) - }, - getBlocksContent () { - return this.$store.state.api.backendInteractor.fetchBlocks() - .then(this.generateExportableUsersContent) - }, - confirmDelete () { - this.deletingAccount = true - }, - deleteAccount () { - this.$store.state.api.backendInteractor.deleteAccount({ password: this.deleteAccountConfirmPasswordInput }) - .then((res) => { - if (res.status === 'success') { - this.$store.dispatch('logout') - this.$router.push({ name: 'root' }) - } else { - this.deleteAccountError = res.error - } - }) - }, - changePassword () { - const params = { - password: this.changePasswordInputs[0], - newPassword: this.changePasswordInputs[1], - newPasswordConfirmation: this.changePasswordInputs[2] - } - this.$store.state.api.backendInteractor.changePassword(params) - .then((res) => { - if (res.status === 'success') { - this.changedPassword = true - this.changePasswordError = false - this.logout() - } else { - this.changedPassword = false - this.changePasswordError = res.error - } - }) - }, - changeEmail () { - const params = { - email: this.newEmail, - password: this.changeEmailPassword - } - this.$store.state.api.backendInteractor.changeEmail(params) - .then((res) => { - if (res.status === 'success') { - this.changedEmail = true - this.changeEmailError = false - } else { - this.changedEmail = false - this.changeEmailError = res.error - } - }) - }, - activateTab (tabName) { - this.activeTab = tabName - }, - logout () { - this.$store.dispatch('logout') - this.$router.replace('/') - }, - revokeToken (id) { - if (window.confirm(`${this.$i18n.t('settings.revoke_token')}?`)) { - this.$store.dispatch('revokeToken', id) - } - }, - filterUnblockedUsers (userIds) { - return reject(userIds, (userId) => { - const user = this.$store.getters.findUser(userId) - return !user || user.statusnet_blocking || user.id === this.$store.state.users.currentUser.id - }) - }, - filterUnMutedUsers (userIds) { - return reject(userIds, (userId) => { - const user = this.$store.getters.findUser(userId) - return !user || user.muted || user.id === this.$store.state.users.currentUser.id - }) - }, - queryUserIds (query) { - return this.$store.dispatch('searchUsers', query) - .then((users) => map(users, 'id')) - }, - blockUsers (ids) { - return this.$store.dispatch('blockUsers', ids) - }, - unblockUsers (ids) { - return this.$store.dispatch('unblockUsers', ids) - }, - muteUsers (ids) { - return this.$store.dispatch('muteUsers', ids) - }, - unmuteUsers (ids) { - return this.$store.dispatch('unmuteUsers', ids) - }, - unmuteDomains (domains) { - return this.$store.dispatch('unmuteDomains', domains) - }, - muteDomain () { - return this.$store.dispatch('muteDomain', this.newDomainToMute) - .then(() => { this.newDomainToMute = '' }) - }, - identity (value) { - return value - } - } -} - -export default UserSettings diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue deleted file mode 100644 index 8b2336b4..00000000 --- a/src/components/user_settings/user_settings.vue +++ /dev/null @@ -1,716 +0,0 @@ -<template> - <div class="settings panel panel-default"> - <div class="panel-heading"> - <div class="title"> - {{ $t('settings.user_settings') }} - </div> - <transition name="fade"> - <template v-if="currentSaveStateNotice"> - <div - v-if="currentSaveStateNotice.error" - class="alert error" - @click.prevent - > - {{ $t('settings.saving_err') }} - </div> - - <div - v-if="!currentSaveStateNotice.error" - class="alert transparent" - @click.prevent - > - {{ $t('settings.saving_ok') }} - </div> - </template> - </transition> - </div> - <div class="panel-body profile-edit"> - <tab-switcher> - <div :label="$t('settings.profile_tab')"> - <div class="setting-item"> - <h2>{{ $t('settings.name_bio') }}</h2> - <p>{{ $t('settings.name') }}</p> - <EmojiInput - v-model="newName" - enable-emoji-picker - :suggest="emojiSuggestor" - > - <input - id="username" - v-model="newName" - classname="name-changer" - > - </EmojiInput> - <p>{{ $t('settings.bio') }}</p> - <EmojiInput - v-model="newBio" - enable-emoji-picker - :suggest="emojiUserSuggestor" - > - <textarea - v-model="newBio" - classname="bio" - /> - </EmojiInput> - <p> - <Checkbox v-model="newLocked"> - {{ $t('settings.lock_account_description') }} - </Checkbox> - </p> - <div> - <label for="default-vis">{{ $t('settings.default_vis') }}</label> - <div - id="default-vis" - class="visibility-tray" - > - <scope-selector - :show-all="true" - :user-default="newDefaultScope" - :initial-scope="newDefaultScope" - :on-scope-change="changeVis" - /> - </div> - </div> - <p> - <Checkbox v-model="newNoRichText"> - {{ $t('settings.no_rich_text_description') }} - </Checkbox> - </p> - <p> - <Checkbox v-model="hideFollows"> - {{ $t('settings.hide_follows_description') }} - </Checkbox> - </p> - <p class="setting-subitem"> - <Checkbox - v-model="hideFollowsCount" - :disabled="!hideFollows" - > - {{ $t('settings.hide_follows_count_description') }} - </Checkbox> - </p> - <p> - <Checkbox v-model="hideFollowers"> - {{ $t('settings.hide_followers_description') }} - </Checkbox> - </p> - <p class="setting-subitem"> - <Checkbox - v-model="hideFollowersCount" - :disabled="!hideFollowers" - > - {{ $t('settings.hide_followers_count_description') }} - </Checkbox> - </p> - <p> - <Checkbox v-model="allowFollowingMove"> - {{ $t('settings.allow_following_move') }} - </Checkbox> - </p> - <p v-if="role === 'admin' || role === 'moderator'"> - <Checkbox v-model="showRole"> - <template v-if="role === 'admin'"> - {{ $t('settings.show_admin_badge') }} - </template> - <template v-if="role === 'moderator'"> - {{ $t('settings.show_moderator_badge') }} - </template> - </Checkbox> - </p> - <p> - <Checkbox v-model="discoverable"> - {{ $t('settings.discoverable') }} - </Checkbox> - </p> - <button - :disabled="newName && newName.length === 0" - class="btn btn-default" - @click="updateProfile" - > - {{ $t('general.submit') }} - </button> - </div> - <div class="setting-item"> - <h2>{{ $t('settings.avatar') }}</h2> - <p class="visibility-notice"> - {{ $t('settings.avatar_size_instruction') }} - </p> - <p>{{ $t('settings.current_avatar') }}</p> - <img - :src="user.profile_image_url_original" - class="current-avatar" - > - <p>{{ $t('settings.set_new_avatar') }}</p> - <button - v-show="pickAvatarBtnVisible" - id="pick-avatar" - class="btn" - type="button" - > - {{ $t('settings.upload_a_photo') }} - </button> - <image-cropper - trigger="#pick-avatar" - :submit-handler="submitAvatar" - @open="pickAvatarBtnVisible=false" - @close="pickAvatarBtnVisible=true" - /> - </div> - <div class="setting-item"> - <h2>{{ $t('settings.profile_banner') }}</h2> - <p>{{ $t('settings.current_profile_banner') }}</p> - <img - :src="user.cover_photo" - class="banner" - > - <p>{{ $t('settings.set_new_profile_banner') }}</p> - <img - v-if="bannerPreview" - class="banner" - :src="bannerPreview" - > - <div> - <input - type="file" - @change="uploadFile('banner', $event)" - > - </div> - <i - v-if="bannerUploading" - class=" icon-spin4 animate-spin uploading" - /> - <button - v-else-if="bannerPreview" - class="btn btn-default" - @click="submitBanner" - > - {{ $t('general.submit') }} - </button> - <div - v-if="bannerUploadError" - class="alert error" - > - Error: {{ bannerUploadError }} - <i - class="button-icon icon-cancel" - @click="clearUploadError('banner')" - /> - </div> - </div> - <div class="setting-item"> - <h2>{{ $t('settings.profile_background') }}</h2> - <p>{{ $t('settings.set_new_profile_background') }}</p> - <img - v-if="backgroundPreview" - class="bg" - :src="backgroundPreview" - > - <div> - <input - type="file" - @change="uploadFile('background', $event)" - > - </div> - <i - v-if="backgroundUploading" - class=" icon-spin4 animate-spin uploading" - /> - <button - v-else-if="backgroundPreview" - class="btn btn-default" - @click="submitBg" - > - {{ $t('general.submit') }} - </button> - <div - v-if="backgroundUploadError" - class="alert error" - > - Error: {{ backgroundUploadError }} - <i - class="button-icon icon-cancel" - @click="clearUploadError('background')" - /> - </div> - </div> - </div> - - <div :label="$t('settings.security_tab')"> - <div class="setting-item"> - <h2>{{ $t('settings.change_email') }}</h2> - <div> - <p>{{ $t('settings.new_email') }}</p> - <input - v-model="newEmail" - type="email" - autocomplete="email" - > - </div> - <div> - <p>{{ $t('settings.current_password') }}</p> - <input - v-model="changeEmailPassword" - type="password" - autocomplete="current-password" - > - </div> - <button - class="btn btn-default" - @click="changeEmail" - > - {{ $t('general.submit') }} - </button> - <p v-if="changedEmail"> - {{ $t('settings.changed_email') }} - </p> - <template v-if="changeEmailError !== false"> - <p>{{ $t('settings.change_email_error') }}</p> - <p>{{ changeEmailError }}</p> - </template> - </div> - - <div class="setting-item"> - <h2>{{ $t('settings.change_password') }}</h2> - <div> - <p>{{ $t('settings.current_password') }}</p> - <input - v-model="changePasswordInputs[0]" - type="password" - > - </div> - <div> - <p>{{ $t('settings.new_password') }}</p> - <input - v-model="changePasswordInputs[1]" - type="password" - > - </div> - <div> - <p>{{ $t('settings.confirm_new_password') }}</p> - <input - v-model="changePasswordInputs[2]" - type="password" - > - </div> - <button - class="btn btn-default" - @click="changePassword" - > - {{ $t('general.submit') }} - </button> - <p v-if="changedPassword"> - {{ $t('settings.changed_password') }} - </p> - <p v-else-if="changePasswordError !== false"> - {{ $t('settings.change_password_error') }} - </p> - <p v-if="changePasswordError"> - {{ changePasswordError }} - </p> - </div> - - <div class="setting-item"> - <h2>{{ $t('settings.oauth_tokens') }}</h2> - <table class="oauth-tokens"> - <thead> - <tr> - <th>{{ $t('settings.app_name') }}</th> - <th>{{ $t('settings.valid_until') }}</th> - <th /> - </tr> - </thead> - <tbody> - <tr - v-for="oauthToken in oauthTokens" - :key="oauthToken.id" - > - <td>{{ oauthToken.appName }}</td> - <td>{{ oauthToken.validUntil }}</td> - <td class="actions"> - <button - class="btn btn-default" - @click="revokeToken(oauthToken.id)" - > - {{ $t('settings.revoke_token') }} - </button> - </td> - </tr> - </tbody> - </table> - </div> - <mfa /> - <div class="setting-item"> - <h2>{{ $t('settings.delete_account') }}</h2> - <p v-if="!deletingAccount"> - {{ $t('settings.delete_account_description') }} - </p> - <div v-if="deletingAccount"> - <p>{{ $t('settings.delete_account_instructions') }}</p> - <p>{{ $t('login.password') }}</p> - <input - v-model="deleteAccountConfirmPasswordInput" - type="password" - > - <button - class="btn btn-default" - @click="deleteAccount" - > - {{ $t('settings.delete_account') }} - </button> - </div> - <p v-if="deleteAccountError !== false"> - {{ $t('settings.delete_account_error') }} - </p> - <p v-if="deleteAccountError"> - {{ deleteAccountError }} - </p> - <button - v-if="!deletingAccount" - class="btn btn-default" - @click="confirmDelete" - > - {{ $t('general.submit') }} - </button> - </div> - </div> - - <div - v-if="pleromaBackend" - :label="$t('settings.notifications')" - > - <div class="setting-item"> - <div class="select-multiple"> - <span class="label">{{ $t('settings.notification_setting') }}</span> - <ul class="option-list"> - <li> - <Checkbox v-model="notificationSettings.follows"> - {{ $t('settings.notification_setting_follows') }} - </Checkbox> - </li> - <li> - <Checkbox v-model="notificationSettings.followers"> - {{ $t('settings.notification_setting_followers') }} - </Checkbox> - </li> - <li> - <Checkbox v-model="notificationSettings.non_follows"> - {{ $t('settings.notification_setting_non_follows') }} - </Checkbox> - </li> - <li> - <Checkbox v-model="notificationSettings.non_followers"> - {{ $t('settings.notification_setting_non_followers') }} - </Checkbox> - </li> - </ul> - </div> - <p>{{ $t('settings.notification_mutes') }}</p> - <p>{{ $t('settings.notification_blocks') }}</p> - <button - class="btn btn-default" - @click="updateNotificationSettings" - > - {{ $t('general.submit') }} - </button> - </div> - </div> - - <div - v-if="pleromaBackend" - :label="$t('settings.data_import_export_tab')" - > - <div class="setting-item"> - <h2>{{ $t('settings.follow_import') }}</h2> - <p>{{ $t('settings.import_followers_from_a_csv_file') }}</p> - <Importer - :submit-handler="importFollows" - :success-message="$t('settings.follows_imported')" - :error-message="$t('settings.follow_import_error')" - /> - </div> - <div class="setting-item"> - <h2>{{ $t('settings.follow_export') }}</h2> - <Exporter - :get-content="getFollowsContent" - filename="friends.csv" - :export-button-label="$t('settings.follow_export_button')" - /> - </div> - <div class="setting-item"> - <h2>{{ $t('settings.block_import') }}</h2> - <p>{{ $t('settings.import_blocks_from_a_csv_file') }}</p> - <Importer - :submit-handler="importBlocks" - :success-message="$t('settings.blocks_imported')" - :error-message="$t('settings.block_import_error')" - /> - </div> - <div class="setting-item"> - <h2>{{ $t('settings.block_export') }}</h2> - <Exporter - :get-content="getBlocksContent" - filename="blocks.csv" - :export-button-label="$t('settings.block_export_button')" - /> - </div> - </div> - - <div :label="$t('settings.blocks_tab')"> - <div class="profile-edit-usersearch-wrapper"> - <Autosuggest - :filter="filterUnblockedUsers" - :query="queryUserIds" - :placeholder="$t('settings.search_user_to_block')" - > - <BlockCard - slot-scope="row" - :user-id="row.item" - /> - </Autosuggest> - </div> - <BlockList - :refresh="true" - :get-key="identity" - > - <template - slot="header" - slot-scope="{selected}" - > - <div class="profile-edit-bulk-actions"> - <ProgressButton - v-if="selected.length > 0" - class="btn btn-default" - :click="() => blockUsers(selected)" - > - {{ $t('user_card.block') }} - <template slot="progress"> - {{ $t('user_card.block_progress') }} - </template> - </ProgressButton> - <ProgressButton - v-if="selected.length > 0" - class="btn btn-default" - :click="() => unblockUsers(selected)" - > - {{ $t('user_card.unblock') }} - <template slot="progress"> - {{ $t('user_card.unblock_progress') }} - </template> - </ProgressButton> - </div> - </template> - <template - slot="item" - slot-scope="{item}" - > - <BlockCard :user-id="item" /> - </template> - <template slot="empty"> - {{ $t('settings.no_blocks') }} - </template> - </BlockList> - </div> - - <div :label="$t('settings.mutes_tab')"> - <tab-switcher> - <div label="Users"> - <div class="profile-edit-usersearch-wrapper"> - <Autosuggest - :filter="filterUnMutedUsers" - :query="queryUserIds" - :placeholder="$t('settings.search_user_to_mute')" - > - <MuteCard - slot-scope="row" - :user-id="row.item" - /> - </Autosuggest> - </div> - <MuteList - :refresh="true" - :get-key="identity" - > - <template - slot="header" - slot-scope="{selected}" - > - <div class="profile-edit-bulk-actions"> - <ProgressButton - v-if="selected.length > 0" - class="btn btn-default" - :click="() => muteUsers(selected)" - > - {{ $t('user_card.mute') }} - <template slot="progress"> - {{ $t('user_card.mute_progress') }} - </template> - </ProgressButton> - <ProgressButton - v-if="selected.length > 0" - class="btn btn-default" - :click="() => unmuteUsers(selected)" - > - {{ $t('user_card.unmute') }} - <template slot="progress"> - {{ $t('user_card.unmute_progress') }} - </template> - </ProgressButton> - </div> - </template> - <template - slot="item" - slot-scope="{item}" - > - <MuteCard :user-id="item" /> - </template> - <template slot="empty"> - {{ $t('settings.no_mutes') }} - </template> - </MuteList> - </div> - - <div :label="$t('settings.domain_mutes')"> - <div class="profile-edit-domain-mute-form"> - <input - v-model="newDomainToMute" - :placeholder="$t('settings.type_domains_to_mute')" - type="text" - @keyup.enter="muteDomain" - > - <ProgressButton - class="btn btn-default" - :click="muteDomain" - > - {{ $t('domain_mute_card.mute') }} - <template slot="progress"> - {{ $t('domain_mute_card.mute_progress') }} - </template> - </ProgressButton> - </div> - <DomainMuteList - :refresh="true" - :get-key="identity" - > - <template - slot="header" - slot-scope="{selected}" - > - <div class="profile-edit-bulk-actions"> - <ProgressButton - v-if="selected.length > 0" - class="btn btn-default" - :click="() => unmuteDomains(selected)" - > - {{ $t('domain_mute_card.unmute') }} - <template slot="progress"> - {{ $t('domain_mute_card.unmute_progress') }} - </template> - </ProgressButton> - </div> - </template> - <template - slot="item" - slot-scope="{item}" - > - <DomainMuteCard :domain="item" /> - </template> - <template slot="empty"> - {{ $t('settings.no_mutes') }} - </template> - </DomainMuteList> - </div> - </tab-switcher> - </div> - </tab-switcher> - </div> - </div> -</template> - -<script src="./user_settings.js"> -</script> - -<style lang="scss"> -@import '../../_variables.scss'; - -.profile-edit { - .bio { - margin: 0; - } - - .visibility-tray { - padding-top: 5px; - } - - input[type=file] { - padding: 5px; - height: auto; - } - - .banner { - max-width: 100%; - } - - .uploading { - font-size: 1.5em; - margin: 0.25em; - } - - .name-changer { - width: 100%; - } - - .bg { - max-width: 100%; - } - - .current-avatar { - display: block; - width: 150px; - height: 150px; - border-radius: $fallback--avatarRadius; - border-radius: var(--avatarRadius, $fallback--avatarRadius); - } - - .oauth-tokens { - width: 100%; - - th { - text-align: left; - } - - .actions { - text-align: right; - } - } - - &-usersearch-wrapper { - padding: 1em; - } - - &-bulk-actions { - text-align: right; - padding: 0 1em; - min-height: 28px; - - button { - width: 10em; - } - } - - &-domain-mute-form { - padding: 1em; - display: flex; - flex-direction: column; - - button { - align-self: flex-end; - margin-top: 1em; - width: 10em; - } - } - - .setting-subitem { - margin-left: 1.75em; - } -} -</style> |
