diff options
Diffstat (limited to 'src')
71 files changed, 1327 insertions, 168 deletions
diff --git a/src/App.scss b/src/App.scss index 52484f59..7c6970c1 100644 --- a/src/App.scss +++ b/src/App.scss @@ -181,8 +181,7 @@ input, textarea, .select { color: $fallback--text; color: var(--text, $fallback--text); } - &:disabled, - { + &:disabled { &, & + label, & + label::before { @@ -649,10 +648,6 @@ nav { color: var(--lightText, $fallback--lightText); } - .text-format { - float: right; - } - div { padding-top: 5px; } @@ -739,3 +734,7 @@ nav { width: 100%; } } + +.btn.btn-default { + min-height: 28px; +} diff --git a/src/components/basic_user_card/basic_user_card.js b/src/components/basic_user_card/basic_user_card.js new file mode 100644 index 00000000..a8441446 --- /dev/null +++ b/src/components/basic_user_card/basic_user_card.js @@ -0,0 +1,28 @@ +import UserCardContent from '../user_card_content/user_card_content.vue' +import UserAvatar from '../user_avatar/user_avatar.vue' +import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' + +const BasicUserCard = { + props: [ + 'user' + ], + data () { + return { + userExpanded: false + } + }, + components: { + UserCardContent, + UserAvatar + }, + methods: { + toggleUserExpanded () { + this.userExpanded = !this.userExpanded + }, + userProfileLink (user) { + return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) + } + } +} + +export default BasicUserCard diff --git a/src/components/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue new file mode 100644 index 00000000..4ede15e9 --- /dev/null +++ b/src/components/basic_user_card/basic_user_card.vue @@ -0,0 +1,92 @@ +<template> + <div class="user-card"> + <router-link :to="userProfileLink(user)"> + <UserAvatar class="avatar" :compact="true" @click.prevent.native="toggleUserExpanded" :src="user.profile_image_url"/> + </router-link> + <div class="user-card-expanded-content" v-if="userExpanded"> + <user-card-content :user="user" :switcher="false"></user-card-content> + </div> + <div class="user-card-collapsed-content" v-else> + <div class="user-card-primary-area"> + <div :title="user.name" class="user-name"> + <span v-if="user.name_html" v-html="user.name_html"></span> + <span v-else>{{ user.name }}</span> + </div> + <div> + <router-link class='user-screen-name' :to="userProfileLink(user)"> + @{{user.screen_name}} + </router-link> + </div> + </div> + <div class="user-card-secondary-area"> + <slot name="secondary-area"></slot> + </div> + </div> + </div> +</template> + +<script src="./basic_user_card.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.user-card { + display: flex; + flex: 1 0; + padding-top: 0.6em; + padding-right: 1em; + padding-bottom: 0.6em; + padding-left: 1em; + border-bottom: 1px solid; + margin: 0; + border-bottom-color: $fallback--border; + border-bottom-color: var(--border, $fallback--border); + + &-collapsed-content { + margin-left: 0.7em; + text-align: left; + flex: 1; + display: flex; + align-items: flex-start; + justify-content: space-between; + } + + &-primary-area { + flex: 1; + .user-name { + img { + object-fit: contain; + height: 16px; + width: 16px; + vertical-align: middle; + } + } + } + + &-secondary-area { + flex: none; + } + + &-expanded-content { + flex: 1; + margin: 0.2em 0 0 0.7em; + border-radius: $fallback--panelRadius; + border-radius: var(--panelRadius, $fallback--panelRadius); + border-style: solid; + border-color: $fallback--border; + border-color: var(--border, $fallback--border); + border-width: 1px; + overflow: hidden; + + .panel-heading { + background: transparent; + flex-direction: column; + align-items: stretch; + } + + p { + margin-bottom: 0; + } + } +} +</style> diff --git a/src/components/block_card/block_card.js b/src/components/block_card/block_card.js new file mode 100644 index 00000000..11fa27b4 --- /dev/null +++ b/src/components/block_card/block_card.js @@ -0,0 +1,37 @@ +import BasicUserCard from '../basic_user_card/basic_user_card.vue' + +const BlockCard = { + props: ['userId'], + data () { + return { + progress: false + } + }, + computed: { + user () { + return this.$store.getters.userById(this.userId) + }, + blocked () { + return this.user.statusnet_blocking + } + }, + components: { + BasicUserCard + }, + methods: { + unblockUser () { + this.progress = true + this.$store.dispatch('unblockUser', this.user.id).then(() => { + this.progress = false + }) + }, + blockUser () { + this.progress = true + this.$store.dispatch('blockUser', this.user.id).then(() => { + this.progress = false + }) + } + } +} + +export default BlockCard diff --git a/src/components/block_card/block_card.vue b/src/components/block_card/block_card.vue new file mode 100644 index 00000000..ed7fe30b --- /dev/null +++ b/src/components/block_card/block_card.vue @@ -0,0 +1,24 @@ +<template> + <basic-user-card :user="user"> + <template slot="secondary-area"> + <button class="btn btn-default" @click="unblockUser" :disabled="progress" v-if="blocked"> + <template v-if="progress"> + {{ $t('user_card.unblock_progress') }} + </template> + <template v-else> + {{ $t('user_card.unblock') }} + </template> + </button> + <button class="btn btn-default" @click="blockUser" :disabled="progress" v-else> + <template v-if="progress"> + {{ $t('user_card.block_progress') }} + </template> + <template v-else> + {{ $t('user_card.block') }} + </template> + </button> + </template> + </basic-user-card> +</template> + +<script src="./block_card.js"></script>
\ No newline at end of file diff --git a/src/components/chat_panel/chat_panel.vue b/src/components/chat_panel/chat_panel.vue index bf65efc5..b37469ac 100644 --- a/src/components/chat_panel/chat_panel.vue +++ b/src/components/chat_panel/chat_panel.vue @@ -3,8 +3,8 @@ <div class="panel panel-default"> <div class="panel-heading timeline-heading" :class="{ 'chat-heading': floating }" @click.stop.prevent="togglePanel"> <div class="title"> - {{$t('chat.title')}} - <i class="icon-cancel" style="float: right;" v-if="floating"></i> + <span>{{$t('chat.title')}}</span> + <i class="icon-cancel" v-if="floating"></i> </div> </div> <div class="chat-window" v-chat-scroll> @@ -98,4 +98,11 @@ resize: none; } } + +.chat-panel { + .title { + display: flex; + justify-content: space-between; + } +} </style> diff --git a/src/components/follow_list/follow_list.js b/src/components/follow_list/follow_list.js index acdb216d..9777c87e 100644 --- a/src/components/follow_list/follow_list.js +++ b/src/components/follow_list/follow_list.js @@ -26,7 +26,9 @@ const FollowList = { entries () { return this.showFollowers ? this.user.followers : this.user.friends }, - showActions () { return this.$store.state.users.currentUser.id === this.userId } + showFollowsYou () { + return !this.showFollowers || (this.showFollowers && this.userId !== this.$store.state.users.currentUser.id) + } }, methods: { fetchEntries () { @@ -55,6 +57,9 @@ const FollowList = { } } }, + watch: { + 'user': 'fetchEntries' + }, components: { UserCard } diff --git a/src/components/follow_list/follow_list.vue b/src/components/follow_list/follow_list.vue index 7be2e7b7..27102edf 100644 --- a/src/components/follow_list/follow_list.vue +++ b/src/components/follow_list/follow_list.vue @@ -3,8 +3,7 @@ <user-card v-for="entry in entries" :key="entry.id" :user="entry" - :showFollows="!showFollowers" - :showActions="showActions" + :noFollowsYou="!showFollowsYou" /> <div class="text-center panel-footer"> <a v-if="error" @click="fetchEntries" class="alert error"> diff --git a/src/components/image_cropper/image_cropper.js b/src/components/image_cropper/image_cropper.js new file mode 100644 index 00000000..990c0370 --- /dev/null +++ b/src/components/image_cropper/image_cropper.js @@ -0,0 +1,128 @@ +import Cropper from 'cropperjs' +import 'cropperjs/dist/cropper.css' + +const ImageCropper = { + props: { + trigger: { + type: [String, window.Element], + required: true + }, + submitHandler: { + type: Function, + required: true + }, + cropperOptions: { + type: Object, + default () { + return { + aspectRatio: 1, + autoCropArea: 1, + viewMode: 1, + movable: false, + zoomable: false, + guides: false + } + } + }, + mimes: { + type: String, + default: 'image/png, image/gif, image/jpeg, image/bmp, image/x-icon' + }, + saveButtonLabel: { + type: String + }, + cancelButtonLabel: { + type: String + } + }, + data () { + return { + cropper: undefined, + dataUrl: undefined, + filename: undefined, + submitting: false, + submitError: null + } + }, + computed: { + saveText () { + return this.saveButtonLabel || this.$t('image_cropper.save') + }, + cancelText () { + return this.cancelButtonLabel || this.$t('image_cropper.cancel') + }, + submitErrorMsg () { + return this.submitError && this.submitError instanceof Error ? this.submitError.toString() : this.submitError + } + }, + methods: { + destroy () { + if (this.cropper) { + this.cropper.destroy() + } + this.$refs.input.value = '' + this.dataUrl = undefined + this.$emit('close') + }, + submit () { + this.submitting = true + this.avatarUploadError = null + this.submitHandler(this.cropper, this.filename) + .then(() => this.destroy()) + .catch((err) => { + this.submitError = err + }) + .finally(() => { + this.submitting = false + }) + }, + pickImage () { + this.$refs.input.click() + }, + createCropper () { + this.cropper = new Cropper(this.$refs.img, this.cropperOptions) + }, + getTriggerDOM () { + return typeof this.trigger === 'object' ? this.trigger : document.querySelector(this.trigger) + }, + readFile () { + const fileInput = this.$refs.input + if (fileInput.files != null && fileInput.files[0] != null) { + let reader = new window.FileReader() + reader.onload = (e) => { + this.dataUrl = e.target.result + this.$emit('open') + } + reader.readAsDataURL(fileInput.files[0]) + this.filename = fileInput.files[0].name || 'unknown' + this.$emit('changed', fileInput.files[0], reader) + } + }, + clearError () { + this.submitError = null + } + }, + mounted () { + // listen for click event on trigger + const trigger = this.getTriggerDOM() + if (!trigger) { + this.$emit('error', 'No image make trigger found.', 'user') + } else { + trigger.addEventListener('click', this.pickImage) + } + // listen for input file changes + const fileInput = this.$refs.input + fileInput.addEventListener('change', this.readFile) + }, + beforeDestroy: function () { + // remove the event listeners + const trigger = this.getTriggerDOM() + if (trigger) { + trigger.removeEventListener('click', this.pickImage) + } + const fileInput = this.$refs.input + fileInput.removeEventListener('change', this.readFile) + } +} + +export default ImageCropper diff --git a/src/components/image_cropper/image_cropper.vue b/src/components/image_cropper/image_cropper.vue new file mode 100644 index 00000000..24a6f3bd --- /dev/null +++ b/src/components/image_cropper/image_cropper.vue @@ -0,0 +1,42 @@ +<template> + <div class="image-cropper"> + <div v-if="dataUrl"> + <div class="image-cropper-image-container"> + <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="destroy" v-text="cancelText"></button> + <i class="icon-spin4 animate-spin" v-if="submitting"></i> + </div> + <div class="alert error" v-if="submitError"> + {{submitErrorMsg}} + <i class="button-icon icon-cancel" @click="clearError"></i> + </div> + </div> + <input ref="input" type="file" class="image-cropper-img-input" :accept="mimes"> + </div> +</template> + +<script src="./image_cropper.js"></script> + +<style lang="scss"> +.image-cropper { + &-img-input { + display: none; + } + + &-image-container { + position: relative; + + img { + display: block; + max-width: 100%; + } + } + + &-buttons-wrapper { + margin-top: 15px; + } +} +</style> diff --git a/src/components/media_modal/media_modal.js b/src/components/media_modal/media_modal.js index 14ae19d4..992d7129 100644 --- a/src/components/media_modal/media_modal.js +++ b/src/components/media_modal/media_modal.js @@ -11,27 +11,62 @@ const MediaModal = { showing () { return this.$store.state.mediaViewer.activated }, + media () { + return this.$store.state.mediaViewer.media + }, currentIndex () { return this.$store.state.mediaViewer.currentIndex }, currentMedia () { - return this.$store.state.mediaViewer.media[this.currentIndex] + return this.media[this.currentIndex] + }, + canNavigate () { + return this.media.length > 1 }, type () { return this.currentMedia ? fileTypeService.fileType(this.currentMedia.mimetype) : null } }, - created () { - document.addEventListener('keyup', e => { - if (e.keyCode === 27 && this.showing) { // escape - this.hide() - } - }) - }, methods: { hide () { this.$store.dispatch('closeMediaViewer') + }, + goPrev () { + if (this.canNavigate) { + const prevIndex = this.currentIndex === 0 ? this.media.length - 1 : (this.currentIndex - 1) + this.$store.dispatch('setCurrent', this.media[prevIndex]) + } + }, + goNext () { + if (this.canNavigate) { + const nextIndex = this.currentIndex === this.media.length - 1 ? 0 : (this.currentIndex + 1) + this.$store.dispatch('setCurrent', this.media[nextIndex]) + } + }, + handleKeyupEvent (e) { + if (this.showing && e.keyCode === 27) { // escape + this.hide() + } + }, + handleKeydownEvent (e) { + if (!this.showing) { + return + } + + if (e.keyCode === 39) { // arrow right + this.goNext() + } else if (e.keyCode === 37) { // arrow left + this.goPrev() + } } + }, + mounted () { + document.addEventListener('keyup', this.handleKeyupEvent) + document.addEventListener('keydown', this.handleKeydownEvent) + }, + destroyed () { + document.removeEventListener('keyup', this.handleKeyupEvent) + document.removeEventListener('keydown', this.handleKeydownEvent) } } diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue index 796d4e40..427bf12b 100644 --- a/src/components/media_modal/media_modal.vue +++ b/src/components/media_modal/media_modal.vue @@ -8,6 +8,22 @@ :controls="true" @click.stop.native=""> </VideoAttachment> + <button + :title="$t('media_modal.previous')" + class="modal-view-button-arrow modal-view-button-arrow--prev" + v-if="canNavigate" + @click.stop.prevent="goPrev" + > + <i class="icon-left-open arrow-icon" /> + </button> + <button + :title="$t('media_modal.next')" + class="modal-view-button-arrow modal-view-button-arrow--next" + v-if="canNavigate" + @click.stop.prevent="goNext" + > + <i class="icon-right-open arrow-icon" /> + </button> </div> </template> @@ -19,15 +35,29 @@ .modal-view { z-index: 1000; position: fixed; - width: 100vw; - height: 100vh; top: 0; left: 0; + right: 0; + bottom: 0; display: flex; justify-content: center; align-items: center; background-color: rgba(0, 0, 0, 0.5); - cursor: pointer; + + &:hover { + .modal-view-button-arrow { + opacity: 0.75; + + &:focus, + &:hover { + outline: none; + box-shadow: none; + } + &:hover { + opacity: 1; + } + } + } } .modal-image { @@ -35,4 +65,49 @@ max-height: 90%; box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5); } + +.modal-view-button-arrow { + position: absolute; + display: block; + top: 50%; + margin-top: -50px; + width: 70px; + height: 100px; + border: 0; + padding: 0; + opacity: 0; + box-shadow: none; + background: none; + appearance: none; + overflow: visible; + cursor: pointer; + transition: opacity 333ms cubic-bezier(.4,0,.22,1); + + .arrow-icon { + position: absolute; + top: 35px; + height: 30px; + width: 32px; + font-size: 14px; + line-height: 30px; + color: #FFF; + text-align: center; + background-color: rgba(0,0,0,.3); + } + + &--prev { + left: 0; + .arrow-icon { + left: 6px; + } + } + + &--next { + right: 0; + .arrow-icon { + right: 6px; + } + } +} + </style> diff --git a/src/components/mute_card/mute_card.js b/src/components/mute_card/mute_card.js new file mode 100644 index 00000000..5dd0a9e5 --- /dev/null +++ b/src/components/mute_card/mute_card.js @@ -0,0 +1,37 @@ +import BasicUserCard from '../basic_user_card/basic_user_card.vue' + +const MuteCard = { + props: ['userId'], + data () { + return { + progress: false + } + }, + computed: { + user () { + return this.$store.getters.userById(this.userId) + }, + muted () { + return this.user.muted + } + }, + components: { + BasicUserCard + }, + methods: { + unmuteUser () { + this.progress = true + this.$store.dispatch('unmuteUser', this.user.id).then(() => { + this.progress = false + }) + }, + muteUser () { + this.progress = true + this.$store.dispatch('muteUser', this.user.id).then(() => { + this.progress = false + }) + } + } +} + +export default MuteCard diff --git a/src/components/mute_card/mute_card.vue b/src/components/mute_card/mute_card.vue new file mode 100644 index 00000000..e1bfe20b --- /dev/null +++ b/src/components/mute_card/mute_card.vue @@ -0,0 +1,24 @@ +<template> + <basic-user-card :user="user"> + <template slot="secondary-area"> + <button class="btn btn-default" @click="unmuteUser" :disabled="progress" v-if="muted"> + <template v-if="progress"> + {{ $t('user_card.unmute_progress') }} + </template> + <template v-else> + {{ $t('user_card.unmute') }} + </template> + </button> + <button class="btn btn-default" @click="muteUser" :disabled="progress" v-else> + <template v-if="progress"> + {{ $t('user_card.mute_progress') }} + </template> + <template v-else> + {{ $t('user_card.mute') }} + </template> + </button> + </template> + </basic-user-card> +</template> + +<script src="./mute_card.js"></script>
\ No newline at end of file diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue index 3aa0a793..1a269adf 100644 --- a/src/components/nav_panel/nav_panel.vue +++ b/src/components/nav_panel/nav_panel.vue @@ -19,7 +19,10 @@ </li> <li v-if='currentUser && currentUser.locked'> <router-link :to="{ name: 'friend-requests' }"> - {{ $t("nav.friend_requests") }} + {{ $t("nav.friend_requests")}} + <span v-if='currentUser.follow_request_count > 0' class="badge follow-request-count"> + {{currentUser.follow_request_count}} + </span> </router-link> </li> <li> @@ -52,6 +55,12 @@ padding: 0; } +.follow-request-count { + margin: -6px 10px; + background-color: $fallback--bg; + background-color: var(--input, $fallback--faint); +} + .nav-panel li { border-bottom: 1px solid; border-color: $fallback--border; diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss index bc81d45c..b3364afc 100644 --- a/src/components/notifications/notifications.scss +++ b/src/components/notifications/notifications.scss @@ -103,6 +103,7 @@ flex: 1 1 0; display: flex; flex-wrap: nowrap; + justify-content: space-between; .name-and-action { flex: 1; @@ -123,8 +124,8 @@ object-fit: contain } } + .timeago { - float: right; font-size: 12px; } diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index ab379c23..c28c51bf 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -56,6 +56,10 @@ const PostStatusForm = { ? this.copyMessageScope : this.$store.state.users.currentUser.default_scope + const contentType = typeof this.$store.state.config.postContentType === 'undefined' + ? this.$store.state.instance.postContentType + : this.$store.state.config.postContentType + return { dropFiles: [], submitDisabled: false, @@ -67,7 +71,8 @@ const PostStatusForm = { status: statusText, nsfw: false, files: [], - visibility: scope + visibility: scope, + contentType }, caret: 0 } @@ -166,11 +171,6 @@ const PostStatusForm = { }, formattingOptionsEnabled () { return this.$store.state.instance.formattingOptionsEnabled - }, - defaultPostContentType () { - return typeof this.$store.state.config.postContentType === 'undefined' - ? this.$store.state.instance.postContentType - : this.$store.state.config.postContentType } }, methods: { diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index 6ed5d92e..5085570b 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -35,7 +35,7 @@ <div class="visibility-tray"> <span class="text-format" v-if="formattingOptionsEnabled"> <label for="post-content-type" class="select"> - <select id="post-content-type" v-model="defaultPostContentType" class="form-control"> + <select id="post-content-type" v-model="newStatus.contentType" class="form-control"> <option value="text/plain">{{$t('post_status.content_type.plain_text')}}</option> <option value="text/html">HTML</option> <option value="text/markdown">Markdown</option> @@ -118,6 +118,14 @@ } } +.post-status-form { + .visibility-tray { + display: flex; + justify-content: space-between; + flex-direction: row-reverse; + } +} + .post-status-form, .login { .form-bottom { display: flex; diff --git a/src/components/registration/registration.vue b/src/components/registration/registration.vue index f428ead3..e22b308d 100644 --- a/src/components/registration/registration.vue +++ b/src/components/registration/registration.vue @@ -9,7 +9,7 @@ <div class='text-fields'> <div class='form-group' :class="{ 'form-group--error': $v.user.username.$error }"> <label class='form--label' for='sign-up-username'>{{$t('login.username')}}</label> - <input :disabled="isPending" v-model.trim='$v.user.username.$model' class='form-control' id='sign-up-username' placeholder='e.g. lain'> + <input :disabled="isPending" v-model.trim='$v.user.username.$model' class='form-control' id='sign-up-username' :placeholder="$t('registration.username_placeholder')"> </div> <div class="form-error" v-if="$v.user.username.$dirty"> <ul> @@ -21,7 +21,7 @@ <div class='form-group' :class="{ 'form-group--error': $v.user.fullname.$error }"> <label class='form--label' for='sign-up-fullname'>{{$t('registration.fullname')}}</label> - <input :disabled="isPending" v-model.trim='$v.user.fullname.$model' class='form-control' id='sign-up-fullname' placeholder='e.g. Lain Iwakura'> + <input :disabled="isPending" v-model.trim='$v.user.fullname.$model' class='form-control' id='sign-up-fullname' :placeholder="$t('registration.fullname_placeholder')"> </div> <div class="form-error" v-if="$v.user.fullname.$dirty"> <ul> @@ -44,8 +44,8 @@ </div> <div class='form-group'> - <label class='form--label' for='bio'>{{$t('registration.bio')}}</label> - <input :disabled="isPending" v-model='user.bio' class='form-control' id='bio'> + <label class='form--label' for='bio'>{{$t('registration.bio')}} ({{$t('general.optional')}})</label> + <textarea :disabled="isPending" v-model='user.bio' class='form-control' id='bio' :placeholder="$t('registration.bio_placeholder')"></textarea> </div> <div class='form-group' :class="{ 'form-group--error': $v.user.password.$error }"> @@ -139,6 +139,10 @@ $validations-cRed: #f04124; flex-direction: column; } + textarea { + min-height: 100px; + } + .form-group { display: flex; flex-direction: column; diff --git a/src/components/settings/settings.js b/src/components/settings/settings.js index 534a9839..6e2dff7b 100644 --- a/src/components/settings/settings.js +++ b/src/components/settings/settings.js @@ -12,6 +12,7 @@ const settings = { return { hideAttachmentsLocal: user.hideAttachments, hideAttachmentsInConvLocal: user.hideAttachmentsInConv, + maxThumbnails: user.maxThumbnails, hideNsfwLocal: user.hideNsfw, useOneClickNsfw: user.useOneClickNsfw, hideISPLocal: user.hideISP, @@ -91,7 +92,8 @@ const settings = { }, currentSaveStateNotice () { return this.$store.state.interface.settings.currentSaveStateNotice - } + }, + instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel } }, watch: { hideAttachmentsLocal (value) { @@ -185,6 +187,10 @@ const settings = { }, useContainFit (value) { this.$store.dispatch('setOption', { name: 'useContainFit', value }) + }, + maxThumbnails (value) { + value = this.maxThumbnails = Math.floor(Math.max(value, 0)) + this.$store.dispatch('setOption', { name: 'maxThumbnails', value }) } } } diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue index dfb2e49d..16814f65 100644 --- a/src/components/settings/settings.vue +++ b/src/components/settings/settings.vue @@ -27,7 +27,7 @@ <li> <interface-language-switcher /> </li> - <li> + <li v-if="instanceSpecificPanelPresent"> <input type="checkbox" id="hideISP" v-model="hideISPLocal"> <label for="hideISP">{{$t('settings.hide_isp')}}</label> </li> @@ -137,6 +137,10 @@ <label for="hideAttachmentsInConv">{{$t('settings.hide_attachments_in_convo')}}</label> </li> <li> + <label for="maxThumbnails">{{$t('settings.max_thumbnails')}}</label> + <input class="number-input" type="number" id="maxThumbnails" v-model.number="maxThumbnails" min="0" step="1"> + </li> + <li> <input type="checkbox" id="hideNsfw" v-model="hideNsfwLocal"> <label for="hideNsfw">{{$t('settings.nsfw_clickthrough')}}</label> </li> @@ -311,25 +315,15 @@ color: $fallback--cRed; } - .old-avatar { - width: 128px; - border-radius: $fallback--avatarRadius; - border-radius: var(--avatarRadius, $fallback--avatarRadius); - } - - .new-avatar { - object-fit: cover; - width: 128px; - height: 128px; - border-radius: $fallback--avatarRadius; - border-radius: var(--avatarRadius, $fallback--avatarRadius); - } - .btn { min-height: 28px; min-width: 10em; padding: 0 2em; } + + .number-input { + max-width: 6em; + } } .select-multiple { display: flex; diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue index a6c6f237..8eca7b8c 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -45,6 +45,10 @@ <li v-if="currentUser && currentUser.locked" @click="toggleDrawer"> <router-link to='/friend-requests'> {{ $t("nav.friend_requests") }} + <span v-if='currentUser.follow_request_count > 0' class="badge follow-request-count"> + {{currentUser.follow_request_count}} + </span> + </router-link> </li> <li @click="toggleDrawer"> diff --git a/src/components/status/status.js b/src/components/status/status.js index 0273a5be..fab2fe62 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -40,8 +40,7 @@ const Status = { expandingSubject: typeof this.$store.state.config.collapseMessageWithSubject === 'undefined' ? !this.$store.state.instance.collapseMessageWithSubject : !this.$store.state.config.collapseMessageWithSubject, - betterShadow: this.$store.state.interface.browserSupport.cssFilter, - maxAttachments: 9 + betterShadow: this.$store.state.interface.browserSupport.cssFilter } }, computed: { @@ -225,7 +224,7 @@ const Status = { attachmentSize () { if ((this.$store.state.config.hideAttachments && !this.inConversation) || (this.$store.state.config.hideAttachmentsInConv && this.inConversation) || - (this.status.attachments.length > this.maxAttachments)) { + (this.status.attachments.length > this.maxThumbnails)) { return 'hide' } else if (this.compact) { return 'small' @@ -249,6 +248,9 @@ const Status = { return this.status.attachments.filter( file => !fileType.fileMatchesSomeType(this.galleryTypes, file) ) + }, + maxThumbnails () { + return this.$store.state.config.maxThumbnails } }, components: { diff --git a/src/components/status/status.vue b/src/components/status/status.vue index aae365d1..3fc5b486 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -554,7 +554,7 @@ a.unmute { .timeline > { .status-el:last-child { - border-bottom-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;; + border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius; border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius); border-bottom: none; } diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index 06832898..62536bc5 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -11,7 +11,8 @@ const Timeline = { 'title', 'userId', 'tag', - 'embedded' + 'embedded', + 'count' ], data () { return { @@ -53,6 +54,8 @@ const Timeline = { window.addEventListener('scroll', this.scrollLoad) + if (this.timelineName === 'friends' && !credentials) { return false } + timelineFetcher.fetchAndUpdate({ store, credentials, diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue index e3eea3bd..8f28d65c 100644 --- a/src/components/timeline/timeline.vue +++ b/src/components/timeline/timeline.vue @@ -20,7 +20,10 @@ </div> </div> <div :class="classes.footer"> - <div v-if="bottomedOut" class="new-status-notification text-center panel-footer faint"> + <div v-if="count===0" class="new-status-notification text-center panel-footer faint"> + {{$t('timeline.no_statuses')}} + </div> + <div v-else-if="bottomedOut" class="new-status-notification text-center panel-footer faint"> {{$t('timeline.no_more_statuses')}} </div> <a v-else-if="!timeline.loading" href="#" v-on:click.prevent='fetchOlderStatuses()'> diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js index a4c84716..28e22f09 100644 --- a/src/components/user_card/user_card.js +++ b/src/components/user_card/user_card.js @@ -6,9 +6,8 @@ import { requestFollow, requestUnfollow } from '../../services/follow_manipulate const UserCard = { props: [ 'user', - 'showFollows', - 'showApproval', - 'showActions' + 'noFollowsYou', + 'showApproval' ], data () { return { @@ -26,7 +25,7 @@ const UserCard = { currentUser () { return this.$store.state.users.currentUser }, following () { return this.updated ? this.updated.following : this.user.following }, showFollow () { - return this.showActions && (!this.showFollows && !this.following || this.updated && !this.updated.following) + return !this.showApproval && (!this.following || this.updated && !this.updated.following) } }, methods: { diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue index 12960c02..ce4edb3c 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -1,27 +1,31 @@ <template> <div class="card"> <router-link :to="userProfileLink(user)"> - <UserAvatar class="avatar" :compact="true" @click.prevent.native="toggleUserExpanded" :src="user.profile_image_url"/> + <UserAvatar class="avatar" @click.prevent.native="toggleUserExpanded" :src="user.profile_image_url"/> </router-link> - <div class="usercard" v-if="userExpanded"> - <user-card-content :user="user" :switcher="false"></user-card-content> - </div> - <div class="name-and-screen-name" v-else> - <div :title="user.name" class="user-name"> - <span v-if="user.name_html" v-html="user.name_html"></span> - <span v-else>{{ user.name }}</span> - <span class="follows-you" v-if="!userExpanded && showFollows && user.follows_you"> + <div class="user-card-main-content"> + <div class="usercard" v-if="userExpanded"> + <user-card-content :user="user" :switcher="false"></user-card-content> + </div> + <div class="name-and-screen-name" v-if="!userExpanded"> + <div :title="user.name" class="user-name"> + <span v-if="user.name_html" v-html="user.name_html"></span> + <span v-else>{{ user.name }}</span> + </div> + <div class="user-link-action"> + <router-link class='user-screen-name' :to="userProfileLink(user)"> + @{{user.screen_name}} + </router-link> + </div> + </div> + <div class="follow-box" v-if="!userExpanded"> + <span class="faint" v-if="!noFollowsYou && user.follows_you"> {{ currentUser.id == user.id ? $t('user_card.its_you') : $t('user_card.follows_you') }} </span> - </div> - <div class="user-link-action"> - <router-link class='user-screen-name' :to="userProfileLink(user)"> - @{{user.screen_name}} - </router-link> - <button - v-if="showFollow" - class="btn btn-default" - @click="followUser" + <button + v-if="showFollow" + class="btn btn-default" + @click="followUser" :disabled="followRequestInProgress" :title="followRequestSent ? $t('user_card.follow_again') : ''" > @@ -35,7 +39,7 @@ {{ $t('user_card.follow') }} </template> </button> - <button v-if="showActions && showFollows && following" class="btn btn-default" @click="unfollowUser" :disabled="followRequestInProgress"> + <button v-if="following" class="btn btn-default pressed" @click="unfollowUser" :disabled="followRequestInProgress"> <template v-if="followRequestInProgress"> {{ $t('user_card.follow_progress') }} </template> @@ -44,10 +48,10 @@ </template> </button> </div> - </div> - <div class="approval" v-if="showApproval"> - <button class="btn btn-default" @click="approveUser">{{ $t('user_card.approve') }}</button> - <button class="btn btn-default" @click="denyUser">{{ $t('user_card.deny') }}</button> + <div class="approval" v-if="showApproval"> + <button class="btn btn-default" @click="approveUser">{{ $t('user_card.approve') }}</button> + <button class="btn btn-default" @click="denyUser">{{ $t('user_card.deny') }}</button> + </div> </div> </div> </template> @@ -57,15 +61,19 @@ <style lang="scss"> @import '../../_variables.scss'; -.name-and-screen-name { +.user-card-main-content { + display: flex; + flex-direction: column; + flex: 1 1 100%; margin-left: 0.7em; - margin-top:0.0em; + min-width: 0; +} + +.name-and-screen-name { text-align: left; width: 100%; - .user-name { - display: flex; - justify-content: space-between; + .user-name { img { object-fit: contain; height: 16px; @@ -73,21 +81,14 @@ vertical-align: middle; } } - + .user-link-action { display: flex; align-items: flex-start; justify-content: space-between; - - button { - margin-top: 3px; - } } } -.follows-you { - margin-left: 2em; -} .card { display: flex; @@ -99,16 +100,31 @@ border-bottom: 1px solid; margin: 0; border-bottom-color: $fallback--border; - border-bottom-color: var(--border, $fallback--border); + border-bottom-color: var(--border, $fallback--border); .avatar { padding: 0; } + + .follow-box { + text-align: center; + flex-shrink: 0; + display: flex; + flex-direction: row; + justify-content: space-between; + flex-wrap: wrap; + line-height: 1.5em; + + .btn { + margin-top: 0.5em; + margin-left: auto; + width: 10em; + } + } } .usercard { width: fill-available; - margin: 0.2em 0 0 0.7em; border-radius: $fallback--panelRadius; border-radius: var(--panelRadius, $fallback--panelRadius); border-style: solid; @@ -129,9 +145,15 @@ } .approval { + display: flex; + flex-direction: row; + flex-wrap: wrap; button { - width: 100%; - margin-bottom: 0.5em; + margin-top: 0.5em; + margin-right: 0.5em; + flex: 1 1; + max-width: 12em; + min-width: 8em; } } </style> diff --git a/src/components/user_card_content/user_card_content.vue b/src/components/user_card_content/user_card_content.vue index 7f9909c4..a3d24eb1 100644 --- a/src/components/user_card_content/user_card_content.vue +++ b/src/components/user_card_content/user_card_content.vue @@ -13,7 +13,7 @@ <router-link :to="{ name: 'user-settings' }" v-if="!isOtherUser"> <i class="button-icon icon-cog usersettings" :title="$t('tool_tip.user_settings')"></i> </router-link> - <a :href="user.statusnet_profile_url" target="_blank" v-if="isOtherUser"> + <a :href="user.statusnet_profile_url" target="_blank" v-if="isOtherUser && !user.is_local"> <i class="icon-link-ext usersettings"></i> </a> </div> @@ -386,6 +386,4 @@ } } -.floater { -} </style> diff --git a/src/components/user_finder/user_finder.js b/src/components/user_finder/user_finder.js index 55c6c402..27153f45 100644 --- a/src/components/user_finder/user_finder.js +++ b/src/components/user_finder/user_finder.js @@ -8,6 +8,7 @@ const UserFinder = { methods: { findUser (username) { this.$router.push({ name: 'user-search', query: { query: username } }) + this.$refs.userSearchInput.focus() }, toggleHidden () { this.hidden = !this.hidden diff --git a/src/components/user_finder/user_finder.vue b/src/components/user_finder/user_finder.vue index 37d628fa..a118ffe2 100644 --- a/src/components/user_finder/user_finder.vue +++ b/src/components/user_finder/user_finder.vue @@ -4,7 +4,7 @@ <i class="icon-spin4 user-finder-icon animate-spin-slow" v-if="loading" /> <a href="#" v-if="hidden" :title="$t('finder.find_user')"><i class="icon-user-plus user-finder-icon" @click.prevent.stop="toggleHidden" /></a> <template v-else> - <input class="user-finder-input" @keyup.enter="findUser(username)" v-model="username" :placeholder="$t('finder.find_user')" id="user-finder-input" type="text"/> + <input class="user-finder-input" ref="userSearchInput" @keyup.enter="findUser(username)" v-model="username" :placeholder="$t('finder.find_user')" id="user-finder-input" type="text"/> <button class="btn search-button" @click="findUser(username)"> <i class="icon-search"/> </button> diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index 79461291..09fb93de 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -10,6 +10,7 @@ <Timeline :label="$t('user_card.statuses')" :disabled="!user.statuses_count" + :count="user.statuses_count" :embedded="true" :title="$t('user_profile.timeline_title')" :timeline="timeline" diff --git a/src/components/user_search/user_search.js b/src/components/user_search/user_search.js index 9c026276..fe67b2ad 100644 --- a/src/components/user_search/user_search.js +++ b/src/components/user_search/user_search.js @@ -10,7 +10,8 @@ const userSearch = { data () { return { username: '', - users: [] + users: [], + loading: false } }, mounted () { @@ -24,14 +25,17 @@ const userSearch = { methods: { newQuery (query) { this.$router.push({ name: 'user-search', query: { query } }) + this.$refs.userSearchInput.focus() }, search (query) { if (!query) { this.users = [] return } + this.loading = true userSearchApi.search({query, store: this.$store}) .then((res) => { + this.loading = false this.users = res }) } diff --git a/src/components/user_search/user_search.vue b/src/components/user_search/user_search.vue index 3c2bd3fb..b39e10f4 100644 --- a/src/components/user_search/user_search.vue +++ b/src/components/user_search/user_search.vue @@ -4,12 +4,15 @@ {{$t('nav.user_search')}} </div> <div class="user-search-input-container"> - <input class="user-finder-input" @keyup.enter="newQuery(username)" v-model="username" :placeholder="$t('finder.find_user')"/> + <input class="user-finder-input" ref="userSearchInput" @keyup.enter="newQuery(username)" v-model="username" :placeholder="$t('finder.find_user')"/> <button class="btn search-button" @click="newQuery(username)"> <i class="icon-search"/> </button> </div> - <div class="panel-body"> + <div v-if="loading" class="text-center loading-icon"> + <i class="icon-spin3 animate-spin"/> + </div> + <div v-else class="panel-body"> <user-card v-for="user in users" :key="user.id" :user="user" :showFollows="true"></user-card> </div> </div> @@ -27,4 +30,8 @@ margin-left: 0.5em; } } + +.loading-icon { + padding: 1em; +} </style> diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js index d20bf308..d6972737 100644 --- a/src/components/user_settings/user_settings.js +++ b/src/components/user_settings/user_settings.js @@ -1,8 +1,32 @@ -import { unescape } from 'lodash' - +import { compose } from 'vue-compose' +import unescape from 'lodash/unescape' +import get from 'lodash/get' import TabSwitcher from '../tab_switcher/tab_switcher.js' +import ImageCropper from '../image_cropper/image_cropper.vue' import StyleSwitcher from '../style_switcher/style_switcher.vue' import fileSizeFormatService from '../../services/file_size_format/file_size_format.js' +import BlockCard from '../block_card/block_card.vue' +import MuteCard from '../mute_card/mute_card.vue' +import withSubscription from '../../hocs/with_subscription/with_subscription' +import withList from '../../hocs/with_list/with_list' + +const BlockList = compose( + withSubscription({ + fetch: (props, $store) => $store.dispatch('fetchBlocks'), + select: (props, $store) => get($store.state.users.currentUser, 'blockIds', []), + childPropName: 'entries' + }), + withList({ getEntryProps: userId => ({ userId }) }) +)(BlockCard) + +const MuteList = compose( + withSubscription({ + fetch: (props, $store) => $store.dispatch('fetchMutes'), + select: (props, $store) => get($store.state.users.currentUser, 'muteIds', []), + childPropName: 'entries' + }), + withList({ getEntryProps: userId => ({ userId }) }) +)(MuteCard) const UserSettings = { data () { @@ -20,14 +44,12 @@ const UserSettings = { followImportError: false, followsImported: false, enableFollowsExport: true, - avatarUploading: false, + pickAvatarBtnVisible: true, bannerUploading: false, backgroundUploading: false, followListUploading: false, - avatarPreview: null, bannerPreview: null, backgroundPreview: null, - avatarUploadError: null, bannerUploadError: null, backgroundUploadError: null, deletingAccount: false, @@ -39,9 +61,15 @@ const UserSettings = { activeTab: 'profile' } }, + created () { + this.$store.dispatch('fetchTokens') + }, components: { StyleSwitcher, - TabSwitcher + TabSwitcher, + ImageCropper, + BlockList, + MuteList }, computed: { user () { @@ -60,6 +88,18 @@ const UserSettings = { private: { selected: this.newDefaultScope === 'private' }, direct: { selected: this.newDefaultScope === 'direct' } } + }, + currentSaveStateNotice () { + return this.$store.state.interface.settings.currentSaveStateNotice + }, + oauthTokens () { + return this.$store.state.oauthTokens.tokens.map(oauthToken => { + return { + id: oauthToken.id, + appName: oauthToken.app_name, + validUntil: new Date(oauthToken.valid_until).toLocaleDateString() + } + }) } }, methods: { @@ -117,35 +157,15 @@ const UserSettings = { } reader.readAsDataURL(file) }, - submitAvatar () { - if (!this.avatarPreview) { return } - - let img = this.avatarPreview - // eslint-disable-next-line no-undef - let imginfo = new Image() - let cropX, cropY, cropW, cropH - imginfo.src = img - if (imginfo.height > imginfo.width) { - cropX = 0 - cropW = imginfo.width - cropY = Math.floor((imginfo.height - imginfo.width) / 2) - cropH = imginfo.width - } else { - cropY = 0 - cropH = imginfo.height - cropX = Math.floor((imginfo.width - imginfo.height) / 2) - cropW = imginfo.height - } - this.avatarUploading = true - this.$store.state.api.backendInteractor.updateAvatar({params: {img, cropX, cropY, cropW, cropH}}).then((user) => { + submitAvatar (cropper) { + const img = cropper.getCroppedCanvas().toDataURL('image/jpeg') + return this.$store.state.api.backendInteractor.updateAvatar({ params: { img } }).then((user) => { if (!user.error) { this.$store.commit('addNewUsers', [user]) this.$store.commit('setCurrentUser', user) - this.avatarPreview = null } else { - this.avatarUploadError = this.$t('upload.error.base') + user.error + throw new Error(this.$t('upload.error.base') + user.error) } - this.avatarUploading = false }) }, clearUploadError (slot) { @@ -299,6 +319,11 @@ const UserSettings = { logout () { this.$store.dispatch('logout') this.$router.replace('/') + }, + revokeToken (id) { + if (window.confirm(`${this.$i18n.t('settings.revoke_token')}?`)) { + this.$store.dispatch('revokeToken', id) + } } } } diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue index d2381da2..a1123638 100644 --- a/src/components/user_settings/user_settings.vue +++ b/src/components/user_settings/user_settings.vue @@ -1,7 +1,20 @@ <template> <div class="settings panel panel-default"> <div class="panel-heading"> - {{$t('settings.user_settings')}} + <div class="title"> + {{$t('settings.user_settings')}} + </div> + <transition name="fade"> + <template v-if="currentSaveStateNotice"> + <div @click.prevent class="alert error" v-if="currentSaveStateNotice.error"> + {{ $t('settings.saving_err') }} + </div> + + <div @click.prevent class="alert transparent" v-if="!currentSaveStateNotice.error"> + {{ $t('settings.saving_ok') }} + </div> + </template> + </transition> </div> <div class="panel-body profile-edit"> <tab-switcher> @@ -48,19 +61,10 @@ <h2>{{$t('settings.avatar')}}</h2> <p class="visibility-notice">{{$t('settings.avatar_size_instruction')}}</p> <p>{{$t('settings.current_avatar')}}</p> - <img :src="user.profile_image_url_original" class="old-avatar"></img> + <img :src="user.profile_image_url_original" class="current-avatar"></img> <p>{{$t('settings.set_new_avatar')}}</p> - <img class="new-avatar" v-bind:src="avatarPreview" v-if="avatarPreview"> - </img> - <div> - <input type="file" @change="uploadFile('avatar', $event)" ></input> - </div> - <i class="icon-spin4 animate-spin" v-if="avatarUploading"></i> - <button class="btn btn-default" v-else-if="avatarPreview" @click="submitAvatar">{{$t('general.submit')}}</button> - <div class='alert error' v-if="avatarUploadError"> - Error: {{ avatarUploadError }} - <i class="button-icon icon-cancel" @click="clearUploadError('avatar')"></i> - </div> + <button class="btn" type="button" id="pick-avatar" v-show="pickAvatarBtnVisible">{{$t('settings.upload_a_photo')}}</button> + <image-cropper trigger="#pick-avatar" :submitHandler="submitAvatar" @open="pickAvatarBtnVisible=false" @close="pickAvatarBtnVisible=true" /> </div> <div class="setting-item"> <h2>{{$t('settings.profile_banner')}}</h2> @@ -118,6 +122,30 @@ </div> <div class="setting-item"> + <h2>{{$t('settings.oauth_tokens')}}</h2> + <table class="oauth-tokens"> + <thead> + <tr> + <th>{{$t('settings.app_name')}}</th> + <th>{{$t('settings.valid_until')}}</th> + <th></th> + </tr> + </thead> + <tbody> + <tr v-for="oauthToken in oauthTokens" :key="oauthToken.id"> + <td>{{oauthToken.appName}}</td> + <td>{{oauthToken.validUntil}}</td> + <td class="actions"> + <button class="btn btn-default" @click="revokeToken(oauthToken.id)"> + {{$t('settings.revoke_token')}} + </button> + </td> + </tr> + </tbody> + </table> + </div> + + <div class="setting-item"> <h2>{{$t('settings.delete_account')}}</h2> <p v-if="!deletingAccount">{{$t('settings.delete_account_description')}}</p> <div v-if="deletingAccount"> @@ -158,6 +186,12 @@ <h2>{{$t('settings.follow_export_processing')}}</h2> </div> </div> + + <div :label="$t('settings.blocks_tab')"> + <block-list :refresh="true"> + <template slot="empty">{{$t('settings.no_blocks')}}</template> + </block-list> + </div> </tab-switcher> </div> </div> @@ -167,6 +201,8 @@ </script> <style lang="scss"> +@import '../../_variables.scss'; + .profile-edit { .bio { margin: 0; @@ -193,5 +229,25 @@ .bg { max-width: 100%; } + + .current-avatar { + display: block; + width: 150px; + height: 150px; + border-radius: $fallback--avatarRadius; + border-radius: var(--avatarRadius, $fallback--avatarRadius); + } + + .oauth-tokens { + width: 100%; + + th { + text-align: left; + } + + .actions { + text-align: right; + } + } } </style> diff --git a/src/hocs/with_list/with_list.js b/src/hocs/with_list/with_list.js new file mode 100644 index 00000000..896f8fc8 --- /dev/null +++ b/src/hocs/with_list/with_list.js @@ -0,0 +1,40 @@ +import Vue from 'vue' +import map from 'lodash/map' +import isEmpty from 'lodash/isEmpty' +import './with_list.scss' + +const defaultEntryPropsGetter = entry => ({ entry }) +const defaultKeyGetter = entry => entry.id + +const withList = ({ + getEntryProps = defaultEntryPropsGetter, // function to accept entry and index values and return props to be passed into the item component + getKey = defaultKeyGetter // funciton to accept entry and index values and return key prop value +}) => (ItemComponent) => ( + Vue.component('withList', { + props: [ + 'entries', // array of entry + 'entryProps', // additional props to be passed into each entry + 'entryListeners' // additional event listeners to be passed into each entry + ], + render (createElement) { + return ( + <div class="with-list"> + {map(this.entries, (entry, index) => { + const props = { + key: getKey(entry, index), + props: { + ...this.$props.entryProps, + ...getEntryProps(entry, index) + }, + on: this.$props.entryListeners + } + return <ItemComponent {...props} /> + })} + {isEmpty(this.entries) && this.$slots.empty && <div class="with-list-empty-content faint">{this.$slots.empty}</div>} + </div> + ) + } + }) +) + +export default withList diff --git a/src/hocs/with_list/with_list.scss b/src/hocs/with_list/with_list.scss new file mode 100644 index 00000000..c6e13d5b --- /dev/null +++ b/src/hocs/with_list/with_list.scss @@ -0,0 +1,6 @@ +.with-list { + &-empty-content { + text-align: center; + padding: 10px; + } +}
\ No newline at end of file diff --git a/src/hocs/with_load_more/with_load_more.js b/src/hocs/with_load_more/with_load_more.js new file mode 100644 index 00000000..e862a39b --- /dev/null +++ b/src/hocs/with_load_more/with_load_more.js @@ -0,0 +1,91 @@ +import Vue from 'vue' +import filter from 'lodash/filter' +import isEmpty from 'lodash/isEmpty' +import './with_load_more.scss' + +const withLoadMore = ({ + fetch, // function to fetch entries and return a promise + select, // function to select data from store + childPropName = 'entries' // name of the prop to be passed into the wrapped component +}) => (WrappedComponent) => { + const originalProps = WrappedComponent.props || [] + const props = filter(originalProps, v => v !== 'entries') + + return Vue.component('withLoadMore', { + render (createElement) { + const props = { + props: { + ...this.$props, + [childPropName]: this.entries + }, + on: this.$listeners, + scopedSlots: this.$scopedSlots + } + const children = Object.entries(this.$slots).map(([key, value]) => createElement('template', { slot: key }, value)) + return ( + <div class="with-load-more"> + <WrappedComponent {...props}> + {children} + </WrappedComponent> + <div class="with-load-more-footer"> + {this.error && <a onClick={this.fetchEntries} class="alert error">{this.$t('general.generic_error')}</a>} + {!this.error && this.loading && <i class="icon-spin3 animate-spin"/>} + {!this.error && !this.loading && !this.bottomedOut && <a onClick={this.fetchEntries}>{this.$t('general.more')}</a>} + </div> + </div> + ) + }, + props, + data () { + return { + loading: false, + bottomedOut: false, + error: false + } + }, + computed: { + entries () { + return select(this.$props, this.$store) || [] + } + }, + created () { + window.addEventListener('scroll', this.scrollLoad) + if (this.entries.length === 0) { + this.fetchEntries() + } + }, + destroyed () { + window.removeEventListener('scroll', this.scrollLoad) + }, + methods: { + fetchEntries () { + if (!this.loading) { + this.loading = true + this.error = false + fetch(this.$props, this.$store) + .then((newEntries) => { + this.loading = false + this.bottomedOut = isEmpty(newEntries) + }) + .catch(() => { + this.loading = false + this.error = true + }) + } + }, + scrollLoad (e) { + const bodyBRect = document.body.getBoundingClientRect() + const height = Math.max(bodyBRect.height, -(bodyBRect.y)) + if (this.loading === false && + this.bottomedOut === false && + this.$el.offsetHeight > 0 && + (window.innerHeight + window.pageYOffset) >= (height - 750) + ) { + this.fetchEntries() + } + } + } + }) +} + +export default withLoadMore diff --git a/src/hocs/with_load_more/with_load_more.scss b/src/hocs/with_load_more/with_load_more.scss new file mode 100644 index 00000000..1a0a9c40 --- /dev/null +++ b/src/hocs/with_load_more/with_load_more.scss @@ -0,0 +1,10 @@ +.with-load-more { + &-footer { + padding: 10px; + text-align: center; + + .error { + font-size: 14px; + } + } +}
\ No newline at end of file diff --git a/src/hocs/with_subscription/with_subscription.js b/src/hocs/with_subscription/with_subscription.js new file mode 100644 index 00000000..1ac67cba --- /dev/null +++ b/src/hocs/with_subscription/with_subscription.js @@ -0,0 +1,84 @@ +import Vue from 'vue' +import reject from 'lodash/reject' +import isEmpty from 'lodash/isEmpty' +import omit from 'lodash/omit' +import './with_subscription.scss' + +const withSubscription = ({ + fetch, // function to fetch entries and return a promise + select, // function to select data from store + childPropName = 'content' // name of the prop to be passed into the wrapped component +}) => (WrappedComponent) => { + const originalProps = WrappedComponent.props || [] + const props = reject(originalProps, v => v === 'content') + + return Vue.component('withSubscription', { + props: [ + ...props, + 'refresh' // boolean saying to force-fetch data whenever created + ], + render (createElement) { + if (!this.error && !this.loading) { + const props = { + props: { + ...omit(this.$props, 'refresh'), + [childPropName]: this.fetchedData + }, + on: this.$listeners, + scopedSlots: this.$scopedSlots + } + const children = Object.entries(this.$slots).map(([key, value]) => createElement('template', { slot: key }, value)) + return ( + <div class="with-subscription"> + <WrappedComponent {...props}> + {children} + </WrappedComponent> + </div> + ) + } else { + return ( + <div class="with-subscription-loading"> + {this.error + ? <a onClick={this.fetchData} class="alert error">{this.$t('general.generic_error')}</a> + : <i class="icon-spin3 animate-spin"/> + } + </div> + ) + } + }, + data () { + return { + loading: false, + error: false + } + }, + computed: { + fetchedData () { + return select(this.$props, this.$store) + } + }, + created () { + if (this.refresh || isEmpty(this.fetchedData)) { + this.fetchData() + } + }, + methods: { + fetchData () { + if (!this.loading) { + this.loading = true + this.error = false + fetch(this.$props, this.$store) + .then(() => { + this.loading = false + }) + .catch(() => { + this.error = true + this.loading = false + }) + } + } + } + }) +} + +export default withSubscription diff --git a/src/hocs/with_subscription/with_subscription.scss b/src/hocs/with_subscription/with_subscription.scss new file mode 100644 index 00000000..52c7d94c --- /dev/null +++ b/src/hocs/with_subscription/with_subscription.scss @@ -0,0 +1,10 @@ +.with-subscription { + &-loading { + padding: 10px; + text-align: center; + + .error { + font-size: 14px; + } + } +}
\ No newline at end of file diff --git a/src/i18n/ar.json b/src/i18n/ar.json index ac7d0f1a..242dab78 100644 --- a/src/i18n/ar.json +++ b/src/i18n/ar.json @@ -134,6 +134,11 @@ "notification_visibility_mentions": "الإشارات", "notification_visibility_repeats": "", "nsfw_clickthrough": "", + "oauth_tokens": "رموز OAuth", + "token": "رمز", + "refresh_token": "رمز التحديث", + "valid_until": "صالح حتى", + "revoke_token": "سحب", "panelRadius": "", "pause_on_unfocused": "", "presets": "النماذج", diff --git a/src/i18n/ca.json b/src/i18n/ca.json index fa517e22..d2f285df 100644 --- a/src/i18n/ca.json +++ b/src/i18n/ca.json @@ -132,6 +132,11 @@ "notification_visibility_repeats": "Republica una entrada meva", "no_rich_text_description": "Neteja el formatat de text de totes les entrades", "nsfw_clickthrough": "Amaga el contingut NSFW darrer d'una imatge clicable", + "oauth_tokens": "Llistats OAuth", + "token": "Token", + "refresh_token": "Actualitza el token", + "valid_until": "Vàlid fins", + "revoke_token": "Revocar", "panelRadius": "Panells", "pause_on_unfocused": "Pausa la reproducció en continu quan la pestanya perdi el focus", "presets": "Temes", diff --git a/src/i18n/de.json b/src/i18n/de.json index d0bfba38..07d44348 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -159,6 +159,11 @@ "hide_follows_description": "Zeige nicht, wem ich folge", "hide_followers_description": "Zeige nicht, wer mir folgt", "nsfw_clickthrough": "Aktiviere ausblendbares Overlay für Anhänge, die als NSFW markiert sind", + "oauth_tokens": "OAuth-Token", + "token": "Zeichen", + "refresh_token": "Token aktualisieren", + "valid_until": "Gültig bis", + "revoke_token": "Widerrufen", "panelRadius": "Panel", "pause_on_unfocused": "Streaming pausieren, wenn das Tab nicht fokussiert ist", "presets": "Voreinstellungen", diff --git a/src/i18n/en.json b/src/i18n/en.json index c664fbfa..df8f6f6c 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -19,7 +19,13 @@ "apply": "Apply", "submit": "Submit", "more": "More", - "generic_error": "An error occured" + "generic_error": "An error occured", + "optional": "optional" + }, + "image_cropper": { + "crop_picture": "Crop picture", + "save": "Save", + "cancel": "Cancel" }, "login": { "login": "Log in", @@ -31,6 +37,10 @@ "username": "Username", "hint": "Log in to join the discussion" }, + "media_modal": { + "previous": "Previous", + "next": "Next" + }, "nav": { "about": "About", "back": "Back", @@ -83,6 +93,9 @@ "token": "Invite token", "captcha": "CAPTCHA", "new_captcha": "Click the image to get a new captcha", + "username_placeholder": "e.g. lain", + "fullname_placeholder": "e.g. Lain Iwakura", + "bio_placeholder": "e.g.\nHi, I'm Lain\nI’m an anime girl living in suburban Japan. You may know me from the Wired.", "validations": { "username_required": "cannot be left blank", "fullname_required": "cannot be left blank", @@ -93,6 +106,7 @@ } }, "settings": { + "app_name": "App name", "attachmentRadius": "Attachments", "attachments": "Attachments", "autoload": "Enable automatic loading when scrolled to the bottom", @@ -101,6 +115,7 @@ "avatarRadius": "Avatars", "background": "Background", "bio": "Bio", + "blocks_tab": "Blocks", "btnRadius": "Buttons", "cBlue": "Blue (Reply, follow)", "cGreen": "Green (Retweet)", @@ -135,6 +150,7 @@ "general": "General", "hide_attachments_in_convo": "Hide attachments in conversations", "hide_attachments_in_tl": "Hide attachments in timeline", + "max_thumbnails": "Maximum amount of thumbnails per post", "hide_isp": "Hide instance-specific panel", "preload_images": "Preload images", "use_one_click_nsfw": "Open NSFW attachments with just one click", @@ -155,6 +171,7 @@ "lock_account_description": "Restrict your account to approved followers only", "loop_video": "Loop videos", "loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")", + "mutes_tab": "Mutes", "play_videos_in_modal": "Play videos directly in the media viewer", "use_contain_fit": "Don't crop the attachment in thumbnails", "name": "Name", @@ -166,11 +183,18 @@ "notification_visibility_mentions": "Mentions", "notification_visibility_repeats": "Repeats", "no_rich_text_description": "Strip rich text formatting from all posts", + "no_blocks": "No blocks", + "no_mutes": "No mutes", "hide_follows_description": "Don't show who I'm following", "hide_followers_description": "Don't show who's following me", "show_admin_badge": "Show Admin badge in my profile", "show_moderator_badge": "Show Moderator badge in my profile", "nsfw_clickthrough": "Enable clickthrough NSFW attachment hiding", + "oauth_tokens": "OAuth tokens", + "token": "Token", + "refresh_token": "Refresh Token", + "valid_until": "Valid Until", + "revoke_token": "Revoke", "panelRadius": "Panels", "pause_on_unfocused": "Pause streaming when tab is not focused", "presets": "Presets", @@ -206,6 +230,7 @@ "theme_help_v2_1": "You can also override certain component's colors and opacity by toggling the checkbox, use \"Clear all\" button to clear all overrides.", "theme_help_v2_2": "Icons underneath some entries are background/text contrast indicators, hover over for detailed info. Please keep in mind that when using transparency contrast indicators show the worst possible case.", "tooltipRadius": "Tooltips/alerts", + "upload_a_photo": "Upload a photo", "user_settings": "User Settings", "values": { "false": "no", @@ -332,7 +357,8 @@ "repeated": "repeated", "show_new": "Show new", "up_to_date": "Up-to-date", - "no_more_statuses": "No more statuses" + "no_more_statuses": "No more statuses", + "no_statuses": "No statuses" }, "user_card": { "approve": "Approve", @@ -344,7 +370,7 @@ "follow_sent": "Request sent!", "follow_progress": "Requesting…", "follow_again": "Send request again?", - "follow_unfollow": "Stop following", + "follow_unfollow": "Unfollow", "followees": "Following", "followers": "Followers", "following": "Following!", @@ -355,7 +381,13 @@ "muted": "Muted", "per_day": "per day", "remote_follow": "Remote follow", - "statuses": "Statuses" + "statuses": "Statuses", + "unblock": "Unblock", + "unblock_progress": "Unblocking...", + "block_progress": "Blocking...", + "unmute": "Unmute", + "unmute_progress": "Unmuting...", + "mute_progress": "Muting..." }, "user_profile": { "timeline_title": "User Timeline" diff --git a/src/i18n/es.json b/src/i18n/es.json index d14e7a31..167e8c42 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -171,6 +171,11 @@ "show_admin_badge": "Mostrar la placa de administrador en mi perfil", "show_moderator_badge": "Mostrar la placa de moderador en mi perfil", "nsfw_clickthrough": "Activar el clic para ocultar los adjuntos NSFW", + "oauth_tokens": "Tokens de OAuth", + "token": "Token", + "refresh_token": "Actualizar el token", + "valid_until": "Válido hasta", + "revoke_token": "Revocar", "panelRadius": "Paneles", "pause_on_unfocused": "Parar la transmisión cuando no estés en foco.", "presets": "Por defecto", diff --git a/src/i18n/fi.json b/src/i18n/fi.json index 5a0c1ea8..c7a25fe1 100644 --- a/src/i18n/fi.json +++ b/src/i18n/fi.json @@ -133,6 +133,7 @@ "general": "Yleinen", "hide_attachments_in_convo": "Piilota liitteet keskusteluissa", "hide_attachments_in_tl": "Piilota liitteet aikajanalla", + "max_thumbnails": "Suurin sallittu määrä liitteitä esikatselussa", "hide_isp": "Piilota palvelimenkohtainen ruutu", "preload_images": "Esilataa kuvat", "use_one_click_nsfw": "Avaa NSFW-liitteet yhdellä painalluksella", @@ -165,6 +166,11 @@ "no_rich_text_description": "Älä näytä tekstin muotoilua.", "hide_network_description": "Älä näytä seurauksiani tai seuraajiani", "nsfw_clickthrough": "Piilota NSFW liitteet klikkauksen taakse", + "oauth_tokens": "OAuth-merkit", + "token": "Token", + "refresh_token": "Päivitä token", + "valid_until": "Voimassa asti", + "revoke_token": "Peruuttaa", "panelRadius": "Ruudut", "pause_on_unfocused": "Pysäytä automaattinen viestien näyttö välilehden ollessa pois fokuksesta", "presets": "Valmiit teemat", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 129b7d7c..1209556a 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -137,6 +137,11 @@ "notification_visibility_mentions": "Mentionnés", "notification_visibility_repeats": "Partages", "nsfw_clickthrough": "Masquer les images marquées comme contenu adulte ou sensible", + "oauth_tokens": "Jetons OAuth", + "token": "Jeton", + "refresh_token": "Refresh Token", + "valid_until": "Valable jusque", + "revoke_token": "Révoquer", "panelRadius": "Fenêtres", "pause_on_unfocused": "Suspendre le streaming lorsque l'onglet n'est pas centré", "presets": "Thèmes prédéfinis", diff --git a/src/i18n/ga.json b/src/i18n/ga.json index 64461202..5be9297a 100644 --- a/src/i18n/ga.json +++ b/src/i18n/ga.json @@ -134,6 +134,11 @@ "notification_visibility_repeats": "Atphostáil", "no_rich_text_description": "Bain formáidiú téacs saibhir ó gach post", "nsfw_clickthrough": "Cumasaigh an ceangaltán NSFW cliceáil ar an gcnaipe", + "oauth_tokens": "Tocanna OAuth", + "token": "Token", + "refresh_token": "Athnuachan Comórtas", + "valid_until": "Bailí Go dtí", + "revoke_token": "Athghairm", "panelRadius": "Painéil", "pause_on_unfocused": "Sruthú ar sos nuair a bhíonn an fócas caillte", "presets": "Réamhshocruithe", diff --git a/src/i18n/he.json b/src/i18n/he.json index 99ae9551..213e6170 100644 --- a/src/i18n/he.json +++ b/src/i18n/he.json @@ -129,6 +129,11 @@ "notification_visibility_mentions": "אזכורים", "notification_visibility_repeats": "חזרות", "nsfw_clickthrough": "החל החבאת צירופים לא בטוחים לצפיה בעת עבודה בעזרת לחיצת עכבר", + "oauth_tokens": "אסימוני OAuth", + "token": "אסימון", + "refresh_token": "רענון האסימון", + "valid_until": "בתוקף עד", + "revoke_token": "בטל", "panelRadius": "פאנלים", "pause_on_unfocused": "השהה זרימת הודעות כשהחלון לא בפוקוס", "presets": "ערכים קבועים מראש", diff --git a/src/i18n/it.json b/src/i18n/it.json index 8f69e7c1..385d21aa 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -93,6 +93,11 @@ "notification_visibility_mentions": "Menzioni", "notification_visibility_repeats": "Condivisioni", "no_rich_text_description": "Togli la formattazione del testo da tutti i post", + "oauth_tokens": "Token OAuth", + "token": "Token", + "refresh_token": "Aggiorna token", + "valid_until": "Valido fino a", + "revoke_token": "Revocare", "panelRadius": "Pannelli", "pause_on_unfocused": "Metti in pausa l'aggiornamento continuo quando la scheda non è in primo piano", "presets": "Valori predefiniti", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 7849aa20..b51fa7fd 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -171,6 +171,11 @@ "show_admin_badge": "アドミンのしるしをみる", "show_moderator_badge": "モデレーターのしるしをみる", "nsfw_clickthrough": "NSFWなファイルをかくす", + "oauth_tokens": "OAuthトークン", + "token": "トークン", + "refresh_token": "トークンを更新", + "valid_until": "まで有効", + "revoke_token": "取り消す", "panelRadius": "パネル", "pause_on_unfocused": "タブにフォーカスがないときストリーミングをとめる", "presets": "プリセット", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index f9e4dfa3..336e464f 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -159,6 +159,11 @@ "hide_follows_description": "내가 팔로우하는 사람을 표시하지 않음", "hide_followers_description": "나를 따르는 사람을 보여주지 마라.", "nsfw_clickthrough": "NSFW 이미지 \"클릭해서 보이기\"를 활성화", + "oauth_tokens": "OAuth 토큰", + "token": "토큰", + "refresh_token": "토큰 새로 고침", + "valid_until": "까지 유효하다", + "revoke_token": "취소", "panelRadius": "패널", "pause_on_unfocused": "탭이 활성 상태가 아닐 때 스트리밍 멈추기", "presets": "프리셋", diff --git a/src/i18n/nb.json b/src/i18n/nb.json index 0f4dca58..39e054f7 100644 --- a/src/i18n/nb.json +++ b/src/i18n/nb.json @@ -132,6 +132,11 @@ "notification_visibility_repeats": "Gjentakelser", "no_rich_text_description": "Fjern all formatering fra statuser", "nsfw_clickthrough": "Krev trykk for å vise statuser som kan være upassende", + "oauth_tokens": "OAuth Tokens", + "token": "Pollett", + "refresh_token": "Refresh Token", + "valid_until": "Gyldig til", + "revoke_token": "Tilbakekall", "panelRadius": "Panel", "pause_on_unfocused": "Stopp henting av poster når vinduet ikke er i fokus", "presets": "Forhåndsdefinerte tema", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index bb388a90..799e22b9 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -159,6 +159,11 @@ "no_rich_text_description": "Strip rich text formattering van alle posts", "hide_network_description": "Toon niet wie mij volgt en wie ik volg.", "nsfw_clickthrough": "Schakel doorklikbaar verbergen van NSFW bijlages in", + "oauth_tokens": "OAuth-tokens", + "token": "Token", + "refresh_token": "Token vernieuwen", + "valid_until": "Geldig tot", + "revoke_token": "Intrekken", "panelRadius": "Panelen", "pause_on_unfocused": "Pauzeer streamen wanneer de tab niet gefocused is", "presets": "Presets", diff --git a/src/i18n/oc.json b/src/i18n/oc.json index 2ce666c6..db66bb98 100644 --- a/src/i18n/oc.json +++ b/src/i18n/oc.json @@ -142,6 +142,7 @@ "notification_visibility_mentions": "Mencions", "notification_visibility_repeats": "Repeticions", "no_rich_text_description": "Netejar lo format tèxte de totas las publicacions", + "oauth_tokens": "Llistats OAuth", "pause_on_unfocused": "Pausar la difusion quand l’onglet es pas seleccionat", "profile_tab": "Perfil", "replies_in_timeline": "Responsas del flux", diff --git a/src/i18n/pl.json b/src/i18n/pl.json index a3952d4f..2e1d7488 100644 --- a/src/i18n/pl.json +++ b/src/i18n/pl.json @@ -86,6 +86,11 @@ "name_bio": "Imię i bio", "new_password": "Nowe hasło", "nsfw_clickthrough": "Włącz domyślne ukrywanie załączników o treści nieprzyzwoitej (NSFW)", + "oauth_tokens": "Tokeny OAuth", + "token": "Token", + "refresh_token": "Odśwież token", + "valid_until": "Ważne do", + "revoke_token": "Odwołać", "panelRadius": "Panele", "presets": "Gotowe motywy", "profile_background": "Tło profilu", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 4b0bd4b4..6799cc96 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -132,6 +132,11 @@ "show_admin_badge": "Показывать значок администратора в моем профиле", "show_moderator_badge": "Показывать значок модератора в моем профиле", "nsfw_clickthrough": "Включить скрытие NSFW вложений", + "oauth_tokens": "OAuth токены", + "token": "Токен", + "refresh_token": "Рефреш токен", + "valid_until": "Годен до", + "revoke_token": "Удалить", "panelRadius": "Панели", "pause_on_unfocused": "Приостановить загрузку когда вкладка не в фокусе", "presets": "Пресеты", diff --git a/src/i18n/zh.json b/src/i18n/zh.json index 7ad23c57..089a98e2 100644 --- a/src/i18n/zh.json +++ b/src/i18n/zh.json @@ -134,6 +134,11 @@ "notification_visibility_repeats": "转发", "no_rich_text_description": "不显示富文本格式", "nsfw_clickthrough": "将不和谐附件隐藏,点击才能打开", + "oauth_tokens": "OAuth令牌", + "token": "代币", + "refresh_token": "刷新令牌", + "valid_until": "有效期至", + "revoke_token": "撤消", "panelRadius": "面板", "pause_on_unfocused": "在离开页面时暂停时间线推送", "presets": "预置", diff --git a/src/lib/persisted_state.js b/src/lib/persisted_state.js index 6f7202ce..e828a74b 100644 --- a/src/lib/persisted_state.js +++ b/src/lib/persisted_state.js @@ -84,12 +84,12 @@ export default function createPersistedState ({ setState(key, reducer(state, paths), storage) .then(success => { if (typeof success !== 'undefined') { - if (mutation.type === 'setOption') { + if (mutation.type === 'setOption' || mutation.type === 'setCurrentUser') { store.dispatch('settingsSaved', { success }) } } }, error => { - if (mutation.type === 'setOption') { + if (mutation.type === 'setOption' || mutation.type === 'setCurrentUser') { store.dispatch('settingsSaved', { error }) } }) diff --git a/src/main.js b/src/main.js index adeb0550..2844194e 100644 --- a/src/main.js +++ b/src/main.js @@ -11,6 +11,7 @@ import configModule from './modules/config.js' 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 VueTimeago from 'vue-timeago' import VueI18n from 'vue-i18n' @@ -64,7 +65,8 @@ createPersistedState(persistedStateOptions).then((persistedState) => { config: configModule, chat: chatModule, oauth: oauthModule, - mediaViewer: mediaViewerModule + mediaViewer: mediaViewerModule, + oauthTokens: oauthTokensModule }, 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 71f71376..1c30c203 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -8,6 +8,7 @@ const defaultState = { collapseMessageWithSubject: undefined, // instance default hideAttachments: false, hideAttachmentsInConv: false, + maxThumbnails: 16, hideNsfw: true, preloadImage: true, loopVideo: true, diff --git a/src/modules/instance.js b/src/modules/instance.js index 59c6b91c..c31d02b9 100644 --- a/src/modules/instance.js +++ b/src/modules/instance.js @@ -21,7 +21,7 @@ const defaultState = { collapseMessageWithSubject: false, hidePostStats: false, hideUserStats: false, - hideFilteredStatuses: true, + hideFilteredStatuses: false, disableChat: false, scopeCopy: true, subjectLineBehavior: 'email', diff --git a/src/modules/oauth_tokens.js b/src/modules/oauth_tokens.js new file mode 100644 index 00000000..00ac1431 --- /dev/null +++ b/src/modules/oauth_tokens.js @@ -0,0 +1,26 @@ +const oauthTokens = { + state: { + tokens: [] + }, + actions: { + fetchTokens ({rootState, commit}) { + rootState.api.backendInteractor.fetchOAuthTokens().then((tokens) => { + commit('swapTokens', tokens) + }) + }, + revokeToken ({rootState, commit, state}, id) { + rootState.api.backendInteractor.revokeOAuthToken(id).then((response) => { + if (response.status === 201) { + commit('swapTokens', state.tokens.filter(token => token.id !== id)) + } + }) + } + }, + mutations: { + swapTokens (state, tokens) { + state.tokens = tokens + } + } +} + +export default oauthTokens diff --git a/src/modules/statuses.js b/src/modules/statuses.js index 46117fd7..826b544c 100644 --- a/src/modules/statuses.js +++ b/src/modules/statuses.js @@ -126,7 +126,7 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us // This makes sure that user timeline won't get data meant for other // user. I.e. opening different user profiles makes request which could // return data late after user already viewing different user profile - if (timeline === 'user' && timelineObject.userId !== userId) { + if ((timeline === 'user' || timeline === 'media') && timelineObject.userId !== userId) { return } @@ -303,6 +303,8 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot setTimeout(notification.close.bind(notification), 5000) } } + } else if (notification.seen) { + state.notifications.idStore[notification.id].seen = true } }) } diff --git a/src/modules/users.js b/src/modules/users.js index 4d56ec6f..77df7168 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -85,6 +85,12 @@ export const mutations = { addNewUsers (state, users) { each(users, (user) => mergeOrAdd(state.users, state.usersObject, user)) }, + saveBlocks (state, blockIds) { + state.currentUser.blockIds = blockIds + }, + saveMutes (state, muteIds) { + state.currentUser.muteIds = muteIds + }, setUserForStatus (state, status) { status.user = state.usersObject[status.user.id] }, @@ -137,6 +143,38 @@ const users = { store.rootState.api.backendInteractor.fetchUser({ id }) .then((user) => store.commit('addNewUsers', [user])) }, + fetchBlocks (store) { + return store.rootState.api.backendInteractor.fetchBlocks() + .then((blocks) => { + store.commit('saveBlocks', map(blocks, 'id')) + store.commit('addNewUsers', blocks) + return blocks + }) + }, + blockUser (store, id) { + return store.rootState.api.backendInteractor.blockUser(id) + .then((user) => store.commit('addNewUsers', [user])) + }, + unblockUser (store, id) { + return store.rootState.api.backendInteractor.unblockUser(id) + .then((user) => store.commit('addNewUsers', [user])) + }, + fetchMutes (store) { + return store.rootState.api.backendInteractor.fetchMutes() + .then((mutedUsers) => { + each(mutedUsers, (user) => { user.muted = true }) + store.commit('addNewUsers', mutedUsers) + store.commit('saveMutes', map(mutedUsers, 'id')) + }) + }, + muteUser (store, id) { + return store.state.api.backendInteractor.setUserMute({ id, muted: true }) + .then((user) => store.commit('addNewUsers', [user])) + }, + unmuteUser (store, id) { + return store.state.api.backendInteractor.setUserMute({ id, muted: false }) + .then((user) => store.commit('addNewUsers', [user])) + }, addFriends ({ rootState, commit }, fetchBy) { return new Promise((resolve, reject) => { const user = rootState.users.usersObject[fetchBy] @@ -231,8 +269,14 @@ const users = { store.commit('setToken', result.access_token) store.dispatch('loginUser', result.access_token) } else { - let data = await response.json() - let errors = humanizeErrors(JSON.parse(data.error)) + const data = await response.json() + let errors = JSON.parse(data.error) + // replace ap_id with username + if (errors.ap_id) { + errors.username = errors.ap_id + delete errors.ap_id + } + errors = humanizeErrors(errors) store.commit('signUpFailure', errors) throw Error(errors) } @@ -257,6 +301,8 @@ const users = { const user = data // user.credentials = userCredentials user.credentials = accessToken + user.blockIds = [] + user.muteIds = [] commit('setCurrentUser', user) commit('addNewUsers', [user]) @@ -273,11 +319,8 @@ const users = { // Start getting fresh posts. store.dispatch('startFetching', { timeline: 'friends' }) - // Get user mutes and follower info - store.rootState.api.backendInteractor.fetchMutes().then((mutedUsers) => { - each(mutedUsers, (user) => { user.muted = true }) - store.commit('addNewUsers', mutedUsers) - }) + // Get user mutes + store.dispatch('fetchMutes') // Fetch our friends store.rootState.api.backendInteractor.fetchFriends({ id: user.id }) diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 13d31d91..7b04343d 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -18,6 +18,7 @@ const MENTIONS_URL = '/api/statuses/mentions.json' const DM_TIMELINE_URL = '/api/statuses/dm_timeline.json' const FOLLOWERS_URL = '/api/statuses/followers.json' const FRIENDS_URL = '/api/statuses/friends.json' +const BLOCKS_URL = '/api/statuses/blocks.json' const FOLLOWING_URL = '/api/friendships/create.json' const UNFOLLOWING_URL = '/api/friendships/destroy.json' const QVITTER_USER_PREF_URL = '/api/qvitter/set_profile_pref.json' @@ -519,6 +520,34 @@ const fetchMutes = ({credentials}) => { }).then((data) => data.json()) } +const fetchBlocks = ({page, credentials}) => { + return fetch(BLOCKS_URL, { + headers: authHeaders(credentials) + }).then((data) => { + if (data.ok) { + return data.json() + } + throw new Error('Error fetching blocks', data) + }) +} + +const fetchOAuthTokens = ({credentials}) => { + const url = '/api/oauth_tokens.json' + + return fetch(url, { + headers: authHeaders(credentials) + }).then((data) => data.json()) +} + +const revokeOAuthToken = ({id, credentials}) => { + const url = `/api/oauth_tokens/${id}` + + return fetch(url, { + headers: authHeaders(credentials), + method: 'DELETE' + }) +} + const suggestions = ({credentials}) => { return fetch(SUGGESTIONS_URL, { headers: authHeaders(credentials) @@ -560,6 +589,9 @@ const apiService = { fetchAllFollowing, setUserMute, fetchMutes, + fetchBlocks, + fetchOAuthTokens, + revokeOAuthToken, register, getCaptcha, updateAvatar, diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js index 80c5cc5e..2278cd45 100644 --- a/src/services/backend_interactor_service/backend_interactor_service.js +++ b/src/services/backend_interactor_service/backend_interactor_service.js @@ -63,7 +63,10 @@ const backendInteractorService = (credentials) => { } const fetchMutes = () => apiService.fetchMutes({credentials}) + const fetchBlocks = (params) => apiService.fetchBlocks({credentials, ...params}) const fetchFollowRequests = () => apiService.fetchFollowRequests({credentials}) + const fetchOAuthTokens = () => apiService.fetchOAuthTokens({credentials}) + const revokeOAuthToken = (id) => apiService.revokeOAuthToken({id, credentials}) const getCaptcha = () => apiService.getCaptcha() const register = (params) => apiService.register(params) @@ -94,6 +97,9 @@ const backendInteractorService = (credentials) => { startFetching, setUserMute, fetchMutes, + fetchBlocks, + fetchOAuthTokens, + revokeOAuthToken, register, getCaptcha, updateAvatar, diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index 828c48f9..d20ce77f 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -117,6 +117,9 @@ export const parseUser = (data) => { output.statuses_count = data.statuses_count output.friends = [] output.followers = [] + if (data.pleroma) { + output.follow_request_count = data.pleroma.follow_request_count + } return output } diff --git a/src/services/notifications_fetcher/notifications_fetcher.service.js b/src/services/notifications_fetcher/notifications_fetcher.service.js index b69ec643..3ecdae6a 100644 --- a/src/services/notifications_fetcher/notifications_fetcher.service.js +++ b/src/services/notifications_fetcher/notifications_fetcher.service.js @@ -16,7 +16,17 @@ const fetchAndUpdate = ({store, credentials, older = false}) => { args['until'] = timelineData.minId } } else { - args['since'] = timelineData.maxId + // load unread notifications repeadedly to provide consistency between browser tabs + const notifications = timelineData.data + const unread = notifications.filter(n => !n.seen).map(n => n.id) + if (!unread.length) { + args['since'] = timelineData.maxId + } else { + args['since'] = Math.min(...unread) - 1 + if (timelineData.maxId !== Math.max(...unread)) { + args['until'] = Math.max(...unread, args['since'] + 20) + } + } } args['timeline'] = 'notifications' |
