aboutsummaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/auth_form/auth_form.js26
-rw-r--r--src/components/autosuggest/autosuggest.js52
-rw-r--r--src/components/autosuggest/autosuggest.vue45
-rw-r--r--src/components/avatar_list/avatar_list.js21
-rw-r--r--src/components/avatar_list/avatar_list.vue38
-rw-r--r--src/components/basic_user_card/basic_user_card.vue31
-rw-r--r--src/components/checkbox/checkbox.vue75
-rw-r--r--src/components/conversation/conversation.js37
-rw-r--r--src/components/conversation/conversation.vue5
-rw-r--r--src/components/delete_button/delete_button.js17
-rw-r--r--src/components/delete_button/delete_button.vue21
-rw-r--r--src/components/dialog_modal/dialog_modal.js14
-rw-r--r--src/components/dialog_modal/dialog_modal.vue94
-rw-r--r--src/components/exporter/exporter.js48
-rw-r--r--src/components/exporter/exporter.vue20
-rw-r--r--src/components/extra_buttons/extra_buttons.js64
-rw-r--r--src/components/extra_buttons/extra_buttons.vue48
-rw-r--r--src/components/features_panel/features_panel.js2
-rw-r--r--src/components/features_panel/features_panel.vue2
-rw-r--r--src/components/follow_card/follow_card.js15
-rw-r--r--src/components/follow_card/follow_card.vue60
-rw-r--r--src/components/follow_requests/follow_requests.vue2
-rw-r--r--src/components/image_cropper/image_cropper.js16
-rw-r--r--src/components/image_cropper/image_cropper.vue4
-rw-r--r--src/components/importer/importer.js53
-rw-r--r--src/components/importer/importer.vue28
-rw-r--r--src/components/interactions/interactions.js25
-rw-r--r--src/components/interactions/interactions.vue25
-rw-r--r--src/components/interface_language_switcher/interface_language_switcher.vue13
-rw-r--r--src/components/list/list.vue42
-rw-r--r--src/components/login_form/login_form.js72
-rw-r--r--src/components/login_form/login_form.vue97
-rw-r--r--src/components/media_modal/media_modal.vue2
-rw-r--r--src/components/mentions/mentions.vue2
-rw-r--r--src/components/mfa_form/recovery_form.js41
-rw-r--r--src/components/mfa_form/recovery_form.vue42
-rw-r--r--src/components/mfa_form/totp_form.js40
-rw-r--r--src/components/mfa_form/totp_form.vue45
-rw-r--r--src/components/mobile_nav/mobile_nav.js82
-rw-r--r--src/components/mobile_nav/mobile_nav.vue144
-rw-r--r--src/components/mobile_post_status_modal/mobile_post_status_modal.js59
-rw-r--r--src/components/mobile_post_status_modal/mobile_post_status_modal.vue2
-rw-r--r--src/components/moderation_tools/moderation_tools.js106
-rw-r--r--src/components/moderation_tools/moderation_tools.vue110
-rw-r--r--src/components/nav_panel/nav_panel.vue4
-rw-r--r--src/components/notification/notification.js16
-rw-r--r--src/components/notification/notification.vue34
-rw-r--r--src/components/notifications/notifications.js25
-rw-r--r--src/components/notifications/notifications.scss6
-rw-r--r--src/components/notifications/notifications.vue12
-rw-r--r--src/components/oauth_callback/oauth_callback.js6
-rw-r--r--src/components/popper/popper.scss127
-rw-r--r--src/components/post_status_form/post_status_form.js34
-rw-r--r--src/components/post_status_form/post_status_form.vue46
-rw-r--r--src/components/progress_button/progress_button.vue35
-rw-r--r--src/components/public_and_external_timeline/public_and_external_timeline.js2
-rw-r--r--src/components/public_timeline/public_timeline.js2
-rw-r--r--src/components/scope_selector/scope_selector.js54
-rw-r--r--src/components/scope_selector/scope_selector.vue46
-rw-r--r--src/components/selectable_list/selectable_list.js66
-rw-r--r--src/components/selectable_list/selectable_list.vue63
-rw-r--r--src/components/settings/settings.js14
-rw-r--r--src/components/settings/settings.vue81
-rw-r--r--src/components/side_drawer/side_drawer.vue8
-rw-r--r--src/components/status/status.js49
-rw-r--r--src/components/status/status.vue138
-rw-r--r--src/components/tab_switcher/tab_switcher.js13
-rw-r--r--src/components/tag_timeline/tag_timeline.js4
-rw-r--r--src/components/timeline/timeline.js2
-rw-r--r--src/components/timeline/timeline.vue2
-rw-r--r--src/components/user_avatar/user_avatar.js2
-rw-r--r--src/components/user_avatar/user_avatar.vue4
-rw-r--r--src/components/user_card/user_card.js16
-rw-r--r--src/components/user_card/user_card.vue55
-rw-r--r--src/components/user_panel/user_panel.js8
-rw-r--r--src/components/user_panel/user_panel.vue11
-rw-r--r--src/components/user_profile/user_profile.js140
-rw-r--r--src/components/user_profile/user_profile.vue46
-rw-r--r--src/components/user_reporting_modal/user_reporting_modal.js106
-rw-r--r--src/components/user_reporting_modal/user_reporting_modal.vue157
-rw-r--r--src/components/user_search/user_search.js14
-rw-r--r--src/components/user_search/user_search.vue2
-rw-r--r--src/components/user_settings/confirm.js9
-rw-r--r--src/components/user_settings/confirm.vue14
-rw-r--r--src/components/user_settings/mfa.js152
-rw-r--r--src/components/user_settings/mfa.vue121
-rw-r--r--src/components/user_settings/mfa_backup_codes.js17
-rw-r--r--src/components/user_settings/mfa_backup_codes.vue22
-rw-r--r--src/components/user_settings/mfa_totp.js49
-rw-r--r--src/components/user_settings/mfa_totp.vue23
-rw-r--r--src/components/user_settings/user_settings.js256
-rw-r--r--src/components/user_settings/user_settings.vue142
-rw-r--r--src/components/who_to_follow/who_to_follow.js3
-rw-r--r--src/components/who_to_follow/who_to_follow.vue2
-rw-r--r--src/components/who_to_follow_panel/who_to_follow_panel.vue24
95 files changed, 3375 insertions, 696 deletions
diff --git a/src/components/auth_form/auth_form.js b/src/components/auth_form/auth_form.js
new file mode 100644
index 00000000..e9a6e2d5
--- /dev/null
+++ b/src/components/auth_form/auth_form.js
@@ -0,0 +1,26 @@
+import LoginForm from '../login_form/login_form.vue'
+import MFARecoveryForm from '../mfa_form/recovery_form.vue'
+import MFATOTPForm from '../mfa_form/totp_form.vue'
+import { mapGetters } from 'vuex'
+
+const AuthForm = {
+ name: 'AuthForm',
+ render (createElement) {
+ return createElement('component', { is: this.authForm })
+ },
+ computed: {
+ authForm () {
+ if (this.requiredTOTP) { return 'MFATOTPForm' }
+ if (this.requiredRecovery) { return 'MFARecoveryForm' }
+ return 'LoginForm'
+ },
+ ...mapGetters('authFlow', ['requiredTOTP', 'requiredRecovery'])
+ },
+ components: {
+ MFARecoveryForm,
+ MFATOTPForm,
+ LoginForm
+ }
+}
+
+export default AuthForm
diff --git a/src/components/autosuggest/autosuggest.js b/src/components/autosuggest/autosuggest.js
new file mode 100644
index 00000000..d4efe912
--- /dev/null
+++ b/src/components/autosuggest/autosuggest.js
@@ -0,0 +1,52 @@
+const debounceMilliseconds = 500
+
+export default {
+ props: {
+ query: { // function to query results and return a promise
+ type: Function,
+ required: true
+ },
+ filter: { // function to filter results in real time
+ type: Function
+ },
+ placeholder: {
+ type: String,
+ default: 'Search...'
+ }
+ },
+ data () {
+ return {
+ term: '',
+ timeout: null,
+ results: [],
+ resultsVisible: false
+ }
+ },
+ computed: {
+ filtered () {
+ return this.filter ? this.filter(this.results) : this.results
+ }
+ },
+ watch: {
+ term (val) {
+ this.fetchResults(val)
+ }
+ },
+ methods: {
+ fetchResults (term) {
+ clearTimeout(this.timeout)
+ this.timeout = setTimeout(() => {
+ this.results = []
+ if (term) {
+ this.query(term).then((results) => { this.results = results })
+ }
+ }, debounceMilliseconds)
+ },
+ onInputClick () {
+ this.resultsVisible = true
+ },
+ onClickOutside () {
+ this.resultsVisible = false
+ }
+ }
+}
diff --git a/src/components/autosuggest/autosuggest.vue b/src/components/autosuggest/autosuggest.vue
new file mode 100644
index 00000000..91657a2d
--- /dev/null
+++ b/src/components/autosuggest/autosuggest.vue
@@ -0,0 +1,45 @@
+<template>
+ <div class="autosuggest" v-click-outside="onClickOutside">
+ <input v-model="term" :placeholder="placeholder" @click="onInputClick" class="autosuggest-input" />
+ <div class="autosuggest-results" v-if="resultsVisible && filtered.length > 0">
+ <slot v-for="item in filtered" :item="item" />
+ </div>
+ </div>
+</template>
+
+<script src="./autosuggest.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.autosuggest {
+ position: relative;
+
+ &-input {
+ display: block;
+ width: 100%;
+ }
+
+ &-results {
+ position: absolute;
+ left: 0;
+ top: 100%;
+ right: 0;
+ max-height: 400px;
+ background-color: $fallback--lightBg;
+ background-color: var(--lightBg, $fallback--lightBg);
+ border-style: solid;
+ border-width: 1px;
+ border-color: $fallback--border;
+ border-color: var(--border, $fallback--border);
+ border-radius: $fallback--inputRadius;
+ border-radius: var(--inputRadius, $fallback--inputRadius);
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6);
+ box-shadow: var(--panelShadow);
+ overflow-y: auto;
+ z-index: 1;
+ }
+}
+</style>
diff --git a/src/components/avatar_list/avatar_list.js b/src/components/avatar_list/avatar_list.js
new file mode 100644
index 00000000..9b6301b2
--- /dev/null
+++ b/src/components/avatar_list/avatar_list.js
@@ -0,0 +1,21 @@
+import UserAvatar from '../user_avatar/user_avatar.vue'
+import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+
+const AvatarList = {
+ props: ['users'],
+ computed: {
+ slicedUsers () {
+ return this.users ? this.users.slice(0, 15) : []
+ }
+ },
+ components: {
+ UserAvatar
+ },
+ methods: {
+ userProfileLink (user) {
+ return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
+ }
+ }
+}
+
+export default AvatarList
diff --git a/src/components/avatar_list/avatar_list.vue b/src/components/avatar_list/avatar_list.vue
new file mode 100644
index 00000000..c0238570
--- /dev/null
+++ b/src/components/avatar_list/avatar_list.vue
@@ -0,0 +1,38 @@
+<template>
+ <div class="avatars">
+ <router-link :to="userProfileLink(user)" class="avatars-item" v-for="user in slicedUsers">
+ <UserAvatar :user="user" class="avatar-small" />
+ </router-link>
+ </div>
+</template>
+
+<script src="./avatar_list.js" ></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.avatars {
+ display: flex;
+ margin: 0;
+ padding: 0;
+
+ // For hiding overflowing elements
+ flex-wrap: wrap;
+ height: 24px;
+
+ .avatars-item {
+ margin: 0 0 5px 5px;
+
+ &:first-child {
+ padding-left: 5px;
+ }
+
+ .avatar-small {
+ border-radius: $fallback--avatarAltRadius;
+ border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+ height: 24px;
+ width: 24px;
+ }
+ }
+}
+</style>
diff --git a/src/components/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue
index 8afe8b44..634d62b3 100644
--- a/src/components/basic_user_card/basic_user_card.vue
+++ b/src/components/basic_user_card/basic_user_card.vue
@@ -1,7 +1,11 @@
<template>
<div class="basic-user-card">
<router-link :to="userProfileLink(user)">
- <UserAvatar class="avatar" @click.prevent.native="toggleUserExpanded" :src="user.profile_image_url"/>
+ <UserAvatar
+ class="avatar"
+ :user="user"
+ @click.prevent.native="toggleUserExpanded"
+ />
</router-link>
<div class="basic-user-card-expanded-content" v-if="userExpanded">
<UserCard :user="user" :rounded="true" :bordered="true"/>
@@ -24,19 +28,11 @@
<script src="./basic_user_card.js"></script>
<style lang="scss">
-@import '../../_variables.scss';
-
.basic-user-card {
display: flex;
flex: 1 0;
margin: 0;
- padding-top: 0.6em;
- padding-right: 1em;
- padding-bottom: 0.6em;
- padding-left: 1em;
- border-bottom: 1px solid;
- border-bottom-color: $fallback--border;
- border-bottom-color: var(--border, $fallback--border);
+ padding: 0.6em 1em;
&-collapsed-content {
margin-left: 0.7em;
@@ -52,14 +48,15 @@
width: 16px;
vertical-align: middle;
}
+ }
- &-value {
- display: inline-block;
- max-width: 100%;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
+ &-user-name-value,
+ &-screen-name {
+ display: inline-block;
+ max-width: 100%;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
}
&-expanded-content {
diff --git a/src/components/checkbox/checkbox.vue b/src/components/checkbox/checkbox.vue
new file mode 100644
index 00000000..4152b049
--- /dev/null
+++ b/src/components/checkbox/checkbox.vue
@@ -0,0 +1,75 @@
+<template>
+ <label class="checkbox">
+ <input type="checkbox" :checked="checked" @change="$emit('change', $event.target.checked)" :indeterminate.prop="indeterminate">
+ <i class="checkbox-indicator" />
+ <span v-if="!!$slots.default"><slot></slot></span>
+ </label>
+</template>
+
+<script>
+export default {
+ model: {
+ prop: 'checked',
+ event: 'change'
+ },
+ props: ['checked', 'indeterminate']
+}
+</script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.checkbox {
+ position: relative;
+ display: inline-block;
+ padding-left: 1.2em;
+ min-height: 1.2em;
+
+ &-indicator::before {
+ position: absolute;
+ left: 0;
+ top: 0;
+ display: block;
+ content: '✔';
+ transition: color 200ms;
+ width: 1.1em;
+ height: 1.1em;
+ border-radius: $fallback--checkboxRadius;
+ border-radius: var(--checkboxRadius, $fallback--checkboxRadius);
+ box-shadow: 0px 0px 2px black inset;
+ box-shadow: var(--inputShadow);
+ background-color: $fallback--fg;
+ background-color: var(--input, $fallback--fg);
+ vertical-align: top;
+ text-align: center;
+ line-height: 1.1em;
+ font-size: 1.1em;
+ color: transparent;
+ overflow: hidden;
+ box-sizing: border-box;
+ }
+
+ input[type=checkbox] {
+ display: none;
+
+ &:checked + .checkbox-indicator::before {
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ }
+
+ &:indeterminate + .checkbox-indicator::before {
+ content: '–';
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ }
+
+ &:disabled + .checkbox-indicator::before {
+ opacity: .5;
+ }
+ }
+
+ & > span {
+ margin-left: .5em;
+ }
+}
+</style>
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index 69058bf6..b3074590 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,5 +1,4 @@
-import { reduce, filter, findIndex } from 'lodash'
-import { set } from 'vue'
+import { reduce, filter, findIndex, clone } from 'lodash'
import Status from '../status/status.vue'
const sortById = (a, b) => {
@@ -36,14 +35,14 @@ const conversation = {
data () {
return {
highlight: null,
- expanded: false,
- converationStatusIds: []
+ expanded: false
}
},
props: [
'statusoid',
'collapsable',
- 'isPage'
+ 'isPage',
+ 'showPinned'
],
created () {
if (this.isPage) {
@@ -54,15 +53,6 @@ const conversation = {
status () {
return this.statusoid
},
- idsToShow () {
- if (this.converationStatusIds.length > 0) {
- return this.converationStatusIds
- } else if (this.statusId) {
- return [this.statusId]
- } else {
- return []
- }
- },
statusId () {
if (this.statusoid.retweeted_status) {
return this.statusoid.retweeted_status.id
@@ -70,6 +60,13 @@ const conversation = {
return this.statusoid.id
}
},
+ conversationId () {
+ if (this.statusoid.retweeted_status) {
+ return this.statusoid.retweeted_status.statusnet_conversation_id
+ } else {
+ return this.statusoid.statusnet_conversation_id
+ }
+ },
conversation () {
if (!this.status) {
return []
@@ -79,12 +76,7 @@ const conversation = {
return [this.status]
}
- const statusesObject = this.$store.state.statuses.allStatusesObject
- const conversation = this.idsToShow.reduce((acc, id) => {
- acc.push(statusesObject[id])
- return acc
- }, [])
-
+ const conversation = clone(this.$store.state.statuses.conversationsObject[this.conversationId])
const statusIndex = findIndex(conversation, { id: this.statusId })
if (statusIndex !== -1) {
conversation[statusIndex] = this.status
@@ -131,10 +123,6 @@ const conversation = {
.then(({ancestors, descendants}) => {
this.$store.dispatch('addNewStatuses', { statuses: ancestors })
this.$store.dispatch('addNewStatuses', { statuses: descendants })
- set(this, 'converationStatusIds', [].concat(
- ancestors.map(_ => _.id).filter(_ => _ !== this.statusId),
- this.statusId,
- descendants.map(_ => _.id).filter(_ => _ !== this.statusId)))
})
.then(() => this.setHighlight(this.statusId))
} else {
@@ -152,6 +140,7 @@ const conversation = {
},
setHighlight (id) {
this.highlight = id
+ this.$store.dispatch('fetchFavsAndRepeats', id)
},
getHighlight () {
return this.isExpanded ? this.highlight : null
diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue
index c39a3ed9..0b4998c3 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -11,9 +11,10 @@
@goto="setHighlight"
@toggleExpanded="toggleExpanded"
:key="status.id"
- :inlineExpanded="collapsable"
+ :inlineExpanded="collapsable && isExpanded"
:statusoid="status"
- :expandable='!expanded'
+ :expandable='!isExpanded'
+ :showPinned="showPinned"
:focused="focused(status.id)"
:inConversation="isExpanded"
:highlight="getHighlight()"
diff --git a/src/components/delete_button/delete_button.js b/src/components/delete_button/delete_button.js
deleted file mode 100644
index f2920666..00000000
--- a/src/components/delete_button/delete_button.js
+++ /dev/null
@@ -1,17 +0,0 @@
-const DeleteButton = {
- props: [ 'status' ],
- methods: {
- deleteStatus () {
- const confirmed = window.confirm('Do you really want to delete this status?')
- if (confirmed) {
- this.$store.dispatch('deleteStatus', { id: this.status.id })
- }
- }
- },
- computed: {
- currentUser () { return this.$store.state.users.currentUser },
- canDelete () { return this.currentUser && this.currentUser.rights.delete_others_notice || this.status.user.id === this.currentUser.id }
- }
-}
-
-export default DeleteButton
diff --git a/src/components/delete_button/delete_button.vue b/src/components/delete_button/delete_button.vue
deleted file mode 100644
index f4c91cfd..00000000
--- a/src/components/delete_button/delete_button.vue
+++ /dev/null
@@ -1,21 +0,0 @@
-<template>
- <div v-if="canDelete">
- <a href="#" v-on:click.prevent="deleteStatus()">
- <i class='button-icon icon-cancel delete-status'></i>
- </a>
- </div>
-</template>
-
-<script src="./delete_button.js" ></script>
-
-<style lang="scss">
-@import '../../_variables.scss';
-
-.icon-cancel,.delete-status {
- cursor: pointer;
- &:hover {
- color: $fallback--cRed;
- color: var(--cRed, $fallback--cRed);
- }
-}
-</style>
diff --git a/src/components/dialog_modal/dialog_modal.js b/src/components/dialog_modal/dialog_modal.js
new file mode 100644
index 00000000..f14e3fe9
--- /dev/null
+++ b/src/components/dialog_modal/dialog_modal.js
@@ -0,0 +1,14 @@
+const DialogModal = {
+ props: {
+ darkOverlay: {
+ default: true,
+ type: Boolean
+ },
+ onCancel: {
+ default: () => {},
+ type: Function
+ }
+ }
+}
+
+export default DialogModal
diff --git a/src/components/dialog_modal/dialog_modal.vue b/src/components/dialog_modal/dialog_modal.vue
new file mode 100644
index 00000000..3da543de
--- /dev/null
+++ b/src/components/dialog_modal/dialog_modal.vue
@@ -0,0 +1,94 @@
+<template>
+ <span v-bind:class="{ 'dark-overlay': darkOverlay }" @click.self.stop='onCancel()'>
+ <div class="dialog-modal panel panel-default" @click.stop=''>
+ <div class="panel-heading dialog-modal-heading">
+ <div class="title">
+ <slot name="header"></slot>
+ </div>
+ </div>
+ <div class="dialog-modal-content">
+ <slot name="default"></slot>
+ </div>
+ <div class="dialog-modal-footer user-interactions panel-footer">
+ <slot name="footer"></slot>
+ </div>
+ </div>
+ </span>
+</template>
+
+<script src="./dialog_modal.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+// TODO: unify with other modals.
+.dark-overlay {
+ &::before {
+ bottom: 0;
+ content: " ";
+ display: block;
+ cursor: default;
+ left: 0;
+ position: fixed;
+ right: 0;
+ top: 0;
+ background: rgba(27,31,35,.5);
+ z-index: 99;
+ }
+}
+
+.dialog-modal.panel {
+ top: 0;
+ left: 50%;
+ max-height: 80vh;
+ max-width: 90vw;
+ margin: 15vh auto;
+ position: fixed;
+ transform: translateX(-50%);
+ z-index: 999;
+ cursor: default;
+ display: block;
+ background-color: $fallback--bg;
+ background-color: var(--bg, $fallback--bg);
+
+ .dialog-modal-heading {
+ padding: .5em .5em;
+ margin-right: auto;
+ margin-bottom: 0;
+ white-space: nowrap;
+ color: var(--panelText);
+ background-color: $fallback--fg;
+ background-color: var(--panel, $fallback--fg);
+
+ .title {
+ margin-bottom: 0;
+ text-align: center;
+ }
+ }
+
+ .dialog-modal-content {
+ margin: 0;
+ padding: 1rem 1rem;
+ background-color: $fallback--lightBg;
+ background-color: var(--lightBg, $fallback--lightBg);
+ white-space: normal;
+ }
+
+ .dialog-modal-footer {
+ margin: 0;
+ padding: .5em .5em;
+ background-color: $fallback--lightBg;
+ background-color: var(--lightBg, $fallback--lightBg);
+ border-top: 1px solid $fallback--bg;
+ border-top: 1px solid var(--bg, $fallback--bg);
+ display: flex;
+ justify-content: flex-end;
+
+ button {
+ width: auto;
+ margin-left: .5rem;
+ }
+ }
+}
+
+</style>
diff --git a/src/components/exporter/exporter.js b/src/components/exporter/exporter.js
new file mode 100644
index 00000000..8f507416
--- /dev/null
+++ b/src/components/exporter/exporter.js
@@ -0,0 +1,48 @@
+const Exporter = {
+ props: {
+ getContent: {
+ type: Function,
+ required: true
+ },
+ filename: {
+ type: String,
+ default: 'export.csv'
+ },
+ exportButtonLabel: {
+ type: String,
+ default () {
+ return this.$t('exporter.export')
+ }
+ },
+ processingMessage: {
+ type: String,
+ default () {
+ return this.$t('exporter.processing')
+ }
+ }
+ },
+ data () {
+ return {
+ processing: false
+ }
+ },
+ methods: {
+ process () {
+ this.processing = true
+ this.getContent()
+ .then((content) => {
+ const fileToDownload = document.createElement('a')
+ fileToDownload.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(content))
+ fileToDownload.setAttribute('download', this.filename)
+ fileToDownload.style.display = 'none'
+ document.body.appendChild(fileToDownload)
+ fileToDownload.click()
+ document.body.removeChild(fileToDownload)
+ // Add delay before hiding processing state since browser takes some time to handle file download
+ setTimeout(() => { this.processing = false }, 2000)
+ })
+ }
+ }
+}
+
+export default Exporter
diff --git a/src/components/exporter/exporter.vue b/src/components/exporter/exporter.vue
new file mode 100644
index 00000000..f22e579e
--- /dev/null
+++ b/src/components/exporter/exporter.vue
@@ -0,0 +1,20 @@
+<template>
+ <div class="exporter">
+ <div v-if="processing">
+ <i class="icon-spin4 animate-spin exporter-processing"></i>
+ <span>{{processingMessage}}</span>
+ </div>
+ <button class="btn btn-default" @click="process" v-else>{{exportButtonLabel}}</button>
+ </div>
+</template>
+
+<script src="./exporter.js"></script>
+
+<style lang="scss">
+.exporter {
+ &-processing {
+ font-size: 1.5em;
+ margin: 0.25em;
+ }
+}
+</style>
diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js
new file mode 100644
index 00000000..528da301
--- /dev/null
+++ b/src/components/extra_buttons/extra_buttons.js
@@ -0,0 +1,64 @@
+import Popper from 'vue-popperjs/src/component/popper.js.vue'
+
+const ExtraButtons = {
+ props: [ 'status' ],
+ components: {
+ Popper
+ },
+ data () {
+ return {
+ showDropDown: false,
+ showPopper: true
+ }
+ },
+ methods: {
+ deleteStatus () {
+ this.refreshPopper()
+ const confirmed = window.confirm(this.$t('status.delete_confirm'))
+ if (confirmed) {
+ this.$store.dispatch('deleteStatus', { id: this.status.id })
+ }
+ },
+ toggleMenu () {
+ this.showDropDown = !this.showDropDown
+ },
+ pinStatus () {
+ this.refreshPopper()
+ this.$store.dispatch('pinStatus', this.status.id)
+ .then(() => this.$emit('onSuccess'))
+ .catch(err => this.$emit('onError', err.error.error))
+ },
+ unpinStatus () {
+ this.refreshPopper()
+ this.$store.dispatch('unpinStatus', this.status.id)
+ .then(() => this.$emit('onSuccess'))
+ .catch(err => this.$emit('onError', err.error.error))
+ },
+ refreshPopper () {
+ this.showPopper = false
+ this.showDropDown = false
+ setTimeout(() => {
+ this.showPopper = true
+ })
+ }
+ },
+ computed: {
+ currentUser () { return this.$store.state.users.currentUser },
+ canDelete () {
+ if (!this.currentUser) { return }
+ const superuser = this.currentUser.rights.moderator || this.currentUser.rights.admin
+ return superuser || this.status.user.id === this.currentUser.id
+ },
+ ownStatus () {
+ return this.status.user.id === this.currentUser.id
+ },
+ canPin () {
+ return this.ownStatus && (this.status.visibility === 'public' || this.status.visibility === 'unlisted')
+ },
+ enabled () {
+ return this.canPin || this.canDelete
+ }
+ }
+}
+
+export default ExtraButtons
diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue
new file mode 100644
index 00000000..a761d313
--- /dev/null
+++ b/src/components/extra_buttons/extra_buttons.vue
@@ -0,0 +1,48 @@
+<template>
+ <Popper
+ trigger="click"
+ @hide='showDropDown = false'
+ append-to-body
+ v-if="enabled && showPopper"
+ :options="{
+ placement: 'top',
+ modifiers: {
+ arrow: { enabled: true },
+ offset: { offset: '0, 5px' },
+ }
+ }"
+ >
+ <div class="popper-wrapper">
+ <div class="dropdown-menu">
+ <button class="dropdown-item dropdown-item-icon" @click.prevent="pinStatus" v-if="!status.pinned && canPin">
+ <i class="icon-pin"></i><span>{{$t("status.pin")}}</span>
+ </button>
+ <button class="dropdown-item dropdown-item-icon" @click.prevent="unpinStatus" v-if="status.pinned && canPin">
+ <i class="icon-pin"></i><span>{{$t("status.unpin")}}</span>
+ </button>
+ <button class="dropdown-item dropdown-item-icon" @click.prevent="deleteStatus" v-if="canDelete">
+ <i class="icon-cancel"></i><span>{{$t("status.delete")}}</span>
+ </button>
+ </div>
+ </div>
+ <div class="button-icon" slot="reference" @click="toggleMenu">
+ <i class='icon-ellipsis' :class="{'icon-clicked': showDropDown}"></i>
+ </div>
+ </Popper>
+</template>
+
+<script src="./extra_buttons.js" ></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+@import '../popper/popper.scss';
+
+.icon-ellipsis {
+ cursor: pointer;
+
+ &:hover, &.icon-clicked {
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ }
+}
+</style>
diff --git a/src/components/features_panel/features_panel.js b/src/components/features_panel/features_panel.js
index e0b7a118..5f0b7b25 100644
--- a/src/components/features_panel/features_panel.js
+++ b/src/components/features_panel/features_panel.js
@@ -6,7 +6,7 @@ const FeaturesPanel = {
gopher: function () { return this.$store.state.instance.gopherAvailable },
whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled },
mediaProxy: function () { return this.$store.state.instance.mediaProxyAvailable },
- scopeOptions: function () { return this.$store.state.instance.scopeOptionsEnabled },
+ minimalScopesMode: function () { return this.$store.state.instance.minimalScopesMode },
textlimit: function () { return this.$store.state.instance.textlimit }
}
}
diff --git a/src/components/features_panel/features_panel.vue b/src/components/features_panel/features_panel.vue
index 445143e9..7a263e01 100644
--- a/src/components/features_panel/features_panel.vue
+++ b/src/components/features_panel/features_panel.vue
@@ -12,7 +12,7 @@
<li v-if="gopher">{{$t('features_panel.gopher')}}</li>
<li v-if="whoToFollow">{{$t('features_panel.who_to_follow')}}</li>
<li v-if="mediaProxy">{{$t('features_panel.media_proxy')}}</li>
- <li v-if="scopeOptions">{{$t('features_panel.scope_options')}}</li>
+ <li>{{$t('features_panel.scope_options')}}</li>
<li>{{$t('features_panel.text_limit')}} = {{textlimit}}</li>
</ul>
</div>
diff --git a/src/components/follow_card/follow_card.js b/src/components/follow_card/follow_card.js
index ac4e265a..dc4a0d41 100644
--- a/src/components/follow_card/follow_card.js
+++ b/src/components/follow_card/follow_card.js
@@ -10,8 +10,7 @@ const FollowCard = {
data () {
return {
inProgress: false,
- requestSent: false,
- updated: false
+ requestSent: false
}
},
components: {
@@ -19,10 +18,8 @@ const FollowCard = {
RemoteFollow
},
computed: {
- isMe () { return this.$store.state.users.currentUser.id === this.user.id },
- following () { return this.updated ? this.updated.following : this.user.following },
- showFollow () {
- return !this.following || this.updated && !this.updated.following
+ isMe () {
+ return this.$store.state.users.currentUser.id === this.user.id
},
loggedIn () {
return this.$store.state.users.currentUser
@@ -31,17 +28,15 @@ const FollowCard = {
methods: {
followUser () {
this.inProgress = true
- requestFollow(this.user, this.$store).then(({ sent, updated }) => {
+ requestFollow(this.user, this.$store).then(({ sent }) => {
this.inProgress = false
this.requestSent = sent
- this.updated = updated
})
},
unfollowUser () {
this.inProgress = true
- requestUnfollow(this.user, this.$store).then(({ updated }) => {
+ requestUnfollow(this.user, this.$store).then(() => {
this.inProgress = false
- this.updated = updated
})
}
}
diff --git a/src/components/follow_card/follow_card.vue b/src/components/follow_card/follow_card.vue
index 9f314fd3..94e2836f 100644
--- a/src/components/follow_card/follow_card.vue
+++ b/src/components/follow_card/follow_card.vue
@@ -4,34 +4,38 @@
<span class="faint" v-if="!noFollowsYou && user.follows_you">
{{ isMe ? $t('user_card.its_you') : $t('user_card.follows_you') }}
</span>
- <div class="follow-card-follow-button" v-if="showFollow && !loggedIn">
- <RemoteFollow :user="user" />
- </div>
- <button
- v-if="showFollow && loggedIn"
- class="btn btn-default follow-card-follow-button"
- @click="followUser"
- :disabled="inProgress"
- :title="requestSent ? $t('user_card.follow_again') : ''"
- >
- <template v-if="inProgress">
- {{ $t('user_card.follow_progress') }}
- </template>
- <template v-else-if="requestSent">
- {{ $t('user_card.follow_sent') }}
- </template>
- <template v-else>
- {{ $t('user_card.follow') }}
- </template>
- </button>
- <button v-if="following" class="btn btn-default follow-card-follow-button pressed" @click="unfollowUser" :disabled="inProgress">
- <template v-if="inProgress">
- {{ $t('user_card.follow_progress') }}
- </template>
- <template v-else>
- {{ $t('user_card.follow_unfollow') }}
- </template>
- </button>
+ <template v-if="!loggedIn">
+ <div class="follow-card-follow-button" v-if="!user.following">
+ <RemoteFollow :user="user" />
+ </div>
+ </template>
+ <template v-else>
+ <button
+ v-if="!user.following"
+ class="btn btn-default follow-card-follow-button"
+ @click="followUser"
+ :disabled="inProgress"
+ :title="requestSent ? $t('user_card.follow_again') : ''"
+ >
+ <template v-if="inProgress">
+ {{ $t('user_card.follow_progress') }}
+ </template>
+ <template v-else-if="requestSent">
+ {{ $t('user_card.follow_sent') }}
+ </template>
+ <template v-else>
+ {{ $t('user_card.follow') }}
+ </template>
+ </button>
+ <button v-else class="btn btn-default follow-card-follow-button pressed" @click="unfollowUser" :disabled="inProgress">
+ <template v-if="inProgress">
+ {{ $t('user_card.follow_progress') }}
+ </template>
+ <template v-else>
+ {{ $t('user_card.follow_unfollow') }}
+ </template>
+ </button>
+ </template>
</div>
</basic-user-card>
</template>
diff --git a/src/components/follow_requests/follow_requests.vue b/src/components/follow_requests/follow_requests.vue
index b83c2d68..36901fb4 100644
--- a/src/components/follow_requests/follow_requests.vue
+++ b/src/components/follow_requests/follow_requests.vue
@@ -4,7 +4,7 @@
{{$t('nav.friend_requests')}}
</div>
<div class="panel-body">
- <FollowRequestCard v-for="request in requests" :key="request.id" :user="request"/>
+ <FollowRequestCard v-for="request in requests" :key="request.id" :user="request" class="list-item"/>
</div>
</div>
</template>
diff --git a/src/components/image_cropper/image_cropper.js b/src/components/image_cropper/image_cropper.js
index 5ba8f04e..01361e25 100644
--- a/src/components/image_cropper/image_cropper.js
+++ b/src/components/image_cropper/image_cropper.js
@@ -70,22 +70,10 @@ const ImageCropper = {
this.dataUrl = undefined
this.$emit('close')
},
- submit () {
+ submit (cropping = true) {
this.submitting = true
this.avatarUploadError = null
- this.submitHandler(this.cropper, this.file)
- .then(() => this.destroy())
- .catch((err) => {
- this.submitError = err
- })
- .finally(() => {
- this.submitting = false
- })
- },
- submitWithoutCropping () {
- this.submitting = true
- this.avatarUploadError = null
- this.submitHandler(false, this.dataUrl)
+ this.submitHandler(cropping && this.cropper, this.file)
.then(() => this.destroy())
.catch((err) => {
this.submitError = err
diff --git a/src/components/image_cropper/image_cropper.vue b/src/components/image_cropper/image_cropper.vue
index 129e6f46..d2b86e9e 100644
--- a/src/components/image_cropper/image_cropper.vue
+++ b/src/components/image_cropper/image_cropper.vue
@@ -5,9 +5,9 @@
<img ref="img" :src="dataUrl" alt="" @load.stop="createCropper" />
</div>
<div class="image-cropper-buttons-wrapper">
- <button class="btn" type="button" :disabled="submitting" @click="submit" v-text="saveText"></button>
+ <button class="btn" type="button" :disabled="submitting" @click="submit()" v-text="saveText"></button>
<button class="btn" type="button" :disabled="submitting" @click="destroy" v-text="cancelText"></button>
- <button class="btn" type="button" :disabled="submitting" @click="submitWithoutCropping" v-text="saveWithoutCroppingText"></button>
+ <button class="btn" type="button" :disabled="submitting" @click="submit(false)" v-text="saveWithoutCroppingText"></button>
<i class="icon-spin4 animate-spin" v-if="submitting"></i>
</div>
<div class="alert error" v-if="submitError">
diff --git a/src/components/importer/importer.js b/src/components/importer/importer.js
new file mode 100644
index 00000000..c5f9e4d2
--- /dev/null
+++ b/src/components/importer/importer.js
@@ -0,0 +1,53 @@
+const Importer = {
+ props: {
+ submitHandler: {
+ type: Function,
+ required: true
+ },
+ submitButtonLabel: {
+ type: String,
+ default () {
+ return this.$t('importer.submit')
+ }
+ },
+ successMessage: {
+ type: String,
+ default () {
+ return this.$t('importer.success')
+ }
+ },
+ errorMessage: {
+ type: String,
+ default () {
+ return this.$t('importer.error')
+ }
+ }
+ },
+ data () {
+ return {
+ file: null,
+ error: false,
+ success: false,
+ submitting: false
+ }
+ },
+ methods: {
+ change () {
+ this.file = this.$refs.input.files[0]
+ },
+ submit () {
+ this.dismiss()
+ this.submitting = true
+ this.submitHandler(this.file)
+ .then(() => { this.success = true })
+ .catch(() => { this.error = true })
+ .finally(() => { this.submitting = false })
+ },
+ dismiss () {
+ this.success = false
+ this.error = false
+ }
+ }
+}
+
+export default Importer
diff --git a/src/components/importer/importer.vue b/src/components/importer/importer.vue
new file mode 100644
index 00000000..0c5aa93d
--- /dev/null
+++ b/src/components/importer/importer.vue
@@ -0,0 +1,28 @@
+<template>
+ <div class="importer">
+ <form>
+ <input type="file" ref="input" v-on:change="change" />
+ </form>
+ <i class="icon-spin4 animate-spin importer-uploading" v-if="submitting"></i>
+ <button class="btn btn-default" v-else @click="submit">{{submitButtonLabel}}</button>
+ <div v-if="success">
+ <i class="icon-cross" @click="dismiss"></i>
+ <p>{{successMessage}}</p>
+ </div>
+ <div v-else-if="error">
+ <i class="icon-cross" @click="dismiss"></i>
+ <p>{{errorMessage}}</p>
+ </div>
+ </div>
+</template>
+
+<script src="./importer.js"></script>
+
+<style lang="scss">
+.importer {
+ &-uploading {
+ font-size: 1.5em;
+ margin: 0.25em;
+ }
+}
+</style>
diff --git a/src/components/interactions/interactions.js b/src/components/interactions/interactions.js
new file mode 100644
index 00000000..d4e3cc17
--- /dev/null
+++ b/src/components/interactions/interactions.js
@@ -0,0 +1,25 @@
+import Notifications from '../notifications/notifications.vue'
+
+const tabModeDict = {
+ mentions: ['mention'],
+ 'likes+repeats': ['repeat', 'like'],
+ follows: ['follow']
+}
+
+const Interactions = {
+ data () {
+ return {
+ filterMode: tabModeDict['mentions']
+ }
+ },
+ methods: {
+ onModeSwitch (index, dataset) {
+ this.filterMode = tabModeDict[dataset.filter]
+ }
+ },
+ components: {
+ Notifications
+ }
+}
+
+export default Interactions
diff --git a/src/components/interactions/interactions.vue b/src/components/interactions/interactions.vue
new file mode 100644
index 00000000..38b2670d
--- /dev/null
+++ b/src/components/interactions/interactions.vue
@@ -0,0 +1,25 @@
+<template>
+ <div class="panel panel-default">
+ <div class="panel-heading">
+ <div class="title">
+ {{ $t("nav.interactions") }}
+ </div>
+ </div>
+ <tab-switcher
+ ref="tabSwitcher"
+ :onSwitch="onModeSwitch"
+ >
+ <span data-tab-dummy data-filter="mentions" :label="$t('nav.mentions')"/>
+ <span data-tab-dummy data-filter="likes+repeats" :label="$t('interactions.favs_repeats')"/>
+ <span data-tab-dummy data-filter="follows" :label="$t('interactions.follows')"/>
+ </tab-switcher>
+ <Notifications
+ ref="notifications"
+ :noHeading="true"
+ :minimalMode="true"
+ :filterMode="filterMode"
+ />
+ </div>
+</template>
+
+<script src="./interactions.js"></script>
diff --git a/src/components/interface_language_switcher/interface_language_switcher.vue b/src/components/interface_language_switcher/interface_language_switcher.vue
index 3f58af2c..9f7877c6 100644
--- a/src/components/interface_language_switcher/interface_language_switcher.vue
+++ b/src/components/interface_language_switcher/interface_language_switcher.vue
@@ -26,7 +26,7 @@
},
languageNames () {
- return _.map(this.languageCodes, ISO6391.getName)
+ return _.map(this.languageCodes, this.getLanguageName)
},
language: {
@@ -36,6 +36,17 @@
this.$i18n.locale = val
}
}
+ },
+
+ methods: {
+ getLanguageName (code) {
+ const specialLanguageNames = {
+ 'ja': 'Japanese (やさしいにほんご)',
+ 'ja_pedantic': 'Japanese (日本語)',
+ 'zh': 'Chinese (简体中文)'
+ }
+ return specialLanguageNames[code] || ISO6391.getName(code)
+ }
}
}
</script>
diff --git a/src/components/list/list.vue b/src/components/list/list.vue
new file mode 100644
index 00000000..7136915b
--- /dev/null
+++ b/src/components/list/list.vue
@@ -0,0 +1,42 @@
+<template>
+ <div class="list">
+ <div v-for="item in items" class="list-item" :key="getKey(item)">
+ <slot name="item" :item="item" />
+ </div>
+ <div class="list-empty-content faint" v-if="items.length === 0 && !!$slots.empty">
+ <slot name="empty" />
+ </div>
+ </div>
+</template>
+
+<script>
+export default {
+ props: {
+ items: {
+ type: Array,
+ default: () => []
+ },
+ getKey: {
+ type: Function,
+ default: item => item.id
+ }
+ }
+}
+</script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.list {
+ &-item:not(:last-child) {
+ border-bottom: 1px solid;
+ border-bottom-color: $fallback--border;
+ border-bottom-color: var(--border, $fallback--border);
+ }
+
+ &-empty-content {
+ text-align: center;
+ padding: 10px;
+ }
+}
+</style>
diff --git a/src/components/login_form/login_form.js b/src/components/login_form/login_form.js
index fb6dc651..93214646 100644
--- a/src/components/login_form/login_form.js
+++ b/src/components/login_form/login_form.js
@@ -1,50 +1,80 @@
+import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'
import oauthApi from '../../services/new_api/oauth.js'
+
const LoginForm = {
data: () => ({
user: {},
- authError: false
+ error: false
}),
computed: {
- loginMethod () { return this.$store.state.instance.loginMethod },
- loggingIn () { return this.$store.state.users.loggingIn },
- registrationOpen () { return this.$store.state.instance.registrationOpen }
+ isPasswordAuth () { return this.requiredPassword },
+ isTokenAuth () { return this.requiredToken },
+ ...mapState({
+ registrationOpen: state => state.instance.registrationOpen,
+ instance: state => state.instance,
+ loggingIn: state => state.users.loggingIn,
+ oauth: state => state.oauth
+ }),
+ ...mapGetters(
+ 'authFlow', ['requiredPassword', 'requiredToken', 'requiredMFA']
+ )
},
methods: {
- oAuthLogin () {
- oauthApi.login({
- oauth: this.$store.state.oauth,
- instance: this.$store.state.instance.server,
+ ...mapMutations('authFlow', ['requireMFA']),
+ ...mapActions({ login: 'authFlow/login' }),
+ submit () {
+ this.isTokenAuth ? this.submitToken() : this.submitPassword()
+ },
+ submitToken () {
+ const { clientId } = this.oauth
+ const data = {
+ clientId,
+ instance: this.instance.server,
commit: this.$store.commit
- })
+ }
+
+ oauthApi.getOrCreateApp(data)
+ .then((app) => { oauthApi.login({ ...app, ...data }) })
},
- submit () {
+ submitPassword () {
+ const { clientId } = this.oauth
const data = {
- oauth: this.$store.state.oauth,
- instance: this.$store.state.instance.server
+ clientId,
+ oauth: this.oauth,
+ instance: this.instance.server,
+ commit: this.$store.commit
}
- this.clearError()
+ this.error = false
+
oauthApi.getOrCreateApp(data).then((app) => {
oauthApi.getTokenWithCredentials(
{
- app,
+ ...app,
instance: data.instance,
username: this.user.username,
password: this.user.password
}
).then((result) => {
if (result.error) {
- this.authError = result.error
- this.user.password = ''
+ if (result.error === 'mfa_required') {
+ this.requireMFA({app: app, settings: result})
+ } else {
+ this.error = result.error
+ this.focusOnPasswordInput()
+ }
return
}
- this.$store.commit('setToken', result.access_token)
- this.$store.dispatch('loginUser', result.access_token)
- this.$router.push({name: 'friends'})
+ this.login(result).then(() => {
+ this.$router.push({name: 'friends'})
+ })
})
})
},
- clearError () {
- this.authError = false
+ clearError () { this.error = false },
+ focusOnPasswordInput () {
+ let passwordInput = this.$refs.passwordInput
+ passwordInput.focus()
+ passwordInput.setSelectionRange(0, passwordInput.value.length)
}
}
}
diff --git a/src/components/login_form/login_form.vue b/src/components/login_form/login_form.vue
index 27a8e48a..a2c5cf8f 100644
--- a/src/components/login_form/login_form.vue
+++ b/src/components/login_form/login_form.vue
@@ -1,47 +1,53 @@
<template>
- <div class="login panel panel-default">
- <!-- Default panel contents -->
- <div class="panel-heading">
- {{$t('login.login')}}
- </div>
- <div class="panel-body">
- <form v-if="loginMethod == 'password'" v-on:submit.prevent='submit(user)' class='login-form'>
+<div class="login panel panel-default">
+ <!-- Default panel contents -->
+
+ <div class="panel-heading">{{$t('login.login')}}</div>
+
+ <div class="panel-body">
+ <form class='login-form' @submit.prevent='submit'>
+ <template v-if="isPasswordAuth">
<div class='form-group'>
<label for='username'>{{$t('login.username')}}</label>
- <input :disabled="loggingIn" v-model='user.username' class='form-control' id='username' v-bind:placeholder="$t('login.placeholder')">
+ <input :disabled="loggingIn" v-model='user.username'
+ class='form-control' id='username'
+ :placeholder="$t('login.placeholder')">
</div>
<div class='form-group'>
<label for='password'>{{$t('login.password')}}</label>
- <input :disabled="loggingIn" v-model='user.password' class='form-control' id='password' type='password'>
- </div>
- <div class='form-group'>
- <div class='login-bottom'>
- <div><router-link :to="{name: 'registration'}" v-if='registrationOpen' class='register'>{{$t('login.register')}}</router-link></div>
- <button :disabled="loggingIn" type='submit' class='btn btn-default'>{{$t('login.login')}}</button>
- </div>
+ <input :disabled="loggingIn" v-model='user.password'
+ ref='passwordInput' class='form-control' id='password' type='password'>
</div>
- </form>
+ </template>
- <form v-if="loginMethod == 'token'" v-on:submit.prevent='oAuthLogin' class="login-form">
- <div class="form-group">
- <p>{{$t('login.description')}}</p>
- </div>
- <div class='form-group'>
- <div class='login-bottom'>
- <div><router-link :to="{name: 'registration'}" v-if='registrationOpen' class='register'>{{$t('login.register')}}</router-link></div>
- <button :disabled="loggingIn" type='submit' class='btn btn-default'>{{$t('login.login')}}</button>
+ <div class="form-group" v-if="isTokenAuth">
+ <p>{{$t('login.description')}}</p>
+ </div>
+
+ <div class='form-group'>
+ <div class='login-bottom'>
+ <div>
+ <router-link :to="{name: 'registration'}"
+ v-if='registrationOpen'
+ class='register'>
+ {{$t('login.register')}}
+ </router-link>
</div>
- </div>
- </form>
-
- <div v-if="authError" class='form-group'>
- <div class='alert error'>
- {{authError}}
- <i class="button-icon icon-cancel" @click="clearError"></i>
+ <button :disabled="loggingIn" type='submit' class='btn btn-default'>
+ {{$t('login.login')}}
+ </button>
</div>
</div>
+ </form>
+ </div>
+
+ <div v-if="error" class='form-group'>
+ <div class='alert error'>
+ {{error}}
+ <i class="button-icon icon-cancel" @click="clearError"></i>
</div>
</div>
+</div>
</template>
<script src="./login_form.js" ></script>
@@ -50,6 +56,10 @@
@import '../../_variables.scss';
.login-form {
+ display: flex;
+ flex-direction: column;
+ padding: 0.6em;
+
.btn {
min-height: 28px;
width: 10em;
@@ -66,9 +76,30 @@
align-items: center;
justify-content: space-between;
}
-}
-.login {
+ .form-group {
+ display: flex;
+ flex-direction: column;
+ padding: 0.3em 0.5em 0.6em;
+ line-height:24px;
+ }
+
+ .form-bottom {
+ display: flex;
+ padding: 0.5em;
+ height: 32px;
+
+ button {
+ width: 10em;
+ }
+
+ p {
+ margin: 0.35em;
+ padding: 0.35em;
+ display: flex;
+ }
+ }
+
.error {
text-align: center;
diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue
index 7f666603..a4c12d74 100644
--- a/src/components/media_modal/media_modal.vue
+++ b/src/components/media_modal/media_modal.vue
@@ -33,6 +33,8 @@
@import '../../_variables.scss';
.media-modal-view {
+ z-index: 1001;
+
&:hover {
.modal-view-button-arrow {
opacity: 0.75;
diff --git a/src/components/mentions/mentions.vue b/src/components/mentions/mentions.vue
index bba06da6..6b4e96e0 100644
--- a/src/components/mentions/mentions.vue
+++ b/src/components/mentions/mentions.vue
@@ -1,5 +1,5 @@
<template>
- <Timeline :title="$t('nav.mentions')" v-bind:timeline="timeline" v-bind:timeline-name="'mentions'"/>
+ <Timeline :title="$t('nav.interactions')" v-bind:timeline="timeline" v-bind:timeline-name="'mentions'"/>
</template>
<script src="./mentions.js"></script>
diff --git a/src/components/mfa_form/recovery_form.js b/src/components/mfa_form/recovery_form.js
new file mode 100644
index 00000000..fbe9b437
--- /dev/null
+++ b/src/components/mfa_form/recovery_form.js
@@ -0,0 +1,41 @@
+import mfaApi from '../../services/new_api/mfa.js'
+import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'
+
+export default {
+ data: () => ({
+ code: null,
+ error: false
+ }),
+ computed: {
+ ...mapGetters({
+ authApp: 'authFlow/app',
+ authSettings: 'authFlow/settings'
+ }),
+ ...mapState({ instance: 'instance' })
+ },
+ methods: {
+ ...mapMutations('authFlow', ['requireTOTP', 'abortMFA']),
+ ...mapActions({ login: 'authFlow/login' }),
+ clearError () { this.error = false },
+ submit () {
+ const data = {
+ app: this.authApp,
+ instance: this.instance.server,
+ mfaToken: this.authSettings.mfa_token,
+ code: this.code
+ }
+
+ mfaApi.verifyRecoveryCode(data).then((result) => {
+ if (result.error) {
+ this.error = result.error
+ this.code = null
+ return
+ }
+
+ this.login(result).then(() => {
+ this.$router.push({name: 'friends'})
+ })
+ })
+ }
+ }
+}
diff --git a/src/components/mfa_form/recovery_form.vue b/src/components/mfa_form/recovery_form.vue
new file mode 100644
index 00000000..e0e2d65b
--- /dev/null
+++ b/src/components/mfa_form/recovery_form.vue
@@ -0,0 +1,42 @@
+<template>
+<div class="login panel panel-default">
+ <!-- Default panel contents -->
+
+ <div class="panel-heading">{{$t('login.heading.recovery')}}</div>
+
+ <div class="panel-body">
+ <form class='login-form' @submit.prevent='submit'>
+ <div class='form-group'>
+ <label for='code'>{{$t('login.recovery_code')}}</label>
+ <input v-model='code' class='form-control' id='code'>
+ </div>
+
+ <div class='form-group'>
+ <div class='login-bottom'>
+ <div>
+ <a href="#" @click.prevent="requireTOTP">
+ {{$t('login.enter_two_factor_code')}}
+ </a>
+ <br />
+ <a href="#" @click.prevent="abortMFA">
+ {{$t('general.cancel')}}
+ </a>
+ </div>
+ <button type='submit' class='btn btn-default'>
+ {{$t('general.verify')}}
+ </button>
+ </div>
+ </div>
+
+ </form>
+ </div>
+
+ <div v-if="error" class='form-group'>
+ <div class='alert error'>
+ {{error}}
+ <i class="button-icon icon-cancel" @click="clearError"></i>
+ </div>
+ </div>
+</div>
+</template>
+<script src="./recovery_form.js" ></script>
diff --git a/src/components/mfa_form/totp_form.js b/src/components/mfa_form/totp_form.js
new file mode 100644
index 00000000..6c94fe52
--- /dev/null
+++ b/src/components/mfa_form/totp_form.js
@@ -0,0 +1,40 @@
+import mfaApi from '../../services/new_api/mfa.js'
+import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'
+export default {
+ data: () => ({
+ code: null,
+ error: false
+ }),
+ computed: {
+ ...mapGetters({
+ authApp: 'authFlow/app',
+ authSettings: 'authFlow/settings'
+ }),
+ ...mapState({ instance: 'instance' })
+ },
+ methods: {
+ ...mapMutations('authFlow', ['requireRecovery', 'abortMFA']),
+ ...mapActions({ login: 'authFlow/login' }),
+ clearError () { this.error = false },
+ submit () {
+ const data = {
+ app: this.authApp,
+ instance: this.instance.server,
+ mfaToken: this.authSettings.mfa_token,
+ code: this.code
+ }
+
+ mfaApi.verifyOTPCode(data).then((result) => {
+ if (result.error) {
+ this.error = result.error
+ this.code = null
+ return
+ }
+
+ this.login(result).then(() => {
+ this.$router.push({name: 'friends'})
+ })
+ })
+ }
+ }
+}
diff --git a/src/components/mfa_form/totp_form.vue b/src/components/mfa_form/totp_form.vue
new file mode 100644
index 00000000..c547785e
--- /dev/null
+++ b/src/components/mfa_form/totp_form.vue
@@ -0,0 +1,45 @@
+<template>
+<div class="login panel panel-default">
+ <!-- Default panel contents -->
+
+ <div class="panel-heading">
+ {{$t('login.heading.totp')}}
+ </div>
+
+ <div class="panel-body">
+ <form class='login-form' @submit.prevent='submit'>
+ <div class='form-group'>
+ <label for='code'>
+ {{$t('login.authentication_code')}}
+ </label>
+ <input v-model='code' class='form-control' id='code'>
+ </div>
+
+ <div class='form-group'>
+ <div class='login-bottom'>
+ <div>
+ <a href="#" @click.prevent="requireRecovery">
+ {{$t('login.enter_recovery_code')}}
+ </a>
+ <br />
+ <a href="#" @click.prevent="abortMFA">
+ {{$t('general.cancel')}}
+ </a>
+ </div>
+ <button type='submit' class='btn btn-default'>
+ {{$t('general.verify')}}
+ </button>
+ </div>
+ </div>
+ </form>
+ </div>
+
+ <div v-if="error" class='form-group'>
+ <div class='alert error'>
+ {{error}}
+ <i class="button-icon icon-cancel" @click="clearError"></i>
+ </div>
+ </div>
+</div>
+</template>
+<script src="./totp_form.js"></script>
diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js
new file mode 100644
index 00000000..9b341a3b
--- /dev/null
+++ b/src/components/mobile_nav/mobile_nav.js
@@ -0,0 +1,82 @@
+import SideDrawer from '../side_drawer/side_drawer.vue'
+import Notifications from '../notifications/notifications.vue'
+import MobilePostStatusModal from '../mobile_post_status_modal/mobile_post_status_modal.vue'
+import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
+import GestureService from '../../services/gesture_service/gesture_service'
+
+const MobileNav = {
+ components: {
+ SideDrawer,
+ Notifications,
+ MobilePostStatusModal
+ },
+ data: () => ({
+ notificationsCloseGesture: undefined,
+ notificationsOpen: false
+ }),
+ created () {
+ this.notificationsCloseGesture = GestureService.swipeGesture(
+ GestureService.DIRECTION_RIGHT,
+ this.closeMobileNotifications,
+ 50
+ )
+ },
+ computed: {
+ currentUser () {
+ return this.$store.state.users.currentUser
+ },
+ unseenNotifications () {
+ return unseenNotificationsFromStore(this.$store)
+ },
+ unseenNotificationsCount () {
+ return this.unseenNotifications.length
+ },
+ sitename () { return this.$store.state.instance.name }
+ },
+ methods: {
+ toggleMobileSidebar () {
+ this.$refs.sideDrawer.toggleDrawer()
+ },
+ openMobileNotifications () {
+ this.notificationsOpen = true
+ },
+ closeMobileNotifications () {
+ if (this.notificationsOpen) {
+ // make sure to mark notifs seen only when the notifs were open and not
+ // from close-calls.
+ this.notificationsOpen = false
+ this.markNotificationsAsSeen()
+ }
+ },
+ notificationsTouchStart (e) {
+ GestureService.beginSwipe(e, this.notificationsCloseGesture)
+ },
+ notificationsTouchMove (e) {
+ GestureService.updateSwipe(e, this.notificationsCloseGesture)
+ },
+ scrollToTop () {
+ window.scrollTo(0, 0)
+ },
+ logout () {
+ this.$router.replace('/main/public')
+ this.$store.dispatch('logout')
+ },
+ markNotificationsAsSeen () {
+ this.$refs.notifications.markAsSeen()
+ },
+ onScroll ({ target: { scrollTop, clientHeight, scrollHeight } }) {
+ if (this.$store.state.config.autoLoad && scrollTop + clientHeight >= scrollHeight) {
+ this.$refs.notifications.fetchOlderNotifications()
+ }
+ }
+ },
+ watch: {
+ $route () {
+ // handles closing notificaitons when you press any router-link on the
+ // notifications.
+ this.closeMobileNotifications()
+ }
+ }
+}
+
+export default MobileNav
diff --git a/src/components/mobile_nav/mobile_nav.vue b/src/components/mobile_nav/mobile_nav.vue
new file mode 100644
index 00000000..dcac440a
--- /dev/null
+++ b/src/components/mobile_nav/mobile_nav.vue
@@ -0,0 +1,144 @@
+<template>
+ <div>
+ <nav class='nav-bar container' id="nav">
+ <div class='mobile-inner-nav' @click="scrollToTop()">
+ <div class='item'>
+ <a href="#" class="mobile-nav-button" @click.stop.prevent="toggleMobileSidebar()">
+ <i class="button-icon icon-menu"></i>
+ </a>
+ <router-link class="site-name" :to="{ name: 'root' }" active-class="home">{{sitename}}</router-link>
+ </div>
+ <div class='item right'>
+ <a class="mobile-nav-button" v-if="currentUser" href="#" @click.stop.prevent="openMobileNotifications()">
+ <i class="button-icon icon-bell-alt"></i>
+ <div class="alert-dot" v-if="unseenNotificationsCount"></div>
+ </a>
+ </div>
+ </div>
+ </nav>
+ <div v-if="currentUser"
+ class="mobile-notifications-drawer"
+ :class="{ 'closed': !notificationsOpen }"
+ @touchstart.stop="notificationsTouchStart"
+ @touchmove.stop="notificationsTouchMove"
+ >
+ <div class="mobile-notifications-header">
+ <span class="title">{{$t('notifications.notifications')}}</span>
+ <a class="mobile-nav-button" @click.stop.prevent="closeMobileNotifications()">
+ <i class="button-icon icon-cancel"/>
+ </a>
+ </div>
+ <div class="mobile-notifications" @scroll="onScroll">
+ <Notifications ref="notifications" :noHeading="true"/>
+ </div>
+ </div>
+ <SideDrawer ref="sideDrawer" :logout="logout"/>
+ <MobilePostStatusModal />
+ </div>
+</template>
+
+<script src="./mobile_nav.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.mobile-inner-nav {
+ width: 100%;
+ display: flex;
+ align-items: center;
+}
+
+.mobile-nav-button {
+ display: flex;
+ justify-content: center;
+ width: 50px;
+ position: relative;
+ cursor: pointer;
+}
+
+.alert-dot {
+ border-radius: 100%;
+ height: 8px;
+ width: 8px;
+ position: absolute;
+ left: calc(50% - 4px);
+ top: calc(50% - 4px);
+ margin-left: 6px;
+ margin-top: -6px;
+ background-color: $fallback--cRed;
+ background-color: var(--badgeNotification, $fallback--cRed);
+}
+
+.mobile-notifications-drawer {
+ width: 100%;
+ height: 100vh;
+ overflow-x: hidden;
+ position: fixed;
+ top: 0;
+ left: 0;
+ box-shadow: 1px 1px 4px rgba(0,0,0,.6);
+ box-shadow: var(--panelShadow);
+ transition-property: transform;
+ transition-duration: 0.25s;
+ transform: translateX(0);
+ z-index: 1001;
+ -webkit-overflow-scrolling: touch;
+
+ &.closed {
+ transform: translateX(100%);
+ }
+}
+
+.mobile-notifications-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ z-index: 1;
+ width: 100%;
+ height: 50px;
+ line-height: 50px;
+ position: absolute;
+ color: var(--topBarText);
+ background-color: $fallback--fg;
+ background-color: var(--topBar, $fallback--fg);
+ box-shadow: 0px 0px 4px rgba(0,0,0,.6);
+ box-shadow: var(--topBarShadow);
+
+ .title {
+ font-size: 1.3em;
+ margin-left: 0.6em;
+ }
+}
+
+.mobile-notifications {
+ margin-top: 50px;
+ width: 100vw;
+ height: calc(100vh - 50px);
+ overflow-x: hidden;
+ overflow-y: scroll;
+
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ background-color: $fallback--bg;
+ background-color: var(--bg, $fallback--bg);
+
+ .notifications {
+ padding: 0;
+ border-radius: 0;
+ box-shadow: none;
+ .panel {
+ border-radius: 0;
+ margin: 0;
+ box-shadow: none;
+ }
+ .panel:after {
+ border-radius: 0;
+ }
+ .panel .panel-heading {
+ border-radius: 0;
+ box-shadow: none;
+ }
+ }
+}
+
+</style>
diff --git a/src/components/mobile_post_status_modal/mobile_post_status_modal.js b/src/components/mobile_post_status_modal/mobile_post_status_modal.js
index 2f24dd08..91b730e7 100644
--- a/src/components/mobile_post_status_modal/mobile_post_status_modal.js
+++ b/src/components/mobile_post_status_modal/mobile_post_status_modal.js
@@ -1,5 +1,5 @@
import PostStatusForm from '../post_status_form/post_status_form.vue'
-import { throttle } from 'lodash'
+import { debounce } from 'lodash'
const MobilePostStatusModal = {
components: {
@@ -16,11 +16,15 @@ const MobilePostStatusModal = {
}
},
created () {
- window.addEventListener('scroll', this.handleScroll)
+ if (this.autohideFloatingPostButton) {
+ this.activateFloatingPostButtonAutohide()
+ }
window.addEventListener('resize', this.handleOSK)
},
destroyed () {
- window.removeEventListener('scroll', this.handleScroll)
+ if (this.autohideFloatingPostButton) {
+ this.deactivateFloatingPostButtonAutohide()
+ }
window.removeEventListener('resize', this.handleOSK)
},
computed: {
@@ -28,10 +32,30 @@ const MobilePostStatusModal = {
return this.$store.state.users.currentUser
},
isHidden () {
- return this.hidden || this.inputActive
+ return this.autohideFloatingPostButton && (this.hidden || this.inputActive)
+ },
+ autohideFloatingPostButton () {
+ return !!this.$store.state.config.autohideFloatingPostButton
+ }
+ },
+ watch: {
+ autohideFloatingPostButton: function (isEnabled) {
+ if (isEnabled) {
+ this.activateFloatingPostButtonAutohide()
+ } else {
+ this.deactivateFloatingPostButtonAutohide()
+ }
}
},
methods: {
+ activateFloatingPostButtonAutohide () {
+ window.addEventListener('scroll', this.handleScrollStart)
+ window.addEventListener('scroll', this.handleScrollEnd)
+ },
+ deactivateFloatingPostButtonAutohide () {
+ window.removeEventListener('scroll', this.handleScrollStart)
+ window.removeEventListener('scroll', this.handleScrollEnd)
+ },
openPostForm () {
this.postFormOpen = true
this.hidden = true
@@ -65,26 +89,19 @@ const MobilePostStatusModal = {
this.inputActive = false
}
},
- handleScroll: throttle(function () {
- const scrollAmount = window.scrollY - this.oldScrollPos
- const scrollingDown = scrollAmount > 0
-
- if (scrollingDown !== this.scrollingDown) {
- this.amountScrolled = 0
- this.scrollingDown = scrollingDown
- if (!scrollingDown) {
- this.hidden = false
- }
- } else if (scrollingDown) {
- this.amountScrolled += scrollAmount
- if (this.amountScrolled > 100 && !this.hidden) {
- this.hidden = true
- }
+ handleScrollStart: debounce(function () {
+ if (window.scrollY > this.oldScrollPos) {
+ this.hidden = true
+ } else {
+ this.hidden = false
}
+ this.oldScrollPos = window.scrollY
+ }, 100, {leading: true, trailing: false}),
+ handleScrollEnd: debounce(function () {
+ this.hidden = false
this.oldScrollPos = window.scrollY
- this.scrollingDown = scrollingDown
- }, 100)
+ }, 100, {leading: false, trailing: true})
}
}
diff --git a/src/components/mobile_post_status_modal/mobile_post_status_modal.vue b/src/components/mobile_post_status_modal/mobile_post_status_modal.vue
index 0a451c28..c762705b 100644
--- a/src/components/mobile_post_status_modal/mobile_post_status_modal.vue
+++ b/src/components/mobile_post_status_modal/mobile_post_status_modal.vue
@@ -7,7 +7,7 @@
>
<div class="post-form-modal-panel panel" @click.stop="">
<div class="panel-heading">{{$t('post_status.new_status')}}</div>
- <PostStatusForm class="panel-body" @posted="closePostForm"/>
+ <PostStatusForm class="panel-body" @posted="closePostForm" />
</div>
</div>
<button
diff --git a/src/components/moderation_tools/moderation_tools.js b/src/components/moderation_tools/moderation_tools.js
new file mode 100644
index 00000000..3eedeaa1
--- /dev/null
+++ b/src/components/moderation_tools/moderation_tools.js
@@ -0,0 +1,106 @@
+import DialogModal from '../dialog_modal/dialog_modal.vue'
+import Popper from 'vue-popperjs/src/component/popper.js.vue'
+
+const FORCE_NSFW = 'mrf_tag:media-force-nsfw'
+const STRIP_MEDIA = 'mrf_tag:media-strip'
+const FORCE_UNLISTED = 'mrf_tag:force-unlisted'
+const DISABLE_REMOTE_SUBSCRIPTION = 'mrf_tag:disable-remote-subscription'
+const DISABLE_ANY_SUBSCRIPTION = 'mrf_tag:disable-any-subscription'
+const SANDBOX = 'mrf_tag:sandbox'
+const QUARANTINE = 'mrf_tag:quarantine'
+
+const ModerationTools = {
+ props: [
+ 'user'
+ ],
+ data () {
+ return {
+ showDropDown: false,
+ tags: {
+ FORCE_NSFW,
+ STRIP_MEDIA,
+ FORCE_UNLISTED,
+ DISABLE_REMOTE_SUBSCRIPTION,
+ DISABLE_ANY_SUBSCRIPTION,
+ SANDBOX,
+ QUARANTINE
+ },
+ showDeleteUserDialog: false
+ }
+ },
+ components: {
+ DialogModal,
+ Popper
+ },
+ computed: {
+ tagsSet () {
+ return new Set(this.user.tags)
+ },
+ hasTagPolicy () {
+ return this.$store.state.instance.tagPolicyAvailable
+ }
+ },
+ methods: {
+ toggleMenu () {
+ this.showDropDown = !this.showDropDown
+ },
+ hasTag (tagName) {
+ return this.tagsSet.has(tagName)
+ },
+ toggleTag (tag) {
+ const store = this.$store
+ if (this.tagsSet.has(tag)) {
+ store.state.api.backendInteractor.untagUser(this.user, tag).then(response => {
+ if (!response.ok) { return }
+ store.commit('untagUser', {user: this.user, tag})
+ })
+ } else {
+ store.state.api.backendInteractor.tagUser(this.user, tag).then(response => {
+ if (!response.ok) { return }
+ store.commit('tagUser', {user: this.user, tag})
+ })
+ }
+ },
+ toggleRight (right) {
+ const store = this.$store
+ if (this.user.rights[right]) {
+ store.state.api.backendInteractor.deleteRight(this.user, right).then(response => {
+ if (!response.ok) { return }
+ store.commit('updateRight', {user: this.user, right: right, value: false})
+ })
+ } else {
+ store.state.api.backendInteractor.addRight(this.user, right).then(response => {
+ if (!response.ok) { return }
+ store.commit('updateRight', {user: this.user, right: right, value: true})
+ })
+ }
+ },
+ toggleActivationStatus () {
+ const store = this.$store
+ const status = !!this.user.deactivated
+ store.state.api.backendInteractor.setActivationStatus(this.user, status).then(response => {
+ if (!response.ok) { return }
+ store.commit('updateActivationStatus', {user: this.user, status: status})
+ })
+ },
+ deleteUserDialog (show) {
+ this.showDeleteUserDialog = show
+ },
+ deleteUser () {
+ const store = this.$store
+ const user = this.user
+ const {id, name} = user
+ store.state.api.backendInteractor.deleteUser(user)
+ .then(e => {
+ this.$store.dispatch('markStatusesAsDeleted', status => user.id === status.user.id)
+ const isProfile = this.$route.name === 'external-user-profile' || this.$route.name === 'user-profile'
+ const isTargetUser = this.$route.params.name === name || this.$route.params.id === id
+ if (isProfile && isTargetUser) {
+ window.history.back()
+ }
+ })
+ }
+ }
+}
+
+export default ModerationTools
diff --git a/src/components/moderation_tools/moderation_tools.vue b/src/components/moderation_tools/moderation_tools.vue
new file mode 100644
index 00000000..6a5e8cc0
--- /dev/null
+++ b/src/components/moderation_tools/moderation_tools.vue
@@ -0,0 +1,110 @@
+<template>
+<div class='block' style='position: relative'>
+ <Popper
+ trigger="click"
+ @hide='showDropDown = false'
+ append-to-body
+ :options="{
+ placement: 'bottom-end',
+ modifiers: {
+ arrow: { enabled: true },
+ offset: { offset: '0, 5px' },
+ }
+ }">
+ <div class="popper-wrapper">
+ <div class="dropdown-menu">
+ <span v-if='user.is_local'>
+ <button class="dropdown-item" @click='toggleRight("admin")'>
+ {{ $t(!!user.rights.admin ? 'user_card.admin_menu.revoke_admin' : 'user_card.admin_menu.grant_admin') }}
+ </button>
+ <button class="dropdown-item" @click='toggleRight("moderator")'>
+ {{ $t(!!user.rights.moderator ? 'user_card.admin_menu.revoke_moderator' : 'user_card.admin_menu.grant_moderator') }}
+ </button>
+ <div role="separator" class="dropdown-divider"></div>
+ </span>
+ <button class="dropdown-item" @click='toggleActivationStatus()'>
+ {{ $t(!!user.deactivated ? 'user_card.admin_menu.activate_account' : 'user_card.admin_menu.deactivate_account') }}
+ </button>
+ <button class="dropdown-item" @click='deleteUserDialog(true)'>
+ {{ $t('user_card.admin_menu.delete_account') }}
+ </button>
+ <div role="separator" class="dropdown-divider" v-if='hasTagPolicy'></div>
+ <span v-if='hasTagPolicy'>
+ <button class="dropdown-item" @click='toggleTag(tags.FORCE_NSFW)'>
+ {{ $t('user_card.admin_menu.force_nsfw') }}
+ <span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_NSFW) }"></span>
+ </button>
+ <button class="dropdown-item" @click='toggleTag(tags.STRIP_MEDIA)'>
+ {{ $t('user_card.admin_menu.strip_media') }}
+ <span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.STRIP_MEDIA) }"></span>
+ </button>
+ <button class="dropdown-item" @click='toggleTag(tags.FORCE_UNLISTED)'>
+ {{ $t('user_card.admin_menu.force_unlisted') }}
+ <span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_UNLISTED) }"></span>
+ </button>
+ <button class="dropdown-item" @click='toggleTag(tags.SANDBOX)'>
+ {{ $t('user_card.admin_menu.sandbox') }}
+ <span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.SANDBOX) }"></span>
+ </button>
+ <button class="dropdown-item" v-if='user.is_local' @click='toggleTag(tags.DISABLE_REMOTE_SUBSCRIPTION)'>
+ {{ $t('user_card.admin_menu.disable_remote_subscription') }}
+ <span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_REMOTE_SUBSCRIPTION) }"></span>
+ </button>
+ <button class="dropdown-item" v-if='user.is_local' @click='toggleTag(tags.DISABLE_ANY_SUBSCRIPTION)'>
+ {{ $t('user_card.admin_menu.disable_any_subscription') }}
+ <span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_ANY_SUBSCRIPTION) }"></span>
+ </button>
+ <button class="dropdown-item" v-if='user.is_local' @click='toggleTag(tags.QUARANTINE)'>
+ {{ $t('user_card.admin_menu.quarantine') }}
+ <span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.QUARANTINE) }"></span>
+ </button>
+ </span>
+ </div>
+ </div>
+ <button slot="reference" v-bind:class="{ pressed: showDropDown }" @click='toggleMenu'>
+ {{ $t('user_card.admin_menu.moderation') }}
+ </button>
+ </Popper>
+ <portal to="modal">
+ <DialogModal v-if="showDeleteUserDialog" :onCancel='deleteUserDialog.bind(this, false)'>
+ <template slot="header">{{ $t('user_card.admin_menu.delete_user') }}</template>
+ <p>{{ $t('user_card.admin_menu.delete_user_confirmation') }}</p>
+ <template slot="footer">
+ <button class="btn btn-default" @click='deleteUserDialog(false)'>
+ {{ $t('general.cancel') }}
+ </button>
+ <button class="btn btn-default danger" @click='deleteUser()'>
+ {{ $t('user_card.admin_menu.delete_user') }}
+ </button>
+ </template>
+ </DialogModal>
+ </portal>
+</div>
+</template>
+
+<script src="./moderation_tools.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+@import '../popper/popper.scss';
+
+.menu-checkbox {
+ float: right;
+ min-width: 22px;
+ max-width: 22px;
+ min-height: 22px;
+ max-height: 22px;
+ line-height: 22px;
+ text-align: center;
+ border-radius: 0px;
+ background-color: $fallback--fg;
+ background-color: var(--input, $fallback--fg);
+ box-shadow: 0px 0px 2px black inset;
+ box-shadow: var(--inputShadow);
+
+ &.menu-checkbox-checked::after {
+ content: '✔';
+ }
+}
+
+</style>
diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue
index 7a7212fb..e6e0f074 100644
--- a/src/components/nav_panel/nav_panel.vue
+++ b/src/components/nav_panel/nav_panel.vue
@@ -8,8 +8,8 @@
</router-link>
</li>
<li v-if='currentUser'>
- <router-link :to="{ name: 'mentions', params: { username: currentUser.screen_name } }">
- {{ $t("nav.mentions") }}
+ <router-link :to="{ name: 'interactions', params: { username: currentUser.screen_name } }">
+ {{ $t("nav.interactions") }}
</router-link>
</li>
<li v-if='currentUser'>
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index fe5b7018..e59e7497 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -21,16 +21,28 @@ const Notification = {
},
userProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
+ },
+ getUser (notification) {
+ return this.$store.state.users.usersObject[notification.from_profile.id]
}
},
computed: {
userClass () {
- return highlightClass(this.notification.action.user)
+ return highlightClass(this.notification.from_profile)
},
userStyle () {
const highlight = this.$store.state.config.highlight
- const user = this.notification.action.user
+ const user = this.notification.from_profile
return highlightStyle(highlight[user.screen_name])
+ },
+ userInStore () {
+ return this.$store.getters.findUser(this.notification.from_profile.id)
+ },
+ user () {
+ if (this.userInStore) {
+ return this.userInStore
+ }
+ return this.notification.from_profile
}
}
}
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index 5e9cef97..3427b9c5 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -1,15 +1,20 @@
<template>
- <status v-if="notification.type === 'mention'" :compact="true" :statusoid="notification.status"></status>
- <div class="non-mention" :class="[userClass, { highlighted: userStyle }]" :style="[ userStyle ]"v-else>
- <a class='avatar-container' :href="notification.action.user.statusnet_profile_url" @click.stop.prevent.capture="toggleUserExpanded">
- <UserAvatar :compact="true" :betterShadow="betterShadow" :src="notification.action.user.profile_image_url_original"/>
+ <status
+ v-if="notification.type === 'mention'"
+ :compact="true"
+ :statusoid="notification.status"
+ >
+ </status>
+ <div class="non-mention" :class="[userClass, { highlighted: userStyle }]" :style="[ userStyle ]" v-else>
+ <a class='avatar-container' :href="notification.from_profile.statusnet_profile_url" @click.stop.prevent.capture="toggleUserExpanded">
+ <UserAvatar :compact="true" :betterShadow="betterShadow" :user="notification.from_profile"/>
</a>
<div class='notification-right'>
- <UserCard :user="notification.action.user" :rounded="true" :bordered="true" v-if="userExpanded"/>
+ <UserCard :user="getUser(notification)" :rounded="true" :bordered="true" v-if="userExpanded" />
<span class="notification-details">
<div class="name-and-action">
- <span class="username" v-if="!!notification.action.user.name_html" :title="'@'+notification.action.user.screen_name" v-html="notification.action.user.name_html"></span>
- <span class="username" v-else :title="'@'+notification.action.user.screen_name">{{ notification.action.user.name }}</span>
+ <span class="username" v-if="!!notification.from_profile.name_html" :title="'@'+notification.from_profile.screen_name" v-html="notification.from_profile.name_html"></span>
+ <span class="username" v-else :title="'@'+notification.from_profile.screen_name">{{ notification.from_profile.name }}</span>
<span v-if="notification.type === 'like'">
<i class="fa icon-star lit"></i>
<small>{{$t('notifications.favorited_you')}}</small>
@@ -23,19 +28,24 @@
<small>{{$t('notifications.followed_you')}}</small>
</span>
</div>
- <div class="timeago">
+ <div class="timeago" v-if="notification.type === 'follow'">
+ <span class="faint">
+ <timeago :since="notification.created_at" :auto-update="240"></timeago>
+ </span>
+ </div>
+ <div class="timeago" v-else>
<router-link v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }" class="faint-link">
- <timeago :since="notification.action.created_at" :auto-update="240"></timeago>
+ <timeago :since="notification.created_at" :auto-update="240"></timeago>
</router-link>
</div>
</span>
<div class="follow-text" v-if="notification.type === 'follow'">
- <router-link :to="userProfileLink(notification.action.user)">
- @{{notification.action.user.screen_name}}
+ <router-link :to="userProfileLink(notification.from_profile)">
+ @{{notification.from_profile.screen_name}}
</router-link>
</div>
<template v-else>
- <status class="faint" :compact="true" :statusoid="notification.status" :noHeading="true"></status>
+ <status class="faint" :compact="true" :statusoid="notification.action" :noHeading="true"></status>
</template>
</div>
</div>
diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js
index 9fc5e38a..6c4054fd 100644
--- a/src/components/notifications/notifications.js
+++ b/src/components/notifications/notifications.js
@@ -7,12 +7,14 @@ import {
} from '../../services/notification_utils/notification_utils.js'
const Notifications = {
- created () {
- const store = this.$store
- const credentials = store.state.users.currentUser.credentials
-
- const fetcherId = notificationsFetcher.startFetching({ store, credentials })
- this.$store.commit('setNotificationFetcher', { fetcherId })
+ props: {
+ // Disables display of panel header
+ noHeading: Boolean,
+ // Disables panel styles, unread mark, potentially other notification-related actions
+ // meant for "Interactions" timeline
+ minimalMode: Boolean,
+ // Custom filter mode, an array of strings, possible values 'mention', 'repeat', 'like', 'follow', used to override global filter for use in "Interactions" timeline
+ filterMode: Array
},
data () {
return {
@@ -20,6 +22,9 @@ const Notifications = {
}
},
computed: {
+ mainClass () {
+ return this.minimalMode ? '' : 'panel panel-default'
+ },
notifications () {
return notificationsFromStore(this.$store)
},
@@ -30,7 +35,7 @@ const Notifications = {
return unseenNotificationsFromStore(this.$store)
},
visibleNotifications () {
- return visibleNotificationsFromStore(this.$store)
+ return visibleNotificationsFromStore(this.$store, this.filterMode)
},
unseenCount () {
return this.unseenNotifications.length
@@ -53,9 +58,13 @@ const Notifications = {
},
methods: {
markAsSeen () {
- this.$store.dispatch('markNotificationsAsSeen', this.visibleNotifications)
+ this.$store.dispatch('markNotificationsAsSeen')
},
fetchOlderNotifications () {
+ if (this.loading) {
+ return
+ }
+
const store = this.$store
const credentials = store.state.users.currentUser.credentials
store.commit('setNotificationsLoading', { value: true })
diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss
index c0b458cc..622d12f4 100644
--- a/src/components/notifications/notifications.scss
+++ b/src/components/notifications/notifications.scss
@@ -1,8 +1,10 @@
@import '../../_variables.scss';
.notifications {
- // a bit of a hack to allow scrolling below notifications
- padding-bottom: 15em;
+ &:not(.minimal) {
+ // a bit of a hack to allow scrolling below notifications
+ padding-bottom: 15em;
+ }
.loadmore-error {
color: $fallback--text;
diff --git a/src/components/notifications/notifications.vue b/src/components/notifications/notifications.vue
index 6f162b62..c71499b2 100644
--- a/src/components/notifications/notifications.vue
+++ b/src/components/notifications/notifications.vue
@@ -1,7 +1,7 @@
<template>
- <div class="notifications">
- <div class="panel panel-default">
- <div class="panel-heading">
+ <div :class="{ minimal: minimalMode }" class="notifications">
+ <div :class="mainClass">
+ <div v-if="!noHeading" class="panel-heading">
<div class="title">
{{$t('notifications.notifications')}}
<span class="badge badge-notification unseen-count" v-if="unseenCount">{{unseenCount}}</span>
@@ -12,7 +12,7 @@
<button v-if="unseenCount" @click.prevent="markAsSeen" class="read-button">{{$t('notifications.read')}}</button>
</div>
<div class="panel-body">
- <div v-for="notification in visibleNotifications" :key="notification.action.id" class="notification" :class='{"unseen": !notification.seen}'>
+ <div v-for="notification in visibleNotifications" :key="notification.id" class="notification" :class='{"unseen": !minimalMode && !notification.seen}'>
<div class="notification-overlay"></div>
<notification :notification="notification"></notification>
</div>
@@ -22,7 +22,9 @@
{{$t('notifications.no_more_notifications')}}
</div>
<a v-else-if="!loading" href="#" v-on:click.prevent="fetchOlderNotifications()">
- <div class="new-status-notification text-center panel-footer">{{$t('notifications.load_older')}}</div>
+ <div class="new-status-notification text-center panel-footer">
+ {{ minimalMode ? $t('interactions.load_older') : $t('notifications.load_older')}}
+ </div>
</a>
<div v-else class="new-status-notification text-center panel-footer">
<i class="icon-spin3 animate-spin"/>
diff --git a/src/components/oauth_callback/oauth_callback.js b/src/components/oauth_callback/oauth_callback.js
index e3d45ee1..2c6ca235 100644
--- a/src/components/oauth_callback/oauth_callback.js
+++ b/src/components/oauth_callback/oauth_callback.js
@@ -4,14 +4,16 @@ const oac = {
props: ['code'],
mounted () {
if (this.code) {
+ const { clientId } = this.$store.state.oauth
+
oauth.getToken({
- app: this.$store.state.oauth,
+ clientId,
instance: this.$store.state.instance.server,
code: this.code
}).then((result) => {
this.$store.commit('setToken', result.access_token)
this.$store.dispatch('loginUser', result.access_token)
- this.$router.push({name: 'friends'})
+ this.$router.push({ name: 'friends' })
})
}
}
diff --git a/src/components/popper/popper.scss b/src/components/popper/popper.scss
new file mode 100644
index 00000000..cfc5c8e7
--- /dev/null
+++ b/src/components/popper/popper.scss
@@ -0,0 +1,127 @@
+@import '../../_variables.scss';
+
+.popper-wrapper {
+ z-index: 8;
+}
+
+.popper-wrapper .popper__arrow {
+ width: 0;
+ height: 0;
+ border-style: solid;
+ position: absolute;
+ margin: 5px;
+}
+
+.popper-wrapper[x-placement^="top"] {
+ margin-bottom: 5px;
+}
+
+.popper-wrapper[x-placement^="top"] .popper__arrow {
+ border-width: 5px 5px 0 5px;
+ border-color: $fallback--bg transparent transparent transparent;
+ border-color: var(--bg, $fallback--bg) transparent transparent transparent;
+ bottom: -5px;
+ left: calc(50% - 5px);
+ margin-top: 0;
+ margin-bottom: 0;
+}
+
+.popper-wrapper[x-placement^="bottom"] {
+ margin-top: 5px;
+}
+
+.popper-wrapper[x-placement^="bottom"] .popper__arrow {
+ border-width: 0 5px 5px 5px;
+ border-color: transparent transparent $fallback--bg transparent;
+ border-color: transparent transparent var(--bg, $fallback--bg) transparent;
+ top: -5px;
+ left: calc(50% - 5px);
+ margin-top: 0;
+ margin-bottom: 0;
+}
+
+.popper-wrapper[x-placement^="right"] {
+ margin-left: 5px;
+}
+
+.popper-wrapper[x-placement^="right"] .popper__arrow {
+ border-width: 5px 5px 5px 0;
+ border-color: transparent $fallback--bg transparent transparent;
+ border-color: transparent var(--bg, $fallback--bg) transparent transparent;
+ left: -5px;
+ top: calc(50% - 5px);
+ margin-left: 0;
+ margin-right: 0;
+}
+
+.popper-wrapper[x-placement^="left"] {
+ margin-right: 5px;
+}
+
+.popper-wrapper[x-placement^="left"] .popper__arrow {
+ border-width: 5px 0 5px 5px;
+ border-color: transparent transparent transparent $fallback--bg;
+ border-color: transparent transparent transparent var(--bg, $fallback--bg);
+ right: -5px;
+ top: calc(50% - 5px);
+ margin-left: 0;
+ margin-right: 0;
+}
+
+.dropdown-menu {
+ display: block;
+ padding: .5rem 0;
+ font-size: 1rem;
+ text-align: left;
+ list-style: none;
+ max-width: 100vw;
+ z-index: 10;
+ box-shadow: 1px 1px 4px rgba(0,0,0,.6);
+ box-shadow: var(--panelShadow);
+ border: none;
+ border-radius: $fallback--btnRadius;
+ border-radius: var(--btnRadius, $fallback--btnRadius);
+ background-color: $fallback--bg;
+ background-color: var(--bg, $fallback--bg);
+
+ .dropdown-divider {
+ height: 0;
+ margin: .5rem 0;
+ overflow: hidden;
+ border-top: 1px solid $fallback--border;
+ border-top: 1px solid var(--border, $fallback--border);
+ }
+
+ .dropdown-item {
+ line-height: 21px;
+ margin-right: 5px;
+ overflow: auto;
+ display: block;
+ padding: .25rem 1.0rem .25rem 1.5rem;
+ clear: both;
+ font-weight: 400;
+ text-align: inherit;
+ white-space: normal;
+ border: none;
+ border-radius: 0px;
+ background-color: transparent;
+ box-shadow: none;
+ width: 100%;
+ height: 100%;
+
+ &-icon {
+ padding-left: 0.5rem;
+
+ i {
+ margin-right: 0.25rem;
+ }
+ }
+
+ &:hover {
+ // TODO: improve the look on breeze themes
+ background-color: $fallback--fg;
+ background-color: var(--btn, $fallback--fg);
+ box-shadow: none;
+ }
+ }
+}
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index 229aefb7..cbd2024a 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -1,5 +1,6 @@
import statusPoster from '../../services/status_poster/status_poster.service.js'
import MediaUpload from '../media_upload/media_upload.vue'
+import ScopeSelector from '../scope_selector/scope_selector.vue'
import EmojiInput from '../emoji-input/emoji-input.vue'
import fileTypeService from '../../services/file_type/file_type.service.js'
import Completion from '../../services/completion/completion.js'
@@ -30,6 +31,7 @@ const PostStatusForm = {
],
components: {
MediaUpload,
+ ScopeSelector,
EmojiInput
},
mounted () {
@@ -80,14 +82,6 @@ const PostStatusForm = {
}
},
computed: {
- vis () {
- return {
- public: { selected: this.newStatus.visibility === 'public' },
- unlisted: { selected: this.newStatus.visibility === 'unlisted' },
- private: { selected: this.newStatus.visibility === 'private' },
- direct: { selected: this.newStatus.visibility === 'direct' }
- }
- },
candidates () {
const firstchar = this.textAtCaret.charAt(0)
if (firstchar === '@') {
@@ -135,6 +129,15 @@ const PostStatusForm = {
users () {
return this.$store.state.users.users
},
+ userDefaultScope () {
+ return this.$store.state.users.currentUser.default_scope
+ },
+ showAllScopes () {
+ const minimalScopesMode = typeof this.$store.state.config.minimalScopesMode === 'undefined'
+ ? this.$store.state.instance.minimalScopesMode
+ : this.$store.state.config.minimalScopesMode
+ return !minimalScopesMode
+ },
emoji () {
return this.$store.state.instance.emoji || []
},
@@ -159,8 +162,8 @@ const PostStatusForm = {
isOverLengthLimit () {
return this.hasStatusLengthLimit && (this.charactersLeft < 0)
},
- scopeOptionsEnabled () {
- return this.$store.state.instance.scopeOptionsEnabled
+ minimalScopesMode () {
+ return this.$store.state.instance.minimalScopesMode
},
alwaysShowSubject () {
if (typeof this.$store.state.config.alwaysShowSubjectInput !== 'undefined') {
@@ -168,7 +171,7 @@ const PostStatusForm = {
} else if (typeof this.$store.state.instance.alwaysShowSubjectInput !== 'undefined') {
return this.$store.state.instance.alwaysShowSubjectInput
} else {
- return this.$store.state.instance.scopeOptionsEnabled
+ return true
}
},
formattingOptionsEnabled () {
@@ -176,6 +179,12 @@ const PostStatusForm = {
},
postFormats () {
return this.$store.state.instance.postFormats || []
+ },
+ safeDMEnabled () {
+ return this.$store.state.instance.safeDM
+ },
+ hideScopeNotice () {
+ return this.$store.state.config.hideScopeNotice
}
},
methods: {
@@ -332,6 +341,9 @@ const PostStatusForm = {
},
changeVis (visibility) {
this.newStatus.visibility = visibility
+ },
+ dismissScopeNotice () {
+ this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true })
}
}
}
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index 9f9f16ba..25c5284f 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -3,13 +3,34 @@
<form @submit.prevent="postStatus(newStatus)">
<div class="form-group" >
<i18n
- v-if="!this.$store.state.users.currentUser.locked && this.newStatus.visibility == 'private'"
+ v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'"
path="post_status.account_not_locked_warning"
tag="p"
class="visibility-notice">
<router-link :to="{ name: 'user-settings' }">{{ $t('post_status.account_not_locked_warning_link') }}</router-link>
</i18n>
- <p v-if="this.newStatus.visibility == 'direct'" class="visibility-notice">{{ $t('post_status.direct_warning') }}</p>
+ <p v-if="!hideScopeNotice && newStatus.visibility === 'public'" class="visibility-notice notice-dismissible">
+ <span>{{ $t('post_status.scope_notice.public') }}</span>
+ <a v-on:click.prevent="dismissScopeNotice()" class="button-icon dismiss">
+ <i class='icon-cancel'></i>
+ </a>
+ </p>
+ <p v-else-if="!hideScopeNotice && newStatus.visibility === 'unlisted'" class="visibility-notice notice-dismissible">
+ <span>{{ $t('post_status.scope_notice.unlisted') }}</span>
+ <a v-on:click.prevent="dismissScopeNotice()" class="button-icon dismiss">
+ <i class='icon-cancel'></i>
+ </a>
+ </p>
+ <p v-else-if="!hideScopeNotice && newStatus.visibility === 'private' && $store.state.users.currentUser.locked" class="visibility-notice notice-dismissible">
+ <span>{{ $t('post_status.scope_notice.private') }}</span>
+ <a v-on:click.prevent="dismissScopeNotice()" class="button-icon dismiss">
+ <i class='icon-cancel'></i>
+ </a>
+ </p>
+ <p v-else-if="newStatus.visibility === 'direct'" class="visibility-notice">
+ <span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span>
+ <span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
+ </p>
<EmojiInput
v-if="newStatus.spoilerText || alwaysShowSubject"
type="text"
@@ -37,7 +58,7 @@
>
</textarea>
<div class="visibility-tray">
- <span class="text-format" v-if="formattingOptionsEnabled">
+ <div class="text-format" v-if="formattingOptionsEnabled">
<label for="post-content-type" class="select">
<select id="post-content-type" v-model="newStatus.contentType" class="form-control">
<option v-for="postFormat in postFormats" :key="postFormat" :value="postFormat">
@@ -46,14 +67,14 @@
</select>
<i class="icon-down-open"></i>
</label>
- </span>
-
- <div v-if="scopeOptionsEnabled">
- <i v-on:click="changeVis('direct')" class="icon-mail-alt" :class="vis.direct" :title="$t('post_status.scope.direct')"></i>
- <i v-on:click="changeVis('private')" class="icon-lock" :class="vis.private" :title="$t('post_status.scope.private')"></i>
- <i v-on:click="changeVis('unlisted')" class="icon-lock-open-alt" :class="vis.unlisted" :title="$t('post_status.scope.unlisted')"></i>
- <i v-on:click="changeVis('public')" class="icon-globe" :class="vis.public" :title="$t('post_status.scope.public')"></i>
</div>
+
+ <scope-selector
+ :showAll="showAllScopes"
+ :userDefault="userDefaultScope"
+ :originalScope="copyMessageScope"
+ :initialScope="newStatus.visibility"
+ :onScopeChange="changeVis"/>
</div>
</div>
<div class="autocomplete-panel" v-if="candidates">
@@ -131,10 +152,11 @@
display: flex;
justify-content: space-between;
flex-direction: row-reverse;
+ padding-top: 5px;
}
}
-.post-status-form, .login {
+.post-status-form {
.form-bottom {
display: flex;
padding: 0.5em;
@@ -229,7 +251,7 @@
.form-group {
display: flex;
flex-direction: column;
- padding: 0.3em 0.5em 0.6em;
+ padding: 0.25em 0.5em 0.5em;
line-height:24px;
}
diff --git a/src/components/progress_button/progress_button.vue b/src/components/progress_button/progress_button.vue
new file mode 100644
index 00000000..737360bb
--- /dev/null
+++ b/src/components/progress_button/progress_button.vue
@@ -0,0 +1,35 @@
+<template>
+ <button :disabled="progress || disabled" @click="onClick">
+ <template v-if="progress">
+ <slot name="progress" />
+ </template>
+ <template v-else>
+ <slot />
+ </template>
+ </button>
+</template>
+
+<script>
+export default {
+ props: {
+ disabled: {
+ type: Boolean
+ },
+ click: { // click event handler. Must return a promise
+ type: Function,
+ default: () => Promise.resolve()
+ }
+ },
+ data () {
+ return {
+ progress: false
+ }
+ },
+ methods: {
+ onClick () {
+ this.progress = true
+ this.click().then(() => { this.progress = false })
+ }
+ }
+}
+</script>
diff --git a/src/components/public_and_external_timeline/public_and_external_timeline.js b/src/components/public_and_external_timeline/public_and_external_timeline.js
index d45677e0..f614c13b 100644
--- a/src/components/public_and_external_timeline/public_and_external_timeline.js
+++ b/src/components/public_and_external_timeline/public_and_external_timeline.js
@@ -7,7 +7,7 @@ const PublicAndExternalTimeline = {
timeline () { return this.$store.state.statuses.timelines.publicAndExternal }
},
created () {
- this.$store.dispatch('startFetching', { timeline: 'publicAndExternal' })
+ this.$store.dispatch('startFetchingTimeline', { timeline: 'publicAndExternal' })
},
destroyed () {
this.$store.dispatch('stopFetching', 'publicAndExternal')
diff --git a/src/components/public_timeline/public_timeline.js b/src/components/public_timeline/public_timeline.js
index 64c951ac..8976a99c 100644
--- a/src/components/public_timeline/public_timeline.js
+++ b/src/components/public_timeline/public_timeline.js
@@ -7,7 +7,7 @@ const PublicTimeline = {
timeline () { return this.$store.state.statuses.timelines.public }
},
created () {
- this.$store.dispatch('startFetching', { timeline: 'public' })
+ this.$store.dispatch('startFetchingTimeline', { timeline: 'public' })
},
destroyed () {
this.$store.dispatch('stopFetching', 'public')
diff --git a/src/components/scope_selector/scope_selector.js b/src/components/scope_selector/scope_selector.js
new file mode 100644
index 00000000..8a42ee7b
--- /dev/null
+++ b/src/components/scope_selector/scope_selector.js
@@ -0,0 +1,54 @@
+const ScopeSelector = {
+ props: [
+ 'showAll',
+ 'userDefault',
+ 'originalScope',
+ 'initialScope',
+ 'onScopeChange'
+ ],
+ data () {
+ return {
+ currentScope: this.initialScope
+ }
+ },
+ computed: {
+ showNothing () {
+ return !this.showPublic && !this.showUnlisted && !this.showPrivate && !this.showDirect
+ },
+ showPublic () {
+ return this.originalScope !== 'direct' && this.shouldShow('public')
+ },
+ showUnlisted () {
+ return this.originalScope !== 'direct' && this.shouldShow('unlisted')
+ },
+ showPrivate () {
+ return this.originalScope !== 'direct' && this.shouldShow('private')
+ },
+ showDirect () {
+ return this.shouldShow('direct')
+ },
+ css () {
+ return {
+ public: {selected: this.currentScope === 'public'},
+ unlisted: {selected: this.currentScope === 'unlisted'},
+ private: {selected: this.currentScope === 'private'},
+ direct: {selected: this.currentScope === 'direct'}
+ }
+ }
+ },
+ methods: {
+ shouldShow (scope) {
+ return this.showAll ||
+ this.currentScope === scope ||
+ this.originalScope === scope ||
+ this.userDefault === scope ||
+ scope === 'direct'
+ },
+ changeVis (scope) {
+ this.currentScope = scope
+ this.onScopeChange && this.onScopeChange(scope)
+ }
+ }
+}
+
+export default ScopeSelector
diff --git a/src/components/scope_selector/scope_selector.vue b/src/components/scope_selector/scope_selector.vue
new file mode 100644
index 00000000..5ebb5d56
--- /dev/null
+++ b/src/components/scope_selector/scope_selector.vue
@@ -0,0 +1,46 @@
+<template>
+<div v-if="!showNothing" class="scope-selector">
+ <i class="icon-mail-alt"
+ :class="css.direct"
+ :title="$t('post_status.scope.direct')"
+ v-if="showDirect"
+ @click="changeVis('direct')">
+ </i>
+ <i class="icon-lock"
+ :class="css.private"
+ :title="$t('post_status.scope.private')"
+ v-if="showPrivate"
+ v-on:click="changeVis('private')">
+ </i>
+ <i class="icon-lock-open-alt"
+ :class="css.unlisted"
+ :title="$t('post_status.scope.unlisted')"
+ v-if="showUnlisted"
+ @click="changeVis('unlisted')">
+ </i>
+ <i class="icon-globe"
+ :class="css.public"
+ :title="$t('post_status.scope.public')"
+ v-if="showPublic"
+ @click="changeVis('public')">
+ </i>
+</div>
+</template>
+
+<script src="./scope_selector.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.scope-selector {
+ i {
+ font-size: 1.2em;
+ cursor: pointer;
+
+ &.selected {
+ color: $fallback--lightText;
+ color: var(--lightText, $fallback--lightText);
+ }
+ }
+}
+</style>
diff --git a/src/components/selectable_list/selectable_list.js b/src/components/selectable_list/selectable_list.js
new file mode 100644
index 00000000..10980d46
--- /dev/null
+++ b/src/components/selectable_list/selectable_list.js
@@ -0,0 +1,66 @@
+import List from '../list/list.vue'
+import Checkbox from '../checkbox/checkbox.vue'
+
+const SelectableList = {
+ components: {
+ List,
+ Checkbox
+ },
+ props: {
+ items: {
+ type: Array,
+ default: () => []
+ },
+ getKey: {
+ type: Function,
+ default: item => item.id
+ }
+ },
+ data () {
+ return {
+ selected: []
+ }
+ },
+ computed: {
+ allKeys () {
+ return this.items.map(this.getKey)
+ },
+ filteredSelected () {
+ return this.allKeys.filter(key => this.selected.indexOf(key) !== -1)
+ },
+ allSelected () {
+ return this.filteredSelected.length === this.items.length
+ },
+ noneSelected () {
+ return this.filteredSelected.length === 0
+ },
+ someSelected () {
+ return !this.allSelected && !this.noneSelected
+ }
+ },
+ methods: {
+ isSelected (item) {
+ return this.filteredSelected.indexOf(this.getKey(item)) !== -1
+ },
+ toggle (checked, item) {
+ const key = this.getKey(item)
+ const oldChecked = this.isSelected(key)
+ if (checked !== oldChecked) {
+ if (checked) {
+ this.selected.push(key)
+ } else {
+ this.selected.splice(this.selected.indexOf(key), 1)
+ }
+ }
+ },
+ toggleAll (value) {
+ if (value) {
+ this.selected = this.allKeys.slice(0)
+ } else {
+ this.selected = []
+ }
+ }
+ }
+}
+
+export default SelectableList
diff --git a/src/components/selectable_list/selectable_list.vue b/src/components/selectable_list/selectable_list.vue
new file mode 100644
index 00000000..3f16c921
--- /dev/null
+++ b/src/components/selectable_list/selectable_list.vue
@@ -0,0 +1,63 @@
+<template>
+ <div class="selectable-list">
+ <div class="selectable-list-header" v-if="items.length > 0">
+ <div class="selectable-list-checkbox-wrapper">
+ <Checkbox :checked="allSelected" @change="toggleAll" :indeterminate="someSelected">{{ $t('selectable_list.select_all') }}</Checkbox>
+ </div>
+ <div class="selectable-list-header-actions">
+ <slot name="header" :selected="filteredSelected" />
+ </div>
+ </div>
+ <List :items="items" :getKey="getKey">
+ <template slot="item" slot-scope="{item}">
+ <div class="selectable-list-item-inner" :class="{ 'selectable-list-item-selected-inner': isSelected(item) }">
+ <div class="selectable-list-checkbox-wrapper">
+ <Checkbox :checked="isSelected(item)" @change="checked => toggle(checked, item)" />
+ </div>
+ <slot name="item" :item="item" />
+ </div>
+ </template>
+ <template slot="empty"><slot name="empty" /></template>
+ </List>
+ </div>
+</template>
+
+<script src="./selectable_list.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.selectable-list {
+ &-item-inner {
+ display: flex;
+ align-items: center;
+
+ > * {
+ min-width: 0;
+ }
+ }
+
+ &-item-selected-inner {
+ background-color: $fallback--lightBg;
+ background-color: var(--lightBg, $fallback--lightBg);
+ }
+
+ &-header {
+ display: flex;
+ align-items: center;
+ padding: 0.6em 0;
+ border-bottom: 2px solid;
+ border-bottom-color: $fallback--border;
+ border-bottom-color: var(--border, $fallback--border);
+
+ &-actions {
+ flex: 1;
+ }
+ }
+
+ &-checkbox-wrapper {
+ padding: 0 10px;
+ flex: none;
+ }
+}
+</style>
diff --git a/src/components/settings/settings.js b/src/components/settings/settings.js
index 1d5f75ed..c4aa45b2 100644
--- a/src/components/settings/settings.js
+++ b/src/components/settings/settings.js
@@ -46,6 +46,7 @@ const settings = {
streamingLocal: user.streaming,
pauseOnUnfocusedLocal: user.pauseOnUnfocused,
hoverPreviewLocal: user.hoverPreview,
+ autohideFloatingPostButtonLocal: user.autohideFloatingPostButton,
hideMutedPostsLocal: typeof user.hideMutedPosts === 'undefined'
? instance.hideMutedPosts
@@ -70,13 +71,18 @@ const settings = {
alwaysShowSubjectInputLocal: typeof user.alwaysShowSubjectInput === 'undefined'
? instance.alwaysShowSubjectInput
: user.alwaysShowSubjectInput,
- alwaysShowSubjectInputDefault: instance.alwaysShowSubjectInput,
+ alwaysShowSubjectInputDefault: this.$t('settings.values.' + instance.alwaysShowSubjectInput),
scopeCopyLocal: typeof user.scopeCopy === 'undefined'
? instance.scopeCopy
: user.scopeCopy,
scopeCopyDefault: this.$t('settings.values.' + instance.scopeCopy),
+ minimalScopesModeLocal: typeof user.minimalScopesMode === 'undefined'
+ ? instance.minimalScopesMode
+ : user.minimalScopesMode,
+ minimalScopesModeDefault: this.$t('settings.values.' + instance.minimalScopesMode),
+
stopGifs: user.stopGifs,
webPushNotificationsLocal: user.webPushNotifications,
loopVideoSilentOnlyLocal: user.loopVideosSilentOnly,
@@ -178,6 +184,9 @@ const settings = {
hoverPreviewLocal (value) {
this.$store.dispatch('setOption', { name: 'hoverPreview', value })
},
+ autohideFloatingPostButtonLocal (value) {
+ this.$store.dispatch('setOption', { name: 'autohideFloatingPostButton', value })
+ },
muteWordsString (value) {
value = filter(value.split('\n'), (word) => trim(word).length > 0)
this.$store.dispatch('setOption', { name: 'muteWords', value })
@@ -200,6 +209,9 @@ const settings = {
postContentTypeLocal (value) {
this.$store.dispatch('setOption', { name: 'postContentType', value })
},
+ minimalScopesModeLocal (value) {
+ this.$store.dispatch('setOption', { name: 'minimalScopesMode', value })
+ },
stopGifs (value) {
this.$store.dispatch('setOption', { name: 'stopGifs', value })
},
diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue
index 33dad549..4cf6fae2 100644
--- a/src/components/settings/settings.vue
+++ b/src/components/settings/settings.vue
@@ -42,9 +42,7 @@
</li>
<li>
<input type="checkbox" id="collapseMessageWithSubject" v-model="collapseMessageWithSubjectLocal">
- <label for="collapseMessageWithSubject">
- {{$t('settings.collapse_subject')}} {{$t('settings.instance_default', { value: collapseMessageWithSubjectDefault })}}
- </label>
+ <label for="collapseMessageWithSubject">{{$t('settings.collapse_subject')}} {{$t('settings.instance_default', { value: collapseMessageWithSubjectDefault })}}</label>
</li>
<li>
<input type="checkbox" id="streaming" v-model="streamingLocal">
@@ -118,6 +116,16 @@
</label>
</div>
</li>
+ <li>
+ <input type="checkbox" id="minimalScopesMode" v-model="minimalScopesModeLocal">
+ <label for="minimalScopesMode">
+ {{$t('settings.minimal_scopes_mode')}} {{$t('settings.instance_default', { value: minimalScopesModeDefault })}}
+ </label>
+ </li>
+ <li>
+ <input type="checkbox" id="autohideFloatingPostButton" v-model="autohideFloatingPostButtonLocal">
+ <label for="autohideFloatingPostButton">{{$t('settings.autohide_floating_post_button')}}</label>
+ </li>
</ul>
</div>
@@ -295,70 +303,3 @@
<script src="./settings.js">
</script>
-
-<style lang="scss">
-@import '../../_variables.scss';
-
-.setting-item {
- border-bottom: 2px solid var(--fg, $fallback--fg);
- margin: 1em 1em 1.4em;
- padding-bottom: 1.4em;
-
- > div {
- margin-bottom: .5em;
- &:last-child {
- margin-bottom: 0;
- }
- }
-
- &:last-child {
- border-bottom: none;
- padding-bottom: 0;
- margin-bottom: 1em;
- }
-
- select {
- min-width: 10em;
- }
-
-
- textarea {
- width: 100%;
- height: 100px;
- }
-
- .unavailable,
- .unavailable i {
- color: var(--cRed, $fallback--cRed);
- color: $fallback--cRed;
- }
-
- .btn {
- min-height: 28px;
- min-width: 10em;
- padding: 0 2em;
- }
-
- .number-input {
- max-width: 6em;
- }
-}
-.select-multiple {
- display: flex;
- .option-list {
- margin: 0;
- padding-left: .5em;
- }
-}
-.setting-list,
-.option-list{
- list-style-type: none;
- padding-left: 2em;
- li {
- margin-bottom: 0.5em;
- }
- .suboptions {
- margin-top: 0.3em
- }
-}
-</style>
diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue
index e5046496..6428b1b0 100644
--- a/src/components/side_drawer/side_drawer.vue
+++ b/src/components/side_drawer/side_drawer.vue
@@ -22,13 +22,13 @@
</router-link>
</li>
<li v-if="currentUser" @click="toggleDrawer">
- <router-link :to="{ name: 'notifications', params: { username: currentUser.screen_name } }">
- {{ $t("notifications.notifications") }} {{ unseenNotificationsCount > 0 ? `(${unseenNotificationsCount})` : '' }}
+ <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
+ {{ $t("nav.dms") }}
</router-link>
</li>
<li v-if="currentUser" @click="toggleDrawer">
- <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
- {{ $t("nav.dms") }}
+ <router-link :to="{ name: 'interactions', params: { username: currentUser.screen_name } }">
+ {{ $t("nav.interactions") }}
</router-link>
</li>
</ul>
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 550fe76f..ea4c2b9d 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -1,17 +1,18 @@
import Attachment from '../attachment/attachment.vue'
import FavoriteButton from '../favorite_button/favorite_button.vue'
import RetweetButton from '../retweet_button/retweet_button.vue'
-import DeleteButton from '../delete_button/delete_button.vue'
+import ExtraButtons from '../extra_buttons/extra_buttons.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue'
import UserCard from '../user_card/user_card.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import Gallery from '../gallery/gallery.vue'
import LinkPreview from '../link-preview/link-preview.vue'
+import AvatarList from '../avatar_list/avatar_list.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import fileType from 'src/services/file_type/file_type.service'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js'
-import { filter, find, unescape } from 'lodash'
+import { filter, find, unescape, uniqBy } from 'lodash'
const Status = {
name: 'Status',
@@ -25,18 +26,19 @@ const Status = {
'replies',
'isPreview',
'noHeading',
- 'inlineExpanded'
+ 'inlineExpanded',
+ 'showPinned'
],
data () {
return {
replying: false,
- expanded: false,
unmuted: false,
userExpanded: false,
preview: null,
showPreview: false,
showingTall: this.inConversation && this.focused,
showingLongSubject: false,
+ error: null,
expandingSubject: typeof this.$store.state.config.collapseMessageWithSubject === 'undefined'
? !this.$store.state.instance.collapseMessageWithSubject
: !this.$store.state.config.collapseMessageWithSubject,
@@ -97,6 +99,10 @@ const Status = {
return this.statusoid
}
},
+ statusFromGlobalRepository () {
+ // NOTE: Consider to replace status with statusFromGlobalRepository
+ return this.$store.state.statuses.allStatusesObject[this.status.id]
+ },
loggedIn () {
return !!this.$store.state.users.currentUser
},
@@ -156,7 +162,7 @@ const Status = {
if (this.$store.state.config.replyVisibility === 'all') {
return false
}
- if (this.inlineExpanded || this.expanded || this.inConversation || !this.isReply) {
+ if (this.inConversation || !this.isReply) {
return false
}
if (this.status.user.id === this.$store.state.users.currentUser.id) {
@@ -170,7 +176,7 @@ const Status = {
if (this.status.user.id === this.status.attentions[i].id) {
continue
}
- if (checkFollowing && this.status.attentions[i].following) {
+ if (checkFollowing && this.$store.getters.findUser(this.status.attentions[i].id).following) {
return false
}
if (this.status.attentions[i].id === this.$store.state.users.currentUser.id) {
@@ -251,18 +257,39 @@ const Status = {
},
maxThumbnails () {
return this.$store.state.config.maxThumbnails
+ },
+ contentHtml () {
+ if (!this.status.summary_html) {
+ return this.status.statusnet_html
+ }
+ return this.status.summary_html + '<br />' + this.status.statusnet_html
+ },
+ combinedFavsAndRepeatsUsers () {
+ // Use the status from the global status repository since favs and repeats are saved in it
+ const combinedUsers = [].concat(
+ this.statusFromGlobalRepository.favoritedBy,
+ this.statusFromGlobalRepository.rebloggedBy
+ )
+ return uniqBy(combinedUsers, 'id')
+ },
+ ownStatus () {
+ return this.status.user.id === this.$store.state.users.currentUser.id
+ },
+ tags () {
+ return this.status.tags.filter(tagObj => tagObj.hasOwnProperty('name')).map(tagObj => tagObj.name).join(' ')
}
},
components: {
Attachment,
FavoriteButton,
RetweetButton,
- DeleteButton,
+ ExtraButtons,
PostStatusForm,
UserCard,
UserAvatar,
Gallery,
- LinkPreview
+ LinkPreview,
+ AvatarList
},
methods: {
visibilityIcon (visibility) {
@@ -277,6 +304,12 @@ const Status = {
return 'icon-globe'
}
},
+ showError (error) {
+ this.error = error
+ },
+ clearError () {
+ this.error = undefined
+ },
linkClicked (event) {
let { target } = event
if (target.tagName === 'SPAN') {
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 1f415534..e1dd81ac 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -1,5 +1,9 @@
<template>
<div class="status-el" v-if="!hideStatus" :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]">
+ <div v-if="error" class="alert error">
+ {{error}}
+ <i class="button-icon icon-cancel" @click="clearError"></i>
+ </div>
<template v-if="muted && !isPreview">
<div class="media status container muted">
<small>
@@ -12,8 +16,12 @@
</div>
</template>
<template v-else>
+ <div v-if="showPinned && statusoid.pinned" class="status-pin">
+ <i class="fa icon-pin faint"></i>
+ <span class="faint">{{$t('status.pinned')}}</span>
+ </div>
<div v-if="retweet && !noHeading && !inConversation" :class="[repeaterClass, { highlighted: repeaterStyle }]" :style="[repeaterStyle]" class="media container retweet-info">
- <UserAvatar class="media-left" v-if="retweet" :betterShadow="betterShadow" :src="statusoid.user.profile_image_url_original"/>
+ <UserAvatar class="media-left" v-if="retweet" :betterShadow="betterShadow" :user="statusoid.user"/>
<div class="media-body faint">
<span class="user-name">
<router-link v-if="retweeterHtml" :to="retweeterProfileLink" v-html="retweeterHtml"/>
@@ -24,10 +32,10 @@
</div>
</div>
- <div :class="[userClass, { highlighted: userStyle, 'is-retweet': retweet && !inConversation }]" :style="[ userStyle ]" class="media status">
+ <div :class="[userClass, { highlighted: userStyle, 'is-retweet': retweet && !inConversation }]" :style="[ userStyle ]" class="media status" :data-tags="tags">
<div v-if="!noHeading" class="media-left">
<router-link :to="userProfileLink" @click.stop.prevent.capture.native="toggleUserExpanded">
- <UserAvatar :compact="compact" :betterShadow="betterShadow" :src="status.user.profile_image_url_original"/>
+ <UserAvatar :compact="compact" :betterShadow="betterShadow" :user="status.user"/>
</router-link>
</div>
<div class="status-body">
@@ -91,23 +99,28 @@
</div>
<div v-if="showPreview" class="status-preview-container">
- <status class="status-preview" v-if="preview" :isPreview="true" :statusoid="preview" :compact=true></status>
- <div class="status-preview status-preview-loading" v-else>
+ <status class="status-preview"
+ v-if="preview"
+ :isPreview="true"
+ :statusoid="preview"
+ :compact="true"
+ />
+ <div v-else class="status-preview status-preview-loading">
<i class="icon-spin4 animate-spin"></i>
</div>
</div>
<div class="status-content-wrapper" :class="{ 'tall-status': !showingLongSubject }" v-if="longSubject">
- <a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="!showingLongSubject" href="#" @click.prevent="showingLongSubject=true">Show more</a>
- <div @click.prevent="linkClicked" class="status-content media-body" v-html="status.statusnet_html"></div>
- <a v-if="showingLongSubject" href="#" class="status-unhider" @click.prevent="showingLongSubject=false">Show less</a>
+ <a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="!showingLongSubject" href="#" @click.prevent="showingLongSubject=true">{{$t("general.show_more")}}</a>
+ <div @click.prevent="linkClicked" class="status-content media-body" v-html="contentHtml"></div>
+ <a v-if="showingLongSubject" href="#" class="status-unhider" @click.prevent="showingLongSubject=false">{{$t("general.show_less")}}</a>
</div>
<div :class="{'tall-status': hideTallStatus}" class="status-content-wrapper" v-else>
- <a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="hideTallStatus" href="#" @click.prevent="toggleShowMore">Show more</a>
- <div @click.prevent="linkClicked" class="status-content media-body" v-html="status.statusnet_html" v-if="!hideSubjectStatus"></div>
+ <a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="hideTallStatus" href="#" @click.prevent="toggleShowMore">{{$t("general.show_more")}}</a>
+ <div @click.prevent="linkClicked" class="status-content media-body" v-html="contentHtml" v-if="!hideSubjectStatus"></div>
<div @click.prevent="linkClicked" class="status-content media-body" v-html="status.summary_html" v-else></div>
- <a v-if="hideSubjectStatus" href="#" class="cw-status-hider" @click.prevent="toggleShowMore">Show more</a>
- <a v-if="showingMore" href="#" class="status-unhider" @click.prevent="toggleShowMore">Show less</a>
+ <a v-if="hideSubjectStatus" href="#" class="cw-status-hider" @click.prevent="toggleShowMore">{{$t("general.show_more")}}</a>
+ <a v-if="showingMore" href="#" class="status-unhider" @click.prevent="toggleShowMore">{{$t("general.show_less")}}</a>
</div>
<div v-if="status.attachments && (!hideSubjectStatus || showingLongSubject)" class="attachments media-body">
@@ -133,19 +146,37 @@
<link-preview :card="status.card" :size="attachmentSize" :nsfw="nsfwClickthrough" />
</div>
+ <transition name="fade">
+ <div class="favs-repeated-users" v-if="isFocused && combinedFavsAndRepeatsUsers.length > 0">
+ <div class="stats">
+ <div class="stat-count" v-if="statusFromGlobalRepository.rebloggedBy && statusFromGlobalRepository.rebloggedBy.length > 0">
+ <a class="stat-title">{{ $t('status.repeats') }}</a>
+ <div class="stat-number">{{ statusFromGlobalRepository.rebloggedBy.length }}</div>
+ </div>
+ <div class="stat-count" v-if="statusFromGlobalRepository.favoritedBy && statusFromGlobalRepository.favoritedBy.length > 0">
+ <a class="stat-title">{{ $t('status.favorites') }}</a>
+ <div class="stat-number">{{ statusFromGlobalRepository.favoritedBy.length }}</div>
+ </div>
+ <div class="avatar-row">
+ <AvatarList :users="combinedFavsAndRepeatsUsers"></AvatarList>
+ </div>
+ </div>
+ </div>
+ </transition>
+
<div v-if="!noHeading && !isPreview" class='status-actions media-body'>
- <div v-if="loggedIn">
- <i class="button-icon icon-reply" v-on:click.prevent="toggleReplying" :title="$t('tool_tip.reply')" :class="{'icon-reply-active': replying}"></i>
+ <div>
+ <i class="button-icon icon-reply" v-on:click.prevent="toggleReplying" :title="$t('tool_tip.reply')" :class="{'button-icon-active': replying}" v-if="loggedIn"/>
+ <i class="button-icon button-icon-disabled icon-reply" :title="$t('tool_tip.reply')" v-else />
<span v-if="status.replies_count > 0">{{status.replies_count}}</span>
</div>
<retweet-button :visibility='status.visibility' :loggedIn='loggedIn' :status='status'></retweet-button>
<favorite-button :loggedIn='loggedIn' :status='status'></favorite-button>
- <delete-button :status='status'></delete-button>
+ <extra-buttons :status="status" @onError="showError" @onSuccess="clearError"></extra-buttons>
</div>
</div>
</div>
<div class="container" v-if="replying">
- <div class="reply-left"/>
<post-status-form class="reply-body" :reply-to="status.id" :attentions="status.attentions" :repliedUser="status.user" :copy-message-scope="status.visibility" :subject="replySubject" v-on:posted="toggleReplying"/>
</div>
</template>
@@ -175,6 +206,13 @@ $status-margin: 0.75em;
max-width: 100%;
}
+.status-pin {
+ padding: $status-margin $status-margin 0;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+}
+
.status-preview {
position: absolute;
max-width: 95%;
@@ -218,7 +256,6 @@ $status-margin: 0.75em;
}
.status-el {
- hyphens: auto;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
@@ -547,15 +584,13 @@ $status-margin: 0.75em;
}
}
-.icon-reply:hover {
- color: $fallback--cBlue;
- color: var(--cBlue, $fallback--cBlue);
- cursor: pointer;
-}
-
-.icon-reply.icon-reply-active {
- color: $fallback--cBlue;
- color: var(--cBlue, $fallback--cBlue);
+.button-icon.icon-reply {
+ &:not(.button-icon-disabled):hover,
+ &.button-icon-active {
+ color: $fallback--cBlue;
+ color: var(--cBlue, $fallback--cBlue);
+ cursor: pointer;
+ }
}
.status:hover .animated.avatar {
@@ -595,16 +630,11 @@ a.unmute {
margin-left: auto;
}
-.reply-left {
- flex: 0;
- min-width: 48px;
-}
-
.reply-body {
flex: 1;
}
-.timeline > {
+.timeline :not(.panel-disabled) > {
.status-el:last-child {
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
@@ -612,6 +642,50 @@ a.unmute {
}
}
+.favs-repeated-users {
+ margin-top: $status-margin;
+
+ .stats {
+ width: 100%;
+ display: flex;
+ line-height: 1em;
+
+ .stat-count {
+ margin-right: $status-margin;
+
+ .stat-title {
+ color: var(--faint, $fallback--faint);
+ font-size: 12px;
+ text-transform: uppercase;
+ position: relative;
+ }
+
+ .stat-number {
+ font-weight: bolder;
+ font-size: 16px;
+ line-height: 1em;
+ }
+ }
+
+ .avatar-row {
+ flex: 1;
+ overflow: hidden;
+ position: relative;
+ display: flex;
+ align-items: center;
+
+ &::before {
+ content: '';
+ position: absolute;
+ height: 100%;
+ width: 1px;
+ left: 0;
+ background-color: var(--faint, $fallback--faint);
+ }
+ }
+ }
+}
+
@media all and (max-width: 800px) {
.status-el {
.retweet-info {
diff --git a/src/components/tab_switcher/tab_switcher.js b/src/components/tab_switcher/tab_switcher.js
index 423df258..c949b458 100644
--- a/src/components/tab_switcher/tab_switcher.js
+++ b/src/components/tab_switcher/tab_switcher.js
@@ -4,15 +4,18 @@ import './tab_switcher.scss'
export default Vue.component('tab-switcher', {
name: 'TabSwitcher',
- props: ['renderOnlyFocused'],
+ props: ['renderOnlyFocused', 'onSwitch'],
data () {
return {
active: this.$slots.default.findIndex(_ => _.tag)
}
},
methods: {
- activateTab (index) {
+ activateTab (index, dataset) {
return () => {
+ if (typeof this.onSwitch === 'function') {
+ this.onSwitch.call(null, index, this.$slots.default[index].elm.dataset)
+ }
this.active = index
}
}
@@ -37,7 +40,11 @@ export default Vue.component('tab-switcher', {
return (
<div class={ classesWrapper.join(' ')}>
- <button disabled={slot.data.attrs.disabled} onClick={this.activateTab(index)} class={ classesTab.join(' ') }>{slot.data.attrs.label}</button>
+ <button
+ disabled={slot.data.attrs.disabled}
+ onClick={this.activateTab(index)}
+ class={classesTab.join(' ')}>
+ {slot.data.attrs.label}</button>
</div>
)
})
diff --git a/src/components/tag_timeline/tag_timeline.js b/src/components/tag_timeline/tag_timeline.js
index 41b09706..458eb1c5 100644
--- a/src/components/tag_timeline/tag_timeline.js
+++ b/src/components/tag_timeline/tag_timeline.js
@@ -3,7 +3,7 @@ import Timeline from '../timeline/timeline.vue'
const TagTimeline = {
created () {
this.$store.commit('clearTimeline', { timeline: 'tag' })
- this.$store.dispatch('startFetching', { timeline: 'tag', tag: this.tag })
+ this.$store.dispatch('startFetchingTimeline', { timeline: 'tag', tag: this.tag })
},
components: {
Timeline
@@ -15,7 +15,7 @@ const TagTimeline = {
watch: {
tag () {
this.$store.commit('clearTimeline', { timeline: 'tag' })
- this.$store.dispatch('startFetching', { timeline: 'tag', tag: this.tag })
+ this.$store.dispatch('startFetchingTimeline', { timeline: 'tag', tag: this.tag })
}
},
destroyed () {
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index 1da7d5cc..19d9a9ac 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -52,7 +52,7 @@ const Timeline = {
window.addEventListener('scroll', this.scrollLoad)
- if (this.timelineName === 'friends' && !credentials) { return false }
+ if (store.state.api.fetchers[this.timelineName]) { return false }
timelineFetcher.fetchAndUpdate({
store,
diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue
index e0a34bd1..e6a8d458 100644
--- a/src/components/timeline/timeline.vue
+++ b/src/components/timeline/timeline.vue
@@ -16,7 +16,7 @@
</div>
<div :class="classes.body">
<div class="timeline">
- <conversation
+ <conversation
v-for="status in timeline.visibleStatuses"
class="status-fadein"
:key="status.id"
diff --git a/src/components/user_avatar/user_avatar.js b/src/components/user_avatar/user_avatar.js
index e6fed3b5..a42b9c71 100644
--- a/src/components/user_avatar/user_avatar.js
+++ b/src/components/user_avatar/user_avatar.js
@@ -2,7 +2,7 @@ import StillImage from '../still-image/still-image.vue'
const UserAvatar = {
props: [
- 'src',
+ 'user',
'betterShadow',
'compact'
],
diff --git a/src/components/user_avatar/user_avatar.vue b/src/components/user_avatar/user_avatar.vue
index 6bf7123d..e5466fdf 100644
--- a/src/components/user_avatar/user_avatar.vue
+++ b/src/components/user_avatar/user_avatar.vue
@@ -1,8 +1,10 @@
<template>
<StillImage
class="avatar"
+ :alt="user.screen_name"
+ :title="user.screen_name"
+ :src="user.profile_image_url_original"
:class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }"
- :src="imgSrc"
:imageLoadError="imageLoadError"
/>
</template>
diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js
index 197c61d5..7c6ffa89 100644
--- a/src/components/user_card/user_card.js
+++ b/src/components/user_card/user_card.js
@@ -1,5 +1,6 @@
import UserAvatar from '../user_avatar/user_avatar.vue'
import RemoteFollow from '../remote_follow/remote_follow.vue'
+import ModerationTools from '../moderation_tools/moderation_tools.vue'
import { hex2rgb } from '../../services/color_convert/color_convert.js'
import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@@ -93,15 +94,17 @@ export default {
}
},
visibleRole () {
- const validRole = (this.user.role === 'admin' || this.user.role === 'moderator')
- const showRole = this.isOtherUser || this.user.show_role
-
- return validRole && showRole && this.user.role
+ const rights = this.user.rights
+ if (!rights) { return }
+ const validRole = rights.admin || rights.moderator
+ const roleTitle = rights.admin ? 'admin' : 'moderator'
+ return validRole && roleTitle
}
},
components: {
UserAvatar,
- RemoteFollow
+ RemoteFollow,
+ ModerationTools
},
methods: {
followUser () {
@@ -148,6 +151,9 @@ export default {
},
userProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
+ },
+ reportUser () {
+ this.$store.dispatch('openUserReportingModal', this.user.id)
}
}
}
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index 3259d1c5..b4495673 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -4,26 +4,26 @@
<div class='user-info'>
<div class='container'>
<router-link :to="userProfileLink(user)">
- <UserAvatar :betterShadow="betterShadow" :src="user.profile_image_url_original"/>
+ <UserAvatar :betterShadow="betterShadow" :user="user"/>
</router-link>
- <div class="name-and-screen-name">
+ <div class="user-summary">
<div class="top-line">
<div :title="user.name" class='user-name' v-if="user.name_html" v-html="user.name_html"></div>
<div :title="user.name" class='user-name' v-else>{{user.name}}</div>
<router-link :to="{ name: 'user-settings' }" v-if="!isOtherUser">
- <i class="button-icon icon-pencil usersettings" :title="$t('tool_tip.user_settings')"></i>
+ <i class="button-icon icon-wrench usersettings" :title="$t('tool_tip.user_settings')"></i>
</router-link>
<a :href="user.statusnet_profile_url" target="_blank" v-if="isOtherUser && !user.is_local">
<i class="icon-link-ext usersettings"></i>
</a>
</div>
- <router-link class='user-screen-name' :to="userProfileLink(user)">
- <span class="handle">@{{user.screen_name}}
- <span class="alert staff" v-if="!hideBio && !!visibleRole">{{visibleRole}}</span>
- </span><span v-if="user.locked"><i class="icon icon-lock"></i></span>
+ <div class="bottom-line">
+ <router-link class="user-screen-name" :to="userProfileLink(user)">@{{user.screen_name}}</router-link>
+ <span class="alert staff" v-if="!hideBio && !!visibleRole">{{visibleRole}}</span>
+ <span v-if="user.locked"><i class="icon icon-lock"></i></span>
<span v-if="!hideUserStatsLocal && !hideBio" class="dailyAvg">{{dailyAvg}} {{ $t('user_card.per_day') }}</span>
- </router-link>
+ </div>
</div>
</div>
<div class="user-meta">
@@ -99,6 +99,14 @@
</button>
</span>
</div>
+ <div class='block' v-if='isOtherUser && loggedIn'>
+ <span>
+ <button @click="reportUser">
+ {{ $t('user_card.report') }}
+ </button>
+ </span>
+ </div>
+ <ModerationTools :user='user' v-if='loggedIn.role === "admin"'/>
</div>
</div>
</div>
@@ -160,7 +168,7 @@
max-width: 100%;
max-height: 400px;
- .emoji {
+ &.emoji {
width: 32px;
height: 32px;
}
@@ -224,7 +232,7 @@
opacity: .8;
}
- .name-and-screen-name {
+ .user-summary {
display: block;
margin-left: 0.6em;
text-align: left;
@@ -241,6 +249,7 @@
vertical-align: middle;
object-fit: contain
}
+
.top-line {
display: flex;
}
@@ -261,15 +270,19 @@
}
}
- .user-screen-name {
- color: $fallback--lightText;
- color: var(--lightText, $fallback--lightText);
- display: inline-block;
+ .bottom-line {
+ display: flex;
font-weight: light;
font-size: 15px;
- padding-right: 0.1em;
- width: 100%;
- display: flex;
+
+ .user-screen-name {
+ min-width: 1px;
+ flex: 0 1 auto;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ color: $fallback--lightText;
+ color: var(--lightText, $fallback--lightText);
+ }
.dailyAvg {
min-width: 1px;
@@ -280,15 +293,9 @@
color: var(--text, $fallback--text);
}
- .handle {
- min-width: 1px;
- flex: 0 1 auto;
- text-overflow: ellipsis;
- overflow: hidden;
- }
-
// TODO use proper colors
.staff {
+ flex: none;
text-transform: capitalize;
color: $fallback--text;
color: var(--btnText, $fallback--text);
diff --git a/src/components/user_panel/user_panel.js b/src/components/user_panel/user_panel.js
index d4478290..c2f51eb6 100644
--- a/src/components/user_panel/user_panel.js
+++ b/src/components/user_panel/user_panel.js
@@ -1,13 +1,15 @@
-import LoginForm from '../login_form/login_form.vue'
+import AuthForm from '../auth_form/auth_form.js'
import PostStatusForm from '../post_status_form/post_status_form.vue'
import UserCard from '../user_card/user_card.vue'
+import { mapState } from 'vuex'
const UserPanel = {
computed: {
- user () { return this.$store.state.users.currentUser }
+ signedIn () { return this.user },
+ ...mapState({ user: state => state.users.currentUser })
},
components: {
- LoginForm,
+ AuthForm,
PostStatusForm,
UserCard
}
diff --git a/src/components/user_panel/user_panel.vue b/src/components/user_panel/user_panel.vue
index 8310f30e..37e28ca5 100644
--- a/src/components/user_panel/user_panel.vue
+++ b/src/components/user_panel/user_panel.vue
@@ -1,13 +1,20 @@
<template>
<div class="user-panel">
- <div v-if='user' class="panel panel-default" style="overflow: visible;">
+
+ <div v-if="signedIn" key="user-panel" class="panel panel-default signed-in">
<UserCard :user="user" :hideBio="true" rounded="top"/>
<div class="panel-footer">
<post-status-form v-if='user'></post-status-form>
</div>
</div>
- <login-form v-if='!user'></login-form>
+ <auth-form v-else key="user-panel"/>
</div>
</template>
<script src="./user_panel.js"></script>
+
+<style lang="scss">
+.user-panel .signed-in {
+ overflow: visible;
+}
+</style>
diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index 82df4510..eab330e7 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -1,47 +1,38 @@
-import { compose } from 'vue-compose'
import get from 'lodash/get'
import UserCard from '../user_card/user_card.vue'
import FollowCard from '../follow_card/follow_card.vue'
import Timeline from '../timeline/timeline.vue'
+import Conversation from '../conversation/conversation.vue'
+import ModerationTools from '../moderation_tools/moderation_tools.vue'
+import List from '../list/list.vue'
import withLoadMore from '../../hocs/with_load_more/with_load_more'
-import withList from '../../hocs/with_list/with_list'
-const FollowerList = compose(
- withLoadMore({
- fetch: (props, $store) => $store.dispatch('addFollowers', props.userId),
- select: (props, $store) => get($store.getters.findUser(props.userId), 'followers', []),
- destory: (props, $store) => $store.dispatch('clearFollowers', props.userId),
- childPropName: 'entries',
- additionalPropNames: ['userId']
- }),
- withList({ getEntryProps: user => ({ user }) })
-)(FollowCard)
+const FollowerList = withLoadMore({
+ fetch: (props, $store) => $store.dispatch('fetchFollowers', props.userId),
+ select: (props, $store) => get($store.getters.findUser(props.userId), 'followerIds', []).map(id => $store.getters.findUser(id)),
+ destroy: (props, $store) => $store.dispatch('clearFollowers', props.userId),
+ childPropName: 'items',
+ additionalPropNames: ['userId']
+})(List)
-const FriendList = compose(
- withLoadMore({
- fetch: (props, $store) => $store.dispatch('addFriends', props.userId),
- select: (props, $store) => get($store.getters.findUser(props.userId), 'friends', []),
- destory: (props, $store) => $store.dispatch('clearFriends', props.userId),
- childPropName: 'entries',
- additionalPropNames: ['userId']
- }),
- withList({ getEntryProps: user => ({ user }) })
-)(FollowCard)
+const FriendList = withLoadMore({
+ fetch: (props, $store) => $store.dispatch('fetchFriends', props.userId),
+ select: (props, $store) => get($store.getters.findUser(props.userId), 'friendIds', []).map(id => $store.getters.findUser(id)),
+ destroy: (props, $store) => $store.dispatch('clearFriends', props.userId),
+ childPropName: 'items',
+ additionalPropNames: ['userId']
+})(List)
const UserProfile = {
data () {
return {
error: false,
- fetchedUserId: null
+ userId: null
}
},
created () {
- if (!this.user.id) {
- this.fetchUserId()
- .then(() => this.startUp())
- } else {
- this.startUp()
- }
+ const routeParams = this.$route.params
+ this.load(routeParams.name || routeParams.id)
},
destroyed () {
this.cleanUp()
@@ -56,29 +47,12 @@ const UserProfile = {
media () {
return this.$store.state.statuses.timelines.media
},
- userId () {
- return this.$route.params.id || this.user.id || this.fetchedUserId
- },
- userName () {
- return this.$route.params.name || this.user.screen_name
- },
isUs () {
return this.userId && this.$store.state.users.currentUser.id &&
this.userId === this.$store.state.users.currentUser.id
},
- userInStore () {
- const routeParams = this.$route.params
- // This needs fetchedUserId so that computed will be refreshed when user is fetched
- return this.$store.getters.findUser(this.fetchedUserId || routeParams.name || routeParams.id)
- },
user () {
- if (this.timeline.statuses[0]) {
- return this.timeline.statuses[0].user
- }
- if (this.userInStore) {
- return this.userInStore
- }
- return {}
+ return this.$store.getters.findUser(this.userId)
},
isExternal () {
return this.$route.name === 'external-user-profile'
@@ -91,40 +65,39 @@ const UserProfile = {
}
},
methods: {
- startFetchFavorites () {
- if (this.isUs) {
- this.$store.dispatch('startFetching', { timeline: 'favorites', userId: this.userId })
- }
- },
- fetchUserId () {
- let fetchPromise
- if (this.userId && !this.$route.params.name) {
- fetchPromise = this.$store.dispatch('fetchUser', this.userId)
+ load (userNameOrId) {
+ // Check if user data is already loaded in store
+ const user = this.$store.getters.findUser(userNameOrId)
+ if (user) {
+ this.userId = user.id
+ this.fetchTimelines()
} else {
- fetchPromise = this.$store.dispatch('fetchUser', this.userName)
+ this.$store.dispatch('fetchUser', userNameOrId)
.then(({ id }) => {
- this.fetchedUserId = id
+ this.userId = id
+ this.fetchTimelines()
+ })
+ .catch((reason) => {
+ const errorMessage = get(reason, 'error.error')
+ if (errorMessage === 'No user with such user_id') { // Known error
+ this.error = this.$t('user_profile.profile_does_not_exist')
+ } else if (errorMessage) {
+ this.error = errorMessage
+ } else {
+ this.error = this.$t('user_profile.profile_loading_error')
+ }
})
}
- return fetchPromise
- .catch((reason) => {
- const errorMessage = get(reason, 'error.error')
- if (errorMessage === 'No user with such user_id') { // Known error
- this.error = this.$t('user_profile.profile_does_not_exist')
- } else if (errorMessage) {
- this.error = errorMessage
- } else {
- this.error = this.$t('user_profile.profile_loading_error')
- }
- })
- .then(() => this.startUp())
},
- startUp () {
- if (this.userId) {
- this.$store.dispatch('startFetching', { timeline: 'user', userId: this.userId })
- this.$store.dispatch('startFetching', { timeline: 'media', userId: this.userId })
- this.startFetchFavorites()
+ fetchTimelines () {
+ const userId = this.userId
+ this.$store.dispatch('startFetchingTimeline', { timeline: 'user', userId })
+ this.$store.dispatch('startFetchingTimeline', { timeline: 'media', userId })
+ if (this.isUs) {
+ this.$store.dispatch('startFetchingTimeline', { timeline: 'favorites', userId })
}
+ // Fetch all pinned statuses immediately
+ this.$store.dispatch('fetchPinnedStatuses', userId)
},
cleanUp () {
this.$store.dispatch('stopFetching', 'user')
@@ -136,18 +109,16 @@ const UserProfile = {
}
},
watch: {
- // userId can be undefined if we don't know it yet
- userId (newVal) {
+ '$route.params.id': function (newVal) {
if (newVal) {
this.cleanUp()
- this.startUp()
+ this.load(newVal)
}
},
- userName () {
- if (this.$route.params.name) {
- this.fetchUserId()
+ '$route.params.name': function (newVal) {
+ if (newVal) {
this.cleanUp()
- this.startUp()
+ this.load(newVal)
}
},
$route () {
@@ -158,7 +129,10 @@ const UserProfile = {
UserCard,
Timeline,
FollowerList,
- FriendList
+ FriendList,
+ ModerationTools,
+ FollowCard,
+ Conversation
}
}
diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index d449eb85..48b774ea 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -1,23 +1,43 @@
<template>
<div>
- <div v-if="user.id" class="user-profile panel panel-default">
+ <div v-if="user" class="user-profile panel panel-default">
<UserCard :user="user" :switcher="true" :selected="timeline.viewing" rounded="top"/>
<tab-switcher :renderOnlyFocused="true" ref="tabSwitcher">
- <Timeline
- :label="$t('user_card.statuses')"
- :disabled="!user.statuses_count"
- :count="user.statuses_count"
- :embedded="true"
- :title="$t('user_profile.timeline_title')"
- :timeline="timeline"
- :timeline-name="'user'"
- :user-id="userId"
- />
+ <div :label="$t('user_card.statuses')" :disabled="!user.statuses_count">
+ <div class="timeline">
+ <template v-for="statusId in user.pinnedStatuseIds">
+ <Conversation
+ v-if="timeline.statusesObject[statusId]"
+ class="status-fadein"
+ :key="statusId"
+ :statusoid="timeline.statusesObject[statusId]"
+ :collapsable="true"
+ :showPinned="true"
+ />
+ </template>
+ </div>
+ <Timeline
+ :count="user.statuses_count"
+ :embedded="true"
+ :title="$t('user_profile.timeline_title')"
+ :timeline="timeline"
+ :timeline-name="'user'"
+ :user-id="userId"
+ />
+ </div>
<div :label="$t('user_card.followees')" v-if="followsTabVisible" :disabled="!user.friends_count">
- <FriendList :userId="userId" />
+ <FriendList :userId="userId">
+ <template slot="item" slot-scope="{item}">
+ <FollowCard :user="item" />
+ </template>
+ </FriendList>
</div>
<div :label="$t('user_card.followers')" v-if="followersTabVisible" :disabled="!user.followers_count">
- <FollowerList :userId="userId" :entryProps="{noFollowsYou: isUs}" />
+ <FollowerList :userId="userId">
+ <template slot="item" slot-scope="{item}">
+ <FollowCard :user="item" :noFollowsYou="isUs" />
+ </template>
+ </FollowerList>
</div>
<Timeline
:label="$t('user_card.media')"
diff --git a/src/components/user_reporting_modal/user_reporting_modal.js b/src/components/user_reporting_modal/user_reporting_modal.js
new file mode 100644
index 00000000..7c6ea409
--- /dev/null
+++ b/src/components/user_reporting_modal/user_reporting_modal.js
@@ -0,0 +1,106 @@
+
+import Status from '../status/status.vue'
+import List from '../list/list.vue'
+import Checkbox from '../checkbox/checkbox.vue'
+
+const UserReportingModal = {
+ components: {
+ Status,
+ List,
+ Checkbox
+ },
+ data () {
+ return {
+ comment: '',
+ forward: false,
+ statusIdsToReport: [],
+ processing: false,
+ error: false
+ }
+ },
+ computed: {
+ isLoggedIn () {
+ return !!this.$store.state.users.currentUser
+ },
+ isOpen () {
+ return this.isLoggedIn && this.$store.state.reports.modalActivated
+ },
+ userId () {
+ return this.$store.state.reports.userId
+ },
+ user () {
+ return this.$store.getters.findUser(this.userId)
+ },
+ remoteInstance () {
+ return !this.user.is_local && this.user.screen_name.substr(this.user.screen_name.indexOf('@') + 1)
+ },
+ statuses () {
+ return this.$store.state.reports.statuses
+ }
+ },
+ watch: {
+ userId: 'resetState'
+ },
+ methods: {
+ resetState () {
+ // Reset state
+ this.comment = ''
+ this.forward = false
+ this.statusIdsToReport = []
+ this.processing = false
+ this.error = false
+ },
+ closeModal () {
+ this.$store.dispatch('closeUserReportingModal')
+ },
+ reportUser () {
+ this.processing = true
+ this.error = false
+ const params = {
+ userId: this.userId,
+ comment: this.comment,
+ forward: this.forward,
+ statusIds: this.statusIdsToReport
+ }
+ this.$store.state.api.backendInteractor.reportUser(params)
+ .then(() => {
+ this.processing = false
+ this.resetState()
+ this.closeModal()
+ })
+ .catch(() => {
+ this.processing = false
+ this.error = true
+ })
+ },
+ clearError () {
+ this.error = false
+ },
+ isChecked (statusId) {
+ return this.statusIdsToReport.indexOf(statusId) !== -1
+ },
+ toggleStatus (checked, statusId) {
+ if (checked === this.isChecked(statusId)) {
+ return
+ }
+
+ if (checked) {
+ this.statusIdsToReport.push(statusId)
+ } else {
+ this.statusIdsToReport.splice(this.statusIdsToReport.indexOf(statusId), 1)
+ }
+ },
+ resize (e) {
+ const target = e.target || e
+ if (!(target instanceof window.Element)) { return }
+ // Auto is needed to make textbox shrink when removing lines
+ target.style.height = 'auto'
+ target.style.height = `${target.scrollHeight}px`
+ if (target.value === '') {
+ target.style.height = null
+ }
+ }
+ }
+}
+
+export default UserReportingModal
diff --git a/src/components/user_reporting_modal/user_reporting_modal.vue b/src/components/user_reporting_modal/user_reporting_modal.vue
new file mode 100644
index 00000000..432dd14d
--- /dev/null
+++ b/src/components/user_reporting_modal/user_reporting_modal.vue
@@ -0,0 +1,157 @@
+<template>
+<div class="modal-view" @click="closeModal" v-if="isOpen">
+ <div class="user-reporting-panel panel" @click.stop="">
+ <div class="panel-heading">
+ <div class="title">{{$t('user_reporting.title', [user.screen_name])}}</div>
+ </div>
+ <div class="panel-body">
+ <div class="user-reporting-panel-left">
+ <div>
+ <p>{{$t('user_reporting.add_comment_description')}}</p>
+ <textarea
+ v-model="comment"
+ class="form-control"
+ :placeholder="$t('user_reporting.additional_comments')"
+ rows="1"
+ @input="resize"
+ />
+ </div>
+ <div v-if="!user.is_local">
+ <p>{{$t('user_reporting.forward_description')}}</p>
+ <Checkbox v-model="forward">{{$t('user_reporting.forward_to', [remoteInstance])}}</Checkbox>
+ </div>
+ <div>
+ <button class="btn btn-default" @click="reportUser" :disabled="processing">{{$t('user_reporting.submit')}}</button>
+ <div class="alert error" v-if="error">
+ {{$t('user_reporting.generic_error')}}
+ </div>
+ </div>
+ </div>
+ <div class="user-reporting-panel-right">
+ <List :items="statuses">
+ <template slot="item" slot-scope="{item}">
+ <div class="status-fadein user-reporting-panel-sitem">
+ <Status :inConversation="false" :focused="false" :statusoid="item" />
+ <Checkbox :checked="isChecked(item.id)" @change="checked => toggleStatus(checked, item.id)" />
+ </div>
+ </template>
+ </List>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script src="./user_reporting_modal.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.user-reporting-panel {
+ width: 90vw;
+ max-width: 700px;
+ min-height: 20vh;
+ max-height: 80vh;
+
+ .panel-heading {
+ .title {
+ text-align: center;
+ // TODO: Consider making these as default of panel
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+
+ .panel-body {
+ display: flex;
+ flex-direction: column-reverse;
+ border-top: 1px solid;
+ border-color: $fallback--border;
+ border-color: var(--border, $fallback--border);
+ overflow: hidden;
+ }
+
+ &-left {
+ padding: 1.1em 0.7em 0.7em;
+ line-height: 1.4em;
+ box-sizing: border-box;
+
+ > div {
+ margin-bottom: 1em;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ p {
+ margin-top: 0;
+ }
+
+ textarea.form-control {
+ line-height: 16px;
+ resize: none;
+ overflow: hidden;
+ transition: min-height 200ms 100ms;
+ min-height: 44px;
+ width: 100%;
+ }
+
+ .btn {
+ min-width: 10em;
+ padding: 0 2em;
+ }
+
+ .alert {
+ margin: 1em 0 0 0;
+ line-height: 1.3em;
+ }
+ }
+
+ &-right {
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ }
+
+ &-sitem {
+ display: flex;
+ justify-content: space-between;
+
+ > .status-el {
+ flex: 1;
+ }
+
+ > .checkbox {
+ margin: 0.75em;
+ }
+ }
+
+ @media all and (min-width: 801px) {
+ .panel-body {
+ flex-direction: row;
+ }
+
+ &-left {
+ width: 50%;
+ max-width: 320px;
+ border-right: 1px solid;
+ border-color: $fallback--border;
+ border-color: var(--border, $fallback--border);
+ padding: 1.1em;
+
+ > div {
+ margin-bottom: 2em;
+ }
+ }
+
+ &-right {
+ width: 50%;
+ flex: 1 1 auto;
+ margin-bottom: 12px;
+ }
+ }
+}
+</style>
diff --git a/src/components/user_search/user_search.js b/src/components/user_search/user_search.js
index 55040826..62dafdf1 100644
--- a/src/components/user_search/user_search.js
+++ b/src/components/user_search/user_search.js
@@ -1,5 +1,6 @@
import FollowCard from '../follow_card/follow_card.vue'
-import userSearchApi from '../../services/new_api/user_search.js'
+import map from 'lodash/map'
+
const userSearch = {
components: {
FollowCard
@@ -10,10 +11,15 @@ const userSearch = {
data () {
return {
username: '',
- users: [],
+ userIds: [],
loading: false
}
},
+ computed: {
+ users () {
+ return this.userIds.map(userId => this.$store.getters.findUser(userId))
+ }
+ },
mounted () {
this.search(this.query)
},
@@ -33,10 +39,10 @@ const userSearch = {
return
}
this.loading = true
- userSearchApi.search({query, store: this.$store})
+ this.$store.dispatch('searchUsers', query)
.then((res) => {
this.loading = false
- this.users = res
+ this.userIds = map(res, 'id')
})
}
}
diff --git a/src/components/user_search/user_search.vue b/src/components/user_search/user_search.vue
index 1269eea6..890b3c13 100644
--- a/src/components/user_search/user_search.vue
+++ b/src/components/user_search/user_search.vue
@@ -13,7 +13,7 @@
<i class="icon-spin3 animate-spin"/>
</div>
<div v-else class="panel-body">
- <FollowCard v-for="user in users" :key="user.id" :user="user"/>
+ <FollowCard v-for="user in users" :key="user.id" :user="user" class="list-item"/>
</div>
</div>
</template>
diff --git a/src/components/user_settings/confirm.js b/src/components/user_settings/confirm.js
new file mode 100644
index 00000000..0f4ddfc9
--- /dev/null
+++ b/src/components/user_settings/confirm.js
@@ -0,0 +1,9 @@
+const Confirm = {
+ props: ['disabled'],
+ data: () => ({}),
+ methods: {
+ confirm () { this.$emit('confirm') },
+ cancel () { this.$emit('cancel') }
+ }
+}
+export default Confirm
diff --git a/src/components/user_settings/confirm.vue b/src/components/user_settings/confirm.vue
new file mode 100644
index 00000000..46a42e38
--- /dev/null
+++ b/src/components/user_settings/confirm.vue
@@ -0,0 +1,14 @@
+<template>
+<div>
+ <slot></slot>
+ <button class="btn btn-default" @click="confirm" :disabled="disabled">
+ {{$t('general.confirm')}}
+ </button>
+ <button class="btn btn-default" @click="cancel" :disabled="disabled">
+ {{$t('general.cancel')}}
+ </button>
+</div>
+</template>
+
+<script src="./confirm.js">
+</script>
diff --git a/src/components/user_settings/mfa.js b/src/components/user_settings/mfa.js
new file mode 100644
index 00000000..2acee862
--- /dev/null
+++ b/src/components/user_settings/mfa.js
@@ -0,0 +1,152 @@
+import RecoveryCodes from './mfa_backup_codes.vue'
+import TOTP from './mfa_totp.vue'
+import Confirm from './confirm.vue'
+import VueQrcode from '@chenfengyuan/vue-qrcode'
+import { mapState } from 'vuex'
+
+const Mfa = {
+ data: () => ({
+ settings: { // current settings of MFA
+ enabled: false,
+ totp: false
+ },
+ setupState: { // setup mfa
+ state: '', // state of setup. '' -> 'getBackupCodes' -> 'setupOTP' -> 'complete'
+ setupOTPState: '' // state of setup otp. '' -> 'prepare' -> 'confirm' -> 'complete'
+ },
+ backupCodes: {
+ getNewCodes: false,
+ inProgress: false, // progress of fetch codes
+ codes: []
+ },
+ otpSettings: { // pre-setup setting of OTP. secret key, qrcode url.
+ provisioning_uri: '',
+ key: ''
+ },
+ currentPassword: null,
+ otpConfirmToken: null,
+ error: null,
+ readyInit: false
+ }),
+ components: {
+ 'recovery-codes': RecoveryCodes,
+ 'totp-item': TOTP,
+ 'qrcode': VueQrcode,
+ 'confirm': Confirm
+ },
+ computed: {
+ canSetupOTP () {
+ return (
+ (this.setupInProgress && this.backupCodesPrepared) ||
+ this.settings.enabled
+ ) && !this.settings.totp && !this.setupOTPInProgress
+ },
+ setupInProgress () {
+ return this.setupState.state !== '' && this.setupState.state !== 'complete'
+ },
+ setupOTPInProgress () {
+ return this.setupState.state === 'setupOTP' && !this.completedOTP
+ },
+ prepareOTP () {
+ return this.setupState.setupOTPState === 'prepare'
+ },
+ confirmOTP () {
+ return this.setupState.setupOTPState === 'confirm'
+ },
+ completedOTP () {
+ return this.setupState.setupOTPState === 'completed'
+ },
+ backupCodesPrepared () {
+ return !this.backupCodes.inProgress && this.backupCodes.codes.length > 0
+ },
+ confirmNewBackupCodes () {
+ return this.backupCodes.getNewCodes
+ },
+ ...mapState({
+ backendInteractor: (state) => state.api.backendInteractor
+ })
+ },
+
+ methods: {
+ activateOTP () {
+ if (!this.settings.enabled) {
+ this.setupState.state = 'getBackupcodes'
+ this.fetchBackupCodes()
+ }
+ },
+ fetchBackupCodes () {
+ this.backupCodes.inProgress = true
+ this.backupCodes.codes = []
+
+ return this.backendInteractor.generateMfaBackupCodes()
+ .then((res) => {
+ this.backupCodes.codes = res.codes
+ this.backupCodes.inProgress = false
+ })
+ },
+ getBackupCodes () { // get a new backup codes
+ this.backupCodes.getNewCodes = true
+ },
+ confirmBackupCodes () { // confirm getting new backup codes
+ this.fetchBackupCodes().then((res) => {
+ this.backupCodes.getNewCodes = false
+ })
+ },
+ cancelBackupCodes () { // cancel confirm form of new backup codes
+ this.backupCodes.getNewCodes = false
+ },
+
+ // Setup OTP
+ setupOTP () { // prepare setup OTP
+ this.setupState.state = 'setupOTP'
+ this.setupState.setupOTPState = 'prepare'
+ this.backendInteractor.mfaSetupOTP()
+ .then((res) => {
+ this.otpSettings = res
+ this.setupState.setupOTPState = 'confirm'
+ })
+ },
+ doConfirmOTP () { // handler confirm enable OTP
+ this.error = null
+ this.backendInteractor.mfaConfirmOTP({
+ token: this.otpConfirmToken,
+ password: this.currentPassword
+ })
+ .then((res) => {
+ if (res.error) {
+ this.error = res.error
+ return
+ }
+ this.completeSetup()
+ })
+ },
+
+ completeSetup () {
+ this.setupState.setupOTPState = 'complete'
+ this.setupState.state = 'complete'
+ this.currentPassword = null
+ this.error = null
+ this.fetchSettings()
+ },
+ cancelSetup () { // cancel setup
+ this.setupState.setupOTPState = ''
+ this.setupState.state = ''
+ this.currentPassword = null
+ this.error = null
+ },
+ // end Setup OTP
+
+ // fetch settings from server
+ async fetchSettings () {
+ let result = await this.backendInteractor.fetchSettingsMFA()
+ this.settings = result.settings
+ return result
+ }
+ },
+ mounted () {
+ this.fetchSettings().then(() => {
+ this.readyInit = true
+ })
+ }
+}
+export default Mfa
diff --git a/src/components/user_settings/mfa.vue b/src/components/user_settings/mfa.vue
new file mode 100644
index 00000000..ded426dd
--- /dev/null
+++ b/src/components/user_settings/mfa.vue
@@ -0,0 +1,121 @@
+<template>
+<div class="setting-item mfa-settings" v-if="readyInit">
+
+ <div class="mfa-heading">
+ <h2>{{$t('settings.mfa.title')}}</h2>
+ </div>
+
+ <div>
+ <div class="setting-item" v-if="!setupInProgress">
+ <!-- Enabled methods -->
+ <h3>{{$t('settings.mfa.authentication_methods')}}</h3>
+ <totp-item :settings="settings" @deactivate="fetchSettings" @activate="activateOTP"/>
+ <br />
+
+ <div v-if="settings.enabled"> <!-- backup codes block-->
+ <recovery-codes :backup-codes="backupCodes" v-if="!confirmNewBackupCodes" />
+ <button class="btn btn-default" @click="getBackupCodes" v-if="!confirmNewBackupCodes">
+ {{$t('settings.mfa.generate_new_recovery_codes')}}
+ </button>
+
+ <div v-if="confirmNewBackupCodes">
+ <confirm @confirm="confirmBackupCodes" @cancel="cancelBackupCodes"
+ :disabled="backupCodes.inProgress">
+ <p class="warning">{{$t('settings.mfa.warning_of_generate_new_codes')}}</p>
+ </confirm>
+ </div>
+ </div>
+ </div>
+
+ <div v-if="setupInProgress"> <!-- setup block-->
+
+ <h3>{{$t('settings.mfa.setup_otp')}}</h3>
+
+ <recovery-codes :backup-codes="backupCodes" v-if="!setupOTPInProgress"/>
+
+
+ <button class="btn btn-default" @click="cancelSetup" v-if="canSetupOTP">
+ {{$t('general.cancel')}}
+ </button>
+
+ <button class="btn btn-default" v-if="canSetupOTP" @click="setupOTP">
+ {{$t('settings.mfa.setup_otp')}}
+ </button>
+
+ <template v-if="setupOTPInProgress">
+ <i v-if="prepareOTP">{{$t('settings.mfa.wait_pre_setup_otp')}}</i>
+
+ <div v-if="confirmOTP">
+ <div class="setup-otp">
+ <div class="qr-code">
+ <h4>{{$t('settings.mfa.scan.title')}}</h4>
+ <p>{{$t('settings.mfa.scan.desc')}}</p>
+ <qrcode :value="otpSettings.provisioning_uri" :options="{ width: 200 }"></qrcode>
+ <p>
+ {{$t('settings.mfa.scan.secret_code')}}:
+ {{otpSettings.key}}
+ </p>
+ </div>
+
+ <div class="verify">
+ <h4>{{$t('general.verify')}}</h4>
+ <p>{{$t('settings.mfa.verify.desc')}}</p>
+ <input type="text" v-model="otpConfirmToken">
+
+ <p>{{$t('settings.enter_current_password_to_confirm')}}:</p>
+ <input type="password" v-model="currentPassword">
+ <div class="confirm-otp-actions">
+ <button class="btn btn-default" @click="doConfirmOTP">
+ {{$t('settings.mfa.confirm_and_enable')}}
+ </button>
+ <button class="btn btn-default" @click="cancelSetup">
+ {{$t('general.cancel')}}
+ </button>
+ </div>
+ <div class="alert error" v-if="error">{{error}}</div>
+ </div>
+ </div>
+ </div>
+ </template>
+ </div>
+
+ </div>
+</div>
+</template>
+
+<script src="./mfa.js"></script>
+<style lang="scss">
+@import '../../_variables.scss';
+.warning {
+ color: $fallback--cOrange;
+ color: var(--cOrange, $fallback--cOrange);
+}
+.mfa-settings {
+ .mfa-heading, .method-item {
+ overflow: hidden;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: baseline;
+ }
+
+ .setup-otp {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+ .qr-code {
+ flex: 1;
+ padding-right: 10px;
+ }
+ .verify { flex: 1; }
+ .error { margin: 4px 0 0 0; }
+ .confirm-otp-actions {
+ button {
+ width: 15em;
+ margin-top: 5px;
+ }
+
+ }
+ }
+}
+</style>
diff --git a/src/components/user_settings/mfa_backup_codes.js b/src/components/user_settings/mfa_backup_codes.js
new file mode 100644
index 00000000..f0a984ec
--- /dev/null
+++ b/src/components/user_settings/mfa_backup_codes.js
@@ -0,0 +1,17 @@
+export default {
+ props: {
+ backupCodes: {
+ type: Object,
+ default: () => ({
+ inProgress: false,
+ codes: []
+ })
+ }
+ },
+ data: () => ({}),
+ computed: {
+ inProgress () { return this.backupCodes.inProgress },
+ ready () { return this.backupCodes.codes.length > 0 },
+ displayTitle () { return this.inProgress || this.ready }
+ }
+}
diff --git a/src/components/user_settings/mfa_backup_codes.vue b/src/components/user_settings/mfa_backup_codes.vue
new file mode 100644
index 00000000..c275bd63
--- /dev/null
+++ b/src/components/user_settings/mfa_backup_codes.vue
@@ -0,0 +1,22 @@
+<template>
+<div>
+ <h4 v-if="displayTitle">{{$t('settings.mfa.recovery_codes')}}</h4>
+ <i v-if="inProgress">{{$t('settings.mfa.waiting_a_recovery_codes')}}</i>
+ <template v-if="ready">
+ <p class="alert warning">{{$t('settings.mfa.recovery_codes_warning')}}</p>
+ <ul class="backup-codes"><li v-for="code in backupCodes.codes">{{code}}</li></ul>
+ </template>
+</div>
+</template>
+<script src="./mfa_backup_codes.js"></script>
+<style lang="scss">
+@import '../../_variables.scss';
+
+.warning {
+ color: $fallback--cOrange;
+ color: var(--cOrange, $fallback--cOrange);
+}
+.backup-codes {
+ font-family: var(--postCodeFont, monospace);
+}
+</style>
diff --git a/src/components/user_settings/mfa_totp.js b/src/components/user_settings/mfa_totp.js
new file mode 100644
index 00000000..8408d8e9
--- /dev/null
+++ b/src/components/user_settings/mfa_totp.js
@@ -0,0 +1,49 @@
+import Confirm from './confirm.vue'
+import { mapState } from 'vuex'
+
+export default {
+ props: ['settings'],
+ data: () => ({
+ error: false,
+ currentPassword: '',
+ deactivate: false,
+ inProgress: false // progress peform request to disable otp method
+ }),
+ components: {
+ 'confirm': Confirm
+ },
+ computed: {
+ isActivated () {
+ return this.settings.totp
+ },
+ ...mapState({
+ backendInteractor: (state) => state.api.backendInteractor
+ })
+ },
+ methods: {
+ doActivate () {
+ this.$emit('activate')
+ },
+ cancelDeactivate () { this.deactivate = false },
+ doDeactivate () {
+ this.error = null
+ this.deactivate = true
+ },
+ confirmDeactivate () { // confirm deactivate TOTP method
+ this.error = null
+ this.inProgress = true
+ this.backendInteractor.mfaDisableOTP({
+ password: this.currentPassword
+ })
+ .then((res) => {
+ this.inProgress = false
+ if (res.error) {
+ this.error = res.error
+ return
+ }
+ this.deactivate = false
+ this.$emit('deactivate')
+ })
+ }
+ }
+}
diff --git a/src/components/user_settings/mfa_totp.vue b/src/components/user_settings/mfa_totp.vue
new file mode 100644
index 00000000..6b73c8f4
--- /dev/null
+++ b/src/components/user_settings/mfa_totp.vue
@@ -0,0 +1,23 @@
+<template>
+<div>
+ <div class="method-item">
+ <strong>{{$t('settings.mfa.otp')}}</strong>
+ <button class="btn btn-default" v-if="!isActivated" @click="doActivate">
+ {{$t('general.enable')}}
+ </button>
+
+ <button class="btn btn-default" :disabled="deactivate" @click="doDeactivate"
+ v-if="isActivated">
+ {{$t('general.disable')}}
+ </button>
+ </div>
+
+ <confirm @confirm="confirmDeactivate" @cancel="cancelDeactivate"
+ :disabled="inProgress" v-if="deactivate">
+ {{$t('settings.enter_current_password_to_confirm')}}:
+ <input type="password" v-model="currentPassword">
+ </confirm>
+ <div class="alert error" v-if="error">{{error}}</div>
+</div>
+</template>
+<script src="./mfa_totp.js"></script>
diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js
index 5cb23b97..69505806 100644
--- a/src/components/user_settings/user_settings.js
+++ b/src/components/user_settings/user_settings.js
@@ -1,33 +1,35 @@
-import { compose } from 'vue-compose'
import unescape from 'lodash/unescape'
import get from 'lodash/get'
+import map from 'lodash/map'
+import reject from 'lodash/reject'
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 ScopeSelector from '../scope_selector/scope_selector.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 SelectableList from '../selectable_list/selectable_list.vue'
+import ProgressButton from '../progress_button/progress_button.vue'
import EmojiInput from '../emoji-input/emoji-input.vue'
+import Autosuggest from '../autosuggest/autosuggest.vue'
+import Importer from '../importer/importer.vue'
+import Exporter from '../exporter/exporter.vue'
import withSubscription from '../../hocs/with_subscription/with_subscription'
-import withList from '../../hocs/with_list/with_list'
+import userSearchApi from '../../services/new_api/user_search.js'
+import Mfa from './mfa.vue'
-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 BlockList = withSubscription({
+ fetch: (props, $store) => $store.dispatch('fetchBlocks'),
+ select: (props, $store) => get($store.state.users.currentUser, 'blockIds', []),
+ childPropName: 'items'
+})(SelectableList)
-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 MuteList = withSubscription({
+ fetch: (props, $store) => $store.dispatch('fetchMutes'),
+ select: (props, $store) => get($store.state.users.currentUser, 'muteIds', []),
+ childPropName: 'items'
+})(SelectableList)
const UserSettings = {
data () {
@@ -41,14 +43,9 @@ const UserSettings = {
hideFollowers: this.$store.state.users.currentUser.hide_followers,
showRole: this.$store.state.users.currentUser.show_role,
role: this.$store.state.users.currentUser.role,
- followList: null,
- followImportError: false,
- followsImported: false,
- enableFollowsExport: true,
pickAvatarBtnVisible: true,
bannerUploading: false,
backgroundUploading: false,
- followListUploading: false,
bannerPreview: null,
backgroundPreview: null,
bannerUploadError: null,
@@ -59,7 +56,8 @@ const UserSettings = {
changePasswordInputs: [ '', '', '' ],
changedPassword: false,
changePasswordError: false,
- activeTab: 'profile'
+ activeTab: 'profile',
+ notificationSettings: this.$store.state.users.currentUser.notification_settings
}
},
created () {
@@ -67,11 +65,19 @@ const UserSettings = {
},
components: {
StyleSwitcher,
+ ScopeSelector,
TabSwitcher,
ImageCropper,
BlockList,
MuteList,
- EmojiInput
+ EmojiInput,
+ Autosuggest,
+ BlockCard,
+ MuteCard,
+ ProgressButton,
+ Importer,
+ Exporter,
+ Mfa
},
computed: {
user () {
@@ -80,8 +86,8 @@ const UserSettings = {
pleromaBackend () {
return this.$store.state.instance.pleromaBackend
},
- scopeOptionsEnabled () {
- return this.$store.state.instance.scopeOptionsEnabled
+ minimalScopesMode () {
+ return this.$store.state.instance.minimalScopesMode
},
vis () {
return {
@@ -106,39 +112,29 @@ const UserSettings = {
},
methods: {
updateProfile () {
- const name = this.newName
- const description = this.newBio
- const locked = this.newLocked
- // Backend notation.
- /* eslint-disable camelcase */
- const default_scope = this.newDefaultScope
- const no_rich_text = this.newNoRichText
- const hide_follows = this.hideFollows
- const hide_followers = this.hideFollowers
- const show_role = this.showRole
-
- /* eslint-enable camelcase */
this.$store.state.api.backendInteractor
.updateProfile({
params: {
- name,
- description,
- locked,
+ note: this.newBio,
+ locked: this.newLocked,
// Backend notation.
/* eslint-disable camelcase */
- default_scope,
- no_rich_text,
- hide_follows,
- hide_followers,
- show_role
+ display_name: this.newName,
+ default_scope: this.newDefaultScope,
+ no_rich_text: this.newNoRichText,
+ hide_follows: this.hideFollows,
+ hide_followers: this.hideFollowers,
+ show_role: this.showRole
/* eslint-enable camelcase */
}}).then((user) => {
- if (!user.error) {
- this.$store.commit('addNewUsers', [user])
- this.$store.commit('setCurrentUser', user)
- }
+ this.$store.commit('addNewUsers', [user])
+ this.$store.commit('setCurrentUser', user)
})
},
+ updateNotificationSettings () {
+ this.$store.state.api.backendInteractor
+ .updateNotificationSettings({ settings: this.notificationSettings })
+ },
changeVis (visibility) {
this.newDefaultScope = visibility
},
@@ -156,23 +152,29 @@ const UserSettings = {
reader.onload = ({target}) => {
const img = target.result
this[slot + 'Preview'] = img
+ this[slot] = file
}
reader.readAsDataURL(file)
},
submitAvatar (cropper, file) {
- let img
- if (cropper) {
- img = cropper.getCroppedCanvas().toDataURL(file.type)
- } else {
- img = file
- }
+ const that = this
+ return new Promise((resolve, reject) => {
+ function updateAvatar (avatar) {
+ that.$store.state.api.backendInteractor.updateAvatar({ avatar })
+ .then((user) => {
+ that.$store.commit('addNewUsers', [user])
+ that.$store.commit('setCurrentUser', user)
+ resolve()
+ })
+ .catch((err) => {
+ reject(new Error(that.$t('upload.error.base') + ' ' + err.message))
+ })
+ }
- return this.$store.state.api.backendInteractor.updateAvatar({ params: { img } }).then((user) => {
- if (!user.error) {
- this.$store.commit('addNewUsers', [user])
- this.$store.commit('setCurrentUser', user)
+ if (cropper) {
+ cropper.getCroppedCanvas().toBlob(updateAvatar, file.type)
} else {
- throw new Error(this.$t('upload.error.base') + user.error)
+ updateAvatar(file)
}
})
},
@@ -182,30 +184,17 @@ const UserSettings = {
submitBanner () {
if (!this.bannerPreview) { return }
- let banner = this.bannerPreview
- // eslint-disable-next-line no-undef
- let imginfo = new Image()
- /* eslint-disable camelcase */
- let offset_top, offset_left, width, height
- imginfo.src = banner
- width = imginfo.width
- height = imginfo.height
- offset_top = 0
- offset_left = 0
this.bannerUploading = true
- this.$store.state.api.backendInteractor.updateBanner({params: {banner, offset_top, offset_left, width, height}}).then((data) => {
- if (!data.error) {
- let clone = JSON.parse(JSON.stringify(this.$store.state.users.currentUser))
- clone.cover_photo = data.url
- this.$store.commit('addNewUsers', [clone])
- this.$store.commit('setCurrentUser', clone)
+ this.$store.state.api.backendInteractor.updateBanner({banner: this.banner})
+ .then((user) => {
+ this.$store.commit('addNewUsers', [user])
+ this.$store.commit('setCurrentUser', user)
this.bannerPreview = null
- } else {
- this.bannerUploadError = this.$t('upload.error.base') + data.error
- }
- this.bannerUploading = false
- })
- /* eslint-enable camelcase */
+ })
+ .catch((err) => {
+ this.bannerUploadError = this.$t('upload.error.base') + ' ' + err.message
+ })
+ .then(() => { this.bannerUploading = false })
},
submitBg () {
if (!this.backgroundPreview) { return }
@@ -232,62 +221,41 @@ const UserSettings = {
this.backgroundUploading = false
})
},
- importFollows () {
- this.followListUploading = true
- const followList = this.followList
- this.$store.state.api.backendInteractor.followImport({params: followList})
+ importFollows (file) {
+ return this.$store.state.api.backendInteractor.importFollows(file)
.then((status) => {
- if (status) {
- this.followsImported = true
- } else {
- this.followImportError = true
+ if (!status) {
+ throw new Error('failed')
}
- this.followListUploading = false
})
},
- /* This function takes an Array of Users
- * and outputs a file with all the addresses for the user to download
- */
- exportPeople (users, filename) {
- // Get all the friends addresses
- var UserAddresses = users.map(function (user) {
+ importBlocks (file) {
+ return this.$store.state.api.backendInteractor.importBlocks(file)
+ .then((status) => {
+ if (!status) {
+ throw new Error('failed')
+ }
+ })
+ },
+ generateExportableUsersContent (users) {
+ // Get addresses
+ return users.map((user) => {
// check is it's a local user
if (user && user.is_local) {
// append the instance address
// eslint-disable-next-line no-undef
- user.screen_name += '@' + location.hostname
+ return user.screen_name + '@' + location.hostname
}
return user.screen_name
}).join('\n')
- // Make the user download the file
- var fileToDownload = document.createElement('a')
- fileToDownload.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(UserAddresses))
- fileToDownload.setAttribute('download', filename)
- fileToDownload.style.display = 'none'
- document.body.appendChild(fileToDownload)
- fileToDownload.click()
- document.body.removeChild(fileToDownload)
- },
- exportFollows () {
- this.enableFollowsExport = false
- this.$store.state.api.backendInteractor
- .exportFriends({
- id: this.$store.state.users.currentUser.id
- })
- .then((friendList) => {
- this.exportPeople(friendList, 'friends.csv')
- setTimeout(() => { this.enableFollowsExport = true }, 2000)
- })
},
- followListChange () {
- // eslint-disable-next-line no-undef
- let formData = new FormData()
- formData.append('list', this.$refs.followlist.files[0])
- this.followList = formData
+ getFollowsContent () {
+ return this.$store.state.api.backendInteractor.exportFriends({ id: this.$store.state.users.currentUser.id })
+ .then(this.generateExportableUsersContent)
},
- dismissImported () {
- this.followsImported = false
- this.followImportError = false
+ getBlocksContent () {
+ return this.$store.state.api.backendInteractor.fetchBlocks()
+ .then(this.generateExportableUsersContent)
},
confirmDelete () {
this.deletingAccount = true
@@ -332,6 +300,40 @@ const UserSettings = {
if (window.confirm(`${this.$i18n.t('settings.revoke_token')}?`)) {
this.$store.dispatch('revokeToken', id)
}
+ },
+ filterUnblockedUsers (userIds) {
+ return reject(userIds, (userId) => {
+ const user = this.$store.getters.findUser(userId)
+ return !user || user.statusnet_blocking || user.id === this.$store.state.users.currentUser.id
+ })
+ },
+ filterUnMutedUsers (userIds) {
+ return reject(userIds, (userId) => {
+ const user = this.$store.getters.findUser(userId)
+ return !user || user.muted || user.id === this.$store.state.users.currentUser.id
+ })
+ },
+ queryUserIds (query) {
+ return userSearchApi.search({query, store: this.$store})
+ .then((users) => {
+ this.$store.dispatch('addNewUsers', users)
+ return map(users, 'id')
+ })
+ },
+ blockUsers (ids) {
+ return this.$store.dispatch('blockUsers', ids)
+ },
+ unblockUsers (ids) {
+ return this.$store.dispatch('unblockUsers', ids)
+ },
+ muteUsers (ids) {
+ return this.$store.dispatch('muteUsers', ids)
+ },
+ unmuteUsers (ids) {
+ return this.$store.dispatch('unmuteUsers', ids)
+ },
+ identity (value) {
+ return value
}
}
}
diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue
index 52df143c..bbe41f11 100644
--- a/src/components/user_settings/user_settings.vue
+++ b/src/components/user_settings/user_settings.vue
@@ -22,7 +22,7 @@
<div class="setting-item" >
<h2>{{$t('settings.name_bio')}}</h2>
<p>{{$t('settings.name')}}</p>
- <EmojiInput
+ <EmojiInput
type="text"
v-model="newName"
id="username"
@@ -38,13 +38,14 @@
<input type="checkbox" v-model="newLocked" id="account-locked">
<label for="account-locked">{{$t('settings.lock_account_description')}}</label>
</p>
- <div v-if="scopeOptionsEnabled">
+ <div>
<label for="default-vis">{{$t('settings.default_vis')}}</label>
<div id="default-vis" class="visibility-tray">
- <i v-on:click="changeVis('direct')" class="icon-mail-alt" :class="vis.direct" :title="$t('post_status.scope.direct')" ></i>
- <i v-on:click="changeVis('private')" class="icon-lock" :class="vis.private" :title="$t('post_status.scope.private')"></i>
- <i v-on:click="changeVis('unlisted')" class="icon-lock-open-alt" :class="vis.unlisted" :title="$t('post_status.scope.unlisted')"></i>
- <i v-on:click="changeVis('public')" class="icon-globe" :class="vis.public" :title="$t('post_status.scope.public')"></i>
+ <scope-selector
+ :showAll="true"
+ :userDefault="newDefaultScope"
+ :initialScope="newDefaultScope"
+ :onScopeChange="changeVis"/>
</div>
</div>
<p>
@@ -151,7 +152,7 @@
</tbody>
</table>
</div>
-
+ <mfa />
<div class="setting-item">
<h2>{{$t('settings.delete_account')}}</h2>
<p v-if="!deletingAccount">{{$t('settings.delete_account_description')}}</p>
@@ -167,43 +168,110 @@
</div>
</div>
+ <div :label="$t('settings.notifications')" v-if="pleromaBackend">
+ <div class="setting-item">
+ <div class="select-multiple">
+ <span class="label">{{$t('settings.notification_setting')}}</span>
+ <ul class="option-list">
+ <li>
+ <input type="checkbox" id="notification-setting-follows" v-model="notificationSettings.follows">
+ <label for="notification-setting-follows">
+ {{$t('settings.notification_setting_follows')}}
+ </label>
+ </li>
+ <li>
+ <input type="checkbox" id="notification-setting-followers" v-model="notificationSettings.followers">
+ <label for="notification-setting-followers">
+ {{$t('settings.notification_setting_followers')}}
+ </label>
+ </li>
+ <li>
+ <input type="checkbox" id="notification-setting-non-follows" v-model="notificationSettings.non_follows">
+ <label for="notification-setting-non-follows">
+ {{$t('settings.notification_setting_non_follows')}}
+ </label>
+ </li>
+ <li>
+ <input type="checkbox" id="notification-setting-non-followers" v-model="notificationSettings.non_followers">
+ <label for="notification-setting-non-followers">
+ {{$t('settings.notification_setting_non_followers')}}
+ </label>
+ </li>
+ </ul>
+ </div>
+ <p>{{$t('settings.notification_mutes')}}</p>
+ <p>{{$t('settings.notification_blocks')}}</p>
+ <button class="btn btn-default" @click="updateNotificationSettings">{{$t('general.submit')}}</button>
+ </div>
+ </div>
+
<div :label="$t('settings.data_import_export_tab')" v-if="pleromaBackend">
<div class="setting-item">
<h2>{{$t('settings.follow_import')}}</h2>
<p>{{$t('settings.import_followers_from_a_csv_file')}}</p>
- <form>
- <input type="file" ref="followlist" v-on:change="followListChange" />
- </form>
- <i class=" icon-spin4 animate-spin uploading" v-if="followListUploading"></i>
- <button class="btn btn-default" v-else @click="importFollows">{{$t('general.submit')}}</button>
- <div v-if="followsImported">
- <i class="icon-cross" @click="dismissImported"></i>
- <p>{{$t('settings.follows_imported')}}</p>
- </div>
- <div v-else-if="followImportError">
- <i class="icon-cross" @click="dismissImported"></i>
- <p>{{$t('settings.follow_import_error')}}</p>
- </div>
+ <Importer :submitHandler="importFollows" :successMessage="$t('settings.follows_imported')" :errorMessage="$t('settings.follow_import_error')" />
</div>
- <div class="setting-item" v-if="enableFollowsExport">
+ <div class="setting-item">
<h2>{{$t('settings.follow_export')}}</h2>
- <button class="btn btn-default" @click="exportFollows">{{$t('settings.follow_export_button')}}</button>
+ <Exporter :getContent="getFollowsContent" filename="friends.csv" :exportButtonLabel="$t('settings.follow_export_button')" />
+ </div>
+ <div class="setting-item">
+ <h2>{{$t('settings.block_import')}}</h2>
+ <p>{{$t('settings.import_blocks_from_a_csv_file')}}</p>
+ <Importer :submitHandler="importBlocks" :successMessage="$t('settings.blocks_imported')" :errorMessage="$t('settings.block_import_error')" />
</div>
- <div class="setting-item" v-else>
- <h2>{{$t('settings.follow_export_processing')}}</h2>
+ <div class="setting-item">
+ <h2>{{$t('settings.block_export')}}</h2>
+ <Exporter :getContent="getBlocksContent" filename="blocks.csv" :exportButtonLabel="$t('settings.block_export_button')" />
</div>
</div>
<div :label="$t('settings.blocks_tab')">
- <block-list :refresh="true">
+ <div class="profile-edit-usersearch-wrapper">
+ <Autosuggest :filter="filterUnblockedUsers" :query="queryUserIds" :placeholder="$t('settings.search_user_to_block')">
+ <BlockCard slot-scope="row" :userId="row.item"/>
+ </Autosuggest>
+ </div>
+ <BlockList :refresh="true" :getKey="identity">
+ <template slot="header" slot-scope="{selected}">
+ <div class="profile-edit-bulk-actions">
+ <ProgressButton class="btn btn-default" v-if="selected.length > 0" :click="() => blockUsers(selected)">
+ {{ $t('user_card.block') }}
+ <template slot="progress">{{ $t('user_card.block_progress') }}</template>
+ </ProgressButton>
+ <ProgressButton class="btn btn-default" v-if="selected.length > 0" :click="() => unblockUsers(selected)">
+ {{ $t('user_card.unblock') }}
+ <template slot="progress">{{ $t('user_card.unblock_progress') }}</template>
+ </ProgressButton>
+ </div>
+ </template>
+ <template slot="item" slot-scope="{item}"><BlockCard :userId="item" /></template>
<template slot="empty">{{$t('settings.no_blocks')}}</template>
- </block-list>
+ </BlockList>
</div>
<div :label="$t('settings.mutes_tab')">
- <mute-list :refresh="true">
+ <div class="profile-edit-usersearch-wrapper">
+ <Autosuggest :filter="filterUnMutedUsers" :query="queryUserIds" :placeholder="$t('settings.search_user_to_mute')">
+ <MuteCard slot-scope="row" :userId="row.item"/>
+ </Autosuggest>
+ </div>
+ <MuteList :refresh="true" :getKey="identity">
+ <template slot="header" slot-scope="{selected}">
+ <div class="profile-edit-bulk-actions">
+ <ProgressButton class="btn btn-default" v-if="selected.length > 0" :click="() => muteUsers(selected)">
+ {{ $t('user_card.mute') }}
+ <template slot="progress">{{ $t('user_card.mute_progress') }}</template>
+ </ProgressButton>
+ <ProgressButton class="btn btn-default" v-if="selected.length > 0" :click="() => unmuteUsers(selected)">
+ {{ $t('user_card.unmute') }}
+ <template slot="progress">{{ $t('user_card.unmute_progress') }}</template>
+ </ProgressButton>
+ </div>
+ </template>
+ <template slot="item" slot-scope="{item}"><MuteCard :userId="item" /></template>
<template slot="empty">{{$t('settings.no_mutes')}}</template>
- </mute-list>
+ </MuteList>
</div>
</tab-switcher>
</div>
@@ -221,6 +289,10 @@
margin: 0;
}
+ .visibility-tray {
+ padding-top: 5px;
+ }
+
input[type=file] {
padding: 5px;
height: auto;
@@ -262,5 +334,19 @@
text-align: right;
}
}
+
+ &-usersearch-wrapper {
+ padding: 1em;
+ }
+
+ &-bulk-actions {
+ text-align: right;
+ padding: 0 1em;
+ min-height: 28px;
+
+ button {
+ width: 10em;
+ }
+ }
}
</style>
diff --git a/src/components/who_to_follow/who_to_follow.js b/src/components/who_to_follow/who_to_follow.js
index be0b8827..7ae602a2 100644
--- a/src/components/who_to_follow/who_to_follow.js
+++ b/src/components/who_to_follow/who_to_follow.js
@@ -20,7 +20,8 @@ const WhoToFollow = {
id: 0,
name: i.display_name,
screen_name: i.acct,
- profile_image_url: i.avatar || '/images/avi.png'
+ profile_image_url: i.avatar || '/images/avi.png',
+ profile_image_url_original: i.avatar || '/images/avi.png'
}
this.users.push(user)
diff --git a/src/components/who_to_follow/who_to_follow.vue b/src/components/who_to_follow/who_to_follow.vue
index 1630f5ac..8bc9a728 100644
--- a/src/components/who_to_follow/who_to_follow.vue
+++ b/src/components/who_to_follow/who_to_follow.vue
@@ -4,7 +4,7 @@
{{$t('who_to_follow.who_to_follow')}}
</div>
<div class="panel-body">
- <FollowCard v-for="user in users" :key="user.id" :user="user"/>
+ <FollowCard v-for="user in users" :key="user.id" :user="user" class="list-item"/>
</div>
</div>
</template>
diff --git a/src/components/who_to_follow_panel/who_to_follow_panel.vue b/src/components/who_to_follow_panel/who_to_follow_panel.vue
index 25e3a9f6..74e82789 100644
--- a/src/components/who_to_follow_panel/who_to_follow_panel.vue
+++ b/src/components/who_to_follow_panel/who_to_follow_panel.vue
@@ -6,14 +6,18 @@
{{$t('who_to_follow.who_to_follow')}}
</div>
</div>
- <div class="panel-body who-to-follow">
- <span v-for="user in usersToFollow">
+ <div class="who-to-follow">
+ <p v-for="user in usersToFollow" class="who-to-follow-items">
<img v-bind:src="user.img" />
<router-link v-bind:to="userProfileLink(user.id, user.name)">
{{user.name}}
</router-link><br />
- </span>
- <img v-bind:src="$store.state.instance.logo"> <router-link :to="{ name: 'who-to-follow' }">{{$t('who_to_follow.more')}}</router-link>
+ </p>
+ <p class="who-to-follow-more">
+ <router-link :to="{ name: 'who-to-follow' }">
+ {{$t('who_to_follow.more')}}
+ </router-link>
+ </p>
</div>
</div>
</div>
@@ -30,11 +34,19 @@
height: 32px;
}
.who-to-follow {
- padding: 0.5em 1em 0.5em 1em;
+ padding: 0em 1em;
margin: 0px;
- line-height: 40px;
+ }
+ .who-to-follow-items {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+ padding: 0px;
+ margin: 1em 0em;
+ }
+ .who-to-follow-more {
+ padding: 0px;
+ margin: 1em 0em;
+ text-align: center;
}
</style>