diff options
| author | Henry Jameson <me@hjkos.com> | 2019-05-13 20:38:54 +0300 |
|---|---|---|
| committer | Henry Jameson <me@hjkos.com> | 2019-05-13 20:38:54 +0300 |
| commit | 750dca4a108bc296afa1586e0d414d3f48bc8032 (patch) | |
| tree | a6c59ae7c3a1b977448bc31fb335dc3c7f63ce79 /src | |
| parent | 1387589ac9dbfe1f8d45073d23d30dca46a9c7bd (diff) | |
| parent | 2322610b62c8593e8ca71a2a8ae7057d4c39b480 (diff) | |
Merge remote-tracking branch 'upstream/develop' into webpack-4-dart-sass
* upstream/develop: (116 commits)
Fix small mistake in Polish translation
link interaction avatars to the user profile
Use more clear explanation in the scope notice, make sure the hide button doesn't overlap with text in notice.
use backendInteractor
refactor api service functions using new helper
clean up
update favorite number earlier
update status interaction upon retweet action response
sync up favoritedBy with favorite/unfavorite action
do not regenerate status object
reduce needless calculation
Move scope visibility notice to the status form, make it dismissible
Revert "eliminate expandable prop in favor of inConversation"
status attention doesn’t have relationship entities
make it short
fix wrong inlineExpanded
expanded is always false, eliminate it
eliminate expandable prop in favor of inConversation
fix conversationId comparision bug using integer format
Display additional scope description above the status form for mobile users.
...
Diffstat (limited to 'src')
50 files changed, 1194 insertions, 371 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..2729e0b0 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; @@ -647,6 +648,19 @@ nav { border-radius: var(--inputRadius, $fallback--inputRadius); } +.notice-dismissible { + padding-right: 4rem; + position: relative; + + .dismiss { + position: absolute; + top: 0; + right: 0; + padding: .5em; + color: inherit; + } +} + @keyframes modal-background-fadein { from { background-color: rgba(0, 0, 0, 0); @@ -793,4 +807,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..9b6301b2 --- /dev/null +++ b/src/components/avatar_list/avatar_list.js @@ -0,0 +1,21 @@ +import UserAvatar from '../user_avatar/user_avatar.vue' +import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' + +const AvatarList = { + props: ['users'], + computed: { + slicedUsers () { + return this.users ? this.users.slice(0, 15) : [] + } + }, + components: { + UserAvatar + }, + methods: { + userProfileLink (user) { + return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) + } + } +} + +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..c0238570 --- /dev/null +++ b/src/components/avatar_list/avatar_list.vue @@ -0,0 +1,38 @@ +<template> + <div class="avatars"> + <router-link :to="userProfileLink(user)" class="avatars-item" v-for="user in slicedUsers"> + <UserAvatar :user="user" class="avatar-small" /> + </router-link> + </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/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/mobile_post_status_modal/mobile_post_status_modal.js b/src/components/mobile_post_status_modal/mobile_post_status_modal.js index 2f24dd08..91b730e7 100644 --- a/src/components/mobile_post_status_modal/mobile_post_status_modal.js +++ b/src/components/mobile_post_status_modal/mobile_post_status_modal.js @@ -1,5 +1,5 @@ import PostStatusForm from '../post_status_form/post_status_form.vue' -import { throttle } from 'lodash' +import { debounce } from 'lodash' const MobilePostStatusModal = { components: { @@ -16,11 +16,15 @@ const MobilePostStatusModal = { } }, created () { - window.addEventListener('scroll', this.handleScroll) + if (this.autohideFloatingPostButton) { + this.activateFloatingPostButtonAutohide() + } window.addEventListener('resize', this.handleOSK) }, destroyed () { - window.removeEventListener('scroll', this.handleScroll) + if (this.autohideFloatingPostButton) { + this.deactivateFloatingPostButtonAutohide() + } window.removeEventListener('resize', this.handleOSK) }, computed: { @@ -28,10 +32,30 @@ const MobilePostStatusModal = { return this.$store.state.users.currentUser }, isHidden () { - return this.hidden || this.inputActive + return this.autohideFloatingPostButton && (this.hidden || this.inputActive) + }, + autohideFloatingPostButton () { + return !!this.$store.state.config.autohideFloatingPostButton + } + }, + watch: { + autohideFloatingPostButton: function (isEnabled) { + if (isEnabled) { + this.activateFloatingPostButtonAutohide() + } else { + this.deactivateFloatingPostButtonAutohide() + } } }, methods: { + activateFloatingPostButtonAutohide () { + window.addEventListener('scroll', this.handleScrollStart) + window.addEventListener('scroll', this.handleScrollEnd) + }, + deactivateFloatingPostButtonAutohide () { + window.removeEventListener('scroll', this.handleScrollStart) + window.removeEventListener('scroll', this.handleScrollEnd) + }, openPostForm () { this.postFormOpen = true this.hidden = true @@ -65,26 +89,19 @@ const MobilePostStatusModal = { this.inputActive = false } }, - handleScroll: throttle(function () { - const scrollAmount = window.scrollY - this.oldScrollPos - const scrollingDown = scrollAmount > 0 - - if (scrollingDown !== this.scrollingDown) { - this.amountScrolled = 0 - this.scrollingDown = scrollingDown - if (!scrollingDown) { - this.hidden = false - } - } else if (scrollingDown) { - this.amountScrolled += scrollAmount - if (this.amountScrolled > 100 && !this.hidden) { - this.hidden = true - } + handleScrollStart: debounce(function () { + if (window.scrollY > this.oldScrollPos) { + this.hidden = true + } else { + this.hidden = false } + this.oldScrollPos = window.scrollY + }, 100, {leading: true, trailing: false}), + handleScrollEnd: debounce(function () { + this.hidden = false this.oldScrollPos = window.scrollY - this.scrollingDown = scrollingDown - }, 100) + }, 100, {leading: false, trailing: true}) } } diff --git a/src/components/mobile_post_status_modal/mobile_post_status_modal.vue b/src/components/mobile_post_status_modal/mobile_post_status_modal.vue index 0a451c28..c762705b 100644 --- a/src/components/mobile_post_status_modal/mobile_post_status_modal.vue +++ b/src/components/mobile_post_status_modal/mobile_post_status_modal.vue @@ -7,7 +7,7 @@ > <div class="post-form-modal-panel panel" @click.stop=""> <div class="panel-heading">{{$t('post_status.new_status')}}</div> - <PostStatusForm class="panel-body" @posted="closePostForm"/> + <PostStatusForm class="panel-body" @posted="closePostForm" /> </div> </div> <button 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/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index c65c27e2..cbd2024a 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -182,6 +182,9 @@ const PostStatusForm = { }, safeDMEnabled () { return this.$store.state.instance.safeDM + }, + hideScopeNotice () { + return this.$store.state.config.hideScopeNotice } }, methods: { @@ -338,6 +341,9 @@ const PostStatusForm = { }, changeVis (visibility) { this.newStatus.visibility = visibility + }, + dismissScopeNotice () { + this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true }) } } } diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index 1ce2b647..b8b93936 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -9,7 +9,25 @@ class="visibility-notice"> <router-link :to="{ name: 'user-settings' }">{{ $t('post_status.account_not_locked_warning_link') }}</router-link> </i18n> - <p v-if="newStatus.visibility === 'direct'" class="visibility-notice"> + <p v-if="!hideScopeNotice && newStatus.visibility === 'public'" class="visibility-notice notice-dismissible"> + <span>{{ $t('post_status.scope_notice.public') }}</span> + <a v-on:click.prevent="dismissScopeNotice()" class="button-icon dismiss"> + <i class='icon-cancel'></i> + </a> + </p> + <p v-else-if="!hideScopeNotice && newStatus.visibility === 'unlisted'" class="visibility-notice notice-dismissible"> + <span>{{ $t('post_status.scope_notice.unlisted') }}</span> + <a v-on:click.prevent="dismissScopeNotice()" class="button-icon dismiss"> + <i class='icon-cancel'></i> + </a> + </p> + <p v-else-if="!hideScopeNotice && newStatus.visibility === 'private' && $store.state.users.currentUser.locked" class="visibility-notice notice-dismissible"> + <span>{{ $t('post_status.scope_notice.private') }}</span> + <a v-on:click.prevent="dismissScopeNotice()" class="button-icon dismiss"> + <i class='icon-cancel'></i> + </a> + </p> + <p v-else-if="newStatus.visibility === 'direct'" class="visibility-notice"> <span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span> <span v-else>{{ $t('post_status.direct_warning_to_all') }}</span> </p> 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/settings/settings.js b/src/components/settings/settings.js index a85ab674..c4aa45b2 100644 --- a/src/components/settings/settings.js +++ b/src/components/settings/settings.js @@ -46,6 +46,7 @@ const settings = { streamingLocal: user.streaming, pauseOnUnfocusedLocal: user.pauseOnUnfocused, hoverPreviewLocal: user.hoverPreview, + autohideFloatingPostButtonLocal: user.autohideFloatingPostButton, hideMutedPostsLocal: typeof user.hideMutedPosts === 'undefined' ? instance.hideMutedPosts @@ -183,6 +184,9 @@ const settings = { hoverPreviewLocal (value) { this.$store.dispatch('setOption', { name: 'hoverPreview', value }) }, + autohideFloatingPostButtonLocal (value) { + this.$store.dispatch('setOption', { name: 'autohideFloatingPostButton', value }) + }, muteWordsString (value) { value = filter(value.split('\n'), (word) => trim(word).length > 0) this.$store.dispatch('setOption', { name: 'muteWords', value }) diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue index 6890220f..920e6e12 100644 --- a/src/components/settings/settings.vue +++ b/src/components/settings/settings.vue @@ -122,6 +122,10 @@ {{$t('settings.minimal_scopes_mode')}} {{$t('settings.instance_default', { value: minimalScopesModeDefault })}} </label> </li> + <li> + <input type="checkbox" id="autohideFloatingPostButton" v-model="autohideFloatingPostButtonLocal"> + <label for="autohideFloatingPostButton">{{$t('settings.autohide_floating_post_button')}}</label> + </li> </ul> </div> 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 34b17970..02715253 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="isFocused && combinedFavsAndRepeatsUsers.length > 0"> + <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> @@ -604,6 +627,58 @@ a.unmute { flex: 1; } +.timeline > { + .status-el:last-child { + border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius; + border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius); + border-bottom: none; + } +} + +.favs-repeated-users { + margin-top: $status-margin; + + .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_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/en.json b/src/i18n/en.json index 711e8d31..b4f0deb2 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", @@ -85,6 +94,11 @@ "direct_warning_to_all": "This post will be visible to all the mentioned users.", "direct_warning_to_first_only": "This post will only be visible to the mentioned users at the beginning of the message.", "posting": "Posting", + "scope_notice": { + "public": "This post will be visible to everyone", + "private": "This post will be visible to your followers only", + "unlisted": "This post will not be visible in Public Timeline and The Whole Known Network" + }, "scope": { "direct": "Direct - Post to mentioned users only", "private": "Followers-only - Post to followers only", @@ -126,6 +140,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)", @@ -153,7 +172,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.", @@ -169,6 +187,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", @@ -219,6 +238,7 @@ "reply_visibility_all": "Show all replies", "reply_visibility_following": "Only show replies directed at me or users I'm following", "reply_visibility_self": "Only show replies directed at me", + "autohide_floating_post_button": "Automatically hide New Post button (mobile)", "saving_err": "Error saving settings", "saving_ok": "Settings saved", "search_user_to_block": "Search whom you want to block", @@ -380,6 +400,8 @@ "no_statuses": "No statuses" }, "status": { + "favorites": "Favorites", + "repeats": "Repeats", "reply_to": "Reply to", "replies_list": "Replies:" }, @@ -404,6 +426,7 @@ "muted": "Muted", "per_day": "per day", "remote_follow": "Remote follow", + "report": "Report", "statuses": "Statuses", "unblock": "Unblock", "unblock_progress": "Unblocking...", @@ -436,6 +459,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 a5826239..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", @@ -126,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)", @@ -153,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.", @@ -188,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", @@ -380,6 +394,8 @@ "no_statuses": "Cap d’estatuts" }, "status": { + "favorites": "Li a agradat", + "repeats": "A repetit", "reply_to": "Respond a", "replies_list": "Responsas :" }, @@ -461,4 +477,4 @@ "TiB": "Tio" } } -} +}
\ No newline at end of file diff --git a/src/i18n/pl.json b/src/i18n/pl.json index 715e5d6e..51cadfb6 100644 --- a/src/i18n/pl.json +++ b/src/i18n/pl.json @@ -363,7 +363,7 @@ "error_fetching": "Błąd pobierania", "load_older": "Załaduj starsze statusy", "no_retweet_hint": "Wpis oznaczony jako tylko dla obserwujących lub bezpośredni nie może zostać powtórzony", - "repeated": "powtórzono", + "repeated": "powtórzył(-a)", "show_new": "Pokaż nowe", "up_to_date": "Na bieżąco", "no_more_statuses": "Brak kolejnych statusów", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 5450f154..b3ab322d 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -42,8 +42,13 @@ "attachments_sensitive": "Вложения содержат чувствительный контент", "content_warning": "Тема (не обязательно)", "default": "Что нового?", - "direct_warning": "Этот пост будет видет только упомянутым пользователям", + "direct_warning": "Этот пост будет виден только упомянутым пользователям", "posting": "Отправляется", + "scope_notice": { + "public": "Этот пост будет виден всем", + "private": "Этот пост будет виден только вашим подписчикам", + "unlisted": "Этот пост не будет виден в публичной и федеративной ленте" + }, "scope": { "direct": "Личное - этот пост видят только те кто в нём упомянут", "private": "Для подписчиков - этот пост видят только подписчики", @@ -152,6 +157,7 @@ "reply_visibility_all": "Показывать все ответы", "reply_visibility_following": "Показывать только ответы мне и тех на кого я подписан", "reply_visibility_self": "Показывать только ответы мне", + "autohide_floating_post_button": "Автоматически скрывать кнопку постинга (в мобильной версии)", "saving_err": "Не удалось сохранить настройки", "saving_ok": "Сохранено", "security_tab": "Безопасность", 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/config.js b/src/modules/config.js index 1666a2c5..a8da525a 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -17,6 +17,7 @@ const defaultState = { autoLoad: true, streaming: false, hoverPreview: true, + autohideFloatingPostButton: false, pauseOnUnfocused: true, stopGifs: false, replyVisibility: 'all', @@ -30,6 +31,7 @@ const defaultState = { muteWords: [], highlight: {}, interfaceLanguage: browserLocale, + hideScopeNotice: false, scopeCopy: undefined, // instance default subjectLineBehavior: undefined, // instance default alwaysShowSubjectInput: undefined, // instance default 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..4c92d4e1 100644 --- a/src/modules/statuses.js +++ b/src/modules/statuses.js @@ -1,4 +1,4 @@ -import { remove, slice, each, find, maxBy, minBy, merge, first, last, isArray, omitBy } from 'lodash' +import { remove, slice, each, findIndex, find, maxBy, minBy, merge, first, last, isArray, omitBy } from 'lodash' import { set } from 'vue' import apiService from '../services/api/api.service.js' // import parse from '../services/status_parser/status_parser.js' @@ -402,12 +402,27 @@ export const mutations = { }, setFavorited (state, { status, value }) { const newStatus = state.allStatusesObject[status.id] + + if (newStatus.favorited !== value) { + if (value) { + newStatus.fave_num++ + } else { + newStatus.fave_num-- + } + } + newStatus.favorited = value }, - setFavoritedConfirm (state, { status }) { + setFavoritedConfirm (state, { status, user }) { const newStatus = state.allStatusesObject[status.id] newStatus.favorited = status.favorited newStatus.fave_num = status.fave_num + const index = findIndex(newStatus.favoritedBy, { id: user.id }) + if (index !== -1 && !newStatus.favorited) { + newStatus.favoritedBy.splice(index, 1) + } else if (index === -1 && newStatus.favorited) { + newStatus.favoritedBy.push(user) + } }, setRetweeted (state, { status, value }) { const newStatus = state.allStatusesObject[status.id] @@ -422,6 +437,17 @@ export const mutations = { newStatus.repeated = value }, + setRetweetedConfirm (state, { status, user }) { + const newStatus = state.allStatusesObject[status.id] + newStatus.repeated = status.repeated + newStatus.repeat_num = status.repeat_num + const index = findIndex(newStatus.rebloggedBy, { id: user.id }) + if (index !== -1 && !newStatus.repeated) { + newStatus.rebloggedBy.splice(index, 1) + } else if (index === -1 && newStatus.repeated) { + newStatus.rebloggedBy.push(user) + } + }, setDeleted (state, { status }) { const newStatus = state.allStatusesObject[status.id] newStatus.deleted = true @@ -459,6 +485,11 @@ export const mutations = { }, queueFlush (state, { timeline, id }) { state.timelines[timeline].flushMarker = id + }, + addFavsAndRepeats (state, { id, favoritedByUsers, rebloggedByUsers }) { + const newStatus = state.allStatusesObject[id] + newStatus.favoritedBy = favoritedByUsers.filter(_ => _) + newStatus.rebloggedBy = rebloggedByUsers.filter(_ => _) } } @@ -493,27 +524,26 @@ const statuses = { favorite ({ rootState, commit }, status) { // Optimistic favoriting... commit('setFavorited', { status, value: true }) - apiService.favorite({ id: status.id, credentials: rootState.users.currentUser.credentials }) - .then(status => { - commit('setFavoritedConfirm', { status }) - }) + rootState.api.backendInteractor.favorite(status.id) + .then(status => commit('setFavoritedConfirm', { status, user: rootState.users.currentUser })) }, unfavorite ({ rootState, commit }, status) { - // Optimistic favoriting... + // Optimistic unfavoriting... commit('setFavorited', { status, value: false }) - apiService.unfavorite({ id: status.id, credentials: rootState.users.currentUser.credentials }) - .then(status => { - commit('setFavoritedConfirm', { status }) - }) + rootState.api.backendInteractor.unfavorite(status.id) + .then(status => commit('setFavoritedConfirm', { status, user: rootState.users.currentUser })) }, retweet ({ rootState, commit }, status) { // Optimistic retweeting... commit('setRetweeted', { status, value: true }) - apiService.retweet({ id: status.id, credentials: rootState.users.currentUser.credentials }) + rootState.api.backendInteractor.retweet(status.id) + .then(status => commit('setRetweetedConfirm', { status: status.retweeted_status, user: rootState.users.currentUser })) }, unretweet ({ rootState, commit }, status) { + // Optimistic unretweeting... commit('setRetweeted', { status, value: false }) - apiService.unretweet({ id: status.id, credentials: rootState.users.currentUser.credentials }) + rootState.api.backendInteractor.unretweet(status.id) + .then(status => commit('setRetweetedConfirm', { status, user: rootState.users.currentUser })) }, queueFlush ({ rootState, commit }, { timeline, id }) { commit('queueFlush', { timeline, id }) @@ -524,6 +554,14 @@ 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, rebloggedByUsers }) + ) } }, mutations diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 6b255e9f..b7a602b8 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)) } @@ -525,62 +506,22 @@ const verifyCredentials = (user) => { } const favorite = ({ id, credentials }) => { - return fetch(MASTODON_FAVORITE_URL(id), { - headers: authHeaders(credentials), - method: 'POST' - }) - .then(response => { - if (response.ok) { - return response.json() - } else { - throw new Error('Error favoriting post') - } - }) + return promisedRequest({ url: MASTODON_FAVORITE_URL(id), method: 'POST', credentials }) .then((data) => parseStatus(data)) } const unfavorite = ({ id, credentials }) => { - return fetch(MASTODON_UNFAVORITE_URL(id), { - headers: authHeaders(credentials), - method: 'POST' - }) - .then(response => { - if (response.ok) { - return response.json() - } else { - throw new Error('Error removing favorite') - } - }) + return promisedRequest({ url: MASTODON_UNFAVORITE_URL(id), method: 'POST', credentials }) .then((data) => parseStatus(data)) } const retweet = ({ id, credentials }) => { - return fetch(MASTODON_RETWEET_URL(id), { - headers: authHeaders(credentials), - method: 'POST' - }) - .then(response => { - if (response.ok) { - return response.json() - } else { - throw new Error('Error repeating post') - } - }) + return promisedRequest({ url: MASTODON_RETWEET_URL(id), method: 'POST', credentials }) .then((data) => parseStatus(data)) } const unretweet = ({ id, credentials }) => { - return fetch(MASTODON_UNRETWEET_URL(id), { - headers: authHeaders(credentials), - method: 'POST' - }) - .then(response => { - if (response.ok) { - return response.json() - } else { - throw new Error('Error removing repeat') - } - }) + return promisedRequest({ url: MASTODON_UNRETWEET_URL(id), method: 'POST', credentials }) .then((data) => parseStatus(data)) } @@ -634,9 +575,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 +626,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 +683,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 +746,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..c2b93de4 100644 --- a/src/services/backend_interactor_service/backend_interactor_service.js +++ b/src/services/backend_interactor_service/backend_interactor_service.js @@ -101,17 +101,27 @@ 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 favorite = (id) => apiService.favorite({id, credentials}) + const unfavorite = (id) => apiService.unfavorite({id, credentials}) + const retweet = (id) => apiService.retweet({id, credentials}) + const unretweet = (id) => apiService.unretweet({id, credentials}) + const backendInteractorServiceInstance = { fetchStatus, fetchConversation, @@ -147,12 +157,20 @@ const backendInteractorService = (credentials) => { updateBanner, updateProfile, externalProfile, - followImport, + importBlocks, + importFollows, deleteAccount, changePassword, fetchFollowRequests, approveUser, - denyUser + denyUser, + fetchFavoritedByUsers, + fetchRebloggedByUsers, + reportUser, + favorite, + unfavorite, + retweet, + unretweet } return backendInteractorServiceInstance diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index e706e7d9..7a8708d5 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -275,6 +275,9 @@ export const parseStatus = (data) => { output.retweeted_status = parseStatus(retweetedStatus) } + output.favoritedBy = [] + output.rebloggedBy = [] + return output } @@ -306,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 } |
