diff options
Diffstat (limited to 'src')
59 files changed, 1681 insertions, 462 deletions
diff --git a/src/App.scss b/src/App.scss index 5fc0dd27..b1c65ade 100644 --- a/src/App.scss +++ b/src/App.scss @@ -101,6 +101,14 @@ button { background-color: $fallback--bg; background-color: var(--bg, $fallback--bg) } + + &.danger { + // TODO: add better color variable + color: $fallback--text; + color: var(--alertErrorPanelText, $fallback--text); + background-color: $fallback--alertError; + background-color: var(--alertError, $fallback--alertError); + } } label.select { diff --git a/src/boot/after_store.js b/src/boot/after_store.js index f2c1aa0f..603de348 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -171,9 +171,10 @@ const getCustomEmoji = async ({ store }) => { try { const res = await window.fetch('/api/pleroma/emoji.json') if (res.ok) { - const values = await res.json() + const result = await res.json() + const values = Array.isArray(result) ? Object.assign({}, ...result) : result const emoji = Object.keys(values).map((key) => { - return { shortcode: key, image_url: values[key] } + return { shortcode: key, image_url: values[key].image_url || values[key] } }) store.dispatch('setInstanceOption', { name: 'customEmoji', value: emoji }) store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: true }) @@ -211,6 +212,7 @@ const getNodeInfo = async ({ store }) => { const frontendVersion = window.___pleromafe_commit_hash store.dispatch('setInstanceOption', { name: 'frontendVersion', value: frontendVersion }) + store.dispatch('setInstanceOption', { name: 'tagPolicyAvailable', value: metadata.federation.mrf_policies.includes('TagPolicy') }) } else { throw (res) } 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/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue index 8afe8b44..48de6678 100644 --- a/src/components/basic_user_card/basic_user_card.vue +++ b/src/components/basic_user_card/basic_user_card.vue @@ -24,19 +24,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; 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..30600f73 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,8 +35,7 @@ const conversation = { data () { return { highlight: null, - expanded: false, - converationStatusIds: [] + expanded: false } }, props: [ @@ -54,15 +52,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 +59,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 +75,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 +122,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 { diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue index c39a3ed9..c3bbb597 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -13,7 +13,7 @@ :key="status.id" :inlineExpanded="collapsable" :statusoid="status" - :expandable='!expanded' + :expandable='!isExpanded' :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 index f2920666..22f24625 100644 --- a/src/components/delete_button/delete_button.js +++ b/src/components/delete_button/delete_button.js @@ -10,7 +10,11 @@ const DeleteButton = { }, 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 } + canDelete () { + if (!this.currentUser) { return } + const superuser = this.currentUser.rights.moderator || this.currentUser.rights.admin + return superuser || this.status.user.id === this.currentUser.id + } } } 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..7621fb20 --- /dev/null +++ b/src/components/dialog_modal/dialog_modal.vue @@ -0,0 +1,92 @@ +<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; + } + } + + .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); + justify-content: flex-end; + + button { + width: auto; + margin-left: .5rem; + } + } +} + +</style> diff --git a/src/components/follow_card/follow_card.js b/src/components/follow_card/follow_card.js index ac4e265a..dc4a0d41 100644 --- a/src/components/follow_card/follow_card.js +++ b/src/components/follow_card/follow_card.js @@ -10,8 +10,7 @@ const FollowCard = { data () { return { inProgress: false, - requestSent: false, - updated: false + requestSent: false } }, components: { @@ -19,10 +18,8 @@ const FollowCard = { RemoteFollow }, computed: { - isMe () { return this.$store.state.users.currentUser.id === this.user.id }, - following () { return this.updated ? this.updated.following : this.user.following }, - showFollow () { - return !this.following || this.updated && !this.updated.following + isMe () { + return this.$store.state.users.currentUser.id === this.user.id }, loggedIn () { return this.$store.state.users.currentUser @@ -31,17 +28,15 @@ const FollowCard = { methods: { followUser () { this.inProgress = true - requestFollow(this.user, this.$store).then(({ sent, updated }) => { + requestFollow(this.user, this.$store).then(({ sent }) => { this.inProgress = false this.requestSent = sent - this.updated = updated }) }, unfollowUser () { this.inProgress = true - requestUnfollow(this.user, this.$store).then(({ updated }) => { + requestUnfollow(this.user, this.$store).then(() => { this.inProgress = false - this.updated = updated }) } } diff --git a/src/components/follow_card/follow_card.vue b/src/components/follow_card/follow_card.vue index 9f314fd3..94e2836f 100644 --- a/src/components/follow_card/follow_card.vue +++ b/src/components/follow_card/follow_card.vue @@ -4,34 +4,38 @@ <span class="faint" v-if="!noFollowsYou && user.follows_you"> {{ isMe ? $t('user_card.its_you') : $t('user_card.follows_you') }} </span> - <div class="follow-card-follow-button" v-if="showFollow && !loggedIn"> - <RemoteFollow :user="user" /> - </div> - <button - v-if="showFollow && loggedIn" - class="btn btn-default follow-card-follow-button" - @click="followUser" - :disabled="inProgress" - :title="requestSent ? $t('user_card.follow_again') : ''" - > - <template v-if="inProgress"> - {{ $t('user_card.follow_progress') }} - </template> - <template v-else-if="requestSent"> - {{ $t('user_card.follow_sent') }} - </template> - <template v-else> - {{ $t('user_card.follow') }} - </template> - </button> - <button v-if="following" class="btn btn-default follow-card-follow-button pressed" @click="unfollowUser" :disabled="inProgress"> - <template v-if="inProgress"> - {{ $t('user_card.follow_progress') }} - </template> - <template v-else> - {{ $t('user_card.follow_unfollow') }} - </template> - </button> + <template v-if="!loggedIn"> + <div class="follow-card-follow-button" v-if="!user.following"> + <RemoteFollow :user="user" /> + </div> + </template> + <template v-else> + <button + v-if="!user.following" + class="btn btn-default follow-card-follow-button" + @click="followUser" + :disabled="inProgress" + :title="requestSent ? $t('user_card.follow_again') : ''" + > + <template v-if="inProgress"> + {{ $t('user_card.follow_progress') }} + </template> + <template v-else-if="requestSent"> + {{ $t('user_card.follow_sent') }} + </template> + <template v-else> + {{ $t('user_card.follow') }} + </template> + </button> + <button v-else class="btn btn-default follow-card-follow-button pressed" @click="unfollowUser" :disabled="inProgress"> + <template v-if="inProgress"> + {{ $t('user_card.follow_progress') }} + </template> + <template v-else> + {{ $t('user_card.follow_unfollow') }} + </template> + </button> + </template> </div> </basic-user-card> </template> diff --git a/src/components/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/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..dc917e47 100644 --- a/src/components/login_form/login_form.js +++ b/src/components/login_form/login_form.js @@ -31,15 +31,19 @@ const LoginForm = { username: this.user.username, password: this.user.password } - ).then((result) => { + ).then(async (result) => { if (result.error) { this.authError = result.error this.user.password = '' return } this.$store.commit('setToken', result.access_token) - this.$store.dispatch('loginUser', result.access_token) - this.$router.push({name: 'friends'}) + try { + await this.$store.dispatch('loginUser', result.access_token) + this.$router.push({name: 'friends'}) + } catch (e) { + console.log(e) + } }) }) }, 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..c24a2280 --- /dev/null +++ b/src/components/moderation_tools/moderation_tools.vue @@ -0,0 +1,158 @@ +<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> + <DialogModal v-if="showDeleteUserDialog" :onCancel='deleteUserDialog.bind(this, false)'> + <span slot="header">{{ $t('user_card.admin_menu.delete_user') }}</span> + <p>{{ $t('user_card.admin_menu.delete_user_confirmation') }}</p> + <span slot="footer"> + <button @click='deleteUserDialog(false)'> + {{ $t('general.cancel') }} + </button> + <button class="danger" @click='deleteUser()'> + {{ $t('user_card.admin_menu.delete_user') }} + </button> + </span> + </DialogModal> +</div> +</template> + +<script src="./moderation_tools.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; +@import '../popper/popper.scss'; + +.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%; + + &:hover { + // TODO: improve the look on breeze themes + background-color: $fallback--fg; + background-color: var(--btn, $fallback--fg); + box-shadow: none; + } + } +} + +.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/notification/notification.js b/src/components/notification/notification.js index 42a48f3f..e59e7497 100644 --- a/src/components/notification/notification.js +++ b/src/components/notification/notification.js @@ -21,25 +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.action.user.id) + return this.$store.getters.findUser(this.notification.from_profile.id) }, user () { if (this.userInStore) { return this.userInStore } - return {} + return this.notification.from_profile } } } diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index 8f532747..ae11d692 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> + <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"/> + <a class='avatar-container' :href="notification.from_profile.statusnet_profile_url" @click.stop.prevent.capture="toggleUserExpanded"> + <UserAvatar :compact="true" :betterShadow="betterShadow" :src="notification.from_profile.profile_image_url_original" /> </a> <div class='notification-right'> - <UserCard :user="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 d3db4b29..e341212e 100644 --- a/src/components/notifications/notifications.js +++ b/src/components/notifications/notifications.js @@ -10,13 +10,6 @@ const Notifications = { props: [ 'noHeading' ], - created () { - const store = this.$store - const credentials = store.state.users.currentUser.credentials - - const fetcherId = notificationsFetcher.startFetching({ store, credentials }) - this.$store.commit('setNotificationFetcher', { fetcherId }) - }, data () { return { bottomedOut: false @@ -56,7 +49,7 @@ const Notifications = { }, methods: { markAsSeen () { - this.$store.dispatch('markNotificationsAsSeen', this.visibleNotifications) + this.$store.dispatch('markNotificationsAsSeen') }, fetchOlderNotifications () { const store = this.$store diff --git a/src/components/notifications/notifications.vue b/src/components/notifications/notifications.vue index 634a03ac..88775be1 100644 --- a/src/components/notifications/notifications.vue +++ b/src/components/notifications/notifications.vue @@ -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": !notification.seen}'> <div class="notification-overlay"></div> <notification :notification="notification"></notification> </div> diff --git a/src/components/popper/popper.scss b/src/components/popper/popper.scss new file mode 100644 index 00000000..0c30d625 --- /dev/null +++ b/src/components/popper/popper.scss @@ -0,0 +1,70 @@ +@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; +} + 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/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..ba1e5266 --- /dev/null +++ b/src/components/selectable_list/selectable_list.vue @@ -0,0 +1,59 @@ +<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; + } + + &-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.vue b/src/components/settings/settings.vue index 6ee103c7..6890220f 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"> @@ -330,6 +328,7 @@ textarea { width: 100%; + max-width: 100%; height: 100px; } 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/user_card/user_card.js b/src/components/user_card/user_card.js index 197c61d5..1a100de3 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 () { diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue index 3259d1c5..e1d3ff57 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -11,7 +11,7 @@ <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> @@ -99,6 +99,8 @@ </button> </span> </div> + <ModerationTools :user='user' v-if='loggedIn.role === "admin"'> + </ModerationTools> </div> </div> </div> @@ -160,7 +162,7 @@ max-width: 100%; max-height: 400px; - .emoji { + &.emoji { width: 32px; height: 32px; } diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index 1df06fe6..4eddb8b1 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -1,47 +1,37 @@ -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 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,26 +46,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.userInStore) { - return this.userInStore - } - return {} + return this.$store.getters.findUser(this.userId) }, isExternal () { return this.$route.name === 'external-user-profile' @@ -88,39 +64,36 @@ 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 }) } }, cleanUp () { @@ -133,18 +106,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 () { @@ -155,7 +126,9 @@ const UserProfile = { UserCard, Timeline, FollowerList, - FriendList + FriendList, + ModerationTools, + FollowCard } } diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index d449eb85..71c625b7 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -1,6 +1,6 @@ <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 @@ -14,10 +14,18 @@ :user-id="userId" /> <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_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/user_settings.js b/src/components/user_settings/user_settings.js index b6a0479d..e88ee612 100644 --- a/src/components/user_settings/user_settings.js +++ b/src/components/user_settings/user_settings.js @@ -1,6 +1,7 @@ -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' @@ -8,27 +9,24 @@ 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 withSubscription from '../../hocs/with_subscription/with_subscription' -import withList from '../../hocs/with_list/with_list' +import userSearchApi from '../../services/new_api/user_search.js' -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 () { @@ -73,7 +71,11 @@ const UserSettings = { ImageCropper, BlockList, MuteList, - EmojiInput + EmojiInput, + Autosuggest, + BlockCard, + MuteCard, + ProgressButton }, computed: { user () { @@ -334,6 +336,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 c08698dc..d68e68fa 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" @@ -195,15 +195,51 @@ </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> @@ -262,5 +298,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.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/hocs/with_list/with_list.js b/src/hocs/with_list/with_list.js deleted file mode 100644 index 896f8fc8..00000000 --- a/src/hocs/with_list/with_list.js +++ /dev/null @@ -1,40 +0,0 @@ -import Vue from 'vue' -import map from 'lodash/map' -import isEmpty from 'lodash/isEmpty' -import './with_list.scss' - -const defaultEntryPropsGetter = entry => ({ entry }) -const defaultKeyGetter = entry => entry.id - -const withList = ({ - getEntryProps = defaultEntryPropsGetter, // function to accept entry and index values and return props to be passed into the item component - getKey = defaultKeyGetter // funciton to accept entry and index values and return key prop value -}) => (ItemComponent) => ( - Vue.component('withList', { - props: [ - 'entries', // array of entry - 'entryProps', // additional props to be passed into each entry - 'entryListeners' // additional event listeners to be passed into each entry - ], - render (createElement) { - return ( - <div class="with-list"> - {map(this.entries, (entry, index) => { - const props = { - key: getKey(entry, index), - props: { - ...this.$props.entryProps, - ...getEntryProps(entry, index) - }, - on: this.$props.entryListeners - } - return <ItemComponent {...props} /> - })} - {isEmpty(this.entries) && this.$slots.empty && <div class="with-list-empty-content faint">{this.$slots.empty}</div>} - </div> - ) - } - }) -) - -export default withList diff --git a/src/hocs/with_list/with_list.scss b/src/hocs/with_list/with_list.scss deleted file mode 100644 index c6e13d5b..00000000 --- a/src/hocs/with_list/with_list.scss +++ /dev/null @@ -1,6 +0,0 @@ -.with-list { - &-empty-content { - text-align: center; - padding: 10px; - } -}
\ No newline at end of file diff --git a/src/hocs/with_load_more/with_load_more.scss b/src/hocs/with_load_more/with_load_more.scss index 1a0a9c40..4cefe2be 100644 --- a/src/hocs/with_load_more/with_load_more.scss +++ b/src/hocs/with_load_more/with_load_more.scss @@ -1,10 +1,16 @@ + +@import '../../_variables.scss'; + .with-load-more { &-footer { padding: 10px; text-align: center; + border-top: 1px solid; + border-top-color: $fallback--border; + border-top-color: var(--border, $fallback--border); .error { font-size: 14px; } } -}
\ No newline at end of file +} diff --git a/src/i18n/cs.json b/src/i18n/cs.json index 020092a6..5f2f2b71 100644 --- a/src/i18n/cs.json +++ b/src/i18n/cs.json @@ -73,7 +73,8 @@ "content_type": { "text/plain": "Prostý text", "text/html": "HTML", - "text/markdown": "Markdown" + "text/markdown": "Markdown", + "text/bbcode": "BBCode" }, "content_warning": "Předmět (volitelný)", "default": "Právě jsem přistál v L.A.", diff --git a/src/i18n/en.json b/src/i18n/en.json index 1e82cd0a..711e8d31 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -22,7 +22,8 @@ "generic_error": "An error occured", "optional": "optional", "show_more": "Show more", - "show_less": "Show less" + "show_less": "Show less", + "cancel": "Cancel" }, "image_cropper": { "crop_picture": "Crop picture", @@ -76,7 +77,8 @@ "content_type": { "text/plain": "Plain text", "text/html": "HTML", - "text/markdown": "Markdown" + "text/markdown": "Markdown", + "text/bbcode": "BBCode" }, "content_warning": "Subject (optional)", "default": "Just landed in L.A.", @@ -111,6 +113,9 @@ "password_confirmation_match": "should be the same as password" } }, + "selectable_list": { + "select_all": "Select all" + }, "settings": { "app_name": "App name", "attachmentRadius": "Attachments", @@ -216,6 +221,8 @@ "reply_visibility_self": "Only show replies directed at me", "saving_err": "Error saving settings", "saving_ok": "Settings saved", + "search_user_to_block": "Search whom you want to block", + "search_user_to_mute": "Search whom you want to mute", "security_tab": "Security", "scope_copy": "Copy scope when replying (DMs are always copied)", "minimal_scopes_mode": "Minimize post scope selection options", @@ -403,7 +410,26 @@ "block_progress": "Blocking...", "unmute": "Unmute", "unmute_progress": "Unmuting...", - "mute_progress": "Muting..." + "mute_progress": "Muting...", + "admin_menu": { + "moderation": "Moderation", + "grant_admin": "Grant Admin", + "revoke_admin": "Revoke Admin", + "grant_moderator": "Grant Moderator", + "revoke_moderator": "Revoke Moderator", + "activate_account": "Activate account", + "deactivate_account": "Deactivate account", + "delete_account": "Delete account", + "force_nsfw": "Mark all posts as NSFW", + "strip_media": "Remove media from posts", + "force_unlisted": "Force posts to be unlisted", + "sandbox": "Force posts to be followers-only", + "disable_remote_subscription": "Disallow following user from remote instances", + "disable_any_subscription": "Disallow following user at all", + "quarantine": "Disallow user posts from federating", + "delete_user": "Delete user", + "delete_user_confirmation": "Are you absolutely sure? This action cannot be undone." + } }, "user_profile": { "timeline_title": "User Timeline", diff --git a/src/i18n/oc.json b/src/i18n/oc.json index 9214799d..a5826239 100644 --- a/src/i18n/oc.json +++ b/src/i18n/oc.json @@ -20,7 +20,10 @@ "submit": "Mandar", "more": "Mai", "generic_error": "Una error s’es producha", - "optional": "opcional" + "optional": "opcional", + "show_more": "Mostrar mai", + "show_less": "Mostrar mens", + "cancel": "Anullar" }, "image_cropper": { "crop_picture": "Talhar l’imatge", @@ -74,11 +77,13 @@ "content_type": { "text/plain": "Tèxte brut", "text/html": "HTML", - "text/markdown": "Markdown" + "text/markdown": "Markdown", + "text/bbcode": "BBCode" }, "content_warning": "Avís de contengut (opcional)", "default": "Escrivètz aquí vòstre estatut.", - "direct_warning": "Aquesta publicacion serà pas que visibla pels utilizaires mencionats.", + "direct_warning_to_all": "Aquesta publicacion serà pas que visibla pels utilizaires mencionats.", + "direct_warning_to_first_only": "Aquesta publicacion serà pas que visibla pels utilizaires mencionats a la debuta del messatge.", "posting": "Mandadís", "scope": { "direct": "Dirècte - Publicar pels utilizaires mencionats solament", @@ -108,6 +113,9 @@ "password_confirmation_match": "deu èsser lo meteis senhal" } }, + "selectable_list": { + "select_all": "O seleccionar tot" + }, "settings": { "app_name": "Nom de l’aplicacion", "attachmentRadius": "Pèças juntas", @@ -213,8 +221,11 @@ "reply_visibility_self": "Mostrar pas que las responsas que me son destinadas", "saving_err": "Error en enregistrant los paramètres", "saving_ok": "Paramètres enregistrats", - "scope_copy": "Copiar lo nivèl de confidencialitat per las responsas (Totjorn aissí pels Messatges Dirèctes)", + "search_user_to_block": "Cercatz qual volètz blocar", + "search_user_to_mute": "Cercatz qual volètz rescondre", "security_tab": "Seguretat", + "scope_copy": "Copiar lo nivèl de confidencialitat per las responsas (Totjorn aissí pels Messatges Dirèctes)", + "minimal_scopes_mode": "Minimizar lo nombre d’opcions per publicacion", "set_new_avatar": "Definir un nòu avatar", "set_new_profile_background": "Definir un nòu fons de perfil", "set_new_profile_banner": "Definir una nòva bandièra de perfil", @@ -349,6 +360,11 @@ "checkbox": "Ai legit los tèrmes e condicions d’utilizacion", "link": "un pichon ligam simpatic" } + }, + "version": { + "title": "Version", + "backend_version": "Version Backend", + "frontend_version": "Version Frontend" } }, "timeline": { @@ -394,7 +410,26 @@ "block_progress": "Blocatge...", "unmute": "Tornar mostrar", "unmute_progress": "Afichatge...", - "mute_progress": "A amagar..." + "mute_progress": "A amagar...", + "admin_menu": { + "moderation": "Moderacion", + "grant_admin": "Passar Admin", + "revoke_admin": "Revocar Admin", + "grant_moderator": "Passar Moderator", + "revoke_moderator": "Revocar Moderator", + "activate_account": "Activar lo compte", + "deactivate_account": "Desactivar lo compte", + "delete_account": "Suprimir lo compte", + "force_nsfw": "Marcar totas las publicacions coma sensiblas", + "strip_media": "Tirar los mèdias de las publicacions", + "force_unlisted": "Forçar las publicacions en pas-listadas", + "sandbox": "Forçar las publicacions en seguidors solament", + "disable_remote_subscription": "Desactivar lo seguiment d’utilizaire d’instàncias alonhadas", + "disable_any_subscription": "Desactivar tot seguiment", + "quarantine": "Defendre la federacion de las publicacions de l’utilizaire", + "delete_user": "Suprimir l’utilizaire", + "delete_user_confirmation": "Volètz vertadièrament far aquò ? Aquesta accion se pòt pas anullar." + } }, "user_profile": { "timeline_title": "Flux utilizaire", @@ -426,4 +461,4 @@ "TiB": "Tio" } } -}
\ No newline at end of file +} diff --git a/src/i18n/pl.json b/src/i18n/pl.json index 8efce168..715e5d6e 100644 --- a/src/i18n/pl.json +++ b/src/i18n/pl.json @@ -74,7 +74,8 @@ "content_type": { "text/plain": "Czysty tekst", "text/html": "HTML", - "text/markdown": "Markdown" + "text/markdown": "Markdown", + "text/bbcode": "BBCode" }, "content_warning": "Temat (nieobowiązkowy)", "default": "Właśnie wróciłem z kościoła", @@ -431,4 +432,4 @@ "TiB": "TiB" } } -}
\ No newline at end of file +} diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 89aa43f4..5450f154 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -8,7 +8,8 @@ }, "general": { "apply": "Применить", - "submit": "Отправить" + "submit": "Отправить", + "cancel": "Отмена" }, "login": { "login": "Войти", @@ -311,7 +312,26 @@ "muted": "Игнорирую", "per_day": "в день", "remote_follow": "Читать удалённо", - "statuses": "Статусы" + "statuses": "Статусы", + "admin_menu": { + "moderation": "Опции модератора", + "grant_admin": "Сделать администратором", + "revoke_admin": "Забрать права администратора", + "grant_moderator": "Сделать модератором", + "revoke_moderator": "Забрать права модератора", + "activate_account": "Активировать аккаунт", + "deactivate_account": "Деактивировать аккаунт", + "delete_account": "Удалить аккаунт", + "force_nsfw": "Отмечать посты пользователя как NSFW", + "strip_media": "Убирать вложения из постов пользователя", + "force_unlisted": "Не добавлять посты в публичные ленты", + "sandbox": "Посты доступны только для подписчиков", + "disable_remote_subscription": "Запретить подписываться с удаленных серверов", + "disable_any_subscription": "Запретить подписываться на пользователя", + "quarantine": "Не федерировать посты пользователя", + "delete_user": "Удалить пользователя", + "delete_user_confirmation": "Вы уверены? Это действие нельзя отменить." + } }, "user_profile": { "timeline_title": "Лента пользователя" diff --git a/src/main.js b/src/main.js index 9ffc3727..725f5806 100644 --- a/src/main.js +++ b/src/main.js @@ -22,6 +22,7 @@ import pushNotifications from './lib/push_notifications_plugin.js' import messages from './i18n/messages.js' import VueChatScroll from 'vue-chat-scroll' +import VueClickOutside from 'v-click-outside' import afterStoreSetup from './boot/after_store.js' @@ -39,6 +40,7 @@ Vue.use(VueTimeago, { }) Vue.use(VueI18n) Vue.use(VueChatScroll) +Vue.use(VueClickOutside) const i18n = new VueI18n({ // By default, use the browser locale, we will update it if neccessary @@ -59,6 +61,11 @@ const persistedStateOptions = { const persistedState = await createPersistedState(persistedStateOptions) const store = new Vuex.Store({ modules: { + i18n: { + getters: { + i18n: () => i18n + } + }, interface: interfaceModule, instance: instanceModule, statuses: statusesModule, diff --git a/src/modules/api.js b/src/modules/api.js index 31cb55c6..7ed3edac 100644 --- a/src/modules/api.js +++ b/src/modules/api.js @@ -13,11 +13,11 @@ const api = { setBackendInteractor (state, backendInteractor) { state.backendInteractor = backendInteractor }, - addFetcher (state, {timeline, fetcher}) { - state.fetchers[timeline] = fetcher + addFetcher (state, { fetcherName, fetcher }) { + state.fetchers[fetcherName] = fetcher }, - removeFetcher (state, {timeline}) { - delete state.fetchers[timeline] + removeFetcher (state, { fetcherName }) { + delete state.fetchers[fetcherName] }, setWsToken (state, token) { state.wsToken = token @@ -33,17 +33,24 @@ const api = { } }, actions: { - startFetching (store, {timeline = 'friends', tag = false, userId = false}) { + startFetchingTimeline (store, { timeline = 'friends', tag = false, userId = false }) { // Don't start fetching if we already are. if (store.state.fetchers[timeline]) return - const fetcher = store.state.backendInteractor.startFetching({ timeline, store, userId, tag }) - store.commit('addFetcher', { timeline, fetcher }) + const fetcher = store.state.backendInteractor.startFetchingTimeline({ timeline, store, userId, tag }) + store.commit('addFetcher', { fetcherName: timeline, fetcher }) }, - stopFetching (store, timeline) { - const fetcher = store.state.fetchers[timeline] + startFetchingNotifications (store) { + // Don't start fetching if we already are. + if (store.state.fetchers['notifications']) return + + const fetcher = store.state.backendInteractor.startFetchingNotifications({ store }) + store.commit('addFetcher', { fetcherName: 'notifications', fetcher }) + }, + stopFetching (store, fetcherName) { + const fetcher = store.state.fetchers[fetcherName] window.clearInterval(fetcher) - store.commit('removeFetcher', {timeline}) + store.commit('removeFetcher', { fetcherName }) }, setWsToken (store, token) { store.commit('setWsToken', token) diff --git a/src/modules/interface.js b/src/modules/interface.js index 71554787..5b2762e5 100644 --- a/src/modules/interface.js +++ b/src/modules/interface.js @@ -48,7 +48,6 @@ const interfaceMod = { commit('setNotificationPermission', permission) }, setMobileLayout ({ commit }, value) { - console.log('setMobileLayout called') commit('setMobileLayout', value) } } diff --git a/src/modules/statuses.js b/src/modules/statuses.js index 8e0203e3..e70c2400 100644 --- a/src/modules/statuses.js +++ b/src/modules/statuses.js @@ -20,20 +20,22 @@ const emptyTl = (userId = 0) => ({ flushMarker: 0 }) +const emptyNotifications = () => ({ + desktopNotificationSilence: true, + maxId: 0, + minId: Number.POSITIVE_INFINITY, + data: [], + idStore: {}, + loading: false, + error: false +}) + export const defaultState = () => ({ allStatuses: [], allStatusesObject: {}, + conversationsObject: {}, maxId: 0, - notifications: { - desktopNotificationSilence: true, - maxId: 0, - minId: Number.POSITIVE_INFINITY, - data: [], - idStore: {}, - loading: false, - error: false, - fetcherId: null - }, + notifications: emptyNotifications(), favorites: new Set(), error: false, timelines: { @@ -111,6 +113,39 @@ const sortTimeline = (timeline) => { return timeline } +// Add status to the global storages (arrays and objects maintaining statuses) except timelines +const addStatusToGlobalStorage = (state, data) => { + const result = mergeOrAdd(state.allStatuses, state.allStatusesObject, data) + if (result.new) { + // Add to conversation + const status = result.item + const conversationsObject = state.conversationsObject + const conversationId = status.statusnet_conversation_id + if (conversationsObject[conversationId]) { + conversationsObject[conversationId].push(status) + } else { + set(conversationsObject, conversationId, [status]) + } + } + return result +} + +// Remove status from the global storages (arrays and objects maintaining statuses) except timelines +const removeStatusFromGlobalStorage = (state, status) => { + remove(state.allStatuses, { id: status.id }) + + // TODO: Need to remove from allStatusesObject? + + // Remove possible notification + remove(state.notifications.data, ({action: {id}}) => id === status.id) + + // Remove from conversation + const conversationId = status.statusnet_conversation_id + if (state.conversationsObject[conversationId]) { + remove(state.conversationsObject[conversationId], { id: status.id }) + } +} + const addNewStatuses = (state, { statuses, showImmediately = false, timeline, user = {}, noIdUpdate = false, userId }) => { // Sanity check if (!isArray(statuses)) { @@ -118,7 +153,6 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us } const allStatuses = state.allStatuses - const allStatusesObject = state.allStatusesObject const timelineObject = state.timelines[timeline] const maxNew = statuses.length > 0 ? maxBy(statuses, 'id').id : 0 @@ -141,7 +175,7 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us } const addStatus = (data, showImmediately, addToTimeline = true) => { - const result = mergeOrAdd(allStatuses, allStatusesObject, data) + const result = addStatusToGlobalStorage(state, data) const status = result.item if (result.new) { @@ -235,16 +269,13 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us }, 'deletion': (deletion) => { const uri = deletion.uri - - // Remove possible notification const status = find(allStatuses, {uri}) if (!status) { return } - remove(state.notifications.data, ({action: {id}}) => id === status.id) + removeStatusFromGlobalStorage(state, status) - remove(allStatuses, { uri }) if (timeline) { remove(timelineObject.statuses, { uri }) remove(timelineObject.visibleStatuses, { uri }) @@ -271,12 +302,12 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us } } -const addNewNotifications = (state, { dispatch, notifications, older, visibleNotificationTypes }) => { - const allStatuses = state.allStatuses - const allStatusesObject = state.allStatusesObject +const addNewNotifications = (state, { dispatch, notifications, older, visibleNotificationTypes, rootGetters }) => { each(notifications, (notification) => { - notification.action = mergeOrAdd(allStatuses, allStatusesObject, notification.action).item - notification.status = notification.status && mergeOrAdd(allStatuses, allStatusesObject, notification.status).item + if (notification.type !== 'follow') { + notification.action = addStatusToGlobalStorage(state, notification.action).item + notification.status = notification.status && addStatusToGlobalStorage(state, notification.status).item + } // Only add a new notification if we don't have one for the same action if (!state.notifications.idStore.hasOwnProperty(notification.id)) { @@ -292,15 +323,32 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot if ('Notification' in window && window.Notification.permission === 'granted') { const notifObj = {} - const action = notification.action - const title = action.user.name - notifObj.icon = action.user.profile_image_url - notifObj.body = action.text // there's a problem that it doesn't put a space before links tho + const status = notification.status + const title = notification.from_profile.name + notifObj.icon = notification.from_profile.profile_image_url + let i18nString + switch (notification.type) { + case 'like': + i18nString = 'favorited_you' + break + case 'repeat': + i18nString = 'repeated_you' + break + case 'follow': + i18nString = 'followed_you' + break + } + + if (i18nString) { + notifObj.body = rootGetters.i18n.t('notifications.' + i18nString) + } else { + notifObj.body = notification.status.text + } // Shows first attached non-nsfw image, if any. Should add configuration for this somehow... - if (action.attachments && action.attachments.length > 0 && !action.nsfw && - action.attachments[0].mimetype.startsWith('image/')) { - notifObj.image = action.attachments[0].url + if (status && status.attachments && status.attachments.length > 0 && !status.nsfw && + status.attachments[0].mimetype.startsWith('image/')) { + notifObj.image = status.attachments[0].url } if (!notification.seen && !state.notifications.desktopNotificationSilence && visibleNotificationTypes.includes(notification.type)) { @@ -340,9 +388,6 @@ export const mutations = { oldTimeline.visibleStatusesObject = {} each(oldTimeline.visibleStatuses, (status) => { oldTimeline.visibleStatusesObject[status.id] = status }) }, - setNotificationFetcher (state, { fetcherId }) { - state.notifications.fetcherId = fetcherId - }, resetStatuses (state) { const emptyState = defaultState() Object.entries(emptyState).forEach(([key, value]) => { @@ -352,6 +397,9 @@ export const mutations = { clearTimeline (state, { timeline }) { state.timelines[timeline] = emptyTl(state.timelines[timeline].userId) }, + clearNotifications (state) { + state.notifications = emptyNotifications() + }, setFavorited (state, { status, value }) { const newStatus = state.allStatusesObject[status.id] newStatus.favorited = value @@ -378,6 +426,13 @@ export const mutations = { const newStatus = state.allStatusesObject[status.id] newStatus.deleted = true }, + setManyDeleted (state, condition) { + Object.values(state.allStatusesObject).forEach(status => { + if (condition(status)) { + status.deleted = true + } + }) + }, setLoading (state, { timeline, value }) { state.timelines[timeline].loading = value }, @@ -413,8 +468,8 @@ const statuses = { addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false, userId }) { commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser, userId }) }, - addNewNotifications ({ rootState, commit, dispatch }, { notifications, older }) { - commit('addNewNotifications', { visibleNotificationTypes: visibleNotificationTypes(rootState), dispatch, notifications, older }) + addNewNotifications ({ rootState, commit, dispatch, rootGetters }, { notifications, older }) { + commit('addNewNotifications', { visibleNotificationTypes: visibleNotificationTypes(rootState), dispatch, notifications, older, rootGetters }) }, setError ({ rootState, commit }, { value }) { commit('setError', { value }) @@ -428,16 +483,13 @@ const statuses = { setNotificationsSilence ({ rootState, commit }, { value }) { commit('setNotificationsSilence', { value }) }, - stopFetchingNotifications ({ rootState, commit }) { - if (rootState.statuses.notifications.fetcherId) { - window.clearInterval(rootState.statuses.notifications.fetcherId) - } - commit('setNotificationFetcher', { fetcherId: null }) - }, deleteStatus ({ rootState, commit }, status) { commit('setDeleted', { status }) apiService.deleteStatus({ id: status.id, credentials: rootState.users.currentUser.credentials }) }, + markStatusesAsDeleted ({ commit }, condition) { + commit('setManyDeleted', condition) + }, favorite ({ rootState, commit }, status) { // Optimistic favoriting... commit('setFavorited', { status, value: true }) diff --git a/src/modules/users.js b/src/modules/users.js index 1a507d31..adcab233 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -1,5 +1,6 @@ import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' -import { compact, map, each, merge, find, last } from 'lodash' +import userSearchApi from '../services/new_api/user_search.js' +import { compact, map, each, merge, last, concat, uniq } from 'lodash' import { set } from 'vue' import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js' import oauthApi from '../services/new_api/oauth' @@ -32,11 +33,62 @@ const getNotificationPermission = () => { return Promise.resolve(Notification.permission) } +const blockUser = (store, id) => { + return store.rootState.api.backendInteractor.blockUser(id) + .then((relationship) => { + store.commit('updateUserRelationship', [relationship]) + store.commit('addBlockId', id) + store.commit('removeStatus', { timeline: 'friends', userId: id }) + store.commit('removeStatus', { timeline: 'public', userId: id }) + store.commit('removeStatus', { timeline: 'publicAndExternal', userId: id }) + }) +} + +const unblockUser = (store, id) => { + return store.rootState.api.backendInteractor.unblockUser(id) + .then((relationship) => store.commit('updateUserRelationship', [relationship])) +} + +const muteUser = (store, id) => { + return store.rootState.api.backendInteractor.muteUser(id) + .then((relationship) => { + store.commit('updateUserRelationship', [relationship]) + store.commit('addMuteId', id) + }) +} + +const unmuteUser = (store, id) => { + return store.rootState.api.backendInteractor.unmuteUser(id) + .then((relationship) => store.commit('updateUserRelationship', [relationship])) +} + export const mutations = { setMuted (state, { user: { id }, muted }) { const user = state.usersObject[id] set(user, 'muted', muted) }, + tagUser (state, { user: { id }, tag }) { + const user = state.usersObject[id] + const tags = user.tags || [] + const newTags = tags.concat([tag]) + set(user, 'tags', newTags) + }, + untagUser (state, { user: { id }, tag }) { + const user = state.usersObject[id] + const tags = user.tags || [] + const newTags = tags.filter(t => t !== tag) + set(user, 'tags', newTags) + }, + updateRight (state, { user: { id }, right, value }) { + const user = state.usersObject[id] + let newRights = user.rights + newRights[right] = value + set(user, 'rights', newRights) + }, + updateActivationStatus (state, { user: { id }, status }) { + const user = state.usersObject[id] + set(user, 'deactivated', !status) + }, setCurrentUser (state, user) { state.lastLoginName = user.screen_name state.currentUser = merge(state.currentUser || {}, user) @@ -51,42 +103,27 @@ export const mutations = { endLogin (state) { state.loggingIn = false }, - // TODO Clean after ourselves? - addFriends (state, { id, friends }) { + saveFriendIds (state, { id, friendIds }) { const user = state.usersObject[id] - each(friends, friend => { - if (!find(user.friends, { id: friend.id })) { - user.friends.push(friend) - } - }) - user.lastFriendId = last(friends).id + user.friendIds = uniq(concat(user.friendIds, friendIds)) }, - addFollowers (state, { id, followers }) { + saveFollowerIds (state, { id, followerIds }) { const user = state.usersObject[id] - each(followers, follower => { - if (!find(user.followers, { id: follower.id })) { - user.followers.push(follower) - } - }) - user.lastFollowerId = last(followers).id + user.followerIds = uniq(concat(user.followerIds, followerIds)) }, // Because frontend doesn't have a reason to keep these stuff in memory // outside of viewing someones user profile. clearFriends (state, userId) { const user = state.usersObject[userId] - if (!user) { - return + if (user) { + set(user, 'friendIds', []) } - user.friends = [] - user.lastFriendId = null }, clearFollowers (state, userId) { const user = state.usersObject[userId] - if (!user) { - return + if (user) { + set(user, 'followerIds', []) } - user.followers = [] - user.lastFollowerId = null }, addNewUsers (state, users) { each(users, (user) => mergeOrAdd(state.users, state.usersObject, user)) @@ -110,6 +147,11 @@ export const mutations = { saveBlockIds (state, blockIds) { state.currentUser.blockIds = blockIds }, + addBlockId (state, blockId) { + if (state.currentUser.blockIds.indexOf(blockId) === -1) { + state.currentUser.blockIds.push(blockId) + } + }, updateMutes (state, mutedUsers) { // Reset muted of all fetched users each(state.users, (user) => { user.muted = false }) @@ -118,12 +160,19 @@ export const mutations = { saveMuteIds (state, muteIds) { state.currentUser.muteIds = muteIds }, + addMuteId (state, muteId) { + if (state.currentUser.muteIds.indexOf(muteId) === -1) { + state.currentUser.muteIds.push(muteId) + } + }, setUserForStatus (state, status) { status.user = state.usersObject[status.user.id] }, setUserForNotification (state, notification) { - notification.action.user = state.usersObject[notification.action.user.id] - notification.from_profile = state.usersObject[notification.action.user.id] + if (notification.type !== 'follow') { + notification.action.user = state.usersObject[notification.action.user.id] + } + notification.from_profile = state.usersObject[notification.from_profile.id] }, setColor (state, { user: { id }, highlighted }) { const user = state.usersObject[id] @@ -176,8 +225,10 @@ const users = { }) }, fetchUserRelationship (store, id) { - return store.rootState.api.backendInteractor.fetchUserRelationship({ id }) - .then((relationships) => store.commit('updateUserRelationship', relationships)) + if (store.state.currentUser) { + store.rootState.api.backendInteractor.fetchUserRelationship({ id }) + .then((relationships) => store.commit('updateUserRelationship', relationships)) + } }, fetchBlocks (store) { return store.rootState.api.backendInteractor.fetchBlocks() @@ -187,18 +238,17 @@ const users = { return blocks }) }, - blockUser (store, userId) { - return store.rootState.api.backendInteractor.blockUser(userId) - .then((relationship) => { - store.commit('updateUserRelationship', [relationship]) - store.commit('removeStatus', { timeline: 'friends', userId }) - store.commit('removeStatus', { timeline: 'public', userId }) - store.commit('removeStatus', { timeline: 'publicAndExternal', userId }) - }) + blockUser (store, id) { + return blockUser(store, id) }, unblockUser (store, id) { - return store.rootState.api.backendInteractor.unblockUser(id) - .then((relationship) => store.commit('updateUserRelationship', [relationship])) + return unblockUser(store, id) + }, + blockUsers (store, ids = []) { + return Promise.all(ids.map(id => blockUser(store, id))) + }, + unblockUsers (store, ids = []) { + return Promise.all(ids.map(id => unblockUser(store, id))) }, fetchMutes (store) { return store.rootState.api.backendInteractor.fetchMutes() @@ -209,32 +259,34 @@ const users = { }) }, muteUser (store, id) { - return store.rootState.api.backendInteractor.muteUser(id) - .then((relationship) => store.commit('updateUserRelationship', [relationship])) + return muteUser(store, id) }, unmuteUser (store, id) { - return store.rootState.api.backendInteractor.unmuteUser(id) - .then((relationship) => store.commit('updateUserRelationship', [relationship])) + return unmuteUser(store, id) }, - addFriends ({ rootState, commit }, fetchBy) { - return new Promise((resolve, reject) => { - const user = rootState.users.usersObject[fetchBy] - const maxId = user.lastFriendId - rootState.api.backendInteractor.fetchFriends({ id: user.id, maxId }) - .then((friends) => { - commit('addFriends', { id: user.id, friends }) - resolve(friends) - }).catch(() => { - reject() - }) - }) + muteUsers (store, ids = []) { + return Promise.all(ids.map(id => muteUser(store, id))) + }, + unmuteUsers (store, ids = []) { + return Promise.all(ids.map(id => unmuteUser(store, id))) + }, + fetchFriends ({ rootState, commit }, id) { + const user = rootState.users.usersObject[id] + const maxId = last(user.friendIds) + return rootState.api.backendInteractor.fetchFriends({ id, maxId }) + .then((friends) => { + commit('addNewUsers', friends) + commit('saveFriendIds', { id, friendIds: map(friends, 'id') }) + return friends + }) }, - addFollowers ({ rootState, commit }, fetchBy) { - const user = rootState.users.usersObject[fetchBy] - const maxId = user.lastFollowerId - return rootState.api.backendInteractor.fetchFollowers({ id: user.id, maxId }) + fetchFollowers ({ rootState, commit }, id) { + const user = rootState.users.usersObject[id] + const maxId = last(user.followerIds) + return rootState.api.backendInteractor.fetchFollowers({ id, maxId }) .then((followers) => { - commit('addFollowers', { id: user.id, followers }) + commit('addNewUsers', followers) + commit('saveFollowerIds', { id, followerIds: map(followers, 'id') }) return followers }) }, @@ -257,6 +309,9 @@ const users = { unregisterPushNotifications(token) }, + addNewUsers ({ commit }, users) { + commit('addNewUsers', users) + }, addNewStatuses (store, { statuses }) { const users = map(statuses, 'user') const retweetedUsers = compact(map(statuses, 'retweeted_status.user')) @@ -287,6 +342,14 @@ const users = { store.commit('setUserForNotification', notification) }) }, + searchUsers (store, query) { + // TODO: Move userSearch api into api.service + return userSearchApi.search({query, store: { state: store.rootState }}) + .then((users) => { + store.commit('addNewUsers', users) + return users + }) + }, async signUp (store, userInfo) { store.commit('signUpPending') @@ -331,7 +394,8 @@ const users = { store.commit('setToken', false) store.dispatch('stopFetching', 'friends') store.commit('setBackendInteractor', backendInteractorService()) - store.dispatch('stopFetchingNotifications') + store.dispatch('stopFetching', 'notifications') + store.commit('clearNotifications') store.commit('resetStatuses') }, loginUser (store, accessToken) { @@ -363,7 +427,10 @@ const users = { } // Start getting fresh posts. - store.dispatch('startFetching', { timeline: 'friends' }) + store.dispatch('startFetchingTimeline', { timeline: 'friends' }) + + // Start fetching notifications + store.dispatch('startFetchingNotifications') // Get user mutes store.dispatch('fetchMutes') diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 030c2f5e..6b255e9f 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -8,7 +8,6 @@ const BG_UPDATE_URL = '/api/qvitter/update_background_image.json' const BANNER_UPDATE_URL = '/api/account/update_profile_banner.json' const PROFILE_UPDATE_URL = '/api/account/update_profile.json' const EXTERNAL_PROFILE_URL = '/api/externalprofile/show.json' -const QVITTER_USER_NOTIFICATIONS_URL = '/api/qvitter/statuses/notifications.json' const QVITTER_USER_NOTIFICATIONS_READ_URL = '/api/qvitter/statuses/notifications/read.json' const FOLLOW_IMPORT_URL = '/api/pleroma/follow_import' const DELETE_ACCOUNT_URL = '/api/pleroma/delete_account' @@ -16,9 +15,14 @@ const CHANGE_PASSWORD_URL = '/api/pleroma/change_password' const FOLLOW_REQUESTS_URL = '/api/pleroma/friend_requests' const APPROVE_USER_URL = '/api/pleroma/friendships/approve' const DENY_USER_URL = '/api/pleroma/friendships/deny' +const TAG_USER_URL = '/api/pleroma/admin/users/tag' +const PERMISSION_GROUP_URL = '/api/pleroma/admin/permission_group' +const ACTIVATION_STATUS_URL = '/api/pleroma/admin/activation_status' +const ADMIN_USER_URL = '/api/pleroma/admin/user' const SUGGESTIONS_URL = '/api/v1/suggestions' const MASTODON_USER_FAVORITES_TIMELINE_URL = '/api/v1/favourites' +const MASTODON_USER_NOTIFICATIONS_URL = '/api/v1/notifications' const MASTODON_FAVORITE_URL = id => `/api/v1/statuses/${id}/favourite` const MASTODON_UNFAVORITE_URL = id => `/api/v1/statuses/${id}/unfavourite` const MASTODON_RETWEET_URL = id => `/api/v1/statuses/${id}/reblog` @@ -46,7 +50,7 @@ const MASTODON_UNMUTE_USER_URL = id => `/api/v1/accounts/${id}/unmute` const MASTODON_POST_STATUS_URL = '/api/v1/statuses' const MASTODON_MEDIA_UPLOAD_URL = '/api/v1/media' -import { each, map } from 'lodash' +import { each, map, concat, last } from 'lodash' import { parseStatus, parseUser, parseNotification, parseAttachment } from '../entity_normalizer/entity_normalizer.service.js' import 'whatwg-fetch' import { StatusCodeError } from '../errors/errors' @@ -290,10 +294,23 @@ const fetchFriends = ({id, maxId, sinceId, limit = 20, credentials}) => { } const exportFriends = ({id, credentials}) => { - let url = MASTODON_FOLLOWING_URL(id) + `?all=true` - return fetch(url, { headers: authHeaders(credentials) }) - .then((data) => data.json()) - .then((data) => data.map(parseUser)) + return new Promise(async (resolve, reject) => { + try { + let friends = [] + let more = true + while (more) { + const maxId = friends.length > 0 ? last(friends).id : undefined + const users = await fetchFriends({id, maxId, credentials}) + friends = concat(friends, users) + if (users.length === 0) { + more = false + } + } + resolve(friends) + } catch (err) { + reject(err) + } + }) } const fetchFollowers = ({id, maxId, sinceId, limit = 20, credentials}) => { @@ -352,13 +369,93 @@ const fetchStatus = ({id, credentials}) => { .then((data) => parseStatus(data)) } +const tagUser = ({tag, credentials, ...options}) => { + const screenName = options.screen_name + const form = { + nicknames: [screenName], + tags: [tag] + } + + const headers = authHeaders(credentials) + headers['Content-Type'] = 'application/json' + + return fetch(TAG_USER_URL, { + method: 'PUT', + headers: headers, + body: JSON.stringify(form) + }) +} + +const untagUser = ({tag, credentials, ...options}) => { + const screenName = options.screen_name + const body = { + nicknames: [screenName], + tags: [tag] + } + + const headers = authHeaders(credentials) + headers['Content-Type'] = 'application/json' + + return fetch(TAG_USER_URL, { + method: 'DELETE', + headers: headers, + body: JSON.stringify(body) + }) +} + +const addRight = ({right, credentials, ...user}) => { + const screenName = user.screen_name + + return fetch(`${PERMISSION_GROUP_URL}/${screenName}/${right}`, { + method: 'POST', + headers: authHeaders(credentials), + body: {} + }) +} + +const deleteRight = ({right, credentials, ...user}) => { + const screenName = user.screen_name + + return fetch(`${PERMISSION_GROUP_URL}/${screenName}/${right}`, { + method: 'DELETE', + headers: authHeaders(credentials), + body: {} + }) +} + +const setActivationStatus = ({status, credentials, ...user}) => { + const screenName = user.screen_name + const body = { + status: status + } + + const headers = authHeaders(credentials) + headers['Content-Type'] = 'application/json' + + return fetch(`${ACTIVATION_STATUS_URL}/${screenName}.json`, { + method: 'PUT', + headers: headers, + body: JSON.stringify(body) + }) +} + +const deleteUser = ({credentials, ...user}) => { + const screenName = user.screen_name + const headers = authHeaders(credentials) + + return fetch(`${ADMIN_USER_URL}.json?nickname=${screenName}`, { + method: 'DELETE', + headers: headers + }) +} + const fetchTimeline = ({timeline, credentials, since = false, until = false, userId = false, tag = false, withMuted = false}) => { const timelineUrls = { public: MASTODON_PUBLIC_TIMELINE, friends: MASTODON_USER_HOME_TIMELINE_URL, mentions: MENTIONS_URL, dms: MASTODON_DIRECT_MESSAGES_TIMELINE_URL, - notifications: QVITTER_USER_NOTIFICATIONS_URL, + notifications: MASTODON_USER_NOTIFICATIONS_URL, 'publicAndExternal': MASTODON_PUBLIC_TIMELINE, user: MASTODON_USER_TIMELINE_URL, media: MASTODON_USER_TIMELINE_URL, @@ -666,6 +763,12 @@ const apiService = { fetchBlocks, fetchOAuthTokens, revokeOAuthToken, + tagUser, + untagUser, + deleteUser, + addRight, + deleteRight, + setActivationStatus, register, getCaptcha, updateAvatar, diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js index 71e78d2f..75bba92b 100644 --- a/src/services/backend_interactor_service/backend_interactor_service.js +++ b/src/services/backend_interactor_service/backend_interactor_service.js @@ -1,5 +1,6 @@ import apiService from '../api/api.service.js' import timelineFetcherService from '../timeline_fetcher/timeline_fetcher.service.js' +import notificationsFetcher from '../notifications_fetcher/notifications_fetcher.service.js' const backendInteractorService = (credentials) => { const fetchStatus = ({id}) => { @@ -58,8 +59,36 @@ const backendInteractorService = (credentials) => { return apiService.denyUser({credentials, id}) } - const startFetching = ({timeline, store, userId = false, tag}) => { - return timelineFetcherService.startFetching({timeline, store, credentials, userId, tag}) + const startFetchingTimeline = ({ timeline, store, userId = false, tag }) => { + return timelineFetcherService.startFetching({ timeline, store, credentials, userId, tag }) + } + + const startFetchingNotifications = ({ store }) => { + return notificationsFetcher.startFetching({ store, credentials }) + } + + const tagUser = ({screen_name}, tag) => { + return apiService.tagUser({screen_name, tag, credentials}) + } + + const untagUser = ({screen_name}, tag) => { + return apiService.untagUser({screen_name, tag, credentials}) + } + + const addRight = ({screen_name}, right) => { + return apiService.addRight({screen_name, right, credentials}) + } + + const deleteRight = ({screen_name}, right) => { + return apiService.deleteRight({screen_name, right, credentials}) + } + + const setActivationStatus = ({screen_name}, status) => { + return apiService.setActivationStatus({screen_name, status, credentials}) + } + + const deleteUser = ({screen_name}) => { + return apiService.deleteUser({screen_name, credentials}) } const fetchMutes = () => apiService.fetchMutes({credentials}) @@ -97,13 +126,20 @@ const backendInteractorService = (credentials) => { fetchUserRelationship, fetchAllFollowing, verifyCredentials: apiService.verifyCredentials, - startFetching, + startFetchingTimeline, + startFetchingNotifications, fetchMutes, muteUser, unmuteUser, fetchBlocks, fetchOAuthTokens, revokeOAuthToken, + tagUser, + untagUser, + addRight, + deleteRight, + deleteUser, + setActivationStatus, register, getCaptcha, updateAvatar, diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index ea57e6b2..e706e7d9 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -39,7 +39,7 @@ export const parseUser = (data) => { return output } - // output.name = ??? missing + output.name = data.display_name output.name_html = addEmojis(data.display_name, data.emojis) // output.description = ??? missing @@ -67,9 +67,14 @@ export const parseUser = (data) => { output.statusnet_blocking = relationship.blocking output.muted = relationship.muting } + + output.rights = { + moderator: data.pleroma.is_moderator, + admin: data.pleroma.is_admin + } } - // Missing, trying to recover + // TODO: handle is_local output.is_local = !output.screen_name.includes('@') } else { output.screen_name = data.screen_name @@ -103,7 +108,12 @@ export const parseUser = (data) => { // QVITTER ONLY FOR NOW // Really only applies to logged in user, really.. I THINK - output.rights = data.rights + if (data.rights) { + output.rights = { + moderator: data.rights.delete_others_notice, + admin: data.rights.admin + } + } output.no_rich_text = data.no_rich_text output.default_scope = data.default_scope output.hide_follows = data.hide_follows @@ -119,12 +129,19 @@ export const parseUser = (data) => { output.locked = data.locked output.followers_count = data.followers_count output.statuses_count = data.statuses_count - output.friends = [] - output.followers = [] + output.friendIds = [] + output.followerIds = [] if (data.pleroma) { output.follow_request_count = data.pleroma.follow_request_count } + if (data.pleroma) { + output.tags = data.pleroma.tags + output.deactivated = data.pleroma.deactivated + } + + output.tags = output.tags || [] + return output } @@ -172,28 +189,28 @@ export const parseStatus = (data) => { output.statusnet_html = addEmojis(data.content, data.emojis) - // Not exactly the same but works? - output.text = data.content + if (data.pleroma) { + const { pleroma } = data + output.text = pleroma.content ? data.pleroma.content['text/plain'] : data.content + output.summary = pleroma.spoiler_text ? data.pleroma.spoiler_text['text/plain'] : data.spoiler_text + output.statusnet_conversation_id = data.pleroma.conversation_id + output.is_local = pleroma.local + output.in_reply_to_screen_name = data.pleroma.in_reply_to_account_acct + } else { + output.text = data.content + output.summary = data.spoiler_text + } output.in_reply_to_status_id = data.in_reply_to_id output.in_reply_to_user_id = data.in_reply_to_account_id output.replies_count = data.replies_count - // Missing!! fix in UI? - // output.in_reply_to_screen_name = ??? - - // Not exactly the same but works - output.statusnet_conversation_id = data.id - if (output.type === 'retweet') { output.retweeted_status = parseStatus(data.reblog) } - output.summary = data.spoiler_text output.summary_html = addEmojis(data.spoiler_text, data.emojis) output.external_url = data.url - - // output.is_local = ??? missing } else { output.favorited = data.favorited output.fave_num = data.fave_num @@ -221,7 +238,6 @@ export const parseStatus = (data) => { output.in_reply_to_status_id = data.in_reply_to_status_id output.in_reply_to_user_id = data.in_reply_to_user_id output.in_reply_to_screen_name = data.in_reply_to_screen_name - output.statusnet_conversation_id = data.statusnet_conversation_id if (output.type === 'retweet') { @@ -272,9 +288,11 @@ export const parseNotification = (data) => { if (masto) { output.type = mastoDict[data.type] || data.type - // output.seen = ??? missing - output.status = parseStatus(data.status) - output.action = output.status // not sure + output.seen = data.pleroma.is_seen + output.status = output.type === 'follow' + ? null + : parseStatus(data.status) + output.action = output.status // TODO: Refactor, this is unneeded output.from_profile = parseUser(data.account) } else { const parsedNotice = parseStatus(data.notice) diff --git a/src/services/follow_manipulate/follow_manipulate.js b/src/services/follow_manipulate/follow_manipulate.js index 51dafe84..b2486e7c 100644 --- a/src/services/follow_manipulate/follow_manipulate.js +++ b/src/services/follow_manipulate/follow_manipulate.js @@ -23,18 +23,12 @@ export const requestFollow = (user, store) => new Promise((resolve, reject) => { // For locked users we just mark it that we sent the follow request if (updated.locked) { - resolve({ - sent: true, - updated - }) + resolve({ sent: true }) } if (updated.following) { // If we get result immediately, just stop. - resolve({ - sent: false, - updated - }) + resolve({ sent: false }) } // But usually we don't get result immediately, so we ask server @@ -48,16 +42,10 @@ export const requestFollow = (user, store) => new Promise((resolve, reject) => { .then((following) => { if (following) { // We confirmed and everything's good. - resolve({ - sent: false, - updated - }) + resolve({ sent: false }) } else { // If after all the tries, just treat it as if user is locked - resolve({ - sent: false, - updated - }) + resolve({ sent: false }) } }) }) diff --git a/src/services/notification_utils/notification_utils.js b/src/services/notification_utils/notification_utils.js index cd8f3f9e..8afd114e 100644 --- a/src/services/notification_utils/notification_utils.js +++ b/src/services/notification_utils/notification_utils.js @@ -10,8 +10,8 @@ export const visibleTypes = store => ([ ].filter(_ => _)) const sortById = (a, b) => { - const seqA = Number(a.action.id) - const seqB = Number(b.action.id) + const seqA = Number(a.id) + const seqB = Number(b.id) const isSeqA = !Number.isNaN(seqA) const isSeqB = !Number.isNaN(seqB) if (isSeqA && isSeqB) { @@ -21,7 +21,7 @@ const sortById = (a, b) => { } else if (!isSeqA && isSeqB) { return -1 } else { - return a.action.id > b.action.id ? -1 : 1 + return a.id > b.id ? -1 : 1 } } diff --git a/src/services/notifications_fetcher/notifications_fetcher.service.js b/src/services/notifications_fetcher/notifications_fetcher.service.js index 3ecdae6a..60c497ae 100644 --- a/src/services/notifications_fetcher/notifications_fetcher.service.js +++ b/src/services/notifications_fetcher/notifications_fetcher.service.js @@ -11,29 +11,35 @@ const fetchAndUpdate = ({store, credentials, older = false}) => { const rootState = store.rootState || store.state const timelineData = rootState.statuses.notifications + args['timeline'] = 'notifications' if (older) { if (timelineData.minId !== Number.POSITIVE_INFINITY) { args['until'] = timelineData.minId } + return fetchNotifications({ store, args, older }) } else { - // load unread notifications repeadedly to provide consistency between browser tabs + // fetch new notifications + if (timelineData.maxId !== Number.POSITIVE_INFINITY) { + args['since'] = timelineData.maxId + } + const result = fetchNotifications({ store, args, older }) + + // load unread notifications repeatedly to provide consistency between browser tabs const notifications = timelineData.data const unread = notifications.filter(n => !n.seen).map(n => n.id) - if (!unread.length) { - args['since'] = timelineData.maxId - } else { - args['since'] = Math.min(...unread) - 1 - if (timelineData.maxId !== Math.max(...unread)) { - args['until'] = Math.max(...unread, args['since'] + 20) - } + if (unread.length) { + args['since'] = Math.min(...unread) + fetchNotifications({ store, args, older }) } - } - args['timeline'] = 'notifications' + return result + } +} +const fetchNotifications = ({ store, args, older }) => { return apiService.fetchTimeline(args) .then((notifications) => { - update({store, notifications, older}) + update({ store, notifications, older }) return notifications }, () => store.dispatch('setNotificationsError', { value: true })) .catch(() => store.dispatch('setNotificationsError', { value: true })) |
