diff options
48 files changed, 1106 insertions, 353 deletions
@@ -10,6 +10,7 @@ import MediaModal from './components/media_modal/media_modal.vue' import SideDrawer from './components/side_drawer/side_drawer.vue' import MobilePostStatusModal from './components/mobile_post_status_modal/mobile_post_status_modal.vue' import MobileNav from './components/mobile_nav/mobile_nav.vue' +import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue' import { windowWidth } from './services/window_utils/window_utils' export default { @@ -26,7 +27,8 @@ export default { MediaModal, SideDrawer, MobilePostStatusModal, - MobileNav + MobileNav, + UserReportingModal }, data: () => ({ mobileActivePanel: 'timeline', diff --git a/src/App.scss b/src/App.scss index b1c65ade..921f2c3b 100644 --- a/src/App.scss +++ b/src/App.scss @@ -379,6 +379,7 @@ main-router { .panel-heading { display: flex; + flex: none; border-radius: $fallback--panelRadius $fallback--panelRadius 0 0; border-radius: var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius) 0 0; background-size: cover; @@ -793,4 +794,4 @@ nav { background-color: var(--lightBg, $fallback--fg); } } -}
\ No newline at end of file +} diff --git a/src/App.vue b/src/App.vue index 3b8623ad..21abd694 100644 --- a/src/App.vue +++ b/src/App.vue @@ -18,17 +18,19 @@ </div> </div> </nav> - <div v-if="" class="container" id="content"> - <div class="sidebar-flexer mobile-hidden" v-if="!isMobileLayout"> + <div class="container" id="content"> + <div class="sidebar-flexer mobile-hidden"> <div class="sidebar-bounds"> <div class="sidebar-scroller"> <div class="sidebar"> <user-panel></user-panel> - <nav-panel></nav-panel> - <instance-specific-panel v-if="showInstanceSpecificPanel"></instance-specific-panel> - <features-panel v-if="!currentUser && showFeaturesPanel"></features-panel> - <who-to-follow-panel v-if="currentUser && suggestionsEnabled"></who-to-follow-panel> - <notifications v-if="currentUser"></notifications> + <div v-if="!isMobileLayout"> + <nav-panel></nav-panel> + <instance-specific-panel v-if="showInstanceSpecificPanel"></instance-specific-panel> + <features-panel v-if="!currentUser && showFeaturesPanel"></features-panel> + <who-to-follow-panel v-if="currentUser && suggestionsEnabled"></who-to-follow-panel> + <notifications v-if="currentUser"></notifications> + </div> </div> </div> </div> @@ -46,6 +48,7 @@ <media-modal></media-modal> </div> <chat-panel :floating="true" v-if="currentUser && chat" class="floating-chat mobile-hidden"></chat-panel> + <UserReportingModal /> </div> </template> 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> diff --git a/src/i18n/cs.json b/src/i18n/cs.json index 020092a6..5f2f2b71 100644 --- a/src/i18n/cs.json +++ b/src/i18n/cs.json @@ -73,7 +73,8 @@ "content_type": { "text/plain": "Prostý text", "text/html": "HTML", - "text/markdown": "Markdown" + "text/markdown": "Markdown", + "text/bbcode": "BBCode" }, "content_warning": "Předmět (volitelný)", "default": "Právě jsem přistál v L.A.", diff --git a/src/i18n/en.json b/src/i18n/en.json index d7d4821e..ae6b9951 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -2,6 +2,10 @@ "chat": { "title": "Chat" }, + "exporter": { + "export": "Export", + "processing": "Processing, you'll soon be asked to download your file" + }, "features_panel": { "chat": "Chat", "gopher": "Gopher", @@ -31,6 +35,11 @@ "save_without_cropping": "Save without cropping", "cancel": "Cancel" }, + "importer": { + "submit": "Submit", + "success": "Imported successfully.", + "error": "An error occured while importing this file." + }, "login": { "login": "Log in", "description": "Log in with OAuth", @@ -77,7 +86,8 @@ "content_type": { "text/plain": "Plain text", "text/html": "HTML", - "text/markdown": "Markdown" + "text/markdown": "Markdown", + "text/bbcode": "BBCode" }, "content_warning": "Subject (optional)", "default": "Just landed in L.A.", @@ -125,6 +135,11 @@ "avatarRadius": "Avatars", "background": "Background", "bio": "Bio", + "block_export": "Block export", + "block_export_button": "Export your blocks to a csv file", + "block_import": "Block import", + "block_import_error": "Error importing blocks", + "blocks_imported": "Blocks imported! Processing them will take a while.", "blocks_tab": "Blocks", "btnRadius": "Buttons", "cBlue": "Blue (Reply, follow)", @@ -152,7 +167,6 @@ "filtering_explanation": "All statuses containing these words will be muted, one per line", "follow_export": "Follow export", "follow_export_button": "Export your follows to a csv file", - "follow_export_processing": "Processing, you'll soon be asked to download your file", "follow_import": "Follow import", "follow_import_error": "Error importing followers", "follows_imported": "Follows imported! Processing them will take a while.", @@ -168,6 +182,7 @@ "hide_post_stats": "Hide post statistics (e.g. the number of favorites)", "hide_user_stats": "Hide user statistics (e.g. the number of followers)", "hide_filtered_statuses": "Hide filtered statuses", + "import_blocks_from_a_csv_file": "Import blocks from a csv file", "import_followers_from_a_csv_file": "Import follows from a csv file", "import_theme": "Load preset", "inputRadius": "Input fields", @@ -380,6 +395,8 @@ "no_statuses": "No statuses" }, "status": { + "favorites": "Favorites", + "repeats": "Repeats", "reply_to": "Reply to", "replies_list": "Replies:" }, @@ -404,6 +421,7 @@ "muted": "Muted", "per_day": "per day", "remote_follow": "Remote follow", + "report": "Report", "statuses": "Statuses", "unblock": "Unblock", "unblock_progress": "Unblocking...", @@ -436,6 +454,15 @@ "profile_does_not_exist": "Sorry, this profile does not exist.", "profile_loading_error": "Sorry, there was an error loading this profile." }, + "user_reporting": { + "title": "Reporting {0}", + "add_comment_description": "The report will be sent to your instance moderators. You can provide an explanation of why you are reporting this account below:", + "additional_comments": "Additional comments", + "forward_description": "The account is from another server. Send a copy of the report there as well?", + "forward_to": "Forward to {0}", + "submit": "Submit", + "generic_error": "An error occurred while processing your request." + }, "who_to_follow": { "more": "More", "who_to_follow": "Who to follow" diff --git a/src/i18n/es.json b/src/i18n/es.json index a692eef9..2e38f859 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -2,6 +2,10 @@ "chat": { "title": "Chat" }, + "exporter": { + "export": "Exportar", + "processing": "Procesando. Pronto se te pedirá que descargues tu archivo" + }, "features_panel": { "chat": "Chat", "gopher": "Gopher", @@ -19,7 +23,22 @@ "apply": "Aplicar", "submit": "Enviar", "more": "Más", - "generic_error": "Ha ocurrido un error" + "generic_error": "Ha ocurrido un error", + "optional": "opcional", + "show_more": "Mostrar más", + "show_less": "Mostrar menos", + "cancel": "Cancelar" + }, + "image_cropper": { + "crop_picture": "Recortar la foto", + "save": "Guardar", + "save_without_cropping": "Guardar sin recortar", + "cancel": "Cancelar" + }, + "importer": { + "submit": "Enviar", + "success": "Importado con éxito", + "error": "Se ha producido un error al importar el archivo." }, "login": { "login": "Identificación", @@ -31,6 +50,10 @@ "username": "Usuario", "hint": "Inicia sesión para unirte a la discusión" }, + "media_modal": { + "previous": "Anterior", + "next": "Siguiente" + }, "nav": { "about": "Sobre", "back": "Volver", @@ -61,15 +84,19 @@ "account_not_locked_warning_link": "bloqueada", "attachments_sensitive": "Contenido sensible", "content_type": { - "text/plain": "Texto Plano" + "text/plain": "Texto Plano", + "text/html": "HTML", + "text/markdown": "Markdown", + "text/bbcode": "BBCode" }, "content_warning": "Tema (opcional)", "default": "Acabo de aterrizar en L.A.", - "direct_warning": "Esta entrada solo será visible para los usuarios mencionados.", + "direct_warning": "Esta publicación solo será visible para los usuarios mencionados.", + "direct_warning_to_first_only": "Esta publicación solo será visible para los usuarios mencionados al comienzo del mensaje.", "posting": "Publicando", "scope": { "direct": "Directo - Solo para los usuarios mencionados.", - "private": "Solo-Seguidores - Solo tus seguidores leeran la entrada", + "private": "Solo-Seguidores - Solo tus seguidores leeran la publicación", "public": "Público - Entradas visibles en las Líneas Temporales Públicas", "unlisted": "Sin Listar - Entradas no visibles en las Líneas Temporales Públicas" } @@ -83,6 +110,9 @@ "token": "Token de invitación", "captcha": "CAPTCHA", "new_captcha": "Click en la imagen para obtener un nuevo captca", + "username_placeholder": "p.ej. lain", + "fullname_placeholder": "p.ej. Lain Iwakura", + "bio_placeholder": "e.g.\nHola, soy un ejemplo.\nAquí puedes poner algo representativo tuyo... o no.", "validations": { "username_required": "no puede estar vacío", "fullname_required": "no puede estar vacío", @@ -92,7 +122,11 @@ "password_confirmation_match": "la contraseña no coincide" } }, + "selectable_list": { + "select_all": "Seleccionarlo todo" + }, "settings": { + "app_name": "Nombre de la aplicación", "attachmentRadius": "Adjuntos", "attachments": "Adjuntos", "autoload": "Activar carga automática al llegar al final de la página", @@ -101,6 +135,12 @@ "avatarRadius": "Avatares", "background": "Fondo", "bio": "Biografía", + "block_export": "Exportar usuarios bloqueados", + "block_export_button": "Exporta la lista de tus usarios bloqueados a un archivo csv", + "block_import": "Importar usuarios bloqueados", + "block_import_error": "Error importando la lista de usuarios bloqueados", + "blocks_imported": "¡Lista de usuarios bloqueados importada! El procesado puede tardar un poco.", + "blocks_tab": "Bloqueados", "btnRadius": "Botones", "cBlue": "Azul (Responder, seguir)", "cGreen": "Verde (Retweet)", @@ -127,7 +167,6 @@ "filtering_explanation": "Todos los estados que contengan estas palabras serán silenciados, una por línea", "follow_export": "Exportar personas que tú sigues", "follow_export_button": "Exporta tus seguidores a un archivo csv", - "follow_export_processing": "Procesando, en breve se te preguntará para guardar el archivo", "follow_import": "Importar personas que tú sigues", "follow_import_error": "Error al importal el archivo", "follows_imported": "¡Importado! Procesarlos llevará tiempo.", @@ -135,12 +174,15 @@ "general": "General", "hide_attachments_in_convo": "Ocultar adjuntos en las conversaciones", "hide_attachments_in_tl": "Ocultar adjuntos en la línea temporal", + "hide_muted_posts": "Ocultar las publicaciones de los usuarios silenciados", + "max_thumbnails": "Cantidad máxima de miniaturas por publicación", "hide_isp": "Ocultar el panel específico de la instancia", "preload_images": "Precargar las imágenes", "use_one_click_nsfw": "Abrir los adjuntos NSFW con un solo click.", "hide_post_stats": "Ocultar las estadísticas de las entradas (p.ej. el número de favoritos)", "hide_user_stats": "Ocultar las estadísticas del usuario (p.ej. el número de seguidores)", "hide_filtered_statuses": "Ocultar estados filtrados", + "import_blocks_from_a_csv_file": "Importar lista de usuarios bloqueados dese un archivo csv", "import_followers_from_a_csv_file": "Importar personas que tú sigues a partir de un archivo csv", "import_theme": "Importar tema", "inputRadius": "Campos de entrada", @@ -155,6 +197,7 @@ "lock_account_description": "Restringir el acceso a tu cuenta solo a seguidores admitidos", "loop_video": "Vídeos en bucle", "loop_video_silent_only": "Bucle solo en vídeos sin sonido (p.ej. \"gifs\" de Mastodon)", + "mutes_tab": "Silenciados", "play_videos_in_modal": "Reproducir los vídeos directamente en el visor de medios", "use_contain_fit": "No recortar los adjuntos en miniaturas", "name": "Nombre", @@ -166,6 +209,8 @@ "notification_visibility_mentions": "Menciones", "notification_visibility_repeats": "Repeticiones (Repeats)", "no_rich_text_description": "Eliminar el formato de texto enriquecido de todas las entradas", + "no_blocks": "No hay usuarios bloqueados", + "no_mutes": "No hay usuarios sinlenciados", "hide_follows_description": "No mostrar a quién sigo", "hide_followers_description": "No mostrar quién me sigue", "show_admin_badge": "Mostrar la placa de administrador en mi perfil", @@ -190,8 +235,11 @@ "reply_visibility_self": "Solo mostrar réplicas para mí", "saving_err": "Error al guardar los ajustes", "saving_ok": "Ajustes guardados", + "search_user_to_block": "Buscar usuarios a bloquear", + "search_user_to_mute": "Buscar usuarios a silenciar", "security_tab": "Seguridad", - "scope_copy": "Copiar la visibilidad cuando contestamos (En los mensajes directos (MDs) siempre se copia)", + "scope_copy": "Copiar la visibilidad de la publicación cuando contestamos (En los mensajes directos (MDs) siempre se copia)", + "minimal_scopes_mode": "Minimizar las opciones de publicación", "set_new_avatar": "Cambiar avatar", "set_new_profile_background": "Cambiar fondo del perfil", "set_new_profile_banner": "Cambiar cabecera del perfil", @@ -210,6 +258,7 @@ "theme_help_v2_1": "También puede invalidar los colores y la opacidad de ciertos componentes si activa la casilla de verificación, use el botón \"Borrar todo\" para deshacer los cambios.", "theme_help_v2_2": "Los iconos debajo de algunas entradas son indicadores de contraste de fondo/texto, desplace el ratón para obtener información detallada. Tenga en cuenta que cuando se utilizan indicadores de contraste de transparencia se muestra el peor caso posible.", "tooltipRadius": "Información/alertas", + "upload_a_photo": "Subir una foto", "user_settings": "Ajustes de Usuario", "values": { "false": "no", @@ -325,6 +374,11 @@ "checkbox": "He revisado los términos y condiciones", "link": "un bonito enlace" } + }, + "version": { + "title": "Versión", + "backend_version": "Versión del Backend", + "frontend_version": "Versión del Frontend" } }, "timeline": { @@ -336,7 +390,14 @@ "repeated": "repetida", "show_new": "Mostrar lo nuevo", "up_to_date": "Actualizado", - "no_more_statuses": "No hay más estados" + "no_more_statuses": "No hay más estados", + "no_statuses": "Sin estados" + }, + "status": { + "favorites": "Favoritos", + "repeats": "Repetidos", + "reply_to": "Responder a", + "replies_list": "Respuestas:" }, "user_card": { "approve": "Aprovar", @@ -359,10 +420,47 @@ "muted": "Silenciado", "per_day": "por día", "remote_follow": "Seguir", - "statuses": "Estados" + "report": "Reportar", + "statuses": "Estados", + "unblock": "Desbloquear", + "unblock_progress": "Desbloqueando...", + "block_progress": "Bloqueando...", + "unmute": "Desenmudecer", + "unmute_progress": "Sesenmudeciendo...", + "mute_progress": "Silenciando...", + "admin_menu": { + "moderation": "Moderación", + "grant_admin": "Conceder permisos de Administrador", + "revoke_admin": "Revocar permisos de Administrador", + "grant_moderator": "Conceder permisos de Moderador", + "revoke_moderator": "Revocar permisos de Moderador", + "activate_account": "Activar cuenta", + "deactivate_account": "Desactivar cuenta", + "delete_account": "Borrar cuenta", + "force_nsfw": "Marcar todas las publicaciones como NSFW (no es seguro/apropiado para el trabajo)", + "strip_media": "Eliminar archivos multimedia de las publicaciones", + "force_unlisted": "Forzar que se publique en el modo -Sin Listar-", + "sandbox": "Forzar que se publique solo para tus seguidores", + "disable_remote_subscription": "No permitir que usuarios de instancias remotas te siga.", + "disable_any_subscription": "No permitir que ningún usuario te siga", + "quarantine": "No permitir publicaciones de usuarios de instancias remotas", + "delete_user": "Borrar usuario", + "delete_user_confirmation": "¿Estás completamente seguro? Esta acción no se puede deshacer." + } }, "user_profile": { - "timeline_title": "Linea temporal del usuario" + "timeline_title": "Linea temporal del usuario", + "profile_does_not_exist": "Lo sentimos, este perfil no existe.", + "profile_loading_error": "Lo sentimos, hubo un error al cargar este perfil." + }, + "user_reporting": { + "title": "Reportando a {0}", + "add_comment_description": "El informe será enviado a los moderadores de su instancia. Puedes proporcionar una explicación de por qué estás reportando esta cuenta a continuación:", + "additional_comments": "Comentarios adicionales", + "forward_description": "La cuenta es de otro servidor. ¿Enviar una copia del informe allí también?", + "forward_to": "Reenviar a {0}", + "submit": "Enviar", + "generic_error": "Se produjo un error al procesar la solicitud." }, "who_to_follow": { "more": "Más", @@ -389,4 +487,4 @@ "TiB": "TiB" } } -} +}
\ No newline at end of file diff --git a/src/i18n/fi.json b/src/i18n/fi.json index fbe676cf..62cbecb8 100644 --- a/src/i18n/fi.json +++ b/src/i18n/fi.json @@ -222,6 +222,8 @@ "no_more_statuses": "Ei enempää viestejä" }, "status": { + "favorites": "Tykkäykset", + "repeats": "Toistot", "reply_to": "Vastaus", "replies_list": "Vastaukset:" }, diff --git a/src/i18n/oc.json b/src/i18n/oc.json index 83bd97cf..5f8d153f 100644 --- a/src/i18n/oc.json +++ b/src/i18n/oc.json @@ -2,6 +2,10 @@ "chat": { "title": "Messatjariá" }, + "exporter": { + "export": "Exportar", + "processing": "Tractament, vos demandarem lèu de telecargar lo fichièr" + }, "features_panel": { "chat": "Chat", "gopher": "Gopher", @@ -31,6 +35,11 @@ "save_without_cropping": "Salvar sens talhada", "cancel": "Anullar" }, + "importer": { + "submit": "Mandar", + "success": "Corrèctament importat.", + "error": "Una error s’es producha pendent l’importacion d’aqueste fichièr." + }, "login": { "login": "Connexion", "description": "Connexion via OAuth", @@ -77,7 +86,8 @@ "content_type": { "text/plain": "Tèxte brut", "text/html": "HTML", - "text/markdown": "Markdown" + "text/markdown": "Markdown", + "text/bbcode": "BBCode" }, "content_warning": "Avís de contengut (opcional)", "default": "Escrivètz aquí vòstre estatut.", @@ -125,6 +135,11 @@ "avatarRadius": "Avatars", "background": "Rèire plan", "bio": "Biografia", + "block_export": "Exportar los blocatges", + "block_export_button": "Exportar los blocatges dins un fichièr csv", + "block_import": "Impòrt de blocatges", + "block_import_error": "Error en importar los blocatges", + "blocks_imported": "Blocatges importats ! Lo tractament tardarà un pauc.", "blocks_tab": "Blocatges", "btnRadius": "Botons", "cBlue": "Blau (Respondre, seguir)", @@ -152,7 +167,6 @@ "filtering_explanation": "Totes los estatuts amb aqueles mots seràn en silenci, un mot per linha", "follow_export": "Exportar los abonaments", "follow_export_button": "Exportar vòstres abonaments dins un fichièr csv", - "follow_export_processing": "Tractament, vos demandarem lèu de telecargar lo fichièr", "follow_import": "Importar los abonaments", "follow_import_error": "Error en important los seguidors", "follows_imported": "Seguidors importats. Lo tractament pòt trigar una estona.", @@ -187,6 +201,7 @@ "use_contain_fit": "Talhar pas las pèças juntas per las vinhetas", "name": "Nom", "name_bio": "Nom & Bio", + "new_password": "Nòu senhal", "notification_visibility_follows": "Abonaments", "notification_visibility_likes": "Aimar", @@ -379,6 +394,8 @@ "no_statuses": "Cap d’estatuts" }, "status": { + "favorites": "Li a agradat", + "repeats": "A repetit", "reply_to": "Respond a", "replies_list": "Responsas :" }, diff --git a/src/i18n/pl.json b/src/i18n/pl.json index 8efce168..715e5d6e 100644 --- a/src/i18n/pl.json +++ b/src/i18n/pl.json @@ -74,7 +74,8 @@ "content_type": { "text/plain": "Czysty tekst", "text/html": "HTML", - "text/markdown": "Markdown" + "text/markdown": "Markdown", + "text/bbcode": "BBCode" }, "content_warning": "Temat (nieobowiązkowy)", "default": "Właśnie wróciłem z kościoła", @@ -431,4 +432,4 @@ "TiB": "TiB" } } -}
\ No newline at end of file +} diff --git a/src/main.js b/src/main.js index 725f5806..92f843b1 100644 --- a/src/main.js +++ b/src/main.js @@ -12,6 +12,7 @@ import chatModule from './modules/chat.js' import oauthModule from './modules/oauth.js' import mediaViewerModule from './modules/media_viewer.js' import oauthTokensModule from './modules/oauth_tokens.js' +import reportsModule from './modules/reports.js' import VueTimeago from 'vue-timeago' import VueI18n from 'vue-i18n' @@ -75,7 +76,8 @@ const persistedStateOptions = { chat: chatModule, oauth: oauthModule, mediaViewer: mediaViewerModule, - oauthTokens: oauthTokensModule + oauthTokens: oauthTokensModule, + reports: reportsModule }, plugins: [persistedState, pushNotifications], strict: false // Socket modifies itself, let's ignore this for now. diff --git a/src/modules/reports.js b/src/modules/reports.js new file mode 100644 index 00000000..904022f1 --- /dev/null +++ b/src/modules/reports.js @@ -0,0 +1,30 @@ +import filter from 'lodash/filter' + +const reports = { + state: { + userId: null, + statuses: [], + modalActivated: false + }, + mutations: { + openUserReportingModal (state, { userId, statuses }) { + state.userId = userId + state.statuses = statuses + state.modalActivated = true + }, + closeUserReportingModal (state) { + state.modalActivated = false + } + }, + actions: { + openUserReportingModal ({ rootState, commit }, userId) { + const statuses = filter(rootState.statuses.allStatuses, status => status.user.id === userId) + commit('openUserReportingModal', { userId, statuses }) + }, + closeUserReportingModal ({ commit }) { + commit('closeUserReportingModal') + } + } +} + +export default reports diff --git a/src/modules/statuses.js b/src/modules/statuses.js index e70c2400..1a223d09 100644 --- a/src/modules/statuses.js +++ b/src/modules/statuses.js @@ -459,6 +459,13 @@ export const mutations = { }, queueFlush (state, { timeline, id }) { state.timelines[timeline].flushMarker = id + }, + addFavsAndRepeats (state, { id, favoritedByUsers, rebloggedByUsers }) { + state.allStatusesObject[id] = { + ...state.allStatusesObject[id], + favoritedBy: favoritedByUsers, + rebloggedBy: rebloggedByUsers + } } } @@ -524,6 +531,21 @@ const statuses = { id: rootState.statuses.notifications.maxId, credentials: rootState.users.currentUser.credentials }) + }, + fetchFavsAndRepeats ({ rootState, commit }, id) { + Promise.all([ + rootState.api.backendInteractor.fetchFavoritedByUsers(id), + rootState.api.backendInteractor.fetchRebloggedByUsers(id) + ]).then(([favoritedByUsers, rebloggedByUsers]) => + commit( + 'addFavsAndRepeats', + { + id, + favoritedByUsers: favoritedByUsers.filter(_ => _), + rebloggedByUsers: rebloggedByUsers.filter(_ => _) + } + ) + ) } }, mutations diff --git a/src/modules/users.js b/src/modules/users.js index c98e353a..adcab233 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -1,4 +1,5 @@ import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' +import userSearchApi from '../services/new_api/user_search.js' import { compact, map, each, merge, last, concat, uniq } from 'lodash' import { set } from 'vue' import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js' @@ -341,6 +342,14 @@ const users = { store.commit('setUserForNotification', notification) }) }, + searchUsers (store, query) { + // TODO: Move userSearch api into api.service + return userSearchApi.search({query, store: { state: store.rootState }}) + .then((users) => { + store.commit('addNewUsers', users) + return users + }) + }, async signUp (store, userInfo) { store.commit('signUpPending') diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 6b255e9f..da44fc54 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -3,12 +3,10 @@ const LOGIN_URL = '/api/account/verify_credentials.json' const ALL_FOLLOWING_URL = '/api/qvitter/allfollowing' const MENTIONS_URL = '/api/statuses/mentions.json' const REGISTRATION_URL = '/api/account/register.json' -const AVATAR_UPDATE_URL = '/api/qvitter/update_avatar.json' const BG_UPDATE_URL = '/api/qvitter/update_background_image.json' -const BANNER_UPDATE_URL = '/api/account/update_profile_banner.json' -const PROFILE_UPDATE_URL = '/api/account/update_profile.json' const EXTERNAL_PROFILE_URL = '/api/externalprofile/show.json' const QVITTER_USER_NOTIFICATIONS_READ_URL = '/api/qvitter/statuses/notifications/read.json' +const BLOCKS_IMPORT_URL = '/api/pleroma/blocks_import' const FOLLOW_IMPORT_URL = '/api/pleroma/follow_import' const DELETE_ACCOUNT_URL = '/api/pleroma/delete_account' const CHANGE_PASSWORD_URL = '/api/pleroma/change_password' @@ -49,6 +47,10 @@ const MASTODON_MUTE_USER_URL = id => `/api/v1/accounts/${id}/mute` const MASTODON_UNMUTE_USER_URL = id => `/api/v1/accounts/${id}/unmute` const MASTODON_POST_STATUS_URL = '/api/v1/statuses' const MASTODON_MEDIA_UPLOAD_URL = '/api/v1/media' +const MASTODON_STATUS_FAVORITEDBY_URL = id => `/api/v1/statuses/${id}/favourited_by` +const MASTODON_STATUS_REBLOGGEDBY_URL = id => `/api/v1/statuses/${id}/reblogged_by` +const MASTODON_PROFILE_UPDATE_URL = '/api/v1/accounts/update_credentials' +const MASTODON_REPORT_USER_URL = '/api/v1/reports' import { each, map, concat, last } from 'lodash' import { parseStatus, parseUser, parseNotification, parseAttachment } from '../entity_normalizer/entity_normalizer.service.js' @@ -65,7 +67,24 @@ let fetch = (url, options) => { return oldfetch(fullUrl, options) } -const promisedRequest = (url, options) => { +const promisedRequest = ({ method, url, payload, credentials, headers = {} }) => { + const options = { + method, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + ...headers + } + } + if (payload) { + options.body = JSON.stringify(payload) + } + if (credentials) { + options.headers = { + ...options.headers, + ...authHeaders(credentials) + } + } return fetch(url, options) .then((response) => { return new Promise((resolve, reject) => response.json() @@ -78,28 +97,16 @@ const promisedRequest = (url, options) => { }) } -// Params -// cropH -// cropW -// cropX -// cropY -// img (base 64 encodend data url) -const updateAvatar = ({credentials, params}) => { - let url = AVATAR_UPDATE_URL - +const updateAvatar = ({credentials, avatar}) => { const form = new FormData() - - each(params, (value, key) => { - if (value) { - form.append(key, value) - } - }) - - return fetch(url, { + form.append('avatar', avatar) + return fetch(MASTODON_PROFILE_UPDATE_URL, { headers: authHeaders(credentials), - method: 'POST', + method: 'PATCH', body: form - }).then((data) => data.json()) + }) + .then((data) => data.json()) + .then((data) => parseUser(data)) } const updateBg = ({credentials, params}) => { @@ -120,52 +127,26 @@ const updateBg = ({credentials, params}) => { }).then((data) => data.json()) } -// Params -// height -// width -// offset_left -// offset_top -// banner (base 64 encodend data url) -const updateBanner = ({credentials, params}) => { - let url = BANNER_UPDATE_URL - +const updateBanner = ({credentials, banner}) => { const form = new FormData() - - each(params, (value, key) => { - if (value) { - form.append(key, value) - } - }) - - return fetch(url, { + form.append('header', banner) + return fetch(MASTODON_PROFILE_UPDATE_URL, { headers: authHeaders(credentials), - method: 'POST', + method: 'PATCH', body: form - }).then((data) => data.json()) + }) + .then((data) => data.json()) + .then((data) => parseUser(data)) } -// Params -// name -// url -// location -// description const updateProfile = ({credentials, params}) => { - // Always include these fields, because they might be empty or false - const fields = ['description', 'locked', 'no_rich_text', 'hide_follows', 'hide_followers', 'show_role'] - let url = PROFILE_UPDATE_URL - - const form = new FormData() - - each(params, (value, key) => { - if (fields.includes(key) || value) { - form.append(key, value) - } + return promisedRequest({ + url: MASTODON_PROFILE_UPDATE_URL, + method: 'PATCH', + payload: params, + credentials }) - return fetch(url, { - headers: authHeaders(credentials), - method: 'POST', - body: form - }).then((data) => data.json()) + .then((data) => parseUser(data)) } // Params needed: @@ -261,7 +242,7 @@ const denyUser = ({id, credentials}) => { const fetchUser = ({id, credentials}) => { let url = `${MASTODON_USER_URL}/${id}` - return promisedRequest(url, { headers: authHeaders(credentials) }) + return promisedRequest({ url, credentials }) .then((data) => parseUser(data)) } @@ -634,9 +615,22 @@ const uploadMedia = ({formData, credentials}) => { .then((data) => parseAttachment(data)) } -const followImport = ({params, credentials}) => { +const importBlocks = ({file, credentials}) => { + const formData = new FormData() + formData.append('list', file) + return fetch(BLOCKS_IMPORT_URL, { + body: formData, + method: 'POST', + headers: authHeaders(credentials) + }) + .then((response) => response.ok) +} + +const importFollows = ({file, credentials}) => { + const formData = new FormData() + formData.append('list', file) return fetch(FOLLOW_IMPORT_URL, { - body: params, + body: formData, method: 'POST', headers: authHeaders(credentials) }) @@ -672,26 +666,20 @@ const changePassword = ({credentials, password, newPassword, newPasswordConfirma } const fetchMutes = ({credentials}) => { - return promisedRequest(MASTODON_USER_MUTES_URL, { headers: authHeaders(credentials) }) + return promisedRequest({ url: MASTODON_USER_MUTES_URL, credentials }) .then((users) => users.map(parseUser)) } const muteUser = ({id, credentials}) => { - return promisedRequest(MASTODON_MUTE_USER_URL(id), { - headers: authHeaders(credentials), - method: 'POST' - }) + return promisedRequest({ url: MASTODON_MUTE_USER_URL(id), credentials, method: 'POST' }) } const unmuteUser = ({id, credentials}) => { - return promisedRequest(MASTODON_UNMUTE_USER_URL(id), { - headers: authHeaders(credentials), - method: 'POST' - }) + return promisedRequest({ url: MASTODON_UNMUTE_USER_URL(id), credentials, method: 'POST' }) } const fetchBlocks = ({credentials}) => { - return promisedRequest(MASTODON_USER_BLOCKS_URL, { headers: authHeaders(credentials) }) + return promisedRequest({ url: MASTODON_USER_BLOCKS_URL, credentials }) .then((users) => users.map(parseUser)) } @@ -735,6 +723,28 @@ const markNotificationsAsSeen = ({id, credentials}) => { }).then((data) => data.json()) } +const fetchFavoritedByUsers = ({id}) => { + return promisedRequest({ url: MASTODON_STATUS_FAVORITEDBY_URL(id) }).then((users) => users.map(parseUser)) +} + +const fetchRebloggedByUsers = ({id}) => { + return promisedRequest({ url: MASTODON_STATUS_REBLOGGEDBY_URL(id) }).then((users) => users.map(parseUser)) +} + +const reportUser = ({credentials, userId, statusIds, comment, forward}) => { + return promisedRequest({ + url: MASTODON_REPORT_USER_URL, + method: 'POST', + payload: { + 'account_id': userId, + 'status_ids': statusIds, + comment, + forward + }, + credentials + }) +} + const apiService = { verifyCredentials, fetchTimeline, @@ -776,14 +786,18 @@ const apiService = { updateProfile, updateBanner, externalProfile, - followImport, + importBlocks, + importFollows, deleteAccount, changePassword, fetchFollowRequests, approveUser, denyUser, suggestions, - markNotificationsAsSeen + markNotificationsAsSeen, + fetchFavoritedByUsers, + fetchRebloggedByUsers, + reportUser } export default apiService diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js index 75bba92b..58bb1248 100644 --- a/src/services/backend_interactor_service/backend_interactor_service.js +++ b/src/services/backend_interactor_service/backend_interactor_service.js @@ -101,17 +101,22 @@ const backendInteractorService = (credentials) => { const getCaptcha = () => apiService.getCaptcha() const register = (params) => apiService.register(params) - const updateAvatar = ({params}) => apiService.updateAvatar({credentials, params}) + const updateAvatar = ({avatar}) => apiService.updateAvatar({credentials, avatar}) const updateBg = ({params}) => apiService.updateBg({credentials, params}) - const updateBanner = ({params}) => apiService.updateBanner({credentials, params}) + const updateBanner = ({banner}) => apiService.updateBanner({credentials, banner}) const updateProfile = ({params}) => apiService.updateProfile({credentials, params}) const externalProfile = (profileUrl) => apiService.externalProfile({profileUrl, credentials}) - const followImport = ({params}) => apiService.followImport({params, credentials}) + const importBlocks = (file) => apiService.importBlocks({file, credentials}) + const importFollows = (file) => apiService.importFollows({file, credentials}) const deleteAccount = ({password}) => apiService.deleteAccount({credentials, password}) const changePassword = ({password, newPassword, newPasswordConfirmation}) => apiService.changePassword({credentials, password, newPassword, newPasswordConfirmation}) + const fetchFavoritedByUsers = (id) => apiService.fetchFavoritedByUsers({id}) + const fetchRebloggedByUsers = (id) => apiService.fetchRebloggedByUsers({id}) + const reportUser = (params) => apiService.reportUser({credentials, ...params}) + const backendInteractorServiceInstance = { fetchStatus, fetchConversation, @@ -147,12 +152,16 @@ const backendInteractorService = (credentials) => { updateBanner, updateProfile, externalProfile, - followImport, + importBlocks, + importFollows, deleteAccount, changePassword, fetchFollowRequests, approveUser, - denyUser + denyUser, + fetchFavoritedByUsers, + fetchRebloggedByUsers, + reportUser } return backendInteractorServiceInstance diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index d68e5a98..7a8708d5 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -195,6 +195,7 @@ export const parseStatus = (data) => { output.summary = pleroma.spoiler_text ? data.pleroma.spoiler_text['text/plain'] : data.spoiler_text output.statusnet_conversation_id = data.pleroma.conversation_id output.is_local = pleroma.local + output.in_reply_to_screen_name = data.pleroma.in_reply_to_account_acct } else { output.text = data.content output.summary = data.spoiler_text @@ -204,8 +205,6 @@ export const parseStatus = (data) => { output.in_reply_to_user_id = data.in_reply_to_account_id output.replies_count = data.replies_count - // Missing!! fix in UI? - // output.in_reply_to_screen_name = ??? if (output.type === 'retweet') { output.retweeted_status = parseStatus(data.reblog) } @@ -276,6 +275,9 @@ export const parseStatus = (data) => { output.retweeted_status = parseStatus(retweetedStatus) } + output.favoritedBy = [] + output.rebloggedBy = [] + return output } @@ -307,7 +309,7 @@ export const parseNotification = (data) => { } output.created_at = new Date(data.created_at) - output.id = data.id + output.id = parseInt(data.id) return output } diff --git a/src/services/follow_manipulate/follow_manipulate.js b/src/services/follow_manipulate/follow_manipulate.js index 51dafe84..b2486e7c 100644 --- a/src/services/follow_manipulate/follow_manipulate.js +++ b/src/services/follow_manipulate/follow_manipulate.js @@ -23,18 +23,12 @@ export const requestFollow = (user, store) => new Promise((resolve, reject) => { // For locked users we just mark it that we sent the follow request if (updated.locked) { - resolve({ - sent: true, - updated - }) + resolve({ sent: true }) } if (updated.following) { // If we get result immediately, just stop. - resolve({ - sent: false, - updated - }) + resolve({ sent: false }) } // But usually we don't get result immediately, so we ask server @@ -48,16 +42,10 @@ export const requestFollow = (user, store) => new Promise((resolve, reject) => { .then((following) => { if (following) { // We confirmed and everything's good. - resolve({ - sent: false, - updated - }) + resolve({ sent: false }) } else { // If after all the tries, just treat it as if user is locked - resolve({ - sent: false, - updated - }) + resolve({ sent: false }) } }) }) |
