aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.scss11
-rw-r--r--src/components/basic_user_card/basic_user_card.js28
-rw-r--r--src/components/basic_user_card/basic_user_card.vue92
-rw-r--r--src/components/block_card/block_card.js37
-rw-r--r--src/components/block_card/block_card.vue24
-rw-r--r--src/components/chat_panel/chat_panel.vue11
-rw-r--r--src/components/follow_list/follow_list.js7
-rw-r--r--src/components/follow_list/follow_list.vue3
-rw-r--r--src/components/image_cropper/image_cropper.js128
-rw-r--r--src/components/image_cropper/image_cropper.vue42
-rw-r--r--src/components/media_modal/media_modal.js51
-rw-r--r--src/components/media_modal/media_modal.vue81
-rw-r--r--src/components/mute_card/mute_card.js37
-rw-r--r--src/components/mute_card/mute_card.vue24
-rw-r--r--src/components/nav_panel/nav_panel.vue11
-rw-r--r--src/components/notifications/notifications.scss3
-rw-r--r--src/components/post_status_form/post_status_form.js12
-rw-r--r--src/components/post_status_form/post_status_form.vue10
-rw-r--r--src/components/registration/registration.vue12
-rw-r--r--src/components/settings/settings.js8
-rw-r--r--src/components/settings/settings.vue24
-rw-r--r--src/components/side_drawer/side_drawer.vue4
-rw-r--r--src/components/status/status.js8
-rw-r--r--src/components/status/status.vue2
-rw-r--r--src/components/timeline/timeline.js5
-rw-r--r--src/components/timeline/timeline.vue5
-rw-r--r--src/components/user_card/user_card.js7
-rw-r--r--src/components/user_card/user_card.vue102
-rw-r--r--src/components/user_card_content/user_card_content.vue4
-rw-r--r--src/components/user_finder/user_finder.js1
-rw-r--r--src/components/user_finder/user_finder.vue2
-rw-r--r--src/components/user_profile/user_profile.vue1
-rw-r--r--src/components/user_search/user_search.js6
-rw-r--r--src/components/user_search/user_search.vue11
-rw-r--r--src/components/user_settings/user_settings.js85
-rw-r--r--src/components/user_settings/user_settings.vue82
-rw-r--r--src/hocs/with_list/with_list.js40
-rw-r--r--src/hocs/with_list/with_list.scss6
-rw-r--r--src/hocs/with_load_more/with_load_more.js91
-rw-r--r--src/hocs/with_load_more/with_load_more.scss10
-rw-r--r--src/hocs/with_subscription/with_subscription.js84
-rw-r--r--src/hocs/with_subscription/with_subscription.scss10
-rw-r--r--src/i18n/ar.json5
-rw-r--r--src/i18n/ca.json5
-rw-r--r--src/i18n/de.json5
-rw-r--r--src/i18n/en.json40
-rw-r--r--src/i18n/es.json5
-rw-r--r--src/i18n/fi.json6
-rw-r--r--src/i18n/fr.json5
-rw-r--r--src/i18n/ga.json5
-rw-r--r--src/i18n/he.json5
-rw-r--r--src/i18n/it.json5
-rw-r--r--src/i18n/ja.json5
-rw-r--r--src/i18n/ko.json5
-rw-r--r--src/i18n/nb.json5
-rw-r--r--src/i18n/nl.json5
-rw-r--r--src/i18n/oc.json1
-rw-r--r--src/i18n/pl.json5
-rw-r--r--src/i18n/ru.json5
-rw-r--r--src/i18n/zh.json5
-rw-r--r--src/lib/persisted_state.js4
-rw-r--r--src/main.js4
-rw-r--r--src/modules/config.js1
-rw-r--r--src/modules/instance.js2
-rw-r--r--src/modules/oauth_tokens.js26
-rw-r--r--src/modules/statuses.js4
-rw-r--r--src/modules/users.js57
-rw-r--r--src/services/api/api.service.js32
-rw-r--r--src/services/backend_interactor_service/backend_interactor_service.js6
-rw-r--r--src/services/entity_normalizer/entity_normalizer.service.js3
-rw-r--r--src/services/notifications_fetcher/notifications_fetcher.service.js12
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'