aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.scss8
-rw-r--r--src/boot/after_store.js6
-rw-r--r--src/components/autosuggest/autosuggest.js52
-rw-r--r--src/components/autosuggest/autosuggest.vue45
-rw-r--r--src/components/basic_user_card/basic_user_card.vue10
-rw-r--r--src/components/checkbox/checkbox.vue75
-rw-r--r--src/components/conversation/conversation.js33
-rw-r--r--src/components/conversation/conversation.vue2
-rw-r--r--src/components/delete_button/delete_button.js6
-rw-r--r--src/components/dialog_modal/dialog_modal.js14
-rw-r--r--src/components/dialog_modal/dialog_modal.vue92
-rw-r--r--src/components/follow_card/follow_card.js15
-rw-r--r--src/components/follow_card/follow_card.vue60
-rw-r--r--src/components/follow_requests/follow_requests.vue2
-rw-r--r--src/components/list/list.vue42
-rw-r--r--src/components/login_form/login_form.js10
-rw-r--r--src/components/moderation_tools/moderation_tools.js106
-rw-r--r--src/components/moderation_tools/moderation_tools.vue158
-rw-r--r--src/components/notification/notification.js11
-rw-r--r--src/components/notification/notification.vue32
-rw-r--r--src/components/notifications/notifications.js9
-rw-r--r--src/components/notifications/notifications.vue2
-rw-r--r--src/components/popper/popper.scss70
-rw-r--r--src/components/progress_button/progress_button.vue35
-rw-r--r--src/components/public_and_external_timeline/public_and_external_timeline.js2
-rw-r--r--src/components/public_timeline/public_timeline.js2
-rw-r--r--src/components/selectable_list/selectable_list.js66
-rw-r--r--src/components/selectable_list/selectable_list.vue59
-rw-r--r--src/components/settings/settings.vue5
-rw-r--r--src/components/tag_timeline/tag_timeline.js4
-rw-r--r--src/components/timeline/timeline.js2
-rw-r--r--src/components/user_card/user_card.js13
-rw-r--r--src/components/user_card/user_card.vue6
-rw-r--r--src/components/user_profile/user_profile.js133
-rw-r--r--src/components/user_profile/user_profile.vue14
-rw-r--r--src/components/user_search/user_search.js14
-rw-r--r--src/components/user_search/user_search.vue2
-rw-r--r--src/components/user_settings/user_settings.js74
-rw-r--r--src/components/user_settings/user_settings.vue60
-rw-r--r--src/components/who_to_follow/who_to_follow.vue2
-rw-r--r--src/hocs/with_list/with_list.js40
-rw-r--r--src/hocs/with_list/with_list.scss6
-rw-r--r--src/hocs/with_load_more/with_load_more.scss8
-rw-r--r--src/i18n/cs.json3
-rw-r--r--src/i18n/en.json32
-rw-r--r--src/i18n/oc.json47
-rw-r--r--src/i18n/pl.json5
-rw-r--r--src/i18n/ru.json24
-rw-r--r--src/main.js7
-rw-r--r--src/modules/api.js27
-rw-r--r--src/modules/interface.js1
-rw-r--r--src/modules/statuses.js130
-rw-r--r--src/modules/users.js189
-rw-r--r--src/services/api/api.service.js117
-rw-r--r--src/services/backend_interactor_service/backend_interactor_service.js42
-rw-r--r--src/services/entity_normalizer/entity_normalizer.service.js58
-rw-r--r--src/services/follow_manipulate/follow_manipulate.js20
-rw-r--r--src/services/notification_utils/notification_utils.js6
-rw-r--r--src/services/notifications_fetcher/notifications_fetcher.service.js28
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 }))