diff options
Diffstat (limited to 'src/components')
31 files changed, 750 insertions, 225 deletions
diff --git a/src/components/avatar_list/avatar_list.js b/src/components/avatar_list/avatar_list.js new file mode 100644 index 00000000..b9e11aaa --- /dev/null +++ b/src/components/avatar_list/avatar_list.js @@ -0,0 +1,15 @@ +import UserAvatar from '../user_avatar/user_avatar.vue' + +const AvatarList = { + props: ['users'], + computed: { + slicedUsers () { + return this.users ? this.users.slice(0, 15) : [] + } + }, + components: { + UserAvatar + } +} + +export default AvatarList diff --git a/src/components/avatar_list/avatar_list.vue b/src/components/avatar_list/avatar_list.vue new file mode 100644 index 00000000..4e0de2c9 --- /dev/null +++ b/src/components/avatar_list/avatar_list.vue @@ -0,0 +1,38 @@ +<template> + <div class="avatars"> + <div class="avatars-item" v-for="user in slicedUsers"> + <UserAvatar :user="user" class="avatar-small" /> + </div> + </div> +</template> + +<script src="./avatar_list.js" ></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.avatars { + display: flex; + margin: 0; + padding: 0; + + // For hiding overflowing elements + flex-wrap: wrap; + height: 24px; + + .avatars-item { + margin: 0 0 5px 5px; + + &:first-child { + padding-left: 5px; + } + + .avatar-small { + border-radius: $fallback--avatarAltRadius; + border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); + height: 24px; + width: 24px; + } + } +} +</style> diff --git a/src/components/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue index 48de6678..634d62b3 100644 --- a/src/components/basic_user_card/basic_user_card.vue +++ b/src/components/basic_user_card/basic_user_card.vue @@ -1,7 +1,11 @@ <template> <div class="basic-user-card"> <router-link :to="userProfileLink(user)"> - <UserAvatar class="avatar" @click.prevent.native="toggleUserExpanded" :src="user.profile_image_url"/> + <UserAvatar + class="avatar" + :user="user" + @click.prevent.native="toggleUserExpanded" + /> </router-link> <div class="basic-user-card-expanded-content" v-if="userExpanded"> <UserCard :user="user" :rounded="true" :bordered="true"/> @@ -44,14 +48,15 @@ width: 16px; vertical-align: middle; } + } - &-value { - display: inline-block; - max-width: 100%; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } + &-user-name-value, + &-screen-name { + display: inline-block; + max-width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } &-expanded-content { diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index 30600f73..ffeb7244 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -139,6 +139,7 @@ const conversation = { }, setHighlight (id) { this.highlight = id + this.$store.dispatch('fetchFavsAndRepeats', id) }, getHighlight () { return this.isExpanded ? this.highlight : null diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue index c3bbb597..d04ff722 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -11,7 +11,7 @@ @goto="setHighlight" @toggleExpanded="toggleExpanded" :key="status.id" - :inlineExpanded="collapsable" + :inlineExpanded="collapsable && isExpanded" :statusoid="status" :expandable='!isExpanded' :focused="focused(status.id)" diff --git a/src/components/exporter/exporter.js b/src/components/exporter/exporter.js new file mode 100644 index 00000000..8f507416 --- /dev/null +++ b/src/components/exporter/exporter.js @@ -0,0 +1,48 @@ +const Exporter = { + props: { + getContent: { + type: Function, + required: true + }, + filename: { + type: String, + default: 'export.csv' + }, + exportButtonLabel: { + type: String, + default () { + return this.$t('exporter.export') + } + }, + processingMessage: { + type: String, + default () { + return this.$t('exporter.processing') + } + } + }, + data () { + return { + processing: false + } + }, + methods: { + process () { + this.processing = true + this.getContent() + .then((content) => { + const fileToDownload = document.createElement('a') + fileToDownload.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(content)) + fileToDownload.setAttribute('download', this.filename) + fileToDownload.style.display = 'none' + document.body.appendChild(fileToDownload) + fileToDownload.click() + document.body.removeChild(fileToDownload) + // Add delay before hiding processing state since browser takes some time to handle file download + setTimeout(() => { this.processing = false }, 2000) + }) + } + } +} + +export default Exporter diff --git a/src/components/exporter/exporter.vue b/src/components/exporter/exporter.vue new file mode 100644 index 00000000..f22e579e --- /dev/null +++ b/src/components/exporter/exporter.vue @@ -0,0 +1,20 @@ +<template> + <div class="exporter"> + <div v-if="processing"> + <i class="icon-spin4 animate-spin exporter-processing"></i> + <span>{{processingMessage}}</span> + </div> + <button class="btn btn-default" @click="process" v-else>{{exportButtonLabel}}</button> + </div> +</template> + +<script src="./exporter.js"></script> + +<style lang="scss"> +.exporter { + &-processing { + font-size: 1.5em; + margin: 0.25em; + } +} +</style> diff --git a/src/components/follow_card/follow_card.js b/src/components/follow_card/follow_card.js index ac4e265a..dc4a0d41 100644 --- a/src/components/follow_card/follow_card.js +++ b/src/components/follow_card/follow_card.js @@ -10,8 +10,7 @@ const FollowCard = { data () { return { inProgress: false, - requestSent: false, - updated: false + requestSent: false } }, components: { @@ -19,10 +18,8 @@ const FollowCard = { RemoteFollow }, computed: { - isMe () { return this.$store.state.users.currentUser.id === this.user.id }, - following () { return this.updated ? this.updated.following : this.user.following }, - showFollow () { - return !this.following || this.updated && !this.updated.following + isMe () { + return this.$store.state.users.currentUser.id === this.user.id }, loggedIn () { return this.$store.state.users.currentUser @@ -31,17 +28,15 @@ const FollowCard = { methods: { followUser () { this.inProgress = true - requestFollow(this.user, this.$store).then(({ sent, updated }) => { + requestFollow(this.user, this.$store).then(({ sent }) => { this.inProgress = false this.requestSent = sent - this.updated = updated }) }, unfollowUser () { this.inProgress = true - requestUnfollow(this.user, this.$store).then(({ updated }) => { + requestUnfollow(this.user, this.$store).then(() => { this.inProgress = false - this.updated = updated }) } } diff --git a/src/components/follow_card/follow_card.vue b/src/components/follow_card/follow_card.vue index 9f314fd3..94e2836f 100644 --- a/src/components/follow_card/follow_card.vue +++ b/src/components/follow_card/follow_card.vue @@ -4,34 +4,38 @@ <span class="faint" v-if="!noFollowsYou && user.follows_you"> {{ isMe ? $t('user_card.its_you') : $t('user_card.follows_you') }} </span> - <div class="follow-card-follow-button" v-if="showFollow && !loggedIn"> - <RemoteFollow :user="user" /> - </div> - <button - v-if="showFollow && loggedIn" - class="btn btn-default follow-card-follow-button" - @click="followUser" - :disabled="inProgress" - :title="requestSent ? $t('user_card.follow_again') : ''" - > - <template v-if="inProgress"> - {{ $t('user_card.follow_progress') }} - </template> - <template v-else-if="requestSent"> - {{ $t('user_card.follow_sent') }} - </template> - <template v-else> - {{ $t('user_card.follow') }} - </template> - </button> - <button v-if="following" class="btn btn-default follow-card-follow-button pressed" @click="unfollowUser" :disabled="inProgress"> - <template v-if="inProgress"> - {{ $t('user_card.follow_progress') }} - </template> - <template v-else> - {{ $t('user_card.follow_unfollow') }} - </template> - </button> + <template v-if="!loggedIn"> + <div class="follow-card-follow-button" v-if="!user.following"> + <RemoteFollow :user="user" /> + </div> + </template> + <template v-else> + <button + v-if="!user.following" + class="btn btn-default follow-card-follow-button" + @click="followUser" + :disabled="inProgress" + :title="requestSent ? $t('user_card.follow_again') : ''" + > + <template v-if="inProgress"> + {{ $t('user_card.follow_progress') }} + </template> + <template v-else-if="requestSent"> + {{ $t('user_card.follow_sent') }} + </template> + <template v-else> + {{ $t('user_card.follow') }} + </template> + </button> + <button v-else class="btn btn-default follow-card-follow-button pressed" @click="unfollowUser" :disabled="inProgress"> + <template v-if="inProgress"> + {{ $t('user_card.follow_progress') }} + </template> + <template v-else> + {{ $t('user_card.follow_unfollow') }} + </template> + </button> + </template> </div> </basic-user-card> </template> diff --git a/src/components/image_cropper/image_cropper.js b/src/components/image_cropper/image_cropper.js index 5ba8f04e..01361e25 100644 --- a/src/components/image_cropper/image_cropper.js +++ b/src/components/image_cropper/image_cropper.js @@ -70,22 +70,10 @@ const ImageCropper = { this.dataUrl = undefined this.$emit('close') }, - submit () { + submit (cropping = true) { this.submitting = true this.avatarUploadError = null - this.submitHandler(this.cropper, this.file) - .then(() => this.destroy()) - .catch((err) => { - this.submitError = err - }) - .finally(() => { - this.submitting = false - }) - }, - submitWithoutCropping () { - this.submitting = true - this.avatarUploadError = null - this.submitHandler(false, this.dataUrl) + this.submitHandler(cropping && this.cropper, this.file) .then(() => this.destroy()) .catch((err) => { this.submitError = err diff --git a/src/components/image_cropper/image_cropper.vue b/src/components/image_cropper/image_cropper.vue index 129e6f46..d2b86e9e 100644 --- a/src/components/image_cropper/image_cropper.vue +++ b/src/components/image_cropper/image_cropper.vue @@ -5,9 +5,9 @@ <img ref="img" :src="dataUrl" alt="" @load.stop="createCropper" /> </div> <div class="image-cropper-buttons-wrapper"> - <button class="btn" type="button" :disabled="submitting" @click="submit" v-text="saveText"></button> + <button class="btn" type="button" :disabled="submitting" @click="submit()" v-text="saveText"></button> <button class="btn" type="button" :disabled="submitting" @click="destroy" v-text="cancelText"></button> - <button class="btn" type="button" :disabled="submitting" @click="submitWithoutCropping" v-text="saveWithoutCroppingText"></button> + <button class="btn" type="button" :disabled="submitting" @click="submit(false)" v-text="saveWithoutCroppingText"></button> <i class="icon-spin4 animate-spin" v-if="submitting"></i> </div> <div class="alert error" v-if="submitError"> diff --git a/src/components/importer/importer.js b/src/components/importer/importer.js new file mode 100644 index 00000000..c5f9e4d2 --- /dev/null +++ b/src/components/importer/importer.js @@ -0,0 +1,53 @@ +const Importer = { + props: { + submitHandler: { + type: Function, + required: true + }, + submitButtonLabel: { + type: String, + default () { + return this.$t('importer.submit') + } + }, + successMessage: { + type: String, + default () { + return this.$t('importer.success') + } + }, + errorMessage: { + type: String, + default () { + return this.$t('importer.error') + } + } + }, + data () { + return { + file: null, + error: false, + success: false, + submitting: false + } + }, + methods: { + change () { + this.file = this.$refs.input.files[0] + }, + submit () { + this.dismiss() + this.submitting = true + this.submitHandler(this.file) + .then(() => { this.success = true }) + .catch(() => { this.error = true }) + .finally(() => { this.submitting = false }) + }, + dismiss () { + this.success = false + this.error = false + } + } +} + +export default Importer diff --git a/src/components/importer/importer.vue b/src/components/importer/importer.vue new file mode 100644 index 00000000..0c5aa93d --- /dev/null +++ b/src/components/importer/importer.vue @@ -0,0 +1,28 @@ +<template> + <div class="importer"> + <form> + <input type="file" ref="input" v-on:change="change" /> + </form> + <i class="icon-spin4 animate-spin importer-uploading" v-if="submitting"></i> + <button class="btn btn-default" v-else @click="submit">{{submitButtonLabel}}</button> + <div v-if="success"> + <i class="icon-cross" @click="dismiss"></i> + <p>{{successMessage}}</p> + </div> + <div v-else-if="error"> + <i class="icon-cross" @click="dismiss"></i> + <p>{{errorMessage}}</p> + </div> + </div> +</template> + +<script src="./importer.js"></script> + +<style lang="scss"> +.importer { + &-uploading { + font-size: 1.5em; + margin: 0.25em; + } +} +</style> diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue index 7f666603..a4c12d74 100644 --- a/src/components/media_modal/media_modal.vue +++ b/src/components/media_modal/media_modal.vue @@ -33,6 +33,8 @@ @import '../../_variables.scss'; .media-modal-view { + z-index: 1001; + &:hover { .modal-view-button-arrow { opacity: 0.75; diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js index bc63d2ba..9b341a3b 100644 --- a/src/components/mobile_nav/mobile_nav.js +++ b/src/components/mobile_nav/mobile_nav.js @@ -63,6 +63,11 @@ const MobileNav = { }, markNotificationsAsSeen () { this.$refs.notifications.markAsSeen() + }, + onScroll ({ target: { scrollTop, clientHeight, scrollHeight } }) { + if (this.$store.state.config.autoLoad && scrollTop + clientHeight >= scrollHeight) { + this.$refs.notifications.fetchOlderNotifications() + } } }, watch: { diff --git a/src/components/mobile_nav/mobile_nav.vue b/src/components/mobile_nav/mobile_nav.vue index 5fa41638..90707ce7 100644 --- a/src/components/mobile_nav/mobile_nav.vue +++ b/src/components/mobile_nav/mobile_nav.vue @@ -1,25 +1,26 @@ <template> - <nav class='nav-bar container' id="nav"> - <div class='mobile-inner-nav' @click="scrollToTop()"> - <div class='item'> - <a href="#" class="mobile-nav-button" @click.stop.prevent="toggleMobileSidebar()"> - <i class="button-icon icon-menu"></i> - </a> - <router-link class="site-name" :to="{ name: 'root' }" active-class="home">{{sitename}}</router-link> - </div> - <div class='item right'> - <a class="mobile-nav-button" v-if="currentUser" href="#" @click.stop.prevent="openMobileNotifications()"> - <i class="button-icon icon-bell-alt"></i> - <div class="alert-dot" v-if="unseenNotificationsCount"></div> - </a> + <div> + <nav class='nav-bar container' id="nav"> + <div class='mobile-inner-nav' @click="scrollToTop()"> + <div class='item'> + <a href="#" class="mobile-nav-button" @click.stop.prevent="toggleMobileSidebar()"> + <i class="button-icon icon-menu"></i> + </a> + <router-link class="site-name" :to="{ name: 'root' }" active-class="home">{{sitename}}</router-link> + </div> + <div class='item right'> + <a class="mobile-nav-button" v-if="currentUser" href="#" @click.stop.prevent="openMobileNotifications()"> + <i class="button-icon icon-bell-alt"></i> + <div class="alert-dot" v-if="unseenNotificationsCount"></div> + </a> + </div> </div> - </div> - <SideDrawer ref="sideDrawer" :logout="logout"/> + </nav> <div v-if="currentUser" class="mobile-notifications-drawer" :class="{ 'closed': !notificationsOpen }" - @touchstart="notificationsTouchStart" - @touchmove="notificationsTouchMove" + @touchstart.stop="notificationsTouchStart" + @touchmove.stop="notificationsTouchMove" > <div class="mobile-notifications-header"> <span class="title">{{$t('notifications.notifications')}}</span> @@ -27,12 +28,13 @@ <i class="button-icon icon-cancel"/> </a> </div> - <div v-if="currentUser" class="mobile-notifications"> + <div class="mobile-notifications" @scroll="onScroll"> <Notifications ref="notifications" noHeading="true"/> </div> </div> + <SideDrawer ref="sideDrawer" :logout="logout"/> <MobilePostStatusModal /> - </nav> + </div> </template> <script src="./mobile_nav.js"></script> @@ -79,6 +81,8 @@ transition-property: transform; transition-duration: 0.25s; transform: translateX(0); + z-index: 1001; + -webkit-overflow-scrolling: touch; &.closed { transform: translateX(100%); diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index ae11d692..3427b9c5 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -7,7 +7,7 @@ </status> <div class="non-mention" :class="[userClass, { highlighted: userStyle }]" :style="[ userStyle ]" v-else> <a class='avatar-container' :href="notification.from_profile.statusnet_profile_url" @click.stop.prevent.capture="toggleUserExpanded"> - <UserAvatar :compact="true" :betterShadow="betterShadow" :src="notification.from_profile.profile_image_url_original" /> + <UserAvatar :compact="true" :betterShadow="betterShadow" :user="notification.from_profile"/> </a> <div class='notification-right'> <UserCard :user="getUser(notification)" :rounded="true" :bordered="true" v-if="userExpanded" /> diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js index e341212e..5b13b98e 100644 --- a/src/components/notifications/notifications.js +++ b/src/components/notifications/notifications.js @@ -52,6 +52,10 @@ const Notifications = { this.$store.dispatch('markNotificationsAsSeen') }, fetchOlderNotifications () { + if (this.loading) { + return + } + const store = this.$store const credentials = store.state.users.currentUser.credentials store.commit('setNotificationsLoading', { value: true }) diff --git a/src/components/selectable_list/selectable_list.vue b/src/components/selectable_list/selectable_list.vue index ba1e5266..3f16c921 100644 --- a/src/components/selectable_list/selectable_list.vue +++ b/src/components/selectable_list/selectable_list.vue @@ -31,6 +31,10 @@ &-item-inner { display: flex; align-items: center; + + > * { + min-width: 0; + } } &-item-selected-inner { diff --git a/src/components/status/status.js b/src/components/status/status.js index 0295cd04..c01cfe79 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -7,11 +7,12 @@ 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 generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import fileType from 'src/services/file_type/file_type.service' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js' -import { filter, find, unescape } from 'lodash' +import { filter, find, unescape, uniqBy } from 'lodash' const Status = { name: 'Status', @@ -30,7 +31,6 @@ const Status = { data () { return { replying: false, - expanded: false, unmuted: false, userExpanded: false, preview: null, @@ -97,6 +97,10 @@ const Status = { return this.statusoid } }, + statusFromGlobalRepository () { + // NOTE: Consider to replace status with statusFromGlobalRepository + return this.$store.state.statuses.allStatusesObject[this.status.id] + }, loggedIn () { return !!this.$store.state.users.currentUser }, @@ -156,7 +160,7 @@ const Status = { if (this.$store.state.config.replyVisibility === 'all') { return false } - if (this.inlineExpanded || this.expanded || this.inConversation || !this.isReply) { + if (this.inConversation || !this.isReply) { return false } if (this.status.user.id === this.$store.state.users.currentUser.id) { @@ -170,7 +174,7 @@ const Status = { if (this.status.user.id === this.status.attentions[i].id) { continue } - if (checkFollowing && this.status.attentions[i].following) { + if (checkFollowing && this.$store.getters.findUser(this.status.attentions[i].id).following) { return false } if (this.status.attentions[i].id === this.$store.state.users.currentUser.id) { @@ -257,6 +261,14 @@ const Status = { return this.status.statusnet_html } return this.status.summary_html + '<br />' + this.status.statusnet_html + }, + combinedFavsAndRepeatsUsers () { + // Use the status from the global status repository since favs and repeats are saved in it + const combinedUsers = [].concat( + this.statusFromGlobalRepository.favoritedBy, + this.statusFromGlobalRepository.rebloggedBy + ) + return uniqBy(combinedUsers, 'id') } }, components: { @@ -268,7 +280,8 @@ const Status = { UserCard, UserAvatar, Gallery, - LinkPreview + LinkPreview, + AvatarList }, methods: { visibilityIcon (visibility) { diff --git a/src/components/status/status.vue b/src/components/status/status.vue index 690e8318..43c3030e 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -13,7 +13,7 @@ </template> <template v-else> <div v-if="retweet && !noHeading && !inConversation" :class="[repeaterClass, { highlighted: repeaterStyle }]" :style="[repeaterStyle]" class="media container retweet-info"> - <UserAvatar class="media-left" v-if="retweet" :betterShadow="betterShadow" :src="statusoid.user.profile_image_url_original"/> + <UserAvatar class="media-left" v-if="retweet" :betterShadow="betterShadow" :user="statusoid.user"/> <div class="media-body faint"> <span class="user-name"> <router-link v-if="retweeterHtml" :to="retweeterProfileLink" v-html="retweeterHtml"/> @@ -27,7 +27,7 @@ <div :class="[userClass, { highlighted: userStyle, 'is-retweet': retweet && !inConversation }]" :style="[ userStyle ]" class="media status"> <div v-if="!noHeading" class="media-left"> <router-link :to="userProfileLink" @click.stop.prevent.capture.native="toggleUserExpanded"> - <UserAvatar :compact="compact" :betterShadow="betterShadow" :src="status.user.profile_image_url_original"/> + <UserAvatar :compact="compact" :betterShadow="betterShadow" :user="status.user"/> </router-link> </div> <div class="status-body"> @@ -91,8 +91,13 @@ </div> <div v-if="showPreview" class="status-preview-container"> - <status class="status-preview" v-if="preview" :isPreview="true" :statusoid="preview" :compact=true></status> - <div class="status-preview status-preview-loading" v-else> + <status class="status-preview" + v-if="preview" + :isPreview="true" + :statusoid="preview" + :compact=true + /> + <div v-else class="status-preview status-preview-loading"> <i class="icon-spin4 animate-spin"></i> </div> </div> @@ -133,6 +138,24 @@ <link-preview :card="status.card" :size="attachmentSize" :nsfw="nsfwClickthrough" /> </div> + <transition name="fade"> + <div class="favs-repeated-users" v-if="combinedFavsAndRepeatsUsers.length > 0 && isFocused"> + <div class="stats"> + <div class="stat-count" v-if="statusFromGlobalRepository.rebloggedBy && statusFromGlobalRepository.rebloggedBy.length > 0"> + <a class="stat-title">{{ $t('status.repeats') }}</a> + <div class="stat-number">{{ statusFromGlobalRepository.rebloggedBy.length }}</div> + </div> + <div class="stat-count" v-if="statusFromGlobalRepository.favoritedBy && statusFromGlobalRepository.favoritedBy.length > 0"> + <a class="stat-title">{{ $t('status.favorites') }}</a> + <div class="stat-number">{{ statusFromGlobalRepository.favoritedBy.length }}</div> + </div> + <div class="avatar-row"> + <AvatarList :users="combinedFavsAndRepeatsUsers"></AvatarList> + </div> + </div> + </div> + </transition> + <div v-if="!noHeading && !isPreview" class='status-actions media-body'> <div v-if="loggedIn"> <i class="button-icon icon-reply" v-on:click.prevent="toggleReplying" :title="$t('tool_tip.reply')" :class="{'icon-reply-active': replying}"></i> @@ -612,6 +635,50 @@ a.unmute { } } +.favs-repeated-users { + margin-top: $status-margin; + + .stats { + width: 100%; + display: flex; + line-height: 1em; + + .stat-count { + margin-right: $status-margin; + + .stat-title { + color: var(--faint, $fallback--faint); + font-size: 12px; + text-transform: uppercase; + position: relative; + } + + .stat-number { + font-weight: bolder; + font-size: 16px; + line-height: 1em; + } + } + + .avatar-row { + flex: 1; + overflow: hidden; + position: relative; + display: flex; + align-items: center; + + &::before { + content: ''; + position: absolute; + height: 100%; + width: 1px; + left: 0; + background-color: var(--faint, $fallback--faint); + } + } + } +} + @media all and (max-width: 800px) { .status-el { .retweet-info { diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue index e0a34bd1..e6a8d458 100644 --- a/src/components/timeline/timeline.vue +++ b/src/components/timeline/timeline.vue @@ -16,7 +16,7 @@ </div> <div :class="classes.body"> <div class="timeline"> - <conversation + <conversation v-for="status in timeline.visibleStatuses" class="status-fadein" :key="status.id" diff --git a/src/components/user_avatar/user_avatar.js b/src/components/user_avatar/user_avatar.js index e6fed3b5..a42b9c71 100644 --- a/src/components/user_avatar/user_avatar.js +++ b/src/components/user_avatar/user_avatar.js @@ -2,7 +2,7 @@ import StillImage from '../still-image/still-image.vue' const UserAvatar = { props: [ - 'src', + 'user', 'betterShadow', 'compact' ], diff --git a/src/components/user_avatar/user_avatar.vue b/src/components/user_avatar/user_avatar.vue index 6bf7123d..e5466fdf 100644 --- a/src/components/user_avatar/user_avatar.vue +++ b/src/components/user_avatar/user_avatar.vue @@ -1,8 +1,10 @@ <template> <StillImage class="avatar" + :alt="user.screen_name" + :title="user.screen_name" + :src="user.profile_image_url_original" :class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }" - :src="imgSrc" :imageLoadError="imageLoadError" /> </template> diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js index 1a100de3..7c6ffa89 100644 --- a/src/components/user_card/user_card.js +++ b/src/components/user_card/user_card.js @@ -151,6 +151,9 @@ export default { }, userProfileLink (user) { return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) + }, + reportUser () { + this.$store.dispatch('openUserReportingModal', this.user.id) } } } diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue index e1d3ff57..2d02ca03 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -4,7 +4,7 @@ <div class='user-info'> <div class='container'> <router-link :to="userProfileLink(user)"> - <UserAvatar :betterShadow="betterShadow" :src="user.profile_image_url_original"/> + <UserAvatar :betterShadow="betterShadow" :user="user"/> </router-link> <div class="name-and-screen-name"> <div class="top-line"> @@ -99,8 +99,14 @@ </button> </span> </div> - <ModerationTools :user='user' v-if='loggedIn.role === "admin"'> - </ModerationTools> + <div class='block' v-if='isOtherUser && loggedIn'> + <span> + <button @click="reportUser"> + {{ $t('user_card.report') }} + </button> + </span> + </div> + <ModerationTools :user='user' v-if='loggedIn.role === "admin"'/> </div> </div> </div> diff --git a/src/components/user_reporting_modal/user_reporting_modal.js b/src/components/user_reporting_modal/user_reporting_modal.js new file mode 100644 index 00000000..7c6ea409 --- /dev/null +++ b/src/components/user_reporting_modal/user_reporting_modal.js @@ -0,0 +1,106 @@ + +import Status from '../status/status.vue' +import List from '../list/list.vue' +import Checkbox from '../checkbox/checkbox.vue' + +const UserReportingModal = { + components: { + Status, + List, + Checkbox + }, + data () { + return { + comment: '', + forward: false, + statusIdsToReport: [], + processing: false, + error: false + } + }, + computed: { + isLoggedIn () { + return !!this.$store.state.users.currentUser + }, + isOpen () { + return this.isLoggedIn && this.$store.state.reports.modalActivated + }, + userId () { + return this.$store.state.reports.userId + }, + user () { + return this.$store.getters.findUser(this.userId) + }, + remoteInstance () { + return !this.user.is_local && this.user.screen_name.substr(this.user.screen_name.indexOf('@') + 1) + }, + statuses () { + return this.$store.state.reports.statuses + } + }, + watch: { + userId: 'resetState' + }, + methods: { + resetState () { + // Reset state + this.comment = '' + this.forward = false + this.statusIdsToReport = [] + this.processing = false + this.error = false + }, + closeModal () { + this.$store.dispatch('closeUserReportingModal') + }, + reportUser () { + this.processing = true + this.error = false + const params = { + userId: this.userId, + comment: this.comment, + forward: this.forward, + statusIds: this.statusIdsToReport + } + this.$store.state.api.backendInteractor.reportUser(params) + .then(() => { + this.processing = false + this.resetState() + this.closeModal() + }) + .catch(() => { + this.processing = false + this.error = true + }) + }, + clearError () { + this.error = false + }, + isChecked (statusId) { + return this.statusIdsToReport.indexOf(statusId) !== -1 + }, + toggleStatus (checked, statusId) { + if (checked === this.isChecked(statusId)) { + return + } + + if (checked) { + this.statusIdsToReport.push(statusId) + } else { + this.statusIdsToReport.splice(this.statusIdsToReport.indexOf(statusId), 1) + } + }, + resize (e) { + const target = e.target || e + if (!(target instanceof window.Element)) { return } + // Auto is needed to make textbox shrink when removing lines + target.style.height = 'auto' + target.style.height = `${target.scrollHeight}px` + if (target.value === '') { + target.style.height = null + } + } + } +} + +export default UserReportingModal diff --git a/src/components/user_reporting_modal/user_reporting_modal.vue b/src/components/user_reporting_modal/user_reporting_modal.vue new file mode 100644 index 00000000..432dd14d --- /dev/null +++ b/src/components/user_reporting_modal/user_reporting_modal.vue @@ -0,0 +1,157 @@ +<template> +<div class="modal-view" @click="closeModal" v-if="isOpen"> + <div class="user-reporting-panel panel" @click.stop=""> + <div class="panel-heading"> + <div class="title">{{$t('user_reporting.title', [user.screen_name])}}</div> + </div> + <div class="panel-body"> + <div class="user-reporting-panel-left"> + <div> + <p>{{$t('user_reporting.add_comment_description')}}</p> + <textarea + v-model="comment" + class="form-control" + :placeholder="$t('user_reporting.additional_comments')" + rows="1" + @input="resize" + /> + </div> + <div v-if="!user.is_local"> + <p>{{$t('user_reporting.forward_description')}}</p> + <Checkbox v-model="forward">{{$t('user_reporting.forward_to', [remoteInstance])}}</Checkbox> + </div> + <div> + <button class="btn btn-default" @click="reportUser" :disabled="processing">{{$t('user_reporting.submit')}}</button> + <div class="alert error" v-if="error"> + {{$t('user_reporting.generic_error')}} + </div> + </div> + </div> + <div class="user-reporting-panel-right"> + <List :items="statuses"> + <template slot="item" slot-scope="{item}"> + <div class="status-fadein user-reporting-panel-sitem"> + <Status :inConversation="false" :focused="false" :statusoid="item" /> + <Checkbox :checked="isChecked(item.id)" @change="checked => toggleStatus(checked, item.id)" /> + </div> + </template> + </List> + </div> + </div> + </div> +</div> +</template> + +<script src="./user_reporting_modal.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.user-reporting-panel { + width: 90vw; + max-width: 700px; + min-height: 20vh; + max-height: 80vh; + + .panel-heading { + .title { + text-align: center; + // TODO: Consider making these as default of panel + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .panel-body { + display: flex; + flex-direction: column-reverse; + border-top: 1px solid; + border-color: $fallback--border; + border-color: var(--border, $fallback--border); + overflow: hidden; + } + + &-left { + padding: 1.1em 0.7em 0.7em; + line-height: 1.4em; + box-sizing: border-box; + + > div { + margin-bottom: 1em; + + &:last-child { + margin-bottom: 0; + } + } + + p { + margin-top: 0; + } + + textarea.form-control { + line-height: 16px; + resize: none; + overflow: hidden; + transition: min-height 200ms 100ms; + min-height: 44px; + width: 100%; + } + + .btn { + min-width: 10em; + padding: 0 2em; + } + + .alert { + margin: 1em 0 0 0; + line-height: 1.3em; + } + } + + &-right { + display: flex; + flex-direction: column; + overflow-y: auto; + } + + &-sitem { + display: flex; + justify-content: space-between; + + > .status-el { + flex: 1; + } + + > .checkbox { + margin: 0.75em; + } + } + + @media all and (min-width: 801px) { + .panel-body { + flex-direction: row; + } + + &-left { + width: 50%; + max-width: 320px; + border-right: 1px solid; + border-color: $fallback--border; + border-color: var(--border, $fallback--border); + padding: 1.1em; + + > div { + margin-bottom: 2em; + } + } + + &-right { + width: 50%; + flex: 1 1 auto; + margin-bottom: 12px; + } + } +} +</style> diff --git a/src/components/user_search/user_search.js b/src/components/user_search/user_search.js index 55040826..62dafdf1 100644 --- a/src/components/user_search/user_search.js +++ b/src/components/user_search/user_search.js @@ -1,5 +1,6 @@ import FollowCard from '../follow_card/follow_card.vue' -import userSearchApi from '../../services/new_api/user_search.js' +import map from 'lodash/map' + const userSearch = { components: { FollowCard @@ -10,10 +11,15 @@ const userSearch = { data () { return { username: '', - users: [], + userIds: [], loading: false } }, + computed: { + users () { + return this.userIds.map(userId => this.$store.getters.findUser(userId)) + } + }, mounted () { this.search(this.query) }, @@ -33,10 +39,10 @@ const userSearch = { return } this.loading = true - userSearchApi.search({query, store: this.$store}) + this.$store.dispatch('searchUsers', query) .then((res) => { this.loading = false - this.users = res + this.userIds = map(res, 'id') }) } } diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js index e88ee612..2418450c 100644 --- a/src/components/user_settings/user_settings.js +++ b/src/components/user_settings/user_settings.js @@ -13,6 +13,8 @@ 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 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 userSearchApi from '../../services/new_api/user_search.js' @@ -40,14 +42,9 @@ const UserSettings = { hideFollowers: this.$store.state.users.currentUser.hide_followers, showRole: this.$store.state.users.currentUser.show_role, role: this.$store.state.users.currentUser.role, - followList: null, - followImportError: false, - followsImported: false, - enableFollowsExport: true, pickAvatarBtnVisible: true, bannerUploading: false, backgroundUploading: false, - followListUploading: false, bannerPreview: null, backgroundPreview: null, bannerUploadError: null, @@ -75,7 +72,9 @@ const UserSettings = { Autosuggest, BlockCard, MuteCard, - ProgressButton + ProgressButton, + Importer, + Exporter }, computed: { user () { @@ -110,37 +109,23 @@ const UserSettings = { }, methods: { updateProfile () { - const name = this.newName - const description = this.newBio - const locked = this.newLocked - // Backend notation. - /* eslint-disable camelcase */ - const default_scope = this.newDefaultScope - const no_rich_text = this.newNoRichText - const hide_follows = this.hideFollows - const hide_followers = this.hideFollowers - const show_role = this.showRole - - /* eslint-enable camelcase */ this.$store.state.api.backendInteractor .updateProfile({ params: { - name, - description, - locked, + note: this.newBio, + locked: this.newLocked, // Backend notation. /* eslint-disable camelcase */ - default_scope, - no_rich_text, - hide_follows, - hide_followers, - show_role + display_name: this.newName, + default_scope: this.newDefaultScope, + no_rich_text: this.newNoRichText, + hide_follows: this.hideFollows, + hide_followers: this.hideFollowers, + show_role: this.showRole /* eslint-enable camelcase */ }}).then((user) => { - if (!user.error) { - this.$store.commit('addNewUsers', [user]) - this.$store.commit('setCurrentUser', user) - } + this.$store.commit('addNewUsers', [user]) + this.$store.commit('setCurrentUser', user) }) }, changeVis (visibility) { @@ -160,23 +145,29 @@ const UserSettings = { reader.onload = ({target}) => { const img = target.result this[slot + 'Preview'] = img + this[slot] = file } reader.readAsDataURL(file) }, submitAvatar (cropper, file) { - let img - if (cropper) { - img = cropper.getCroppedCanvas().toDataURL(file.type) - } else { - img = 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)) + }) + } - return this.$store.state.api.backendInteractor.updateAvatar({ params: { img } }).then((user) => { - if (!user.error) { - this.$store.commit('addNewUsers', [user]) - this.$store.commit('setCurrentUser', user) + if (cropper) { + cropper.getCroppedCanvas().toBlob(updateAvatar, file.type) } else { - throw new Error(this.$t('upload.error.base') + user.error) + updateAvatar(file) } }) }, @@ -186,30 +177,17 @@ const UserSettings = { submitBanner () { if (!this.bannerPreview) { return } - let banner = this.bannerPreview - // eslint-disable-next-line no-undef - let imginfo = new Image() - /* eslint-disable camelcase */ - let offset_top, offset_left, width, height - imginfo.src = banner - width = imginfo.width - height = imginfo.height - offset_top = 0 - offset_left = 0 this.bannerUploading = true - this.$store.state.api.backendInteractor.updateBanner({params: {banner, offset_top, offset_left, width, height}}).then((data) => { - if (!data.error) { - let clone = JSON.parse(JSON.stringify(this.$store.state.users.currentUser)) - clone.cover_photo = data.url - this.$store.commit('addNewUsers', [clone]) - this.$store.commit('setCurrentUser', clone) + this.$store.state.api.backendInteractor.updateBanner({banner: this.banner}) + .then((user) => { + this.$store.commit('addNewUsers', [user]) + this.$store.commit('setCurrentUser', user) this.bannerPreview = null - } else { - this.bannerUploadError = this.$t('upload.error.base') + data.error - } - this.bannerUploading = false - }) - /* eslint-enable camelcase */ + }) + .catch((err) => { + this.bannerUploadError = this.$t('upload.error.base') + ' ' + err.message + }) + .then(() => { this.bannerUploading = false }) }, submitBg () { if (!this.backgroundPreview) { return } @@ -236,62 +214,41 @@ const UserSettings = { this.backgroundUploading = false }) }, - importFollows () { - this.followListUploading = true - const followList = this.followList - this.$store.state.api.backendInteractor.followImport({params: followList}) + importFollows (file) { + return this.$store.state.api.backendInteractor.importFollows(file) .then((status) => { - if (status) { - this.followsImported = true - } else { - this.followImportError = true + if (!status) { + throw new Error('failed') + } + }) + }, + importBlocks (file) { + return this.$store.state.api.backendInteractor.importBlocks(file) + .then((status) => { + if (!status) { + throw new Error('failed') } - this.followListUploading = false }) }, - /* This function takes an Array of Users - * and outputs a file with all the addresses for the user to download - */ - exportPeople (users, filename) { - // Get all the friends addresses - var UserAddresses = users.map(function (user) { + 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 - user.screen_name += '@' + location.hostname + return user.screen_name + '@' + location.hostname } return user.screen_name }).join('\n') - // Make the user download the file - var fileToDownload = document.createElement('a') - fileToDownload.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(UserAddresses)) - fileToDownload.setAttribute('download', filename) - fileToDownload.style.display = 'none' - document.body.appendChild(fileToDownload) - fileToDownload.click() - document.body.removeChild(fileToDownload) }, - exportFollows () { - this.enableFollowsExport = false - this.$store.state.api.backendInteractor - .exportFriends({ - id: this.$store.state.users.currentUser.id - }) - .then((friendList) => { - this.exportPeople(friendList, 'friends.csv') - setTimeout(() => { this.enableFollowsExport = true }, 2000) - }) - }, - followListChange () { - // eslint-disable-next-line no-undef - let formData = new FormData() - formData.append('list', this.$refs.followlist.files[0]) - this.followList = formData + getFollowsContent () { + return this.$store.state.api.backendInteractor.exportFriends({ id: this.$store.state.users.currentUser.id }) + .then(this.generateExportableUsersContent) }, - dismissImported () { - this.followsImported = false - this.followImportError = false + getBlocksContent () { + return this.$store.state.api.backendInteractor.fetchBlocks() + .then(this.generateExportableUsersContent) }, confirmDelete () { this.deletingAccount = true diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue index d68e68fa..8a94f0b8 100644 --- a/src/components/user_settings/user_settings.vue +++ b/src/components/user_settings/user_settings.vue @@ -171,26 +171,20 @@ <div class="setting-item"> <h2>{{$t('settings.follow_import')}}</h2> <p>{{$t('settings.import_followers_from_a_csv_file')}}</p> - <form> - <input type="file" ref="followlist" v-on:change="followListChange" /> - </form> - <i class=" icon-spin4 animate-spin uploading" v-if="followListUploading"></i> - <button class="btn btn-default" v-else @click="importFollows">{{$t('general.submit')}}</button> - <div v-if="followsImported"> - <i class="icon-cross" @click="dismissImported"></i> - <p>{{$t('settings.follows_imported')}}</p> - </div> - <div v-else-if="followImportError"> - <i class="icon-cross" @click="dismissImported"></i> - <p>{{$t('settings.follow_import_error')}}</p> - </div> + <Importer :submitHandler="importFollows" :successMessage="$t('settings.follows_imported')" :errorMessage="$t('settings.follow_import_error')" /> </div> - <div class="setting-item" v-if="enableFollowsExport"> + <div class="setting-item"> <h2>{{$t('settings.follow_export')}}</h2> - <button class="btn btn-default" @click="exportFollows">{{$t('settings.follow_export_button')}}</button> + <Exporter :getContent="getFollowsContent" filename="friends.csv" :exportButtonLabel="$t('settings.follow_export_button')" /> </div> - <div class="setting-item" v-else> - <h2>{{$t('settings.follow_export_processing')}}</h2> + <div class="setting-item"> + <h2>{{$t('settings.block_import')}}</h2> + <p>{{$t('settings.import_blocks_from_a_csv_file')}}</p> + <Importer :submitHandler="importBlocks" :successMessage="$t('settings.blocks_imported')" :errorMessage="$t('settings.block_import_error')" /> + </div> + <div class="setting-item"> + <h2>{{$t('settings.block_export')}}</h2> + <Exporter :getContent="getBlocksContent" filename="blocks.csv" :exportButtonLabel="$t('settings.block_export_button')" /> </div> </div> |
